Skip to content

Commit dc4b20e

Browse files
authored
[#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*' ```
1 parent 19d88d7 commit dc4b20e

5 files changed

Lines changed: 383 additions & 0 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.gravitino.policy;
20+
21+
import com.google.common.base.Preconditions;
22+
import com.google.common.collect.ImmutableMap;
23+
import com.google.common.collect.ImmutableSet;
24+
import java.util.ArrayList;
25+
import java.util.Collections;
26+
import java.util.LinkedHashMap;
27+
import java.util.List;
28+
import java.util.Map;
29+
import java.util.Objects;
30+
import java.util.Set;
31+
import java.util.stream.Collectors;
32+
import org.apache.commons.lang3.StringUtils;
33+
import org.apache.gravitino.MetadataObject;
34+
import org.apache.gravitino.authorization.Privilege;
35+
36+
/**
37+
* The content of a {@link Policy.BuiltInType#ACCESS_CONTROL} policy.
38+
*
39+
* <p>The policy is bound to a tag, and every object carrying that tag confers {@link #privileges()}
40+
* on any caller whose active roles include one of {@link #applicableRoles()}. The roles are a
41+
* condition on the rule, not the principals it is granted to.
42+
*/
43+
public final class AccessControlContent implements PolicyContent {
44+
45+
/** Rule key for the privileges the rule confers. */
46+
public static final String PRIVILEGES_KEY = "privileges";
47+
48+
/** Rule key for the roles that satisfy the rule's condition. */
49+
public static final String APPLICABLE_ROLES_KEY = "applicableRoles";
50+
51+
/**
52+
* The privileges a tag is allowed to confer.
53+
*
54+
* <p>An allowlist rather than a denylist, so the boundary fails closed: a privilege added to
55+
* {@link Privilege.Name} later confers nothing through a tag until it is added here deliberately.
56+
* It holds only privileges that act on the tagged object itself. Privileges that hand out access,
57+
* create entities, change what other principals execute, or open a path to untagged objects are
58+
* excluded.
59+
*/
60+
public static final Set<Privilege.Name> PERMITTED_PRIVILEGES =
61+
ImmutableSet.of(
62+
Privilege.Name.SELECT_TABLE,
63+
Privilege.Name.MODIFY_TABLE,
64+
Privilege.Name.PROBE_TABLE_LIKE,
65+
Privilege.Name.SELECT_VIEW,
66+
Privilege.Name.READ_FILESET,
67+
Privilege.Name.WRITE_FILESET,
68+
Privilege.Name.CONSUME_TOPIC,
69+
Privilege.Name.PRODUCE_TOPIC,
70+
Privilege.Name.USE_MODEL,
71+
Privilege.Name.EXECUTE_FUNCTION);
72+
73+
// The object types a tag can be applied to, minus COLUMN, which no permitted privilege can be
74+
// scoped to. METALAKE is absent because a tag cannot be applied to a metalake.
75+
private static final Set<MetadataObject.Type> SUPPORTED_OBJECT_TYPES =
76+
ImmutableSet.of(
77+
MetadataObject.Type.CATALOG,
78+
MetadataObject.Type.SCHEMA,
79+
MetadataObject.Type.TABLE,
80+
MetadataObject.Type.VIEW,
81+
MetadataObject.Type.FILESET,
82+
MetadataObject.Type.TOPIC,
83+
MetadataObject.Type.MODEL,
84+
MetadataObject.Type.FUNCTION);
85+
86+
private final List<Privilege.Name> privileges;
87+
private final List<String> applicableRoles;
88+
89+
/** Default constructor for Jackson deserialization only. */
90+
private AccessControlContent() {
91+
this(null, null);
92+
}
93+
94+
AccessControlContent(List<Privilege.Name> privileges, List<String> applicableRoles) {
95+
// Copied without rejecting nulls so that validate() is the single place that reports a bad
96+
// entry, with the offending value named.
97+
this.privileges =
98+
privileges == null
99+
? Collections.emptyList()
100+
: Collections.unmodifiableList(new ArrayList<>(privileges));
101+
this.applicableRoles =
102+
applicableRoles == null
103+
? Collections.emptyList()
104+
: Collections.unmodifiableList(new ArrayList<>(applicableRoles));
105+
}
106+
107+
/**
108+
* Returns the privileges this rule confers on the tagged object.
109+
*
110+
* @return the conferred privileges
111+
*/
112+
public List<Privilege.Name> privileges() {
113+
return privileges;
114+
}
115+
116+
/**
117+
* Returns the roles that satisfy this rule's condition.
118+
*
119+
* @return the applicable role names
120+
*/
121+
public List<String> applicableRoles() {
122+
return applicableRoles;
123+
}
124+
125+
@Override
126+
public Set<MetadataObject.Type> supportedObjectTypes() {
127+
return SUPPORTED_OBJECT_TYPES;
128+
}
129+
130+
@Override
131+
public Map<String, String> properties() {
132+
return ImmutableMap.of();
133+
}
134+
135+
@Override
136+
public Map<String, Object> rules() {
137+
Map<String, Object> rules = new LinkedHashMap<>();
138+
rules.put(
139+
PRIVILEGES_KEY,
140+
privileges.stream()
141+
.map(privilege -> privilege == null ? null : privilege.name())
142+
.collect(Collectors.toList()));
143+
rules.put(APPLICABLE_ROLES_KEY, applicableRoles);
144+
return Collections.unmodifiableMap(rules);
145+
}
146+
147+
@Override
148+
public void validate() throws IllegalArgumentException {
149+
PolicyContent.super.validate();
150+
Preconditions.checkArgument(!privileges.isEmpty(), "privileges cannot be empty");
151+
privileges.forEach(
152+
privilege ->
153+
Preconditions.checkArgument(
154+
PERMITTED_PRIVILEGES.contains(privilege),
155+
"privilege %s cannot be conferred by a tag, permitted privileges are %s",
156+
privilege,
157+
PERMITTED_PRIVILEGES));
158+
159+
Preconditions.checkArgument(!applicableRoles.isEmpty(), "applicableRoles cannot be empty");
160+
applicableRoles.forEach(
161+
role ->
162+
Preconditions.checkArgument(
163+
StringUtils.isNotBlank(role), "applicable role name cannot be blank"));
164+
}
165+
166+
@Override
167+
public boolean equals(Object o) {
168+
if (this == o) {
169+
return true;
170+
}
171+
if (!(o instanceof AccessControlContent)) {
172+
return false;
173+
}
174+
AccessControlContent that = (AccessControlContent) o;
175+
return Objects.equals(privileges, that.privileges)
176+
&& Objects.equals(applicableRoles, that.applicableRoles);
177+
}
178+
179+
@Override
180+
public int hashCode() {
181+
return Objects.hash(privileges, applicableRoles);
182+
}
183+
184+
@Override
185+
public String toString() {
186+
return "AccessControlContent{"
187+
+ "privileges="
188+
+ privileges
189+
+ ", applicableRoles="
190+
+ applicableRoles
191+
+ '}';
192+
}
193+
}

‎api/src/main/java/org/apache/gravitino/policy/Policy.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ enum BuiltInType {
6262
/** Built-in policy type for masking columns of tagged tables or tagged columns. */
6363
COLUMN_MASK(BUILT_IN_TYPE_PREFIX + "column_mask", ColumnMaskContent.class),
6464

65+
/** Built-in policy type for access conferred by a tag. */
66+
ACCESS_CONTROL(BUILT_IN_TYPE_PREFIX + "access_control", AccessControlContent.class),
67+
6568
/** Non-built-in policies use the fixed wire value {@code custom}. */
6669
CUSTOM("custom", PolicyContents.CustomContent.class);
6770

‎api/src/main/java/org/apache/gravitino/policy/PolicyContents.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
package org.apache.gravitino.policy;
2020

2121
import com.google.common.collect.ImmutableSet;
22+
import java.util.List;
2223
import java.util.Map;
2324
import java.util.Objects;
2425
import java.util.Set;
2526
import javax.annotation.Nullable;
2627
import org.apache.gravitino.MetadataObject;
28+
import org.apache.gravitino.authorization.Privilege;
2729

2830
/** Utility class for creating instances of {@link PolicyContent}. */
2931
public class PolicyContents {
@@ -218,6 +220,19 @@ public static ColumnMaskContent columnMask(String expression) {
218220
return new ColumnMaskContent(expression);
219221
}
220222

223+
/**
224+
* Creates an access control policy content.
225+
*
226+
* @param privileges the privileges the rule confers on the tagged object, each of which must be
227+
* in {@link AccessControlContent#PERMITTED_PRIVILEGES}
228+
* @param applicableRoles the roles that satisfy the rule's condition
229+
* @return access control policy content
230+
*/
231+
public static PolicyContent accessControl(
232+
List<Privilege.Name> privileges, List<String> applicableRoles) {
233+
return new AccessControlContent(privileges, applicableRoles);
234+
}
235+
221236
private PolicyContents() {}
222237

223238
/**

‎api/src/test/java/org/apache/gravitino/policy/TestPolicyBuiltInType.java‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,15 @@ void testBuiltInTypeContentClass() {
8484
Assertions.assertEquals(RowFilterContent.class, Policy.BuiltInType.ROW_FILTER.contentClass());
8585
Assertions.assertEquals(ColumnMaskContent.class, Policy.BuiltInType.COLUMN_MASK.contentClass());
8686
}
87+
88+
@Test
89+
void testAccessControlPolicyType() {
90+
Assertions.assertEquals(
91+
Policy.BuiltInType.ACCESS_CONTROL,
92+
Policy.BuiltInType.fromPolicyType("system_access_control"));
93+
Assertions.assertEquals(
94+
"system_access_control", Policy.BuiltInType.ACCESS_CONTROL.policyType());
95+
Assertions.assertEquals(
96+
AccessControlContent.class, Policy.BuiltInType.ACCESS_CONTROL.contentClass());
97+
}
8798
}

0 commit comments

Comments
 (0)