This is an automated email from the ASF dual-hosted git repository.

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 65b613d061 [#12176] feat(core): add policy-on-tag core support (#12718)
65b613d061 is described below

commit 65b613d0615bc5ef74fb1e5c668e1fd167cd9168
Author: roryqi <[email protected]>
AuthorDate: Wed Sep 2 17:09:22 2026 +0800

    [#12176] feat(core): add policy-on-tag core support (#12718)
    
    ### What changes were proposed in this pull request?
    
    - Add Core operations for listing, adding, and removing policy-to-tag
    associations.
    - Add JSON serialization and deserialization for `ALL_VALUES` and
    `TAG_VALUE` policy association selectors.
    - Add effective tag resolution with nearest-assignment override
    semantics.
    - Add a reusable object policy resolver that evaluates effective tags
    and selectors, filters disabled policies, detects conflicting matches,
    and deduplicates policies.
    - Keep the existing metadata-object policy APIs based on direct policy
    associations. Integration with tag-derived policies is deferred to
    follow-up API work.
    
    ### Why are the changes needed?
    
    These changes provide the Core runtime foundation for the policy-on-tag
    governance model while preserving the behavior of existing
    metadata-object policy APIs until the related public APIs and
    integration are ready.
    
    Fix: #12176
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. The new capabilities are internal Core operations and resolvers.
    Existing metadata-object policy APIs continue to return directly
    associated policies only.
    
    ### How was this patch tested?
    
    - `./gradlew :common:test --tests
    org.apache.gravitino.json.TestPolicyAssociationSelectorSerde`
    - `./gradlew :core:test --tests
    org.apache.gravitino.policy.TestObjectPolicyResolver --tests
    org.apache.gravitino.policy.TestPolicyManager --tests
    org.apache.gravitino.tag.TestEffectiveTagResolver --tests
    org.apache.gravitino.tag.TestTagManager`
    - GitHub Actions PR checks passed.
---
 .../json/PolicyAssociationSelectorSerde.java       | 132 ++++++++++++++
 .../json/TestPolicyAssociationSelectorSerde.java   |  78 +++++++++
 .../gravitino/policy/ObjectPolicyResolver.java     | 177 +++++++++++++++++++
 .../apache/gravitino/policy/PolicyDispatcher.java  |  25 +++
 .../org/apache/gravitino/policy/PolicyManager.java | 124 ++++++++------
 .../apache/gravitino/tag/EffectiveTagResolver.java |  96 +++++++++++
 .../org/apache/gravitino/tag/TagDispatcher.java    |  52 ++++++
 .../java/org/apache/gravitino/tag/TagManager.java  | 134 +++++++++++++--
 .../gravitino/policy/TestObjectPolicyResolver.java | 190 +++++++++++++++++++++
 .../gravitino/tag/TestEffectiveTagResolver.java    | 133 +++++++++++++++
 .../org/apache/gravitino/tag/TestTagManager.java   |  67 ++++++++
 11 files changed, 1140 insertions(+), 68 deletions(-)

diff --git 
a/common/src/main/java/org/apache/gravitino/json/PolicyAssociationSelectorSerde.java
 
b/common/src/main/java/org/apache/gravitino/json/PolicyAssociationSelectorSerde.java
new file mode 100644
index 0000000000..266cdda849
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/json/PolicyAssociationSelectorSerde.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.json;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import java.io.IOException;
+import org.apache.gravitino.policy.AllValuesSelector;
+import org.apache.gravitino.policy.PolicyAssociationSelector;
+import org.apache.gravitino.policy.TagValueSelector;
+
+/** JSON serializer and deserializer for policy-to-tag selectors. */
+public final class PolicyAssociationSelectorSerde {
+
+  private static final String TYPE = "type";
+  private static final String VALUE = "value";
+
+  private static final ObjectMapper MAPPER =
+      JsonUtils.anyFieldMapper()
+          .copy()
+          .registerModule(
+              new SimpleModule()
+                  .addSerializer(PolicyAssociationSelector.class, new 
Serializer())
+                  .addDeserializer(PolicyAssociationSelector.class, new 
Deserializer()));
+
+  private PolicyAssociationSelectorSerde() {}
+
+  /**
+   * Serializes a selector.
+   *
+   * @param selector The selector to serialize.
+   * @return The selector JSON.
+   */
+  public static String serialize(PolicyAssociationSelector selector) {
+    try {
+      return 
MAPPER.writerFor(PolicyAssociationSelector.class).writeValueAsString(selector);
+    } catch (JsonProcessingException e) {
+      throw new IllegalArgumentException("Failed to serialize policy 
association selector", e);
+    }
+  }
+
+  /**
+   * Deserializes a selector.
+   *
+   * @param json The selector JSON.
+   * @return The selector.
+   */
+  public static PolicyAssociationSelector deserialize(String json) {
+    try {
+      return MAPPER.readValue(json, PolicyAssociationSelector.class);
+    } catch (JsonProcessingException e) {
+      throw new IllegalArgumentException("Failed to deserialize policy 
association selector", e);
+    }
+  }
+
+  /** Serializes a policy association selector. */
+  public static final class Serializer extends 
JsonSerializer<PolicyAssociationSelector> {
+
+    @Override
+    public void serialize(
+        PolicyAssociationSelector selector,
+        JsonGenerator generator,
+        SerializerProvider serializerProvider)
+        throws IOException {
+      if (!(selector instanceof AllValuesSelector) && !(selector instanceof 
TagValueSelector)) {
+        throw JsonMappingException.from(
+            generator, "Unsupported policy association selector: " + selector);
+      }
+
+      generator.writeStartObject();
+      generator.writeStringField(TYPE, selector.type());
+      if (selector instanceof TagValueSelector) {
+        generator.writeStringField(VALUE, ((TagValueSelector) 
selector).value());
+      }
+      generator.writeEndObject();
+    }
+  }
+
+  /** Deserializes a policy association selector. */
+  public static final class Deserializer extends 
JsonDeserializer<PolicyAssociationSelector> {
+
+    @Override
+    public PolicyAssociationSelector deserialize(
+        JsonParser parser, DeserializationContext deserializationContext) 
throws IOException {
+      JsonNode node = parser.getCodec().readTree(parser);
+      String type = requiredTextField(parser, node, TYPE);
+      if (AllValuesSelector.TYPE.equals(type)) {
+        return AllValuesSelector.get();
+      }
+      if (TagValueSelector.TYPE.equals(type)) {
+        return TagValueSelector.of(requiredTextField(parser, node, VALUE));
+      }
+      throw JsonMappingException.from(
+          parser, "Unsupported policy association selector type: " + type);
+    }
+
+    private static String requiredTextField(JsonParser parser, JsonNode node, 
String fieldName)
+        throws JsonMappingException {
+      JsonNode field = node == null ? null : node.get(fieldName);
+      if (field == null || field.isNull() || !field.isTextual()) {
+        throw JsonMappingException.from(
+            parser, "Missing or invalid required selector field: " + 
fieldName);
+      }
+      return field.asText();
+    }
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/json/TestPolicyAssociationSelectorSerde.java
 
b/common/src/test/java/org/apache/gravitino/json/TestPolicyAssociationSelectorSerde.java
new file mode 100644
index 0000000000..b071775205
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/json/TestPolicyAssociationSelectorSerde.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.json;
+
+import org.apache.gravitino.policy.AllValuesSelector;
+import org.apache.gravitino.policy.PolicyAssociationSelector;
+import org.apache.gravitino.policy.TagValueSelector;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestPolicyAssociationSelectorSerde {
+
+  @Test
+  void testAllValuesRoundTrip() {
+    String json = 
PolicyAssociationSelectorSerde.serialize(AllValuesSelector.get());
+
+    Assertions.assertEquals("{\"type\":\"ALL_VALUES\"}", json);
+    Assertions.assertSame(
+        AllValuesSelector.get(), 
PolicyAssociationSelectorSerde.deserialize(json));
+  }
+
+  @Test
+  void testTagValueRoundTrip() {
+    PolicyAssociationSelector selector = TagValueSelector.of("finance");
+
+    String json = PolicyAssociationSelectorSerde.serialize(selector);
+
+    Assertions.assertEquals("{\"type\":\"TAG_VALUE\",\"value\":\"finance\"}", 
json);
+    Assertions.assertEquals(selector, 
PolicyAssociationSelectorSerde.deserialize(json));
+  }
+
+  @Test
+  void testRejectUnsupportedSelector() {
+    PolicyAssociationSelector selector = () -> "CUSTOM";
+
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
PolicyAssociationSelectorSerde.serialize(selector));
+  }
+
+  @Test
+  void testRejectUnsupportedType() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> 
PolicyAssociationSelectorSerde.deserialize("{\"type\":\"UNKNOWN\"}"));
+  }
+
+  @Test
+  void testRejectMissingRequiredFields() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
PolicyAssociationSelectorSerde.deserialize("{}"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> PolicyAssociationSelectorSerde.deserialize("{\"type\":null}"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> 
PolicyAssociationSelectorSerde.deserialize("{\"type\":\"TAG_VALUE\"}"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            
PolicyAssociationSelectorSerde.deserialize("{\"type\":\"TAG_VALUE\",\"value\":null}"));
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/policy/ObjectPolicyResolver.java 
b/core/src/main/java/org/apache/gravitino/policy/ObjectPolicyResolver.java
new file mode 100644
index 0000000000..c9b0f4a0fb
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/policy/ObjectPolicyResolver.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.policy;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.RelationalEntity;
+import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.json.PolicyAssociationSelectorSerde;
+import org.apache.gravitino.meta.PolicyEntity;
+import org.apache.gravitino.meta.TagEntity;
+import org.apache.gravitino.tag.EffectiveTagResolver;
+import org.apache.gravitino.tag.TagAssignment;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Resolves policies for a metadata object from its effective tag assignments.
+ *
+ * <p>The resolver evaluates relation selectors, rejects mixed match results 
for the same policy,
+ * filters disabled policies, and deduplicates repeated matches by policy 
entity ID.
+ */
+public class ObjectPolicyResolver {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(ObjectPolicyResolver.class);
+
+  private final EntityStore entityStore;
+  private final EffectiveTagResolver effectiveTagResolver;
+
+  /**
+   * Creates an object policy resolver.
+   *
+   * @param entityStore The entity store used to read policy-to-tag relations.
+   */
+  public ObjectPolicyResolver(EntityStore entityStore) {
+    this(entityStore, new EffectiveTagResolver(entityStore));
+  }
+
+  ObjectPolicyResolver(EntityStore entityStore, EffectiveTagResolver 
effectiveTagResolver) {
+    this.entityStore = entityStore;
+    this.effectiveTagResolver = effectiveTagResolver;
+  }
+
+  /**
+   * Resolves enabled policies for a metadata object.
+   *
+   * @param metalake The metalake name.
+   * @param metadataObject The metadata object.
+   * @return Enabled policies selected by the object's effective tags.
+   */
+  public PolicyEntity[] resolve(String metalake, MetadataObject 
metadataObject) {
+    TagEntity[] effectiveTags = effectiveTagResolver.resolve(metalake, 
metadataObject);
+    if (effectiveTags.length == 0) {
+      return new PolicyEntity[0];
+    }
+
+    Map<String, TagEntity> tagsByName =
+        Arrays.stream(effectiveTags)
+            .collect(
+                Collectors.toMap(
+                    TagEntity::name, tag -> tag, (left, right) -> left, 
LinkedHashMap::new));
+    List<NameIdentifier> tagIdentifiers =
+        tagsByName.keySet().stream()
+            .map(tagName -> NameIdentifierUtil.ofTag(metalake, tagName))
+            .collect(Collectors.toList());
+
+    List<RelationalEntity<?>> relations;
+    try {
+      relations =
+          entityStore
+              .relationOperations()
+              .batchListEntitiesByRelation(
+                  SupportsRelationOperations.Type.POLICY_TAG_REL,
+                  tagIdentifiers,
+                  Entity.EntityType.TAG);
+    } catch (IOException e) {
+      LOG.error("Failed to resolve policies for metadata object {}", 
metadataObject, e);
+      throw new RuntimeException(e);
+    }
+
+    Map<Long, MatchState> matchStates = new LinkedHashMap<>();
+    for (RelationalEntity<?> relation : relations) {
+      TagEntity tag = tagsByName.get(relation.source().name());
+      if (tag == null) {
+        continue;
+      }
+      PolicyEntity policy = (PolicyEntity) relation.targetEntity();
+      PolicyAssociationSelector selector =
+          relation
+              .relationValue()
+              .map(PolicyAssociationSelectorSerde::deserialize)
+              .orElseGet(AllValuesSelector::get);
+      TagAssignment assignment = 
tag.assignment().orElseGet(TagAssignment::noValue);
+      boolean matches = matches(selector, assignment);
+      MatchState state =
+          matchStates.computeIfAbsent(policy.id(), ignored -> new 
MatchState(policy));
+      state.record(matches);
+      if (state.hasConflict()) {
+        throw new IllegalStateException(
+            String.format(
+                "Policy %s has conflicting selector results for metadata 
object %s",
+                policy.name(), metadataObject));
+      }
+    }
+
+    return matchStates.values().stream()
+        .filter(MatchState::matched)
+        .map(MatchState::policy)
+        .filter(PolicyEntity::enabled)
+        .toArray(PolicyEntity[]::new);
+  }
+
+  private static boolean matches(PolicyAssociationSelector selector, 
TagAssignment assignment) {
+    if (selector instanceof AllValuesSelector) {
+      return true;
+    }
+    if (selector instanceof TagValueSelector) {
+      String expectedValue = ((TagValueSelector) selector).value();
+      return Arrays.asList(assignment.values()).contains(expectedValue);
+    }
+    throw new IllegalArgumentException(
+        "Unsupported policy association selector type: " + selector.type());
+  }
+
+  private static final class MatchState {
+
+    private final PolicyEntity policy;
+    private boolean matched;
+    private boolean unmatched;
+
+    private MatchState(PolicyEntity policy) {
+      this.policy = policy;
+    }
+
+    private void record(boolean matches) {
+      matched |= matches;
+      unmatched |= !matches;
+    }
+
+    private boolean hasConflict() {
+      return matched && unmatched;
+    }
+
+    private boolean matched() {
+      return matched;
+    }
+
+    private PolicyEntity policy() {
+      return policy;
+    }
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/policy/PolicyDispatcher.java 
b/core/src/main/java/org/apache/gravitino/policy/PolicyDispatcher.java
index 9ac1dac7d1..fd4f4b9d6a 100644
--- a/core/src/main/java/org/apache/gravitino/policy/PolicyDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/policy/PolicyDispatcher.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.policy;
 
 import java.util.Arrays;
 import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.annotation.Evolving;
 import org.apache.gravitino.exceptions.NoSuchPolicyException;
 import org.apache.gravitino.exceptions.PolicyAlreadyExistsException;
@@ -126,6 +127,30 @@ public interface PolicyDispatcher {
    */
   MetadataObject[] listMetadataObjectsForPolicy(String metalake, String 
policyName);
 
+  /**
+   * List tag names directly associated with the specified policy.
+   *
+   * @param metalake The name of the metalake.
+   * @param policyName The name of the policy.
+   * @return The directly associated tag names.
+   */
+  default String[] listTagsForPolicy(String metalake, String policyName) {
+    return Arrays.stream(listTagAssociationsForPolicy(metalake, policyName))
+        .map(association -> association.targetEntity().name())
+        .toArray(String[]::new);
+  }
+
+  /**
+   * List tag associations, including selectors, for the specified policy.
+   *
+   * @param metalake The name of the metalake.
+   * @param policyName The name of the policy.
+   * @return The policy-to-tag associations.
+   */
+  default RelationalEntity<?>[] listTagAssociationsForPolicy(String metalake, 
String policyName) {
+    throw new UnsupportedOperationException("Listing tag associations is not 
supported");
+  }
+
   /**
    * List all the policy names associated with a metadata object under a 
metalake.
    *
diff --git a/core/src/main/java/org/apache/gravitino/policy/PolicyManager.java 
b/core/src/main/java/org/apache/gravitino/policy/PolicyManager.java
index 2367a4a2ef..ce91fe393e 100644
--- a/core/src/main/java/org/apache/gravitino/policy/PolicyManager.java
+++ b/core/src/main/java/org/apache/gravitino/policy/PolicyManager.java
@@ -26,6 +26,7 @@ import com.google.common.collect.Sets;
 import java.io.IOException;
 import java.time.Instant;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 import org.apache.gravitino.Entity;
@@ -33,6 +34,7 @@ import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.SupportsRelationOperations;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
@@ -261,41 +263,44 @@ public class PolicyManager implements PolicyDispatcher {
   }
 
   @Override
-  public PolicyEntity[] listPolicyInfosForMetadataObject(
-      String metalake, MetadataObject metadataObject) {
-    NameIdentifier entityIdent = MetadataObjectUtil.toEntityIdent(metalake, 
metadataObject);
-    Entity.EntityType entityType = 
MetadataObjectUtil.toEntityType(metadataObject);
-
-    MetadataObjectUtil.checkMetadataObject(metalake, metadataObject);
+  public RelationalEntity<?>[] listTagAssociationsForPolicy(String metalake, 
String policyName) {
+    NameIdentifier policyIdentifier = NameIdentifierUtil.ofPolicy(metalake, 
policyName);
     checkMetalake(NameIdentifier.of(metalake), entityStore);
-
     return TreeLockUtils.doWithTreeLock(
-        entityIdent,
+        policyIdentifier,
         LockType.READ,
         () -> {
+          getPolicyWithoutLock(metalake, policyName);
           try {
             return entityStore
                 .relationOperations()
-                .listEntitiesByRelation(
-                    SupportsRelationOperations.Type.POLICY_METADATA_OBJECT_REL,
-                    entityIdent,
-                    entityType,
-                    true /* allFields */)
-                .stream()
-                .map(entity -> (PolicyEntity) entity)
-                .toArray(PolicyEntity[]::new);
-          } catch (NoSuchEntityException e) {
-            throw new NoSuchMetadataObjectException(
-                e,
-                "Failed to list policies for metadata object %s due to not 
found",
-                metadataObject);
+                .batchListEntitiesByRelation(
+                    SupportsRelationOperations.Type.POLICY_TAG_REL,
+                    Collections.singletonList(policyIdentifier),
+                    Entity.EntityType.POLICY)
+                .toArray(new RelationalEntity<?>[0]);
           } catch (IOException e) {
-            LOG.error("Failed to list policies for metadata object {}", 
metadataObject, e);
+            LOG.error(
+                "Failed to list tag associations for policy {} under metalake 
{}",
+                policyName,
+                metalake,
+                e);
             throw new RuntimeException(e);
           }
         });
   }
 
+  @Override
+  public PolicyEntity[] listPolicyInfosForMetadataObject(
+      String metalake, MetadataObject metadataObject) {
+    NameIdentifier entityIdent = MetadataObjectUtil.toEntityIdent(metalake, 
metadataObject);
+    Entity.EntityType entityType = 
MetadataObjectUtil.toEntityType(metadataObject);
+    MetadataObjectUtil.checkMetadataObject(metalake, metadataObject);
+    checkMetalake(NameIdentifier.of(metalake), entityStore);
+
+    return listDirectPoliciesForMetadataObject(entityIdent, entityType, 
metadataObject);
+  }
+
   @Override
   public String[] associatePoliciesForMetadataObject(
       String metalake,
@@ -373,13 +378,23 @@ public class PolicyManager implements PolicyDispatcher {
   @Override
   public PolicyEntity getPolicyForMetadataObject(
       String metalake, MetadataObject metadataObject, String policyName) {
-    NameIdentifier entityIdent = MetadataObjectUtil.toEntityIdent(metalake, 
metadataObject);
-    Entity.EntityType entityType = 
MetadataObjectUtil.toEntityType(metadataObject);
-    NameIdentifier policyIdent = NameIdentifierUtil.ofPolicy(metalake, 
policyName);
-
-    MetadataObjectUtil.checkMetadataObject(metalake, metadataObject);
-    checkMetalake(NameIdentifier.of(metalake), entityStore);
+    try {
+      return Arrays.stream(listPolicyInfosForMetadataObject(metalake, 
metadataObject))
+          .filter(policy -> policy.name().equals(policyName))
+          .findFirst()
+          .orElseThrow(
+              () ->
+                  new NoSuchPolicyException(
+                      "Policy %s does not exist for metadata object %s",
+                      policyName, metadataObject));
+    } catch (NoSuchMetadataObjectException e) {
+      throw new NoSuchMetadataObjectException(
+          e, "Failed to get policy for metadata object %s due to not found", 
metadataObject);
+    }
+  }
 
+  private PolicyEntity[] listDirectPoliciesForMetadataObject(
+      NameIdentifier entityIdent, Entity.EntityType entityType, MetadataObject 
metadataObject) {
     return TreeLockUtils.doWithTreeLock(
         entityIdent,
         LockType.READ,
@@ -387,30 +402,40 @@ public class PolicyManager implements PolicyDispatcher {
           try {
             return entityStore
                 .relationOperations()
-                .getEntityByRelation(
+                .listEntitiesByRelation(
                     SupportsRelationOperations.Type.POLICY_METADATA_OBJECT_REL,
                     entityIdent,
                     entityType,
-                    policyIdent);
+                    true /* allFields */)
+                .stream()
+                .map(entity -> (PolicyEntity) entity)
+                .toArray(PolicyEntity[]::new);
           } catch (NoSuchEntityException e) {
-            // The store reports a missing policy and a missing metadata 
object with the same
-            // exception type, so the message is the only thing that tells 
them apart.
-            if (isMissingEntity(e, Entity.EntityType.POLICY, policyName)) {
-              throw new NoSuchPolicyException(
-                  e, "Policy %s does not exist for metadata object %s", 
policyName, metadataObject);
-            } else {
-              throw new NoSuchMetadataObjectException(
-                  e,
-                  "Failed to get policy for metadata object %s due to not 
found",
-                  metadataObject);
-            }
+            throw new NoSuchMetadataObjectException(
+                e,
+                "Failed to list policies for metadata object %s due to not 
found",
+                metadataObject);
           } catch (IOException e) {
-            LOG.error("Failed to get policy for metadata object {}", 
metadataObject, e);
+            LOG.error("Failed to list policies for metadata object {}", 
metadataObject, e);
             throw new RuntimeException(e);
           }
         });
   }
 
+  private PolicyEntity getPolicyWithoutLock(String metalake, String 
policyName) {
+    try {
+      return entityStore.get(
+          NameIdentifierUtil.ofPolicy(metalake, policyName),
+          Entity.EntityType.POLICY,
+          PolicyEntity.class);
+    } catch (NoSuchEntityException e) {
+      throw new NoSuchPolicyException(
+          e, "Policy with name %s under metalake %s does not exist", 
policyName, metalake);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
   private void changePolicyEnabledState(
       String metalake, String policyName, boolean expectedEnabledState) {
     NameIdentifier metalakeIdent = NameIdentifierUtil.ofMetalake(metalake);
@@ -478,6 +503,7 @@ public class PolicyManager implements PolicyDispatcher {
     }
   }
 
+  @SuppressWarnings("deprecation")
   private PolicyEntity updatePolicyEntity(PolicyEntity policyEntity, 
PolicyChange... changes) {
     String newName = policyEntity.name();
     String newComment = policyEntity.comment();
@@ -521,18 +547,4 @@ public class PolicyManager implements PolicyDispatcher {
 
     return builder.build();
   }
-
-  /**
-   * Tells a "the related entity does not exist" failure apart from a "the 
metadata object does not
-   * exist" one. The store signals both with {@link NoSuchEntityException}, so 
the message built by
-   * the relational services is the only discriminator; it is rebuilt here 
from the same constant
-   * and the same lowercasing behavior they use.
-   */
-  private static boolean isMissingEntity(
-      NoSuchEntityException e, Entity.EntityType type, String name) {
-    String expected =
-        String.format(
-            NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE, 
type.name().toLowerCase(), name);
-    return expected.equals(e.getMessage());
-  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/tag/EffectiveTagResolver.java 
b/core/src/main/java/org/apache/gravitino/tag/EffectiveTagResolver.java
new file mode 100644
index 0000000000..60b106cfe1
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/tag/EffectiveTagResolver.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.tag;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
+import org.apache.gravitino.meta.TagEntity;
+import org.apache.gravitino.utils.MetadataObjectUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Resolves effective tags for a metadata object using direct-assignment 
override semantics.
+ *
+ * <p>The requested object is evaluated first, followed by its ancestors from 
nearest to outermost.
+ * The first assignment for a tag name wins, so a direct or nearer assignment 
overrides a more
+ * distant inherited assignment, including its assignment values.
+ */
+public class EffectiveTagResolver {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(EffectiveTagResolver.class);
+
+  private final EntityStore entityStore;
+
+  /**
+   * Creates an effective tag resolver.
+   *
+   * @param entityStore The entity store used to read tag relations.
+   */
+  public EffectiveTagResolver(EntityStore entityStore) {
+    this.entityStore = entityStore;
+  }
+
+  /**
+   * Resolves effective tags for a metadata object.
+   *
+   * @param metalake The metalake name.
+   * @param metadataObject The metadata object.
+   * @return Effective tags in deterministic nearest-assignment order.
+   */
+  public TagEntity[] resolve(String metalake, MetadataObject metadataObject) {
+    MetadataObjectUtil.checkMetadataObject(metalake, metadataObject);
+    List<MetadataObject> resolutionOrder = new ArrayList<>();
+    resolutionOrder.add(metadataObject);
+    
resolutionOrder.addAll(MetadataObjectUtil.getParentMetadataObjects(metadataObject));
+
+    Map<String, TagEntity> effectiveTags = new LinkedHashMap<>();
+    for (MetadataObject object : resolutionOrder) {
+      NameIdentifier identifier = MetadataObjectUtil.toEntityIdent(metalake, 
object);
+      Entity.EntityType entityType = MetadataObjectUtil.toEntityType(object);
+      try {
+        List<TagEntity> tags =
+            entityStore
+                .relationOperations()
+                .listEntitiesByRelation(
+                    SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+                    identifier,
+                    entityType);
+        tags.forEach(tag -> effectiveTags.putIfAbsent(tag.name(), tag));
+      } catch (NoSuchEntityException e) {
+        throw new NoSuchMetadataObjectException(
+            e, "Failed to resolve effective tags for metadata object %s due to 
not found", object);
+      } catch (IOException e) {
+        LOG.error("Failed to resolve effective tags for metadata object {}", 
object, e);
+        throw new RuntimeException(e);
+      }
+    }
+    return effectiveTags.values().toArray(new TagEntity[0]);
+  }
+}
diff --git a/core/src/main/java/org/apache/gravitino/tag/TagDispatcher.java 
b/core/src/main/java/org/apache/gravitino/tag/TagDispatcher.java
index 2a2dbbd7f0..e354254eca 100644
--- a/core/src/main/java/org/apache/gravitino/tag/TagDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/tag/TagDispatcher.java
@@ -22,8 +22,10 @@ import java.util.Arrays;
 import java.util.Map;
 import javax.annotation.Nullable;
 import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.exceptions.NoSuchTagException;
 import org.apache.gravitino.exceptions.TagAlreadyExistsException;
+import org.apache.gravitino.policy.PolicyAssociationSelector;
 
 /**
  * {@code TagDispatcher} interface provides functionalities for managing tags 
within a metalake. It
@@ -117,6 +119,56 @@ public interface TagDispatcher {
    */
   MetadataObject[] listMetadataObjectsForTag(String metalake, String name);
 
+  /**
+   * List policy names directly associated with the specified tag.
+   *
+   * @param metalake The name of the metalake.
+   * @param name The name of the tag.
+   * @return The directly associated policy names.
+   */
+  default String[] listPoliciesForTag(String metalake, String name) {
+    return Arrays.stream(listPolicyAssociationsForTag(metalake, name))
+        .map(association -> association.targetEntity().name())
+        .toArray(String[]::new);
+  }
+
+  /**
+   * List policy associations, including selectors, for the specified tag.
+   *
+   * @param metalake The name of the metalake.
+   * @param name The name of the tag.
+   * @return The policy-to-tag associations.
+   */
+  default RelationalEntity<?>[] listPolicyAssociationsForTag(String metalake, 
String name) {
+    throw new UnsupportedOperationException("Listing policy associations is 
not supported");
+  }
+
+  /**
+   * Add one policy association for a tag.
+   *
+   * @param metalake The name of the metalake.
+   * @param tagName The name of the tag.
+   * @param policyName The name of the policy.
+   * @param selector The non-null policy association selector.
+   */
+  default void addPolicyForTag(
+      String metalake, String tagName, String policyName, 
PolicyAssociationSelector selector) {
+    throw new UnsupportedOperationException("Adding a policy for a tag is not 
supported");
+  }
+
+  /**
+   * Remove one policy association from a tag.
+   *
+   * <p>Removing a missing association is an idempotent no-op. The policy and 
tag must still exist.
+   *
+   * @param metalake The name of the metalake.
+   * @param tagName The name of the tag.
+   * @param policyName The name of the policy.
+   */
+  default void removePolicyFromTag(String metalake, String tagName, String 
policyName) {
+    throw new UnsupportedOperationException("Removing a policy from a tag is 
not supported");
+  }
+
   /**
    * List all metadata objects associated with the specified tag and exact 
assignment value.
    *
diff --git a/core/src/main/java/org/apache/gravitino/tag/TagManager.java 
b/core/src/main/java/org/apache/gravitino/tag/TagManager.java
index 9e2cf23b83..27ccd5a518 100644
--- a/core/src/main/java/org/apache/gravitino/tag/TagManager.java
+++ b/core/src/main/java/org/apache/gravitino/tag/TagManager.java
@@ -40,18 +40,22 @@ import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.RelationEdgeTarget;
 import org.apache.gravitino.RelationQuery;
 import org.apache.gravitino.RelationUpdate;
+import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.SupportsRelationOperations;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
 import org.apache.gravitino.exceptions.NoSuchTagException;
 import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.exceptions.PolicyAlreadyAssociatedException;
 import org.apache.gravitino.exceptions.TagAlreadyAssociatedException;
 import org.apache.gravitino.exceptions.TagAlreadyExistsException;
+import org.apache.gravitino.json.PolicyAssociationSelectorSerde;
 import org.apache.gravitino.lock.LockType;
 import org.apache.gravitino.lock.TreeLockUtils;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.GenericEntity;
 import org.apache.gravitino.meta.TagEntity;
+import org.apache.gravitino.policy.PolicyAssociationSelector;
 import org.apache.gravitino.storage.IdGenerator;
 import org.apache.gravitino.storage.relational.service.MetadataObjectService;
 import org.apache.gravitino.utils.MetadataObjectUtil;
@@ -160,18 +164,7 @@ public class TagManager implements TagDispatcher {
     return TreeLockUtils.doWithTreeLock(
         NameIdentifierUtil.ofTag(metalake, name),
         LockType.READ,
-        () -> {
-          try {
-            return entityStore.get(
-                NameIdentifierUtil.ofTag(metalake, name), 
Entity.EntityType.TAG, TagEntity.class);
-          } catch (NoSuchEntityException e) {
-            throw new NoSuchTagException(
-                "Tag with name %s under metalake %s does not exist", name, 
metalake);
-          } catch (IOException ioe) {
-            LOG.error("Failed to get tag {} under metalake {}", name, 
metalake, ioe);
-            throw new RuntimeException(ioe);
-          }
-        });
+        () -> getTagWithoutLock(metalake, name));
   }
 
   public Tag alterTag(String metalake, String name, TagChange... changes)
@@ -227,6 +220,110 @@ public class TagManager implements TagDispatcher {
     return listMetadataObjectsForTag(metalake, name, null);
   }
 
+  @Override
+  public RelationalEntity<?>[] listPolicyAssociationsForTag(String metalake, 
String name) {
+    NameIdentifier tagIdentifier = NameIdentifierUtil.ofTag(metalake, name);
+    checkMetalake(NameIdentifier.of(metalake), entityStore);
+    return TreeLockUtils.doWithTreeLock(
+        tagIdentifier,
+        LockType.READ,
+        () -> {
+          getTagWithoutLock(metalake, name);
+          try {
+            return entityStore
+                .relationOperations()
+                .batchListEntitiesByRelation(
+                    SupportsRelationOperations.Type.POLICY_TAG_REL,
+                    Collections.singletonList(tagIdentifier),
+                    Entity.EntityType.TAG)
+                .toArray(new RelationalEntity<?>[0]);
+          } catch (IOException e) {
+            LOG.error(
+                "Failed to list policy associations for tag {} under metalake 
{}",
+                name,
+                metalake,
+                e);
+            throw new RuntimeException(e);
+          }
+        });
+  }
+
+  @Override
+  public void addPolicyForTag(
+      String metalake, String tagName, String policyName, 
PolicyAssociationSelector selector) {
+    NameIdentifier tagIdentifier = NameIdentifierUtil.ofTag(metalake, tagName);
+    NameIdentifier policyIdentifier = NameIdentifierUtil.ofPolicy(metalake, 
policyName);
+    checkMetalake(NameIdentifier.of(metalake), entityStore);
+    TreeLockUtils.doWithTreeLock(
+        tagIdentifier,
+        LockType.WRITE,
+        () -> {
+          RelationUpdate update =
+              RelationUpdate.of(
+                  SupportsRelationOperations.Type.POLICY_TAG_REL,
+                  tagIdentifier,
+                  Entity.EntityType.TAG,
+                  new RelationEdgeTarget[] {
+                    RelationEdgeTarget.of(
+                        policyIdentifier,
+                        Entity.EntityType.POLICY,
+                        PolicyAssociationSelectorSerde.serialize(selector))
+                  },
+                  new RelationEdgeTarget[0]);
+          try {
+            return 
entityStore.relationOperations().updateEntityRelations(update);
+          } catch (EntityAlreadyExistsException e) {
+            throw new PolicyAlreadyAssociatedException(
+                e,
+                "Policy %s is already associated with tag %s under metalake 
%s",
+                policyName,
+                tagName,
+                metalake);
+          } catch (IOException e) {
+            LOG.error(
+                "Failed to add policy {} for tag {} under metalake {}",
+                policyName,
+                tagName,
+                metalake,
+                e);
+            throw new RuntimeException(e);
+          }
+        });
+  }
+
+  @Override
+  public void removePolicyFromTag(String metalake, String tagName, String 
policyName) {
+    NameIdentifier tagIdentifier = NameIdentifierUtil.ofTag(metalake, tagName);
+    NameIdentifier policyIdentifier = NameIdentifierUtil.ofPolicy(metalake, 
policyName);
+    checkMetalake(NameIdentifier.of(metalake), entityStore);
+    TreeLockUtils.doWithTreeLock(
+        tagIdentifier,
+        LockType.WRITE,
+        () -> {
+          RelationUpdate update =
+              RelationUpdate.of(
+                  SupportsRelationOperations.Type.POLICY_TAG_REL,
+                  tagIdentifier,
+                  Entity.EntityType.TAG,
+                  new RelationEdgeTarget[0],
+                  new RelationEdgeTarget[] {
+                    RelationEdgeTarget.of(policyIdentifier, 
Entity.EntityType.POLICY, null)
+                  });
+          try {
+            entityStore.relationOperations().updateEntityRelations(update);
+          } catch (IOException e) {
+            LOG.error(
+                "Failed to remove policy {} from tag {} under metalake {}",
+                policyName,
+                tagName,
+                metalake,
+                e);
+            throw new RuntimeException(e);
+          }
+          return null;
+        });
+  }
+
   @Override
   public MetadataObject[] listMetadataObjectsForTag(
       String metalake, String name, @Nullable String value) throws 
NoSuchTagException {
@@ -555,6 +652,19 @@ public class TagManager implements TagDispatcher {
         .build();
   }
 
+  private TagEntity getTagWithoutLock(String metalake, String name) {
+    try {
+      return entityStore.get(
+          NameIdentifierUtil.ofTag(metalake, name), Entity.EntityType.TAG, 
TagEntity.class);
+    } catch (NoSuchEntityException e) {
+      throw new NoSuchTagException(
+          e, "Tag with name %s under metalake %s does not exist", name, 
metalake);
+    } catch (IOException e) {
+      LOG.error("Failed to get tag {} under metalake {}", name, metalake, e);
+      throw new RuntimeException(e);
+    }
+  }
+
   /**
    * Tells a "the related entity does not exist" failure apart from a "the 
metadata object does not
    * exist" one. The store signals both with {@link NoSuchEntityException}, so 
the message built by
diff --git 
a/core/src/test/java/org/apache/gravitino/policy/TestObjectPolicyResolver.java 
b/core/src/test/java/org/apache/gravitino/policy/TestObjectPolicyResolver.java
new file mode 100644
index 0000000000..f418887299
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/policy/TestObjectPolicyResolver.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.policy;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.RelationalEntity;
+import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.json.PolicyAssociationSelectorSerde;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.PolicyEntity;
+import org.apache.gravitino.meta.TagEntity;
+import org.apache.gravitino.tag.EffectiveTagResolver;
+import org.apache.gravitino.tag.TagAssignment;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TestObjectPolicyResolver {
+
+  private static final String METALAKE = "metalake";
+  private static final MetadataObject OBJECT =
+      MetadataObjects.of(Arrays.asList("catalog", "schema", "table"), 
MetadataObject.Type.TABLE);
+
+  private EntityStore entityStore;
+  private SupportsRelationOperations relationOperations;
+  private EffectiveTagResolver effectiveTagResolver;
+  private ObjectPolicyResolver resolver;
+
+  @BeforeEach
+  public void setUp() {
+    entityStore = mock(EntityStore.class);
+    relationOperations = mock(SupportsRelationOperations.class);
+    effectiveTagResolver = mock(EffectiveTagResolver.class);
+    when(entityStore.relationOperations()).thenReturn(relationOperations);
+    resolver = new ObjectPolicyResolver(entityStore, effectiveTagResolver);
+  }
+
+  @Test
+  public void testResolveByPresenceAndValueSelector() throws Exception {
+    TagEntity domain = tag(1L, "domain", TagAssignment.ofValues("finance"));
+    TagEntity classified = tag(2L, "classified", TagAssignment.noValue());
+    PolicyEntity selected = policy(10L, "selected", true);
+    PolicyEntity disabled = policy(11L, "disabled", false);
+    when(effectiveTagResolver.resolve(METALAKE, OBJECT))
+        .thenReturn(new TagEntity[] {domain, classified});
+    when(relationOperations.batchListEntitiesByRelation(
+            SupportsRelationOperations.Type.POLICY_TAG_REL,
+            Arrays.asList(
+                NameIdentifierUtil.ofTag(METALAKE, "domain"),
+                NameIdentifierUtil.ofTag(METALAKE, "classified")),
+            Entity.EntityType.TAG))
+        .thenReturn(
+            Arrays.asList(
+                relation(domain, selected, TagValueSelector.of("finance")),
+                relation(classified, selected, AllValuesSelector.get()),
+                relation(classified, disabled, AllValuesSelector.get())));
+
+    PolicyEntity[] policies = resolver.resolve(METALAKE, OBJECT);
+
+    Assertions.assertArrayEquals(new PolicyEntity[] {selected}, policies);
+  }
+
+  @Test
+  public void testResolveMissingSelectorAsAllValues() throws Exception {
+    TagEntity domain = tag(1L, "domain", TagAssignment.ofValues("finance"));
+    PolicyEntity policy = policy(10L, "policy", true);
+    when(effectiveTagResolver.resolve(METALAKE, OBJECT)).thenReturn(new 
TagEntity[] {domain});
+    when(relationOperations.batchListEntitiesByRelation(
+            SupportsRelationOperations.Type.POLICY_TAG_REL,
+            Collections.singletonList(NameIdentifierUtil.ofTag(METALAKE, 
"domain")),
+            Entity.EntityType.TAG))
+        .thenReturn(
+            Collections.singletonList(
+                new RelationalEntity<>(
+                    SupportsRelationOperations.Type.POLICY_TAG_REL,
+                    domain.nameIdentifier(),
+                    Entity.EntityType.TAG,
+                    policy,
+                    null)));
+
+    Assertions.assertArrayEquals(new PolicyEntity[] {policy}, 
resolver.resolve(METALAKE, OBJECT));
+  }
+
+  @Test
+  public void testDropNonMatchingSelector() throws Exception {
+    TagEntity domain = tag(1L, "domain", 
TagAssignment.ofValues("engineering"));
+    PolicyEntity policy = policy(10L, "policy", true);
+    when(effectiveTagResolver.resolve(METALAKE, OBJECT)).thenReturn(new 
TagEntity[] {domain});
+    when(relationOperations.batchListEntitiesByRelation(
+            SupportsRelationOperations.Type.POLICY_TAG_REL,
+            Collections.singletonList(NameIdentifierUtil.ofTag(METALAKE, 
"domain")),
+            Entity.EntityType.TAG))
+        .thenReturn(
+            Collections.singletonList(relation(domain, policy, 
TagValueSelector.of("finance"))));
+
+    Assertions.assertEquals(0, resolver.resolve(METALAKE, OBJECT).length);
+  }
+
+  @Test
+  public void testRejectMixedSelectorResults() throws Exception {
+    TagEntity domain = tag(1L, "domain", TagAssignment.ofValues("finance"));
+    TagEntity classified = tag(2L, "classified", 
TagAssignment.ofValues("public"));
+    PolicyEntity policy = policy(10L, "policy", true);
+    when(effectiveTagResolver.resolve(METALAKE, OBJECT))
+        .thenReturn(new TagEntity[] {domain, classified});
+    when(relationOperations.batchListEntitiesByRelation(
+            SupportsRelationOperations.Type.POLICY_TAG_REL,
+            Arrays.asList(
+                NameIdentifierUtil.ofTag(METALAKE, "domain"),
+                NameIdentifierUtil.ofTag(METALAKE, "classified")),
+            Entity.EntityType.TAG))
+        .thenReturn(
+            Arrays.asList(
+                relation(domain, policy, TagValueSelector.of("finance")),
+                relation(classified, policy, TagValueSelector.of("pii"))));
+
+    IllegalStateException exception =
+        Assertions.assertThrows(
+            IllegalStateException.class, () -> resolver.resolve(METALAKE, 
OBJECT));
+    Assertions.assertTrue(exception.getMessage().contains("conflicting 
selector results"));
+  }
+
+  private static RelationalEntity<PolicyEntity> relation(
+      TagEntity tag, PolicyEntity policy, PolicyAssociationSelector selector) {
+    return new RelationalEntity<>(
+        SupportsRelationOperations.Type.POLICY_TAG_REL,
+        tag.nameIdentifier(),
+        Entity.EntityType.TAG,
+        policy,
+        PolicyAssociationSelectorSerde.serialize(selector));
+  }
+
+  private static TagEntity tag(long id, String name, TagAssignment assignment) 
{
+    return TagEntity.builder()
+        .withId(id)
+        .withName(name)
+        .withNamespace(NamespaceUtil.ofTag(METALAKE))
+        .withProperties(Collections.emptyMap())
+        .withAuditInfo(audit())
+        .build()
+        .copyWithAssignment(assignment);
+  }
+
+  private static PolicyEntity policy(long id, String name, boolean enabled) {
+    return PolicyEntity.builder()
+        .withId(id)
+        .withName(name)
+        .withNamespace(NamespaceUtil.ofPolicy(METALAKE))
+        .withPolicyType(Policy.BuiltInType.CUSTOM)
+        .withEnabled(enabled)
+        .withContent(
+            PolicyContents.custom(
+                ImmutableMap.of("rule", "value"), 
ImmutableSet.of(MetadataObject.Type.TABLE), null))
+        .withAuditInfo(audit())
+        .build();
+  }
+
+  private static AuditInfo audit() {
+    return 
AuditInfo.builder().withCreator("tester").withCreateTime(Instant.now()).build();
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/tag/TestEffectiveTagResolver.java 
b/core/src/test/java/org/apache/gravitino/tag/TestEffectiveTagResolver.java
new file mode 100644
index 0000000000..b4d3ba7cc6
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/tag/TestEffectiveTagResolver.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.tag;
+
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.TagEntity;
+import org.apache.gravitino.utils.MetadataObjectUtil;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+public class TestEffectiveTagResolver {
+  private static final String METALAKE = "metalake";
+  private static final MetadataObject TABLE =
+      MetadataObjects.of(Arrays.asList("catalog", "schema", "table"), 
MetadataObject.Type.TABLE);
+  private static final MetadataObject SCHEMA =
+      MetadataObjects.of("catalog", "schema", MetadataObject.Type.SCHEMA);
+  private static final MetadataObject CATALOG =
+      MetadataObjects.of(null, "catalog", MetadataObject.Type.CATALOG);
+
+  private SupportsRelationOperations relationOperations;
+  private EffectiveTagResolver resolver;
+  private MockedStatic<MetadataObjectUtil> metadataObjectUtil;
+
+  @BeforeEach
+  void setUp() {
+    EntityStore entityStore = mock(EntityStore.class);
+    relationOperations = mock(SupportsRelationOperations.class);
+    when(entityStore.relationOperations()).thenReturn(relationOperations);
+    resolver = new EffectiveTagResolver(entityStore);
+    metadataObjectUtil = mockStatic(MetadataObjectUtil.class, 
CALLS_REAL_METHODS);
+    metadataObjectUtil
+        .when(() -> MetadataObjectUtil.checkMetadataObject(METALAKE, TABLE))
+        .thenAnswer(invocation -> null);
+  }
+
+  @AfterEach
+  void tearDown() {
+    metadataObjectUtil.close();
+  }
+
+  @Test
+  void testNearestAssignmentOverridesAncestorAndOrderIsDeterministic() throws 
Exception {
+    TagEntity directDomain = tag(1L, "domain", 
TagAssignment.ofValues("finance"));
+    TagEntity schemaDomain = tag(2L, "domain", TagAssignment.ofValues("risk"));
+    TagEntity schemaClassification =
+        tag(3L, "classification", TagAssignment.ofValues("confidential"));
+    TagEntity catalogOwner = tag(4L, "owner", 
TagAssignment.ofValues("data-platform"));
+    when(relationOperations.listEntitiesByRelation(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            MetadataObjectUtil.toEntityIdent(METALAKE, TABLE),
+            Entity.EntityType.TABLE))
+        .thenReturn(Collections.singletonList(directDomain));
+    when(relationOperations.listEntitiesByRelation(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            MetadataObjectUtil.toEntityIdent(METALAKE, SCHEMA),
+            Entity.EntityType.SCHEMA))
+        .thenReturn(Arrays.asList(schemaDomain, schemaClassification));
+    when(relationOperations.listEntitiesByRelation(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            MetadataObjectUtil.toEntityIdent(METALAKE, CATALOG),
+            Entity.EntityType.CATALOG))
+        .thenReturn(Collections.singletonList(catalogOwner));
+
+    TagEntity[] effectiveTags = resolver.resolve(METALAKE, TABLE);
+
+    Assertions.assertArrayEquals(
+        new TagEntity[] {directDomain, schemaClassification, catalogOwner}, 
effectiveTags);
+    Assertions.assertArrayEquals(
+        new String[] {"finance"}, 
effectiveTags[0].assignment().orElseThrow().values());
+  }
+
+  @Test
+  void testMissingObjectIsTranslated() throws Exception {
+    NoSuchEntityException exception = new NoSuchEntityException("missing");
+    when(relationOperations.listEntitiesByRelation(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            MetadataObjectUtil.toEntityIdent(METALAKE, TABLE),
+            Entity.EntityType.TABLE))
+        .thenThrow(exception);
+
+    NoSuchMetadataObjectException actual =
+        Assertions.assertThrows(
+            NoSuchMetadataObjectException.class, () -> 
resolver.resolve(METALAKE, TABLE));
+    Assertions.assertSame(exception, actual.getCause());
+  }
+
+  private static TagEntity tag(long id, String name, TagAssignment assignment) 
{
+    return TagEntity.builder()
+        .withId(id)
+        .withName(name)
+        .withNamespace(NamespaceUtil.ofTag(METALAKE))
+        .withProperties(Collections.emptyMap())
+        .withAuditInfo(
+            
AuditInfo.builder().withCreator("tester").withCreateTime(Instant.now()).build())
+        .build()
+        .copyWithAssignment(assignment);
+  }
+}
diff --git a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java 
b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
index e19008836f..7dfeb6029a 100644
--- a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
+++ b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
@@ -61,6 +61,7 @@ import org.apache.gravitino.EntityStoreFactory;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.Namespace;
+import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.catalog.CatalogDispatcher;
 import org.apache.gravitino.catalog.FunctionDispatcher;
 import org.apache.gravitino.catalog.SchemaDispatcher;
@@ -69,6 +70,7 @@ import org.apache.gravitino.catalog.ViewDispatcher;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
 import org.apache.gravitino.exceptions.NoSuchTagException;
 import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.exceptions.PolicyAlreadyAssociatedException;
 import org.apache.gravitino.exceptions.TagAlreadyAssociatedException;
 import org.apache.gravitino.exceptions.TagAlreadyExistsException;
 import org.apache.gravitino.function.FunctionDefinition;
@@ -78,17 +80,23 @@ import org.apache.gravitino.function.FunctionImpls;
 import org.apache.gravitino.function.FunctionParam;
 import org.apache.gravitino.function.FunctionParams;
 import org.apache.gravitino.function.FunctionType;
+import org.apache.gravitino.json.PolicyAssociationSelectorSerde;
 import org.apache.gravitino.lock.LockManager;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.CatalogEntity;
 import org.apache.gravitino.meta.ColumnEntity;
 import org.apache.gravitino.meta.FunctionEntity;
+import org.apache.gravitino.meta.PolicyEntity;
 import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.SchemaVersion;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.meta.ViewEntity;
 import org.apache.gravitino.metalake.MetalakeDispatcher;
+import org.apache.gravitino.policy.AllValuesSelector;
+import org.apache.gravitino.policy.Policy;
+import org.apache.gravitino.policy.PolicyContents;
+import org.apache.gravitino.policy.TagValueSelector;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.Representation;
 import org.apache.gravitino.rel.SQLRepresentation;
@@ -1085,6 +1093,65 @@ public class TestTagManager {
         e3.getMessage().contains("Failed to get tag for metadata object " + 
nonExistentObject));
   }
 
+  @Test
+  public void testPolicyAssociationsForTag() throws IOException {
+    String tagName = "policy_tag";
+    String policyName = "policy_for_tag";
+    Assertions.assertThrows(
+        NoSuchTagException.class, () -> 
tagManager.listPolicyAssociationsForTag(METALAKE, tagName));
+    tagManager.createTag(
+        METALAKE, tagName, null, null, 
TagValueConstraint.ofAllowedValues("finance", "risk"));
+    PolicyEntity policy =
+        PolicyEntity.builder()
+            .withId(idGenerator.nextId())
+            .withName(policyName)
+            .withNamespace(Namespace.of(METALAKE))
+            .withPolicyType(Policy.BuiltInType.CUSTOM)
+            .withEnabled(true)
+            .withContent(
+                PolicyContents.custom(
+                    ImmutableMap.of("rule", "value"),
+                    ImmutableSet.of(MetadataObject.Type.TABLE),
+                    null))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+            .build();
+    entityStore.put(policy, false);
+
+    try {
+      tagManager.addPolicyForTag(METALAKE, tagName, policyName, 
AllValuesSelector.get());
+      RelationalEntity<?>[] associations =
+          tagManager.listPolicyAssociationsForTag(METALAKE, tagName);
+      Assertions.assertEquals(1, associations.length);
+      Assertions.assertEquals(policyName, 
associations[0].targetEntity().name());
+      Assertions.assertEquals(tagName, associations[0].source().name());
+      Assertions.assertSame(
+          AllValuesSelector.get(),
+          PolicyAssociationSelectorSerde.deserialize(
+              associations[0].relationValue().orElseThrow()));
+
+      Assertions.assertThrows(
+          PolicyAlreadyAssociatedException.class,
+          () ->
+              tagManager.addPolicyForTag(
+                  METALAKE, tagName, policyName, 
TagValueSelector.of("finance")));
+
+      tagManager.removePolicyFromTag(METALAKE, tagName, policyName);
+      Assertions.assertEquals(0, 
tagManager.listPolicyAssociationsForTag(METALAKE, tagName).length);
+
+      tagManager.addPolicyForTag(METALAKE, tagName, policyName, 
TagValueSelector.of("finance"));
+      associations = tagManager.listPolicyAssociationsForTag(METALAKE, 
tagName);
+      Assertions.assertEquals(
+          TagValueSelector.of("finance"),
+          PolicyAssociationSelectorSerde.deserialize(
+              associations[0].relationValue().orElseThrow()));
+      tagManager.removePolicyFromTag(METALAKE, tagName, policyName);
+    } finally {
+      entityStore.delete(
+          NameIdentifierUtil.ofPolicy(METALAKE, policyName), 
Entity.EntityType.POLICY);
+    }
+  }
+
   private static Set<String> tagNames(Tag[] tags) {
     return Arrays.stream(tags).map(Tag::name).collect(Collectors.toSet());
   }

Reply via email to