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 a03467c5e5 [#11967] feat(auth): narrow allow enforcement to active
roles (deny stays global) (#12044)
a03467c5e5 is described below
commit a03467c5e552b4093f88c093068a5f4106ca7bb3
Author: Bharath Krishna <[email protected]>
AuthorDate: Sun Jul 19 21:28:00 2026 -0700
[#11967] feat(auth): narrow allow enforcement to active roles (deny stays
global) (#12044)
### What changes were proposed in this pull request?
Phase 1 role-assumption **enforcement** in `JcasbinAuthorizer`. When a
request declares a subset of the caller's roles as active, allow
policies are evaluated against only that subset; deny stays global.
- `AuthorizationRequestContext`: carry an immutable `ActiveRoles`
(defaults to `ALL`, so an unset value is byte-for-byte today's
behavior).
- `JcasbinAuthorizer`: only the allow enforcer narrows. When a subset is
declared, `enforceNarrowed`:
- applies deny **globally** first (over the caller's full role union) —
a deny on any held role always applies, keeping narrowing subtractive;
- then ORs allow across the active set = `(declared names) ∩ (caller's
effective roles)`.
- `NONE` activates no role; `ALL` / absent is unchanged.
- Ownership is deliberately not narrowed: access a caller derives from
*owning* an object comes from the ownership grant, not a role, so
`isOwner` is untouched and owners keep that access regardless of which
roles are active. This is the design's default (Option A in
`design-docs/gravitino-role-assumption.md` §5).
This is intentionally scoped to enforcement so it can be reviewed on its
own. Carrying the value from the `X-Gravitino-Active-Roles` header onto
the request and the `400`/`403` mapping follow in later PRs.
### Why are the changes needed?
Implements the enforcement step of the approved design
[`design-docs/gravitino-role-assumption.md`](https://github.com/apache/gravitino/blob/main/design-docs/gravitino-role-assumption.md)
(discussion
[#10894](https://github.com/apache/gravitino/discussions/10894)). A
caller needs authorization to be narrowed to a declared subset of its
roles; this PR is the server-side narrowing the rest of the feature
builds on.
Part of #11965. Closes #11967.
### Does this PR introduce any user-facing change?
No. The narrowing path only activates when an `ActiveRoles` subset is
set on the request; nothing populates it yet, so behavior is unchanged.
### How was this patch tested?
New unit tests in `TestJcasbinAuthorizer`: allow-narrowing to a named
role, `NONE`, deny-stays-global when the allow is narrowed,
group-inherited active role, and unheld-role-activates-nothing
(subtractive). Verified locally:
- `./gradlew :server-common:test --tests
"org.apache.gravitino.server.authorization.jcasbin.TestJcasbinAuthorizer"`
- `./gradlew :core:test --tests
"org.apache.gravitino.authorization.TestAuthorizationRequestContext"`
- `./gradlew :server-common:spotlessCheck :core:spotlessCheck`
---
.../authorization/AuthorizationRequestContext.java | 27 ++++
.../authorization/jcasbin/JcasbinAuthorizer.java | 86 +++++++++++--
.../jcasbin/TestJcasbinAuthorizer.java | 137 +++++++++++++++++++++
3 files changed, 242 insertions(+), 8 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
index d30f15bee1..d676f101b5 100644
---
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
+++
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
@@ -30,6 +30,7 @@ import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.auth.ActiveRoles;
import org.apache.gravitino.storage.relational.po.auth.GroupUpdatedAt;
import org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
import org.apache.gravitino.storage.relational.po.auth.RoleUpdatedAt;
@@ -84,6 +85,12 @@ public class AuthorizationRequestContext {
private volatile String originalAuthorizationExpression;
+ /**
+ * The roles the caller has declared active for this request (role
assumption). Defaults to {@link
+ * ActiveRoles#all()}, which evaluates every role the caller holds (no
narrowing).
+ */
+ private volatile ActiveRoles activeRoles = ActiveRoles.all();
+
/**
* check allow
*
@@ -213,6 +220,26 @@ public class AuthorizationRequestContext {
this.prefetchedRoleVersions = prefetchedRoleVersions;
}
+ /**
+ * Returns the roles declared active for this request. Never {@code null};
defaults to {@link
+ * ActiveRoles#all()}.
+ *
+ * @return the active-role declaration
+ */
+ public ActiveRoles getActiveRoles() {
+ return activeRoles;
+ }
+
+ /**
+ * Sets the roles declared active for this request. Narrowing is
subtractive: only roles the
+ * caller actually holds are ever consulted, regardless of what is declared
here.
+ *
+ * @param activeRoles the active-role declaration; must not be {@code null}
+ */
+ public void setActiveRoles(ActiveRoles activeRoles) {
+ this.activeRoles = Objects.requireNonNull(activeRoles, "activeRoles must
not be null");
+ }
+
/**
* Composite key for {@link #allowAuthorizerCache} / {@link
#denyAuthorizerCache}. Immutable —
* mutating any field after construction would silently corrupt the {@link
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
index 47108b3799..ef355dea0c 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
@@ -24,6 +24,7 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
@@ -46,6 +47,7 @@ import org.apache.gravitino.MetadataObjects;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.UserGroup;
import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.ActiveRoles;
import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.authorization.AuthorizationRequestContext;
import org.apache.gravitino.authorization.AuthorizationUtils;
@@ -192,9 +194,9 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
// Initialize enforcers before caches that reference them in removal
listeners
allowEnforcer = new SyncedEnforcer(getModel("/jcasbin_model.conf"), new
GravitinoAdapter());
- allowInternalAuthorizer = new InternalAuthorizer(allowEnforcer);
+ allowInternalAuthorizer = new InternalAuthorizer(allowEnforcer, true);
denyEnforcer = new SyncedEnforcer(getModel("/jcasbin_model.conf"), new
GravitinoAdapter());
- denyInternalAuthorizer = new InternalAuthorizer(denyEnforcer);
+ denyInternalAuthorizer = new InternalAuthorizer(denyEnforcer, false);
// loadedRoles: roleId -> updated_at.
// When evicted, we must clean up the corresponding JCasbin policies.
@@ -696,8 +698,16 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
Enforcer enforcer;
- public InternalAuthorizer(Enforcer enforcer) {
+ /**
+ * When {@code true}, evaluation considers only the request's active roles
instead of every role
+ * the caller holds. Enabled for the allow authorizer so role assumption
can drop allows; the
+ * deny authorizer leaves it {@code false} so denies always apply. See
{@link #enforceNarrowed}.
+ */
+ private final boolean narrowByActiveRoles;
+
+ public InternalAuthorizer(Enforcer enforcer, boolean narrowByActiveRoles) {
this.enforcer = enforcer;
+ this.narrowByActiveRoles = narrowByActiveRoles;
}
private boolean authorizeInternal(
@@ -809,11 +819,71 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return ownerMatchesUserOrGroups(
owner, PrincipalUtils.getCurrentPrincipal(), metalake,
requestContext);
}
- return enforcer.enforce(
- String.valueOf(userId),
- String.valueOf(metadataObject.type()),
- String.valueOf(metadataId),
- privilege);
+
+ String metadataType = String.valueOf(metadataObject.type());
+ String metadataIdStr = String.valueOf(metadataId);
+
+ // Role assumption: if the caller activated only a subset of their
roles, check this allow
+ // against just those roles (enforceNarrowed). ALL or an absent header
falls through to the
+ // normal check over every role the caller holds.
+ ActiveRoles activeRoles = requestContext.getActiveRoles();
+ if (narrowByActiveRoles && !activeRoles.isAll()) {
+ return enforceNarrowed(
+ userId, metadataType, metadataIdStr, privilege, activeRoles,
requestContext);
+ }
+
+ return enforcer.enforce(String.valueOf(userId), metadataType,
metadataIdStr, privilege);
+ }
+
+ /**
+ * Enforces one object/privilege against only the active roles the caller
actually holds, so
+ * narrowing can never grant access the caller lacks. {@link
ActiveRoles#none()} activates no
+ * role and denies every role-derived privilege.
+ *
+ * <p>Deny stays global: denyEnforcer is checked over the caller's full
role union first, so a
+ * deny on any held role still applies even when that role is inactive.
+ */
+ private boolean enforceNarrowed(
+ long userId,
+ String metadataType,
+ String metadataIdStr,
+ String privilege,
+ ActiveRoles activeRoles,
+ AuthorizationRequestContext requestContext) {
+ String userIdStr = String.valueOf(userId);
+ if (denyEnforcer.enforce(userIdStr, metadataType, metadataIdStr,
privilege)) {
+ return false;
+ }
+ if (activeRoles.isNone()) {
+ return false;
+ }
+ for (String roleId : activeRoleIds(userId, activeRoles, requestContext))
{
+ if (enforcer.enforce(roleId, metadataType, metadataIdStr, privilege)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns the ids of the caller's roles whose names are in the active
set. Only roles the
+ * caller actually holds are considered, so naming a role the caller lacks
activates nothing.
+ */
+ private Set<String> activeRoleIds(
+ long userId, ActiveRoles activeRoles, AuthorizationRequestContext
requestContext) {
+ Map<Long, RoleUpdatedAt> roleVersions =
requestContext.getPrefetchedRoleVersions();
+ if (roleVersions == null || roleVersions.isEmpty()) {
+ return Collections.emptySet();
+ }
+ Set<String> activeNames = activeRoles.roleNames();
+ Set<String> result = new HashSet<>();
+ for (String roleId : enforcer.getRolesForUser(String.valueOf(userId))) {
+ RoleUpdatedAt roleInfo = roleVersions.get(Long.parseLong(roleId));
+ if (roleInfo != null && activeNames.contains(roleInfo.getRoleName())) {
+ result.add(roleId);
+ }
+ }
+ return result;
}
}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
index d13437d5d9..7eb04c08cb 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
@@ -64,6 +64,7 @@ import org.apache.gravitino.Namespace;
import org.apache.gravitino.SupportsRelationOperations;
import org.apache.gravitino.UserGroup;
import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.ActiveRoles;
import org.apache.gravitino.authorization.AuthorizationRequestContext;
import org.apache.gravitino.authorization.Privilege;
import org.apache.gravitino.authorization.SecurableObject;
@@ -1294,6 +1295,131 @@ public class TestJcasbinAuthorizer {
getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
}
+ /**
+ * Role assumption narrows allows to the active subset. The caller holds a
granting and a
+ * non-granting role; the per-assertion comments below cover each case.
+ */
+ @Test
+ public void testActiveRolesNarrowAllowToNamedRole() throws Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ Principal currentPrincipal = setCurrentPrincipalWithGroup(null);
+
+ RoleEntity grantingRole =
+ mockRoleInStore(ALLOW_ROLE_ID, "grantingRole",
ImmutableList.of(getAllowSecurableObject()));
+ RoleEntity nonGrantingRole = mockRoleInStore(30L, "nonGrantingRole",
ImmutableList.of());
+ mockDirectUserRoles(grantingRole, nonGrantingRole);
+
+ // Default (ALL) evaluates every role -> allowed.
+ assertTrue(doAuthorizeWithActiveRoles(currentPrincipal,
ActiveRoles.all()));
+
+ // Narrowing to the non-granting role removes the granting role's allow ->
denied.
+ assertFalse(
+ doAuthorizeWithActiveRoles(
+ currentPrincipal,
ActiveRoles.of(ImmutableList.of("nonGrantingRole"))));
+
+ // Narrowing to the granting role keeps the allow -> allowed.
+ assertTrue(
+ doAuthorizeWithActiveRoles(
+ currentPrincipal,
ActiveRoles.of(ImmutableList.of("grantingRole"))));
+
+ restoreDefaultPrincipal();
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ }
+
+ /** {@code NONE} activates no role, so every role-derived allow is dropped.
*/
+ @Test
+ public void testActiveRolesNoneDeniesRoleDerivedAccess() throws Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ Principal currentPrincipal = setCurrentPrincipalWithGroup(null);
+
+ RoleEntity grantingRole =
+ mockRoleInStore(
+ ALLOW_ROLE_ID, "noneGrantingRole",
ImmutableList.of(getAllowSecurableObject()));
+ mockDirectUserRoles(grantingRole);
+
+ assertTrue(doAuthorizeWithActiveRoles(currentPrincipal,
ActiveRoles.all()));
+ assertFalse(doAuthorizeWithActiveRoles(currentPrincipal,
ActiveRoles.none()));
+
+ restoreDefaultPrincipal();
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ }
+
+ /**
+ * Deny stays global: a deny carried by a role that is <em>not</em> in the
active set still blocks
+ * access. Here only the granting role is active, but the caller also holds
a deny role, so the
+ * request is denied.
+ */
+ @Test
+ public void testActiveRolesDenyStaysGlobalWhenAllowNarrowed() throws
Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ Principal currentPrincipal = setCurrentPrincipalWithGroup(null);
+
+ RoleEntity grantingRole =
+ mockRoleInStore(
+ ALLOW_ROLE_ID, "denyGlobalAllowRole",
ImmutableList.of(getAllowSecurableObject()));
+ RoleEntity denyRole =
+ mockRoleInStore(
+ DENY_ROLE_ID, "denyGlobalDenyRole",
ImmutableList.of(getDenySecurableObject()));
+ mockDirectUserRoles(grantingRole, denyRole);
+
+ assertFalse(
+ doAuthorizeWithActiveRoles(
+ currentPrincipal,
ActiveRoles.of(ImmutableList.of("denyGlobalAllowRole"))));
+
+ restoreDefaultPrincipal();
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ }
+
+ /** A group-inherited role can be activated by name exactly like a
directly-granted role. */
+ @Test
+ public void testActiveRolesNarrowingCoversGroupInheritedRole() throws
Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+
+ UserPrincipal groupPrincipal = setCurrentPrincipalWithGroup(GROUP_NAME);
+
+ Long groupRoleId = 31L;
+ RoleEntity groupRole =
+ mockRoleInStore(
+ groupRoleId, "activeGroupRole",
ImmutableList.of(getAllowSecurableObject()));
+ mockNoDirectUserRoles();
+ mockGroupWithRoles(
+ GROUP_NAME, ImmutableList.of(groupRoleId),
ImmutableList.of(groupRole.name()));
+
+ assertTrue(
+ doAuthorizeWithActiveRoles(
+ groupPrincipal,
ActiveRoles.of(ImmutableList.of("activeGroupRole"))));
+
+ restoreDefaultPrincipal();
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ }
+
+ /**
+ * Narrowing is subtractive: naming only a role the caller does not hold
activates nothing. The
+ * {@code 403} rejection for unheld roles happens earlier in the request
pipeline, not here.
+ */
+ @Test
+ public void testActiveRolesUnheldRoleActivatesNothing() throws Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ Principal currentPrincipal = setCurrentPrincipalWithGroup(null);
+
+ RoleEntity grantingRole =
+ mockRoleInStore(
+ ALLOW_ROLE_ID, "heldGrantingRole",
ImmutableList.of(getAllowSecurableObject()));
+ mockDirectUserRoles(grantingRole);
+
+ assertFalse(
+ doAuthorizeWithActiveRoles(
+ currentPrincipal,
ActiveRoles.of(ImmutableList.of("roleTheUserDoesNotHold"))));
+
+ restoreDefaultPrincipal();
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ }
+
/**
* Sets the current principal mock to a {@link UserPrincipal} with the given
group, or with no
* groups when {@code groupName} is null. Returns the principal for use in
assertions.
@@ -1407,6 +1533,17 @@ public class TestJcasbinAuthorizer {
new AuthorizationRequestContext());
}
+ private Boolean doAuthorizeWithActiveRoles(Principal currentPrincipal,
ActiveRoles activeRoles) {
+ AuthorizationRequestContext requestContext = new
AuthorizationRequestContext();
+ requestContext.setActiveRoles(activeRoles);
+ return jcasbinAuthorizer.authorize(
+ currentPrincipal,
+ "testMetalake",
+ MetadataObjects.of(null, "testCatalog", MetadataObject.Type.CATALOG),
+ USE_CATALOG,
+ requestContext);
+ }
+
private Boolean doAuthorizeOwner(Principal currentPrincipal) {
AuthorizationRequestContext authorizationRequestContext = new
AuthorizationRequestContext();
return jcasbinAuthorizer.isOwner(