Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
mohitjeswani01 marked this conversation as resolved.
@Getter protected final boolean supportsDomains;
protected final boolean supportsDataProducts;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -37,6 +40,7 @@ public class ResourceContext<T extends EntityInterface> implements ResourceConte
private final UUID id;
private final String name;
private T entity; // Will be lazily initialized
private Map<String, Object> cachedCustomProperties;
Comment thread
gitar-bot[bot] marked this conversation as resolved.
private ResourceContextInterface.Operation operation = ResourceContextInterface.Operation.NONE;
private Include include;
private Fields requestedFields;
Expand Down Expand Up @@ -186,6 +190,31 @@ public List<EntityReference> getDomains() {
return entity.getDomains();
}

@Override
public Map<String, Object> getCustomProperties() {
if (cachedCustomProperties != null) {
return cachedCustomProperties;
}
resolveEntity();
if (entity == null || entity.getExtension() == null) {
cachedCustomProperties = Collections.emptyMap();
return cachedCustomProperties;
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> props = JsonUtils.convertValue(entity.getExtension(), Map.class);
Comment on lines +199 to +205
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getCustomProperties() caches Collections.emptyMap() as soon as entity.getExtension() is null. When this ResourceContext is constructed with a pre-loaded entity (constructor (resource, entity, repository)), resolveEntity() will not fetch missing fields, so an entity that supports extensions but was loaded without them will permanently appear to have no custom properties for the rest of the evaluation (because the empty map is cached). Consider lazily fetching the extension when missing (e.g., via entityRepository.getExtension(entity) / a lightweight reload with FIELD_EXTENSION) before caching the empty result, and only cache empty after that attempt.

Suggested change
if (entity == null || entity.getExtension() == null) {
cachedCustomProperties = Collections.emptyMap();
return cachedCustomProperties;
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> props = JsonUtils.convertValue(entity.getExtension(), Map.class);
if (entity == null) {
cachedCustomProperties = Collections.emptyMap();
return cachedCustomProperties;
}
Object extension = entity.getExtension();
if (extension == null) {
try {
extension = entityRepository.getExtension(entity);
} catch (Exception e) {
LOG.warn(
"Failed to fetch custom properties for entity {}: {}",
entity.getId(),
e.getMessage());
}
}
if (extension == null) {
cachedCustomProperties = Collections.emptyMap();
return cachedCustomProperties;
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> props = JsonUtils.convertValue(extension, Map.class);

Copilot uses AI. Check for mistakes.
cachedCustomProperties = props != null ? props : Collections.emptyMap();
return cachedCustomProperties;
} catch (Exception e) {
LOG.warn(
"Failed to deserialize custom properties for entity {}: {}",
entity.getId(),
e.getMessage());
Comment thread
gitar-bot[bot] marked this conversation as resolved.
cachedCustomProperties = Collections.emptyMap();
return cachedCustomProperties;
}
}

Comment thread
mohitjeswani01 marked this conversation as resolved.
private EntityInterface resolveEntity() {
if (entity == null) {
Fields fieldList;
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,4 +27,10 @@ enum Operation {
EntityInterface getEntity();

List<EntityReference> 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<String, Object> getCustomProperties() {
return Collections.emptyMap();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Object> getCustomProperties() {
if (resourceContext == null) {
return Collections.emptyMap();
}
return resourceContext.getCustomProperties();
Comment thread
mohitjeswani01 marked this conversation as resolved.
}
Comment thread
mohitjeswani01 marked this conversation as resolved.

/**
* SpEL helper: returns true if the resource has a custom property with the given name whose value
* equals the given expected value.
*
* <p>Usage in policy rule condition:
*
* <p>matchCustomProperty('dataSensitivity', 'PII') matchCustomProperty('department', 'Finance')
*
* <p>Returns false (not an error) when:
*
* <p>- 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;
Comment thread
mohitjeswani01 marked this conversation as resolved.
}
Map<String, Object> 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()));
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matchCustomProperty(...) can throw an NPE when the custom property value is a list that contains a null element (it does item.toString() inside anyMatch). Since the helper is documented as “handles all null/missing cases gracefully”, guard against null list items (and/or handle NullNode -> null) so the function returns false instead of failing evaluation.

Suggested change
return list.stream().anyMatch(item -> expectedValue.equals(item.toString()));
return list.stream().anyMatch(item -> item != null && expectedValue.equals(item.toString()));

Copilot uses AI. Check for mistakes.
}

return expectedValue.equals(actual.toString());
Comment thread
gitar-bot[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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<String, Object> props =
parseExpression("getCustomProperties()").getValue(evaluationContext, Map.class);
assertNotNull(props);
assertTrue(props.isEmpty());
Comment thread
mohitjeswani01 marked this conversation as resolved.
}

@Test
void test_getCustomProperties_withExtension() {
ObjectNode node = JsonUtils.getObjectMapper().createObjectNode();
node.put("dataSensitivity", "PII");
table.setExtension(node);

@SuppressWarnings("unchecked")
Map<String, Object> 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
Expand Down Expand Up @@ -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.");
}
}
Loading