From d1164f293ec3102ceffa299a726aa450d319dc99 Mon Sep 17 00:00:00 2001 From: Mohit Jeswani <2022.mohit.jeswani@ves.ac.in> Date: Fri, 10 Apr 2026 03:07:24 +0530 Subject: [PATCH 1/5] feat(policy): expose custom properties in SpEL policy rule conditions Fixes #26774 --- .../policyevaluator/ResourceContext.java | 30 ++++++++ .../ResourceContextInterface.java | 8 +++ .../policyevaluator/RuleEvaluator.java | 43 ++++++++++++ .../policyevaluator/RuleEvaluatorTest.java | 68 +++++++++++++++++++ 4 files changed, 149 insertions(+) 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..c17b190c10c3 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; @@ -186,6 +189,25 @@ public List getDomains() { return entity.getDomains(); } + @Override + public Map getCustomProperties() { + resolveEntity(); + if (entity == null || entity.getExtension() == null) { + return Collections.emptyMap(); + } + try { + @SuppressWarnings("unchecked") + Map props = JsonUtils.convertValue(entity.getExtension(), Map.class); + return props != null ? props : Collections.emptyMap(); + } catch (Exception e) { + LOG.warn( + "Failed to deserialize custom properties for entity {}: {}", + entity.getId(), + e.getMessage()); + return Collections.emptyMap(); + } + } + private EntityInterface resolveEntity() { if (entity == null) { Fields fieldList; @@ -210,6 +232,14 @@ private EntityInterface resolveEntity() { if (entityRepository.isSupportsReviewers()) { fields = EntityUtil.addField(fields, Entity.FIELD_REVIEWERS); } + try { + Fields testFields = entityRepository.getFields(FIELD_EXTENSION); + if (testFields != null) { + fields = EntityUtil.addField(fields, FIELD_EXTENSION); + } + } catch (Exception ignored) { + // entity type does not support extension field — skip silently + } 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..dcc3c011af7f 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,45 @@ private void validateEntityByName(String entityType, String name) { name); } } + + @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 + */ + @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; + } + 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..611f74dd9403 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; 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; @@ -19,7 +21,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.UUID; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.ImmutablePair; import org.junit.jupiter.api.AfterEach; @@ -452,6 +456,70 @@ 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() { + // Set extension on the table entity + // Use JsonUtils to create a JsonNode with test properties + // set table.setExtension(node) + ObjectNode node = JsonUtils.getObjectMapper().createObjectNode(); + node.put("dataSensitivity", "PII"); + table.setExtension(node); + + // Then verify getCustomProperties() returns the expected map + @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 From c9b69aa56b6f222fa3e05cce89213b5f88f82982 Mon Sep 17 00:00:00 2001 From: Mohit Jeswani <2022.mohit.jeswani@ves.ac.in> Date: Fri, 10 Apr 2026 03:33:58 +0530 Subject: [PATCH 2/5] fix(policy): address review comments - add @Function annotations, cache customProperties, use isSupportsExtension() --- .../service/jdbi3/EntityRepository.java | 2 +- .../policyevaluator/ResourceContext.java | 22 ++++++++++--------- .../policyevaluator/RuleEvaluator.java | 2 ++ .../policyevaluator/RuleEvaluatorTest.java | 10 ++++----- 4 files changed, 19 insertions(+), 17 deletions(-) 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 2e1ab6552f44..c7743ee575fb 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 @@ -377,7 +377,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 c17b190c10c3..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 @@ -40,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; @@ -191,20 +192,26 @@ public List getDomains() { @Override public Map getCustomProperties() { + if (cachedCustomProperties != null) { + return cachedCustomProperties; + } resolveEntity(); if (entity == null || entity.getExtension() == null) { - return Collections.emptyMap(); + cachedCustomProperties = Collections.emptyMap(); + return cachedCustomProperties; } try { @SuppressWarnings("unchecked") Map props = JsonUtils.convertValue(entity.getExtension(), Map.class); - return props != null ? props : Collections.emptyMap(); + cachedCustomProperties = props != null ? props : Collections.emptyMap(); + return cachedCustomProperties; } catch (Exception e) { LOG.warn( "Failed to deserialize custom properties for entity {}: {}", entity.getId(), e.getMessage()); - return Collections.emptyMap(); + cachedCustomProperties = Collections.emptyMap(); + return cachedCustomProperties; } } @@ -232,13 +239,8 @@ private EntityInterface resolveEntity() { if (entityRepository.isSupportsReviewers()) { fields = EntityUtil.addField(fields, Entity.FIELD_REVIEWERS); } - try { - Fields testFields = entityRepository.getFields(FIELD_EXTENSION); - if (testFields != null) { - fields = EntityUtil.addField(fields, FIELD_EXTENSION); - } - } catch (Exception ignored) { - // entity type does not support extension field — skip silently + 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/RuleEvaluator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java index dcc3c011af7f..75521716f743 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 @@ -417,6 +417,7 @@ private void validateEntityByName(String entityType, String name) { } } + @Function @SuppressWarnings("unused") public Map getCustomProperties() { if (resourceContext == null) { @@ -438,6 +439,7 @@ public Map getCustomProperties() { *

