This is an automated email from the ASF dual-hosted git repository.
bharos 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 dc4b20e957 [#13510] feat(api): add AccessControlContent policy type
(#13511)
dc4b20e957 is described below
commit dc4b20e9579ab3fdd6e631e15afc6fdd1029a829
Author: Bharath Krishna <[email protected]>
AuthorDate: Thu Oct 15 21:24:18 2026 -0300
[#13510] feat(api): add AccessControlContent policy type (#13511)
### What changes were proposed in this pull request?
Adds the `ACCESS_CONTROL` built-in policy type (wire name
`system_access_control`) and its
content class `AccessControlContent`, constructed through
`PolicyContents.accessControl(...)`.
The content carries two fields:
- `privileges` — the privileges the rule confers on an object the tag is
applied to.
- `applicableRoles` — the roles that satisfy the rule's condition. These
are a *condition* on
the caller's roles, not the principals the privilege is granted to.
A rule is authored as an ordinary policy and bound to a tag:
```java
PolicyContent content =
PolicyContents.accessControl(
ImmutableList.of(Privilege.Name.SELECT_TABLE,
Privilege.Name.MODIFY_TABLE),
ImmutableList.of("analyst", "data_engineer"));
```
which carries the content
```json
{
"privileges": ["SELECT_TABLE", "MODIFY_TABLE"],
"applicableRoles": ["analyst", "data_engineer"]
}
```
and, once bound to the tag `certified`, reads: *a caller whose active
roles include either
`analyst` or `data_engineer` may select from and modify any object
carrying the tag `certified`.*
Any listed role satisfies the condition; every listed privilege is then
conferred.
Two invariants are enforced in the class:
- `PERMITTED_PRIVILEGES` is an explicit allowlist rather than a
denylist, so the boundary fails
closed: a privilege added to `Privilege.Name` later confers nothing
through a tag until it is
listed here deliberately. `USE_CATALOG` and `USE_SCHEMA` are excluded on
purpose — traversal
stays RBAC, so a tag can only widen access inside territory a role can
already reach.
- `supportedObjectTypes()` is the union of `canBindTo` over
`PERMITTED_PRIVILEGES`, minus
`METALAKE`.
This is the API surface only. Nothing reads it yet: no authorizer
change, no enforcement, no
REST path.
**This is part 1 of M1** in the milestone table of the design doc. The
rest of M1 — the content
DTO, the `DTOConverters` branches, and the derived policy-to-role record
written on create and
update — follows in separate PRs. Until the DTO lands,
`system_access_control` is accepted by
`fromPolicyType` but is not creatable over REST.
### Why are the changes needed?
Tag-based access control needs a policy content type before a rule can
be authored, stored or
evaluated. Every later milestone rests on this shape.
Fix: #13510
### Does this PR introduce _any_ user-facing change?
New public API in the `api` module:
- `Policy.BuiltInType.ACCESS_CONTROL`, wire name `system_access_control`
- `AccessControlContent`, with `privileges()` and `applicableRoles()`
- `PolicyContents.accessControl(List<Privilege.Name>, List<String>)`
No property keys are added and no existing behaviour changes. The type
is not reachable over
REST yet.
### How was this patch tested?
Unit tests in `TestPolicyContents` and `TestPolicyBuiltInType`,
covering:
- every privilege in `PERMITTED_PRIVILEGES` is accepted, and privileges
outside it — including
`USE_CATALOG` and `USE_SCHEMA` — are rejected
- `privileges` and `applicableRoles` reject null, empty, blank and
empty-string entries
- `supportedObjectTypes()` is recomputed from `PERMITTED_PRIVILEGES` in
the test rather than
asserted against a hardcoded set, so the two cannot drift apart silently
- equality and the built-in type name mapping in both directions
```
./gradlew :api:test --tests '*TestPolicyContents*' --tests
'*TestPolicyBuiltInType*'
```
---
.../gravitino/policy/AccessControlContent.java | 193 +++++++++++++++++++++
.../java/org/apache/gravitino/policy/Policy.java | 3 +
.../apache/gravitino/policy/PolicyContents.java | 15 ++
.../gravitino/policy/TestPolicyBuiltInType.java | 11 ++
.../gravitino/policy/TestPolicyContents.java | 161 +++++++++++++++++
5 files changed, 383 insertions(+)
diff --git
a/api/src/main/java/org/apache/gravitino/policy/AccessControlContent.java
b/api/src/main/java/org/apache/gravitino/policy/AccessControlContent.java
new file mode 100644
index 0000000000..63c254a571
--- /dev/null
+++ b/api/src/main/java/org/apache/gravitino/policy/AccessControlContent.java
@@ -0,0 +1,193 @@
+/*
+ * 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 com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.authorization.Privilege;
+
+/**
+ * The content of a {@link Policy.BuiltInType#ACCESS_CONTROL} policy.
+ *
+ * <p>The policy is bound to a tag, and every object carrying that tag confers
{@link #privileges()}
+ * on any caller whose active roles include one of {@link #applicableRoles()}.
The roles are a
+ * condition on the rule, not the principals it is granted to.
+ */
+public final class AccessControlContent implements PolicyContent {
+
+ /** Rule key for the privileges the rule confers. */
+ public static final String PRIVILEGES_KEY = "privileges";
+
+ /** Rule key for the roles that satisfy the rule's condition. */
+ public static final String APPLICABLE_ROLES_KEY = "applicableRoles";
+
+ /**
+ * The privileges a tag is allowed to confer.
+ *
+ * <p>An allowlist rather than a denylist, so the boundary fails closed: a
privilege added to
+ * {@link Privilege.Name} later confers nothing through a tag until it is
added here deliberately.
+ * It holds only privileges that act on the tagged object itself. Privileges
that hand out access,
+ * create entities, change what other principals execute, or open a path to
untagged objects are
+ * excluded.
+ */
+ public static final Set<Privilege.Name> PERMITTED_PRIVILEGES =
+ ImmutableSet.of(
+ Privilege.Name.SELECT_TABLE,
+ Privilege.Name.MODIFY_TABLE,
+ Privilege.Name.PROBE_TABLE_LIKE,
+ Privilege.Name.SELECT_VIEW,
+ Privilege.Name.READ_FILESET,
+ Privilege.Name.WRITE_FILESET,
+ Privilege.Name.CONSUME_TOPIC,
+ Privilege.Name.PRODUCE_TOPIC,
+ Privilege.Name.USE_MODEL,
+ Privilege.Name.EXECUTE_FUNCTION);
+
+ // The object types a tag can be applied to, minus COLUMN, which no
permitted privilege can be
+ // scoped to. METALAKE is absent because a tag cannot be applied to a
metalake.
+ private static final Set<MetadataObject.Type> SUPPORTED_OBJECT_TYPES =
+ ImmutableSet.of(
+ MetadataObject.Type.CATALOG,
+ MetadataObject.Type.SCHEMA,
+ MetadataObject.Type.TABLE,
+ MetadataObject.Type.VIEW,
+ MetadataObject.Type.FILESET,
+ MetadataObject.Type.TOPIC,
+ MetadataObject.Type.MODEL,
+ MetadataObject.Type.FUNCTION);
+
+ private final List<Privilege.Name> privileges;
+ private final List<String> applicableRoles;
+
+ /** Default constructor for Jackson deserialization only. */
+ private AccessControlContent() {
+ this(null, null);
+ }
+
+ AccessControlContent(List<Privilege.Name> privileges, List<String>
applicableRoles) {
+ // Copied without rejecting nulls so that validate() is the single place
that reports a bad
+ // entry, with the offending value named.
+ this.privileges =
+ privileges == null
+ ? Collections.emptyList()
+ : Collections.unmodifiableList(new ArrayList<>(privileges));
+ this.applicableRoles =
+ applicableRoles == null
+ ? Collections.emptyList()
+ : Collections.unmodifiableList(new ArrayList<>(applicableRoles));
+ }
+
+ /**
+ * Returns the privileges this rule confers on the tagged object.
+ *
+ * @return the conferred privileges
+ */
+ public List<Privilege.Name> privileges() {
+ return privileges;
+ }
+
+ /**
+ * Returns the roles that satisfy this rule's condition.
+ *
+ * @return the applicable role names
+ */
+ public List<String> applicableRoles() {
+ return applicableRoles;
+ }
+
+ @Override
+ public Set<MetadataObject.Type> supportedObjectTypes() {
+ return SUPPORTED_OBJECT_TYPES;
+ }
+
+ @Override
+ public Map<String, String> properties() {
+ return ImmutableMap.of();
+ }
+
+ @Override
+ public Map<String, Object> rules() {
+ Map<String, Object> rules = new LinkedHashMap<>();
+ rules.put(
+ PRIVILEGES_KEY,
+ privileges.stream()
+ .map(privilege -> privilege == null ? null : privilege.name())
+ .collect(Collectors.toList()));
+ rules.put(APPLICABLE_ROLES_KEY, applicableRoles);
+ return Collections.unmodifiableMap(rules);
+ }
+
+ @Override
+ public void validate() throws IllegalArgumentException {
+ PolicyContent.super.validate();
+ Preconditions.checkArgument(!privileges.isEmpty(), "privileges cannot be
empty");
+ privileges.forEach(
+ privilege ->
+ Preconditions.checkArgument(
+ PERMITTED_PRIVILEGES.contains(privilege),
+ "privilege %s cannot be conferred by a tag, permitted
privileges are %s",
+ privilege,
+ PERMITTED_PRIVILEGES));
+
+ Preconditions.checkArgument(!applicableRoles.isEmpty(), "applicableRoles
cannot be empty");
+ applicableRoles.forEach(
+ role ->
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(role), "applicable role name cannot be
blank"));
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof AccessControlContent)) {
+ return false;
+ }
+ AccessControlContent that = (AccessControlContent) o;
+ return Objects.equals(privileges, that.privileges)
+ && Objects.equals(applicableRoles, that.applicableRoles);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(privileges, applicableRoles);
+ }
+
+ @Override
+ public String toString() {
+ return "AccessControlContent{"
+ + "privileges="
+ + privileges
+ + ", applicableRoles="
+ + applicableRoles
+ + '}';
+ }
+}
diff --git a/api/src/main/java/org/apache/gravitino/policy/Policy.java
b/api/src/main/java/org/apache/gravitino/policy/Policy.java
index c71a9c213b..337ca0f7ce 100644
--- a/api/src/main/java/org/apache/gravitino/policy/Policy.java
+++ b/api/src/main/java/org/apache/gravitino/policy/Policy.java
@@ -62,6 +62,9 @@ public interface Policy extends Auditable {
/** Built-in policy type for masking columns of tagged tables or tagged
columns. */
COLUMN_MASK(BUILT_IN_TYPE_PREFIX + "column_mask", ColumnMaskContent.class),
+ /** Built-in policy type for access conferred by a tag. */
+ ACCESS_CONTROL(BUILT_IN_TYPE_PREFIX + "access_control",
AccessControlContent.class),
+
/** Non-built-in policies use the fixed wire value {@code custom}. */
CUSTOM("custom", PolicyContents.CustomContent.class);
diff --git a/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
b/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
index b7e92193f6..9905811353 100644
--- a/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
+++ b/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
@@ -19,11 +19,13 @@
package org.apache.gravitino.policy;
import com.google.common.collect.ImmutableSet;
+import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import javax.annotation.Nullable;
import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.authorization.Privilege;
/** Utility class for creating instances of {@link PolicyContent}. */
public class PolicyContents {
@@ -218,6 +220,19 @@ public class PolicyContents {
return new ColumnMaskContent(expression);
}
+ /**
+ * Creates an access control policy content.
+ *
+ * @param privileges the privileges the rule confers on the tagged object,
each of which must be
+ * in {@link AccessControlContent#PERMITTED_PRIVILEGES}
+ * @param applicableRoles the roles that satisfy the rule's condition
+ * @return access control policy content
+ */
+ public static PolicyContent accessControl(
+ List<Privilege.Name> privileges, List<String> applicableRoles) {
+ return new AccessControlContent(privileges, applicableRoles);
+ }
+
private PolicyContents() {}
/**
diff --git
a/api/src/test/java/org/apache/gravitino/policy/TestPolicyBuiltInType.java
b/api/src/test/java/org/apache/gravitino/policy/TestPolicyBuiltInType.java
index 93df4a1d6a..ce2bde09a7 100644
--- a/api/src/test/java/org/apache/gravitino/policy/TestPolicyBuiltInType.java
+++ b/api/src/test/java/org/apache/gravitino/policy/TestPolicyBuiltInType.java
@@ -84,4 +84,15 @@ public class TestPolicyBuiltInType {
Assertions.assertEquals(RowFilterContent.class,
Policy.BuiltInType.ROW_FILTER.contentClass());
Assertions.assertEquals(ColumnMaskContent.class,
Policy.BuiltInType.COLUMN_MASK.contentClass());
}
+
+ @Test
+ void testAccessControlPolicyType() {
+ Assertions.assertEquals(
+ Policy.BuiltInType.ACCESS_CONTROL,
+ Policy.BuiltInType.fromPolicyType("system_access_control"));
+ Assertions.assertEquals(
+ "system_access_control",
Policy.BuiltInType.ACCESS_CONTROL.policyType());
+ Assertions.assertEquals(
+ AccessControlContent.class,
Policy.BuiltInType.ACCESS_CONTROL.contentClass());
+ }
}
diff --git
a/api/src/test/java/org/apache/gravitino/policy/TestPolicyContents.java
b/api/src/test/java/org/apache/gravitino/policy/TestPolicyContents.java
index 4bac0a9d0f..2e13103d2a 100644
--- a/api/src/test/java/org/apache/gravitino/policy/TestPolicyContents.java
+++ b/api/src/test/java/org/apache/gravitino/policy/TestPolicyContents.java
@@ -20,9 +20,16 @@
package org.apache.gravitino.policy;
import com.google.common.collect.ImmutableSet;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.authorization.Privileges;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -172,6 +179,160 @@ public class TestPolicyContents {
Assertions.assertTrue(exception.getMessage().contains("maxPartitionNum"));
}
+ @Test
+ void testAccessControlContent() {
+ AccessControlContent content =
+ (AccessControlContent)
+ PolicyContents.accessControl(
+ Arrays.asList(Privilege.Name.SELECT_TABLE,
Privilege.Name.MODIFY_TABLE),
+ Arrays.asList("analyst", "data_engineer"));
+
+ Assertions.assertDoesNotThrow(content::validate);
+ Assertions.assertEquals(
+ Arrays.asList(Privilege.Name.SELECT_TABLE,
Privilege.Name.MODIFY_TABLE),
+ content.privileges());
+ Assertions.assertEquals(Arrays.asList("analyst", "data_engineer"),
content.applicableRoles());
+ Assertions.assertEquals(
+ Arrays.asList("SELECT_TABLE", "MODIFY_TABLE"),
+ content.rules().get(AccessControlContent.PRIVILEGES_KEY));
+ Assertions.assertEquals(
+ Arrays.asList("analyst", "data_engineer"),
+ content.rules().get(AccessControlContent.APPLICABLE_ROLES_KEY));
+ Assertions.assertTrue(content.properties().isEmpty());
+ }
+
+ @Test
+ void testAccessControlContentSupportedObjectTypes() {
+ Assertions.assertEquals(
+ ImmutableSet.of(
+ MetadataObject.Type.CATALOG,
+ MetadataObject.Type.SCHEMA,
+ MetadataObject.Type.TABLE,
+ MetadataObject.Type.VIEW,
+ MetadataObject.Type.FILESET,
+ MetadataObject.Type.TOPIC,
+ MetadataObject.Type.MODEL,
+ MetadataObject.Type.FUNCTION),
+ PolicyContents.accessControl(
+ Collections.singletonList(Privilege.Name.SELECT_TABLE),
+ Collections.singletonList("analyst"))
+ .supportedObjectTypes());
+ }
+
+ @Test
+ void testAccessControlContentSupportedObjectTypesTrackPermittedPrivileges() {
+ Set<MetadataObject.Type> union =
+ Arrays.stream(MetadataObject.Type.values())
+ .filter(type -> type != MetadataObject.Type.METALAKE)
+ .filter(
+ type ->
+ AccessControlContent.PERMITTED_PRIVILEGES.stream()
+ .anyMatch(name ->
Privileges.allow(name).canBindTo(type)))
+ .collect(Collectors.toSet());
+
+ Assertions.assertEquals(
+ union,
+ PolicyContents.accessControl(
+ Collections.singletonList(Privilege.Name.SELECT_TABLE),
+ Collections.singletonList("analyst"))
+ .supportedObjectTypes(),
+ "supportedObjectTypes must stay the union of canBindTo over
PERMITTED_PRIVILEGES, "
+ + "minus METALAKE, which a tag cannot be applied to");
+ }
+
+ @Test
+ void testAccessControlContentAcceptsEveryPermittedPrivilege() {
+ for (Privilege.Name privilege : AccessControlContent.PERMITTED_PRIVILEGES)
{
+ PolicyContent content =
+ PolicyContents.accessControl(
+ Collections.singletonList(privilege),
Collections.singletonList("analyst"));
+ Assertions.assertDoesNotThrow(content::validate, privilege + " should be
permitted");
+ }
+ }
+
+ @Test
+ void testAccessControlContentRejectsPrivilegesOutsideAllowlist() {
+ for (Privilege.Name privilege : Privilege.Name.values()) {
+ if (AccessControlContent.PERMITTED_PRIVILEGES.contains(privilege)) {
+ continue;
+ }
+
+ PolicyContent content =
+ PolicyContents.accessControl(
+ Collections.singletonList(privilege),
Collections.singletonList("analyst"));
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ content::validate,
+ privilege + " should not be conferrable by a tag");
+ Assertions.assertTrue(exception.getMessage().contains(privilege.name()));
+ }
+ }
+
+ @Test
+ void testAccessControlContentRejectsEmptyPrivileges() {
+ List<String> roles = Collections.singletonList("analyst");
+ Assertions.assertTrue(
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> PolicyContents.accessControl(null, roles).validate())
+ .getMessage()
+ .contains("privileges cannot be empty"));
+ Assertions.assertTrue(
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> PolicyContents.accessControl(Collections.emptyList(),
roles).validate())
+ .getMessage()
+ .contains("privileges cannot be empty"));
+ }
+
+ @Test
+ void testAccessControlContentRejectsInvalidApplicableRoles() {
+ List<Privilege.Name> privileges =
Collections.singletonList(Privilege.Name.SELECT_TABLE);
+ Assertions.assertTrue(
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> PolicyContents.accessControl(privileges,
Collections.emptyList()).validate())
+ .getMessage()
+ .contains("applicableRoles cannot be empty"));
+ Assertions.assertTrue(
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> PolicyContents.accessControl(privileges,
null).validate())
+ .getMessage()
+ .contains("applicableRoles cannot be empty"));
+
+ for (List<String> roles :
+ Arrays.asList(
+ Arrays.asList("analyst", " "),
+ Arrays.asList("analyst", ""),
+ Arrays.asList("analyst", (String) null))) {
+ Assertions.assertTrue(
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> PolicyContents.accessControl(privileges,
roles).validate())
+ .getMessage()
+ .contains("applicable role name cannot be blank"),
+ roles + " should be rejected");
+ }
+ }
+
+ @Test
+ void testAccessControlContentEquality() {
+ List<Privilege.Name> privileges =
Collections.singletonList(Privilege.Name.SELECT_TABLE);
+ PolicyContent content =
+ PolicyContents.accessControl(privileges,
Collections.singletonList("analyst"));
+
+ Assertions.assertEquals(
+ content, PolicyContents.accessControl(privileges,
Collections.singletonList("analyst")));
+ Assertions.assertEquals(
+ content.hashCode(),
+ PolicyContents.accessControl(privileges,
Collections.singletonList("analyst")).hashCode());
+ Assertions.assertNotEquals(
+ content,
+ PolicyContents.accessControl(privileges,
Collections.singletonList("data_engineer")));
+ }
+
private static Map<String, String> mapOf(String key, String value) {
Map<String, String> map = new HashMap<>();
map.put(key, value);