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());
}