diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index 51bdbaa78794..dd726b6472bd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -441,7 +441,7 @@ public Integer load(String key) { @Getter protected final boolean supportsCertification; @Getter protected final boolean supportsChildren; protected final boolean supportsFollower; - protected final boolean supportsExtension; + @Getter protected final boolean supportsExtension; protected final boolean supportsVotes; @Getter protected final boolean supportsDomains; protected final boolean supportsDataProducts; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContext.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContext.java index 49e86b609029..2e975f374b92 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContext.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContext.java @@ -1,12 +1,14 @@ package org.openmetadata.service.security.policyevaluator; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; +import static org.openmetadata.service.Entity.FIELD_EXTENSION; import static org.openmetadata.service.Entity.FIELD_OWNERS; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.UUID; import lombok.Getter; import lombok.NonNull; @@ -17,6 +19,7 @@ import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.TagLabel; +import org.openmetadata.schema.utils.JsonUtils; import org.openmetadata.service.Entity; import org.openmetadata.service.exception.EntityNotFoundException; import org.openmetadata.service.jdbi3.EntityRepository; @@ -37,6 +40,7 @@ public class ResourceContext implements ResourceConte private final UUID id; private final String name; private T entity; // Will be lazily initialized + private Map cachedCustomProperties; private ResourceContextInterface.Operation operation = ResourceContextInterface.Operation.NONE; private Include include; private Fields requestedFields; @@ -186,6 +190,31 @@ public List getDomains() { return entity.getDomains(); } + @Override + public Map getCustomProperties() { + if (cachedCustomProperties != null) { + return cachedCustomProperties; + } + resolveEntity(); + if (entity == null || entity.getExtension() == null) { + cachedCustomProperties = Collections.emptyMap(); + return cachedCustomProperties; + } + try { + @SuppressWarnings("unchecked") + Map props = JsonUtils.convertValue(entity.getExtension(), Map.class); + cachedCustomProperties = props != null ? props : Collections.emptyMap(); + return cachedCustomProperties; + } catch (Exception e) { + LOG.warn( + "Failed to deserialize custom properties for entity {}: {}", + entity.getId(), + e.getMessage()); + cachedCustomProperties = Collections.emptyMap(); + return cachedCustomProperties; + } + } + private EntityInterface resolveEntity() { if (entity == null) { Fields fieldList; @@ -210,6 +239,9 @@ private EntityInterface resolveEntity() { if (entityRepository.isSupportsReviewers()) { fields = EntityUtil.addField(fields, Entity.FIELD_REVIEWERS); } + if (entityRepository.isSupportsExtension()) { + fields = EntityUtil.addField(fields, FIELD_EXTENSION); + } fieldList = entityRepository.getFields(fields); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContextInterface.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContextInterface.java index b8042227c56e..a78f923ae062 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContextInterface.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContextInterface.java @@ -1,6 +1,8 @@ package org.openmetadata.service.security.policyevaluator; +import java.util.Collections; import java.util.List; +import java.util.Map; import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.TagLabel; @@ -25,4 +27,10 @@ enum Operation { EntityInterface getEntity(); List getDomains(); + + // Returns custom properties of a resource as a Map for use in SpEL + // policy conditions. Returns empty map if entity has no custom properties. + default Map getCustomProperties() { + return Collections.emptyMap(); + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java index 2ca13730cc67..c4a4b85ccb80 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java @@ -4,7 +4,9 @@ import static org.openmetadata.schema.type.Include.NON_DELETED; import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @@ -414,4 +416,61 @@ private void validateEntityByName(String entityType, String name) { name); } } + + @Function( + name = "getCustomProperties", + input = "None", + description = "Returns a map of custom properties for the resource being evaluated.", + examples = {"getCustomProperties()"}) + @SuppressWarnings("unused") + public Map getCustomProperties() { + if (resourceContext == null) { + return Collections.emptyMap(); + } + return resourceContext.getCustomProperties(); + } + + /** + * SpEL helper: returns true if the resource has a custom property with the given name whose value + * equals the given expected value. + * + *

Usage in policy rule condition: + * + *

matchCustomProperty('dataSensitivity', 'PII') matchCustomProperty('department', 'Finance') + * + *

Returns false (not an error) when: + * + *

- the entity has no custom properties - the property does not exist on this entity - the + * property value is null + */ + @Function( + name = "matchCustomProperty", + input = "propertyName (String), expectedValue (String)", + description = + "Returns true if the resource has a custom property with the given name and its value matches the expected value.", + examples = {"matchCustomProperty('propertyName', 'expectedValue')"}) + @SuppressWarnings("unused") + public boolean matchCustomProperty(String propertyName, String expectedValue) { + if (expressionValidation) { + // During validation mode — just confirm syntax is valid, return false + return false; + } + if (resourceContext == null || propertyName == null || expectedValue == null) { + return false; + } + Map props = resourceContext.getCustomProperties(); + if (props == null || props.isEmpty()) { + return false; + } + Object actual = props.get(propertyName); + if (actual == null) { + return false; + } + + if (actual instanceof List list) { + return list.stream().anyMatch(item -> expectedValue.equals(item.toString())); + } + + return expectedValue.equals(actual.toString()); + } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java index 3be365f015db..94d71a03c080 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java @@ -1,7 +1,9 @@ package org.openmetadata.service.security.policyevaluator; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -15,12 +17,13 @@ import static org.openmetadata.service.security.policyevaluator.CompiledRule.parseExpression; import static org.openmetadata.service.security.policyevaluator.SubjectContext.TEAM_FIELDS; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.UUID; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.ImmutablePair; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -57,7 +60,6 @@ import org.springframework.expression.EvaluationContext; import org.springframework.expression.spel.support.StandardEvaluationContext; -@Slf4j class RuleEvaluatorTest { private static final String DATA_CONSUMER_ROLE_NAME = "DataConsumer"; private static final Table table = @@ -452,6 +454,68 @@ void test_matchAnyTag() { assertTrue(evaluateExpression("!matchAnyTag('tag4')")); } + @Test + void test_getCustomProperties_noExtension() { + // entity with no custom properties + table.setExtension(null); + @SuppressWarnings("unchecked") + Map props = + parseExpression("getCustomProperties()").getValue(evaluationContext, Map.class); + assertNotNull(props); + assertTrue(props.isEmpty()); + } + + @Test + void test_getCustomProperties_withExtension() { + ObjectNode node = JsonUtils.getObjectMapper().createObjectNode(); + node.put("dataSensitivity", "PII"); + table.setExtension(node); + + @SuppressWarnings("unchecked") + Map props = + parseExpression("getCustomProperties()").getValue(evaluationContext, Map.class); + assertNotNull(props); + assertEquals("PII", props.get("dataSensitivity")); + } + + @Test + void test_matchCustomProperty_matches() { + // Set dataSensitivity = PII on entity extension + ObjectNode node = JsonUtils.getObjectMapper().createObjectNode(); + node.put("dataSensitivity", "PII"); + table.setExtension(node); + + assertTrue(evaluateExpression("matchCustomProperty('dataSensitivity', 'PII')")); + assertFalse(evaluateExpression("!matchCustomProperty('dataSensitivity', 'PII')")); + } + + @Test + void test_matchCustomProperty_noMatch() { + // Set dataSensitivity = PII but check for CONFIDENTIAL + ObjectNode node = JsonUtils.getObjectMapper().createObjectNode(); + node.put("dataSensitivity", "PII"); + table.setExtension(node); + + assertFalse(evaluateExpression("matchCustomProperty('dataSensitivity', 'CONFIDENTIAL')")); + assertTrue(evaluateExpression("!matchCustomProperty('dataSensitivity', 'CONFIDENTIAL')")); + } + + @Test + void test_matchCustomProperty_missingProperty() { + // Set extension with dataSensitivity but check nonExistentProp + ObjectNode node = JsonUtils.getObjectMapper().createObjectNode(); + node.put("dataSensitivity", "PII"); + table.setExtension(node); + + assertFalse(evaluateExpression("matchCustomProperty('nonExistentProp', 'anyValue')")); + } + + @Test + void test_matchCustomProperty_noExtension() { + table.setExtension(null); + assertFalse(evaluateExpression("matchCustomProperty('dataSensitivity', 'PII')")); + } + @Test void test_matchAnyCertification() { // Certification is not Present @@ -1081,9 +1145,10 @@ void test_noDomain() { @AfterEach void resetContext() { + table.setExtension(null); + resourceContext = new ResourceContext<>(Entity.TABLE, table, tableRepository); subjectContext = new SubjectContext(user, null); RuleEvaluator ruleEvaluator = new RuleEvaluator(null, subjectContext, resourceContext); evaluationContext = new StandardEvaluationContext(ruleEvaluator); - LOG.info("Context reset to default state after test completion."); } }