- the entity has no custom properties - the property does not exist on this entity - the * property value is null */ + @Function @SuppressWarnings("unused") public boolean matchCustomProperty(String propertyName, String expectedValue) { if (expressionValidation) { 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 611f74dd9403..a426584e8c12 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 @@ -461,23 +461,21 @@ void test_getCustomProperties_noExtension() { // entity with no custom properties table.setExtension(null); @SuppressWarnings("unchecked") - Map props = parseExpression("getCustomProperties()").getValue(evaluationContext, Map.class); + Map props = + parseExpression("getCustomProperties()").getValue(evaluationContext, Map.class); assertNotNull(props); assertTrue(props.isEmpty()); } @Test void test_getCustomProperties_withExtension() { - // Set extension on the table entity - // Use JsonUtils to create a JsonNode with test properties - // set table.setExtension(node) ObjectNode node = JsonUtils.getObjectMapper().createObjectNode(); node.put("dataSensitivity", "PII"); table.setExtension(node); - // Then verify getCustomProperties() returns the expected map @SuppressWarnings("unchecked") - Map props = parseExpression("getCustomProperties()").getValue(evaluationContext, Map.class); + Map props = + parseExpression("getCustomProperties()").getValue(evaluationContext, Map.class); assertNotNull(props); assertEquals("PII", props.get("dataSensitivity")); } From 2e365ada1ccff22b68602ced3c0b1280c145cdf0 Mon Sep 17 00:00:00 2001 From: Mohit Jeswani <2022.mohit.jeswani@ves.ac.in> Date: Thu, 16 Apr 2026 07:48:37 +0530 Subject: [PATCH 3/5] chore: run spotless:apply to fix checkstyle failures --- .../service/security/policyevaluator/RuleEvaluatorTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a426584e8c12..6dbb823b8524 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,7 @@ package org.openmetadata.service.security.policyevaluator; -import static org.junit.jupiter.api.Assertions.assertFalse; 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; @@ -17,13 +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 com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.ImmutablePair; import org.junit.jupiter.api.AfterEach; From c6d5ee2178e3e1ea56c7cb4457f8038ff3efd472 Mon Sep 17 00:00:00 2001 From: Mohit Jeswani <2022.mohit.jeswani@ves.ac.in> Date: Thu, 16 Apr 2026 08:06:40 +0530 Subject: [PATCH 4/5] fix(security): address bot feedback on SpEL functions and test contamination --- .../policyevaluator/RuleEvaluator.java | 18 ++++++++++++++++-- .../policyevaluator/RuleEvaluatorTest.java | 4 +++- 2 files changed, 19 insertions(+), 3 deletions(-) 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 75521716f743..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 @@ -417,7 +417,11 @@ private void validateEntityByName(String entityType, String name) { } } - @Function + @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) { @@ -439,7 +443,12 @@ public Map getCustomProperties() { *

- the entity has no custom properties - the property does not exist on this entity - the * property value is null */ - @Function + @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) { @@ -457,6 +466,11 @@ public boolean matchCustomProperty(String propertyName, String expectedValue) { 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 6dbb823b8524..274ede351145 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 @@ -1147,9 +1147,11 @@ 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."); + log.info("Context reset to default state after test completion."); } } From 7a24084afb0dd888705b23e40ccda423e3451a1e Mon Sep 17 00:00:00 2001 From: Mohit Jeswani <2022.mohit.jeswani@ves.ac.in> Date: Wed, 22 Apr 2026 19:26:21 +0530 Subject: [PATCH 5/5] Fix test compile error by removing lombok logger from RuleEvaluatorTest --- .../service/security/policyevaluator/RuleEvaluatorTest.java | 3 --- 1 file changed, 3 deletions(-) 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 274ede351145..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 @@ -24,7 +24,6 @@ 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; @@ -61,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 = @@ -1152,6 +1150,5 @@ void resetContext() { 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."); } }