This is an automated email from the ASF dual-hosted git repository.
yuqi1129 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 46cbef5bdd [#13748] fix(auth): Isolate request-specific role
membership (#13747)
46cbef5bdd is described below
commit 46cbef5bdd62c040eac385e6c1af079b666489a5
Author: Qi Yu <[email protected]>
AuthorDate: Wed Oct 14 19:08:52 2026 +0800
[#13748] fix(auth): Isolate request-specific role membership (#13747)
### What changes were proposed in this pull request?
Stop publishing request-specific user/group membership into shared
JCasbin grouping rows. Evaluate policies against the current request’s
held role IDs using the expression engine’s existing `include` function
in `jcasbin_model.conf`.
Remove the custom `RequestRoleFunction`, separate request model and
derived held/active subject caches. Compute active subjects locally,
keep one policy scan per probe, preserve global DENY across inactive
roles, and retain policy reload locking/generation checks. Make request
prefetch single-flight for parallel list workers, including runtime
failures. Cache the result or original exception only in the request
context; subsequent role-based checks in that failed request remain
denied, and a fresh request can retry. Successful query paths and SQL
are unchanged.
### Why are the changes needed?
Concurrent requests of the same stored user with different IdP groups
can inherit another request’s ALLOW or lose their own DENY, including
list filtering.
Fix: #13748
This is a request-isolation fix, separate from JCasbin replacement and
the broader performance work in #13593. No dependency upgrade or custom
matcher extension is introduced. Partial role-policy load failures
remain tracked separately in #13691.
### Does this PR introduce _any_ user-facing change?
Authorization uses only the current request’s group membership.
Active-role and global-DENY semantics remain unchanged. No REST API or
configuration changes.
### How was this patch tested?
- 21 core request-context tests, 260 server-common authorization tests
and 96 server REST/filter authorization tests passed. Core and
server-common Spotless checks passed. The full core suite was not run.
- Full server-common non-Docker suite: 494 tests, 493 passed; the
unchanged TLS rejection test
`TestHttpsServerAuthentication.testMutualTlsRejectsMissingClientCertificate`
received an HTTP read `IOException` instead of the expected handshake
exception. Isolated rerun of the TLS class also failed (5/7 passed;
missing and untrusted client certificate cases had the same
exception-shape mismatch). These Jetty-only tests do not execute the
authorizer; the previous PR head also exhibited this TLS failure. The
full suite is not claimed green.
- Retained interleaving/isolation regressions; added real-worker tests
for single-flight prefetch and concurrent named-active-role checks with
inactive-role DENY. Bypassing matcher membership made the role-isolation
model regression fail; the restored implementation passes. Reverting
failure memoization makes the 8-worker test issue 8 failed queries
instead of 1, and makes the public authorization regression incorrectly
allow a subsequent check in the same failed request. Tests also verify
recovery in a fresh request.
- Previously measured against this PR’s previous head, `f0f3d70aef`,
with the same dependencies and a 512 MiB heap. Three JVM forks per
variant cover 1/8/32 held roles, 0/100/1000 unrelated roles, cached
requests and real 1000-table filtering. For 8/32 held roles, list-filter
median fell from 186.8/182.1 ms to 175.6/171.4 ms. A longer warmed
32-role denied-request probe measured 108.0 -> 93.1 us.
- Same-JVM alternating matcher measurements cover 27 cases: median time
changes range from -17.2% to +3.4%. Paired concurrency throughput
changes range from -0.8% to +12.7%; a longer follow-up of the noisy
single-thread 6000-policy tail case measured p99 1412.1 -> 934.8 us.
Across JVMs, some short measurements varied substantially; these results
support no stable material regression in the tested warm-cache
workloads, not a universal zero-regression claim. They exclude HTTP, DB,
cold loading and failures; these benchmarks were not rerun for the
failure-memoization follow-up.
---
.../authorization/AuthorizationRequestContext.java | 47 ++
.../TestAuthorizationRequestContext.java | 71 +++
.../authorization/jcasbin/JcasbinAuthorizer.java | 262 ++++------
.../jcasbin/JcasbinLoadedRolesCache.java | 7 +-
.../src/main/resources/jcasbin_model.conf | 32 +-
.../server/authorization/TestJcasbinModel.java | 236 ++++++---
.../jcasbin/TestJcasbinAuthorizer.java | 562 ++++++++++++++++++++-
.../src/test/resources/jcasbin_policy.txt | 6 -
8 files changed, 899 insertions(+), 324 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 8b453a1e1a..22c47cf23f 100644
---
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
+++
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
@@ -32,6 +32,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
+import java.util.function.Supplier;
import javax.annotation.Nullable;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
@@ -103,6 +104,12 @@ public class AuthorizationRequestContext {
*/
private volatile Map<Long, RoleUpdatedAt> prefetchedRoleVersions;
+ /** Completed auth-subject prefetch result, including a missing user, for
this request only. */
+ @Nullable private volatile Optional<UserUpdatedAt> authSubjectPrefetchResult;
+
+ /** Failed prefetch attempts are shared by all workers of this request. */
+ @Nullable private volatile RuntimeException authSubjectPrefetchFailure;
+
private volatile String originalAuthorizationExpression;
/**
@@ -197,6 +204,46 @@ public class AuthorizationRequestContext {
}
}
+ /**
+ * Runs auth-subject prefetch at most once for this request, sharing its
result or failure.
+ *
+ * <p>The loader may populate the request's user, group and role-version
caches before returning
+ * the user identity, or {@link Optional#empty()} when the user is absent.
Waiting workers reuse
+ * the same outcome. A runtime failure is retained for the request so later
checks cannot repeat
+ * the failed query; a fresh request can try again. Fatal {@link Error}s are
not cached.
+ *
+ * @param loader the non-null prefetch operation, which must return a
non-null optional
+ * @return the prefetched user identity, or empty when the user is absent
+ * @throws RuntimeException if the loader failed, including on subsequent
calls in this request
+ */
+ public Optional<UserUpdatedAt> computeAuthSubjectPrefetchIfAbsent(
+ Supplier<Optional<UserUpdatedAt>> loader) {
+ Objects.requireNonNull(loader, "Prefetch loader must not be null");
+ RuntimeException failure = authSubjectPrefetchFailure;
+ if (failure != null) {
+ throw failure;
+ }
+ Optional<UserUpdatedAt> result = authSubjectPrefetchResult;
+ if (result != null) {
+ return result;
+ }
+ synchronized (this) {
+ if (authSubjectPrefetchFailure != null) {
+ throw authSubjectPrefetchFailure;
+ }
+ if (authSubjectPrefetchResult == null) {
+ try {
+ authSubjectPrefetchResult =
+ Objects.requireNonNull(loader.get(), "Prefetch loader must not
return null");
+ } catch (RuntimeException e) {
+ authSubjectPrefetchFailure = e;
+ throw e;
+ }
+ }
+ return authSubjectPrefetchResult;
+ }
+ }
+
/**
* Per-request {@link UserUpdatedAt} dedup. Loader may return {@link
Optional#empty()} to cache
* the "user not found" outcome and avoid repeated DB lookups within a
single request.
diff --git
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationRequestContext.java
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationRequestContext.java
index 6e8437d957..7c30896fc8 100644
---
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationRequestContext.java
+++
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationRequestContext.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.authorization;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -547,4 +548,74 @@ public class TestAuthorizationRequestContext {
assertThrows(NullPointerException.class, () ->
context.setBoundRoleIds(null));
assertEquals(7L, context.getRolePolicyGeneration());
}
+
+ /** Successful auth-subject prefetch is reused without executing another
loader. */
+ @Test
+ public void testAuthSubjectPrefetchCachesUser() {
+ AuthorizationRequestContext context = new AuthorizationRequestContext();
+ Optional<UserUpdatedAt> user = Optional.of(new UserUpdatedAt(1L, 2L));
+ AtomicInteger queries = new AtomicInteger();
+ assertSame(
+ user,
+ context.computeAuthSubjectPrefetchIfAbsent(
+ () -> {
+ queries.incrementAndGet();
+ return user;
+ }));
+ assertSame(
+ user,
+ context.computeAuthSubjectPrefetchIfAbsent(
+ () -> {
+ throw new AssertionError("Successful prefetch must not be
repeated");
+ }));
+ assertEquals(1, queries.get());
+ }
+
+ /** A missing user is a completed prefetch result, not a reason to repeat
the query. */
+ @Test
+ public void testAuthSubjectPrefetchCachesMissingUser() {
+ AuthorizationRequestContext context = new AuthorizationRequestContext();
+ AtomicInteger queries = new AtomicInteger();
+ for (int i = 0; i < 2; i++) {
+ assertEquals(
+ Optional.empty(),
+ context.computeAuthSubjectPrefetchIfAbsent(
+ () -> {
+ queries.incrementAndGet();
+ return Optional.empty();
+ }));
+ }
+ assertEquals(1, queries.get());
+ }
+
+ /** Failed prefetch stays failed in this request, while a fresh request can
recover. */
+ @Test
+ public void testAuthSubjectPrefetchFailureIsRequestScoped() {
+ AuthorizationRequestContext context = new AuthorizationRequestContext();
+ RuntimeException failure = new RuntimeException("database unavailable");
+ AtomicInteger queries = new AtomicInteger();
+ for (int i = 0; i < 2; i++) {
+ assertSame(
+ failure,
+ assertThrows(
+ RuntimeException.class,
+ () ->
+ context.computeAuthSubjectPrefetchIfAbsent(
+ () -> {
+ queries.incrementAndGet();
+ throw failure;
+ })));
+ }
+ assertEquals(1, queries.get());
+ AuthorizationRequestContext nextRequest = new
AuthorizationRequestContext();
+ Optional<UserUpdatedAt> recovered = Optional.of(new UserUpdatedAt(1L, 2L));
+ assertSame(
+ recovered,
+ nextRequest.computeAuthSubjectPrefetchIfAbsent(
+ () -> {
+ queries.incrementAndGet();
+ return recovered;
+ }));
+ assertEquals(2, queries.get());
+ }
}
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 4d0e4a0d94..695b61c299 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
@@ -159,12 +159,11 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
* against authorization reads; otherwise a concurrent {@code enforce()} can
observe the temporary
* empty policy set and deny a request that should be allowed.
*
- * <p>Writers include {@link #invalidateRolePolicies}, {@link
#replaceRolePolicies}, {@link
- * #bindUserRoles}, stale grouping-row pruning, and the {@link
JcasbinLoadedRolesCache} removal
- * listener. Readers include all {@code enforce()}, grouping, and
policy-inspection calls. The
- * lock only guards in-memory enforcer access: metadata ids are resolved
before write-lock
- * acquisition (see {@link #resolveRolePolicies}), so no DB round-trip runs
inside the critical
- * section.
+ * <p>Writers include {@link #invalidateRolePolicies}, {@link
#replaceRolePolicies}, and the
+ * {@link JcasbinLoadedRolesCache} removal listener. Readers include all
{@code enforce()} and
+ * policy-inspection calls. The lock only guards in-memory enforcer access:
metadata ids are
+ * resolved before write-lock acquisition (see {@link
#resolveRolePolicies}), so no DB round-trip
+ * runs inside the critical section.
*/
private final ReentrantReadWriteLock rolePolicyLock = new
ReentrantReadWriteLock();
@@ -479,12 +478,10 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
}
UserUpdatedAt userInfo = userInfoOpt.get();
long userId = userInfo.getUserId();
- // Bind the user's (direct + group-inherited) roles into the enforcers so
the in-memory scan
- // below sees every policy the user can carry. Idempotent within a request.
+ // Capture this request's direct + group-inherited roles for the in-memory
policy scan.
loadRolePrivilege(metalake, principal.getName(), userId, userInfo,
requestContext);
Set<String> privilegeNames =
privileges.stream().map(Enum::name).collect(Collectors.toSet());
- String userIdStr = String.valueOf(userId);
// A missing answer means the user's roles kept losing their policies;
report a deny so the
// caller falls back to per-object checks instead of short-circuiting on
incomplete policies.
return evaluateWithLoadedRolePolicies(
@@ -499,7 +496,7 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
// listed objects. The match is intentionally scope-agnostic (no
metadataType filter):
// a parent-scope deny hides the whole subtree and an
object-scope deny hides one
// object, and both must disable the short-circuit.
- for (String roleId : denyEnforcer.getRolesForUser(userIdStr)) {
+ for (String roleId : roleSubjects(requestContext, false)) {
// getFilteredNamedPolicy returns every "p" row (p = sub,
metadataType, metadataId,
// act, eft) whose field at POLICY_SUBJECT_FIELD_INDEX (sub)
equals roleId, i.e. all
// rules carried by this role. denyEnforcer is a dedicated
enforcer that is only
@@ -570,7 +567,7 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
}
for (String groupname : currentPrincipalGroupNames()) {
- List<Long> groupRoleIds = loadGroupRoles(metalake, groupname,
userId, requestContext);
+ List<Long> groupRoleIds = loadGroupRoles(metalake, groupname,
requestContext);
if (groupRoleIds.contains(resolvedRoleId)) {
return true;
}
@@ -867,7 +864,8 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
/**
* 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}.
+ * deny authorizer leaves it {@code false} so denies always apply. Denies
always use the full
+ * request role union.
*/
private final boolean narrowByActiveRoles;
@@ -903,7 +901,7 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
String privilege,
AuthorizationRequestContext requestContext) {
// OWNER does not consult JCasbin policies — it short-circuits to the
owner cache in
- // authorizeByJcasbin. Skip the fat prefetch and role-binding work when
no non-OWNER
+ // authorizeByJcasbin. Skip the fat prefetch and role-loading work when
no non-OWNER
// privilege has been evaluated yet in this request.
boolean ownerOnly =
AuthConstants.OWNER.equals(privilege)
@@ -992,7 +990,7 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
String privilege,
AuthorizationRequestContext requestContext) {
// Step 4: JCasbin enforce (pure in-memory) — except OWNER, which is
resolved via the
- // owner cache rather than g-rows.
+ // owner cache rather than role policies.
if (AuthConstants.OWNER.equals(privilege)) {
// Cold-path: resolveOwnerId loads from DB when neither the
per-request nor the shared
// Caffeine cache has the entry, ensuring the first OWNER check
doesn't spuriously deny.
@@ -1005,85 +1003,58 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
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();
+ // Evaluate the request's role union in one policy scan. Shared g(user,
role)
+ // rows cannot represent concurrent requests carrying different IdP
group claims.
boolean allowed =
evaluateWithLoadedRolePolicies(
metalake,
requestContext,
() -> {
- if (narrowByActiveRoles && !activeRoles.isAll()) {
- return enforceNarrowed(
- userId,
- metadataType,
- metadataIdStr,
- privilege,
- activeRoles,
- requestContext);
+ Set<String> subjects = roleSubjects(requestContext,
narrowByActiveRoles);
+ if (subjects.isEmpty()) {
+ return false;
}
- return enforcer.enforce(
- String.valueOf(userId), metadataType, metadataIdStr,
privilege);
+ // Inactive roles still contribute DENY policies when
ALLOW is narrowed.
+ if (narrowByActiveRoles
+ && !requestContext.getActiveRoles().isAll()
+ && denyEnforcer.enforce(
+ roleSubjects(requestContext, false),
+ metadataType,
+ metadataIdStr,
+ privilege)) {
+ return false;
+ }
+ return enforcer.enforce(subjects, metadataType,
metadataIdStr, privilege);
})
.orElse(unstablePolicyResult);
if (narrowByActiveRoles && !allowed) {
- diagnoseDenial(userId, metadataType, metadataIdStr, privilege);
+ diagnoseDenial(userId, metadataType, metadataIdStr, privilege,
requestContext);
}
return allowed;
}
+ }
- /**
- * 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;
+ /** Builds subjects from this request only, without publishing derived
shared state. */
+ private static Set<String> roleSubjects(
+ AuthorizationRequestContext requestContext, boolean activeOnly) {
+ ActiveRoles activeRoles = requestContext.getActiveRoles();
+ if (activeOnly && activeRoles.isNone()) {
+ return Collections.emptySet();
}
-
- /**
- * 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);
+ Map<Long, RoleUpdatedAt> versions =
requestContext.getPrefetchedRoleVersions();
+ Set<String> subjects = new HashSet<>();
+ for (Long roleId : requestContext.getBoundRoleIds()) {
+ if (!activeOnly || activeRoles.isAll()) {
+ subjects.add(String.valueOf(roleId));
+ } else if (versions != null) {
+ RoleUpdatedAt role = versions.get(roleId);
+ if (role != null &&
activeRoles.roleNames().contains(role.getRoleName())) {
+ subjects.add(String.valueOf(roleId));
}
}
- return result;
}
+ return subjects;
}
//
---------------------------------------------------------------------------
@@ -1124,7 +1095,8 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
* consumed by {@link #versionCheckAndLoadRoles} to skip its dedicated
probe.
* </ul>
*
- * <p>The fat prefetch runs at most once per request, gated by {@code
prefetchedRoleVersions}.
+ * <p>The fat prefetch runs at most once per request. Its success or runtime
failure is shared by
+ * parallel workers; a failed prefetch can be retried by a fresh request.
*/
private Optional<UserUpdatedAt> prefetchUserAndGroupInfo(
String metalake,
@@ -1132,6 +1104,20 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
List<String> groupNames,
AuthorizationRequestContext requestContext) {
+ if (requestContext.getPrefetchedRoleVersions() != null) {
+ return loadUserInfo(metalake, username, requestContext);
+ }
+ // Use the request's terminal result, including failures, so waiting list
workers do not
+ // serialize repeated failed queries. This shares the monitor used by role
loading.
+ return requestContext.computeAuthSubjectPrefetchIfAbsent(
+ () -> prefetchUserAndGroupInfoOnce(metalake, username, groupNames,
requestContext));
+ }
+
+ private Optional<UserUpdatedAt> prefetchUserAndGroupInfoOnce(
+ String metalake,
+ String username,
+ List<String> groupNames,
+ AuthorizationRequestContext requestContext) {
String userKey = JcasbinAuthorizationCacheKeys.userRoleKey(metalake,
username);
if (requestContext.getPrefetchedRoleVersions() != null) {
return loadUserInfo(metalake, username, requestContext);
@@ -1279,49 +1265,14 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
// version-validated path as users (group_meta.updated_at as the
staleness sentinel).
List<Long> groupInheritedRoleIds = new ArrayList<>();
for (String groupname : currentPrincipalGroupNames()) {
- groupInheritedRoleIds.addAll(
- loadGroupRoles(metalake, groupname, userId, requestContext));
+ groupInheritedRoleIds.addAll(loadGroupRoles(metalake, groupname,
requestContext));
}
- // Prune stale g-rows: any role currently bound but no longer in the
desired
- // set (e.g. user removed from a group at the IdP, or role
unassigned).
- Set<String> desiredRoleIds = new HashSet<>();
- for (Long id : userDirectRoleIds) {
- desiredRoleIds.add(String.valueOf(id));
- }
- for (Long id : groupInheritedRoleIds) {
- desiredRoleIds.add(String.valueOf(id));
- }
- String userIdStr = String.valueOf(userId);
- List<String> staleRoleIds = new ArrayList<>();
- rolePolicyLock.readLock().lock();
- try {
- Set<String> currentRoleIds = new
HashSet<>(allowEnforcer.getRolesForUser(userIdStr));
- currentRoleIds.addAll(denyEnforcer.getRolesForUser(userIdStr));
- for (String currentRole : currentRoleIds) {
- if (!desiredRoleIds.contains(currentRole)) {
- staleRoleIds.add(currentRole);
- }
- }
- } finally {
- rolePolicyLock.readLock().unlock();
- }
- if (!staleRoleIds.isEmpty()) {
- rolePolicyLock.writeLock().lock();
- try {
- for (String currentRole : staleRoleIds) {
- allowEnforcer.deleteRoleForUser(userIdStr, currentRole);
- denyEnforcer.deleteRoleForUser(userIdStr, currentRole);
- }
- } finally {
- rolePolicyLock.writeLock().unlock();
- }
- }
-
- // Step 3: batch version-check all role IDs (direct +
group-inherited),
- // load stale ones (1 query for the version probe).
- List<Long> allRoleIds = new ArrayList<>(userDirectRoleIds);
- allRoleIds.addAll(groupInheritedRoleIds);
+ // Deduplicate the request's role union without publishing user
membership to shared
+ // enforcers. Other requests may carry a different group set for the
same stored user.
+ Set<Long> roleUnion = new LinkedHashSet<>(userDirectRoleIds);
+ roleUnion.addAll(groupInheritedRoleIds);
+ List<Long> allRoleIds = new ArrayList<>(roleUnion);
if (!allRoleIds.isEmpty()) {
requestContext.setUnreadableRoleIds(
versionCheckAndLoadRoles(metalake, allRoleIds,
requestContext));
@@ -1342,7 +1293,6 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
// Cache is still valid. The user id check prevents reusing roles after
deleting and
// recreating the same username with a new entity id.
CachedUserRoleRels cached = cachedOpt.get();
- bindUserRoles(userId, cached.getRoleIds());
return cached.getRoleIds();
}
@@ -1353,7 +1303,6 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
userRoleCache.put(
userCacheKey, new CachedUserRoleRels(userId, userInfo.getUpdatedAt(),
roleIds));
- bindUserRoles(userId, roleIds);
return roleIds;
}
@@ -1376,12 +1325,11 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
* Version-validated group-role load, mirroring {@link #loadUserRoles}. A
cached snapshot is valid
* only when it belongs to the current group id and is at least as fresh as
{@code
* group_meta.updated_at}; the group id check prevents reusing stale roles
after a
- * delete-and-create of the same group name. In both cases the resulting
role IDs are bound to the
- * user's jcasbin g-rows so that the enforcer sees inherited privileges.
Groups missing from the
- * DB return an empty list.
+ * delete-and-create of the same group name. The resulting role IDs are
added only to the
+ * request's role union. Groups missing from the DB return an empty list.
*/
private List<Long> loadGroupRoles(
- String metalake, String groupname, long userId,
AuthorizationRequestContext requestContext) {
+ String metalake, String groupname, AuthorizationRequestContext
requestContext) {
Optional<GroupUpdatedAt> groupInfoOpt = loadGroupInfo(metalake, groupname,
requestContext);
if (!groupInfoOpt.isPresent()) {
return new ArrayList<>();
@@ -1394,7 +1342,6 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
if (cachedOpt.isPresent()) {
CachedGroupRoleRels cached = cachedOpt.get();
if (cached.getGroupId() == groupId && cached.getUpdatedAt() >=
groupInfo.getUpdatedAt()) {
- bindUserRoles(userId, cached.getRoleIds());
return cached.getRoleIds();
}
}
@@ -1405,7 +1352,6 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
groupRoleCache.put(
groupCacheKey, new CachedGroupRoleRels(groupId,
groupInfo.getUpdatedAt(), roleIds));
- bindUserRoles(userId, roleIds);
return roleIds;
}
@@ -1457,8 +1403,7 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
}
// Any roleId asked about but not returned has been deleted in the DB;
clear its policies so
- // a stale grouping row in the enforcer can't keep granting privileges
before the next
- // userRoleCache reload prunes the g-row itself.
+ // a request-local reference to the deleted role cannot keep granting
privileges.
Set<Long> existingRoleIds = new HashSet<>(roleVersions.size());
for (RoleUpdatedAt rv : roleVersions) {
existingRoleIds.add(rv.getRoleId());
@@ -1591,10 +1536,9 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return false;
}
- // Refresh only permission policies. deleteRole would also remove the
current user's freshly
- // bound grouping links. If a marker exists, its synchronous removal
callback clears the old
- // policies. With no marker, rows can still remain from a previous
partial load and must be
- // cleared explicitly.
+ // Refresh permission policies. If a marker exists, its synchronous
removal callback clears
+ // the old policies. With no marker, rows can still remain from a
previous partial load and
+ // must be cleared explicitly.
roleBeingReplaced = roleId;
try {
loadedRoles.invalidate(roleId);
@@ -1724,11 +1668,11 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
*
* <p>A request loads its roles once, but each check releases {@link
#rolePolicyLock} before the
* next one starts. In between, a {@link #loadedRoles} TTL or size eviction,
or a privilege change
- * on one of the roles, may clear that role's policies while the user's
grouping row stays. A
- * later check would then evaluate without the role: it can deny what the
role allows, or allow
- * what the role denies. So before each evaluation this method checks, under
the read lock,
- * whether a bound role was cleared after the request's recorded generation.
If so, it reloads the
- * cleared roles outside the lock, the same way a request's role load does,
and checks again.
+ * on one of the roles, may clear policies while the request still holds the
role id. A later
+ * check would then evaluate without the role: it can deny what the role
allows, or allow what the
+ * role denies. So before each evaluation this method checks, under the read
lock, whether a bound
+ * role was cleared after the request's recorded generation. If so, it
reloads the cleared roles
+ * outside the lock, the same way a request's role load does, and checks
again.
*
* <p>Earlier decisions keep their existing request-scoped results. Each new
policy evaluation
* runs inside one read-locked section without holding the lock across
metadata or DB lookups.
@@ -1812,42 +1756,6 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return false;
}
- private void bindUserRoles(long userId, List<Long> roleIds) {
- if (roleIds.isEmpty()) {
- return;
- }
-
- String userIdStr = String.valueOf(userId);
- List<Long> missingRoleIds = new ArrayList<>();
- rolePolicyLock.readLock().lock();
- try {
- Set<String> allowRoleIds = new
HashSet<>(allowEnforcer.getRolesForUser(userIdStr));
- Set<String> denyRoleIds = new
HashSet<>(denyEnforcer.getRolesForUser(userIdStr));
- for (Long roleId : roleIds) {
- String roleIdStr = String.valueOf(roleId);
- if (!allowRoleIds.contains(roleIdStr) ||
!denyRoleIds.contains(roleIdStr)) {
- missingRoleIds.add(roleId);
- }
- }
- } finally {
- rolePolicyLock.readLock().unlock();
- }
- if (missingRoleIds.isEmpty()) {
- return;
- }
-
- rolePolicyLock.writeLock().lock();
- try {
- for (Long roleId : missingRoleIds) {
- String roleIdStr = String.valueOf(roleId);
- allowEnforcer.addRoleForUser(userIdStr, roleIdStr);
- denyEnforcer.addRoleForUser(userIdStr, roleIdStr);
- }
- } finally {
- rolePolicyLock.writeLock().unlock();
- }
- }
-
//
---------------------------------------------------------------------------
// Policy loading from role entity
//
---------------------------------------------------------------------------
@@ -1993,7 +1901,11 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
* under investigation; the diagnosis itself is then reported at {@code
WARN}.
*/
private void diagnoseDenial(
- long userId, String metadataType, String metadataIdStr, String
privilege) {
+ long userId,
+ String metadataType,
+ String metadataIdStr,
+ String privilege,
+ AuthorizationRequestContext requestContext) {
if (!LOG.isDebugEnabled()) {
return;
}
@@ -2002,10 +1914,10 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
Map<String, Integer> rolePolicyCounts = new HashMap<>();
rolePolicyLock.readLock().lock();
try {
- List<String> boundRoles = allowEnforcer.getRolesForUser(userIdStr);
+ Set<String> boundRoles = roleSubjects(requestContext, false);
if (boundRoles.isEmpty()) {
LOG.debug(
- "Denied [{}, {}, {}, {}]: no role is bound to the user in the
allow enforcer",
+ "Denied [{}, {}, {}, {}]: no role is bound to this request",
userIdStr,
metadataType,
metadataIdStr,
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java
index 46f20068fe..467141a965 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java
@@ -34,10 +34,9 @@ import org.slf4j.LoggerFactory;
* cleaner may ignore a stale removal when the role was reloaded while the
callback waited for the
* authorizer's policy mutation lock.
*
- * <p>This cache owns role permission policies only. Therefore, eviction must
clear only {@code
- * p(roleId, ...)} policies and must not delete the role itself, because
JCasbin's {@code
- * deleteRole(roleId)} also removes {@code g(user/group, roleId)} bindings
that are managed
- * separately by {@link JcasbinAuthorizer}.
+ * <p>This cache owns role permission policies only. Eviction clears the
{@code p(roleId, ...)}
+ * policies; requests evaluate their own role-id sets, so there is no user or
group membership in
+ * the enforcers to remove.
*
* <p>The TTL is <b>write-based</b>, matching every other authorization cache.
An access-based TTL
* would be renewed by the version probe that {@code versionCheckAndLoadRoles}
performs on every
diff --git a/server-common/src/main/resources/jcasbin_model.conf
b/server-common/src/main/resources/jcasbin_model.conf
index ba17c87b09..cd0c7de05b 100644
--- a/server-common/src/main/resources/jcasbin_model.conf
+++ b/server-common/src/main/resources/jcasbin_model.conf
@@ -15,41 +15,17 @@
; specific language governing permissions and limitations
; under the License.
-; The model file of jcasbin defines a permission model used to describe the
permissions
-; of users, groups, and roles for a single metadata type.
-
-; "r" represents the parameters passed when making a request to jCasbin.
-; "sub" represents a roleId, userId, or groupCodeId.
-; The combination of `sub` and `metalakeId` uniquely identifies a user, role,
or group.
-; "metadataType" represents the type of metadata.
-; "metadataId" represents the id of metadata
-; "act" represents the privilege that needs to be authorized or whether it is
an OWNER.
+; r.sub is the set of role-id strings held by this request.
+; p.sub is one role-id string. No shared user/group membership is consulted.
[request_definition]
r = sub, metadataType, metadataId, act
-; "p" represents a permission policy.
-; "eft" stands for "effect" which can be either "allow" or "deny".
[policy_definition]
p = sub, metadataType, metadataId, act, eft
-; "g" represents the membership or ownership relationship of users, groups, or
roles.
-[role_definition]
-g = _, _
-
-; "e" represents the effect of the "eft",eft performs a logical combination
judgment on the matching results
-; of Matchers.
-; e = some(where(p.eft == allow)): This statement means that if the matching
strategy result p.eft has the result
-; of (some) allow
-; e = some(where (p.eft == allow)) && !some(where (p.eft == deny)): The
logical meaning of this example combination
-; is: if there is a strategy that matches the result of allow and no strategy
that matches the result of deny,
-; the result is true. In other words, it is true when the matching strategies
are all allow. If there is any deny,
-; both are false (more simply, when allow and deny exist at the same time,
deny takes precedence).
-;
-; see more in https://casbin.org/zh/docs/how-it-works/#effect
[policy_effect]
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
-; "m" represents the matching rules of the model
[matchers]
-m = g(r.sub, p.sub) && r.metadataId == p.metadataId && r.metadataType ==
p.metadataType && ( (p.act == "OWNER" && r.act == "OWNER") || r.act == p.act )
-
+; include is provided by the existing expression engine; no custom function is
registered.
+m = include(r.sub, p.sub) && r.metadataId == p.metadataId && r.metadataType ==
p.metadataType && r.act == p.act
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestJcasbinModel.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestJcasbinModel.java
index 5ddfe35ba2..d74791e1bc 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestJcasbinModel.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestJcasbinModel.java
@@ -24,12 +24,17 @@ import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.server.authorization.jcasbin.GravitinoAdapter;
+import org.apache.gravitino.server.authorization.jcasbin.JcasbinAuthorizer;
import org.casbin.jcasbin.main.Enforcer;
+import org.casbin.jcasbin.main.SyncedEnforcer;
import org.casbin.jcasbin.model.Model;
import org.casbin.jcasbin.persist.file_adapter.FileAdapter;
import org.junit.jupiter.api.Assertions;
@@ -39,6 +44,17 @@ import org.junit.jupiter.api.Test;
/** Test for jcasbin model. */
public class TestJcasbinModel {
+ /**
+ * Roles each test principal holds. {@link JcasbinAuthorizer} resolves user
and group membership
+ * before evaluation, so the model only sees the request's role ids.
+ */
+ private static final Map<String, Set<String>> ROLES_BY_PRINCIPAL =
+ Map.of(
+ "user1", Set.of("role5"),
+ "user2", Set.of("role5"),
+ "user3", Set.of("role1", "role5"),
+ "group1", Set.of("role5"));
+
/** Jcasbin enforcer */
private static Enforcer enforcer;
@@ -67,6 +83,13 @@ public class TestJcasbinModel {
}
}
+ private static Set<String> roles(String principal) {
+ if (principal.startsWith("role")) {
+ return Set.of(principal);
+ }
+ return ROLES_BY_PRINCIPAL.getOrDefault(principal, Set.of());
+ }
+
/**
* "role1" is the OWNER of metalake1. Determine whether role1 has the OWNER
permission for
* metalake1.
@@ -74,18 +97,20 @@ public class TestJcasbinModel {
@Test
public void testMetalakeOwner() {
Assertions.assertTrue(
- enforcer.enforce("role1", MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
+ enforcer.enforce(
+ roles("role1"), MetadataObject.Type.METALAKE.name(), "metalake1",
"OWNER"));
Assertions.assertFalse(
enforcer.enforce(
- "role1",
+ roles("role1"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
- enforcer.enforce("role1", MetadataObject.Type.METALAKE.name(),
"metalake2", "OWNER"));
+ enforcer.enforce(
+ roles("role1"), MetadataObject.Type.METALAKE.name(), "metalake2",
"OWNER"));
Assertions.assertFalse(
enforcer.enforce(
- "role1",
+ roles("role1"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.USE_CATALOG.name()));
@@ -98,24 +123,24 @@ public class TestJcasbinModel {
@Test
public void testCatalogOwner() {
Assertions.assertTrue(
- enforcer.enforce("role2", MetadataObject.Type.CATALOG.name(),
"catalog1", "OWNER"));
+ enforcer.enforce(roles("role2"), MetadataObject.Type.CATALOG.name(),
"catalog1", "OWNER"));
Assertions.assertFalse(
enforcer.enforce(
- "role2",
+ roles("role2"),
MetadataObject.Type.CATALOG.name(),
"catalog1",
Privilege.Name.USE_SCHEMA.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role2",
+ roles("role2"),
MetadataObject.Type.CATALOG.name(),
"catalog1",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
- enforcer.enforce("role2", MetadataObject.Type.CATALOG.name(),
"catalog2", "OWNER"));
+ enforcer.enforce(roles("role2"), MetadataObject.Type.CATALOG.name(),
"catalog2", "OWNER"));
Assertions.assertFalse(
enforcer.enforce(
- "role2",
+ roles("role2"),
MetadataObject.Type.CATALOG.name(),
"catalog2",
Privilege.Name.USE_CATALOG.name()));
@@ -127,18 +152,18 @@ public class TestJcasbinModel {
@Test
public void testSchemaOwner() {
Assertions.assertTrue(
- enforcer.enforce("role3", MetadataObject.Type.SCHEMA.name(),
"schema1", "OWNER"));
+ enforcer.enforce(roles("role3"), MetadataObject.Type.SCHEMA.name(),
"schema1", "OWNER"));
Assertions.assertFalse(
enforcer.enforce(
- "role3",
+ roles("role3"),
MetadataObject.Type.SCHEMA.name(),
"schema1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
- enforcer.enforce("role3", MetadataObject.Type.SCHEMA.name(),
"schema2", "OWNER"));
+ enforcer.enforce(roles("role3"), MetadataObject.Type.SCHEMA.name(),
"schema2", "OWNER"));
Assertions.assertFalse(
enforcer.enforce(
- "role3",
+ roles("role3"),
MetadataObject.Type.SCHEMA.name(),
"schema2",
Privilege.Name.SELECT_TABLE.name()));
@@ -148,26 +173,26 @@ public class TestJcasbinModel {
@Test
public void testTableOwner() {
Assertions.assertTrue(
- enforcer.enforce("role4", MetadataObject.Type.TABLE.name(), "table1",
"OWNER"));
+ enforcer.enforce(roles("role4"), MetadataObject.Type.TABLE.name(),
"table1", "OWNER"));
Assertions.assertFalse(
enforcer.enforce(
- "role4",
+ roles("role4"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role4",
+ roles("role4"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
- enforcer.enforce("role3", MetadataObject.Type.SCHEMA.name(), "table1",
"OWNER"));
+ enforcer.enforce(roles("role3"), MetadataObject.Type.SCHEMA.name(),
"table1", "OWNER"));
Assertions.assertFalse(
- enforcer.enforce("role3", MetadataObject.Type.SCHEMA.name(), "table2",
"OWNER"));
+ enforcer.enforce(roles("role3"), MetadataObject.Type.SCHEMA.name(),
"table2", "OWNER"));
Assertions.assertFalse(
enforcer.enforce(
- "role4",
+ roles("role4"),
MetadataObject.Type.TABLE.name(),
"table2",
Privilege.Name.SELECT_TABLE.name()));
@@ -178,94 +203,96 @@ public class TestJcasbinModel {
public void testRolePrivilege() {
// "role5" has partial privilege.
Assertions.assertFalse(
- enforcer.enforce("role5", MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
+ enforcer.enforce(
+ roles("role5"), MetadataObject.Type.METALAKE.name(), "metalake1",
"OWNER"));
Assertions.assertTrue(
enforcer.enforce(
- "role5",
+ roles("role5"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertTrue(
enforcer.enforce(
- "role5",
+ roles("role5"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertTrue(
enforcer.enforce(
- "role5",
+ roles("role5"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role5",
+ roles("role5"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role5",
+ roles("role5"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role5",
+ roles("role5"),
MetadataObject.Type.TABLE.name(),
"table2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role5",
+ roles("role5"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
// role1000 has no privilege.
Assertions.assertFalse(
- enforcer.enforce("role1000", MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
+ enforcer.enforce(
+ roles("role1000"), MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
Assertions.assertFalse(
enforcer.enforce(
- "role1000",
+ roles("role1000"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role1000",
+ roles("role1000"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role1000",
+ roles("role1000"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role1000",
+ roles("role1000"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role1000",
+ roles("role1000"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role1000",
+ roles("role1000"),
MetadataObject.Type.TABLE.name(),
"table2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "role1000",
+ roles("role1000"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
@@ -279,94 +306,96 @@ public class TestJcasbinModel {
public void testGroupPrivilege() {
// "group1" possesses role5, and therefore, group5 has the permissions of
role5.
Assertions.assertFalse(
- enforcer.enforce("group1", MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
+ enforcer.enforce(
+ roles("group1"), MetadataObject.Type.METALAKE.name(), "metalake1",
"OWNER"));
Assertions.assertTrue(
enforcer.enforce(
- "group1",
+ roles("group1"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertTrue(
enforcer.enforce(
- "group1",
+ roles("group1"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertTrue(
enforcer.enforce(
- "group1",
+ roles("group1"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "group1",
+ roles("group1"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "group1",
+ roles("group1"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "group1",
+ roles("group1"),
MetadataObject.Type.TABLE.name(),
"table2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "group1",
+ roles("group1"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
// group1000 has no roles and therefore has no permissions.
Assertions.assertFalse(
- enforcer.enforce("group1000", MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
+ enforcer.enforce(
+ roles("group1000"), MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
Assertions.assertFalse(
enforcer.enforce(
- "group1000",
+ roles("group1000"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "group1000",
+ roles("group1000"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "group1000",
+ roles("group1000"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "group1000",
+ roles("group1000"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "group1000",
+ roles("group1000"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "group1000",
+ roles("group1000"),
MetadataObject.Type.TABLE.name(),
"table2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "group1000",
+ roles("group1000"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
@@ -380,190 +409,231 @@ public class TestJcasbinModel {
public void testUserPrivilege() {
// ”user1" has the privilege of role5.
Assertions.assertFalse(
- enforcer.enforce("user1", MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
+ enforcer.enforce(
+ roles("user1"), MetadataObject.Type.METALAKE.name(), "metalake1",
"OWNER"));
Assertions.assertTrue(
enforcer.enforce(
- "user1",
+ roles("user1"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertTrue(
enforcer.enforce(
- "user1",
+ roles("user1"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertTrue(
enforcer.enforce(
- "user1",
+ roles("user1"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user1",
+ roles("user1"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user1",
+ roles("user1"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user1",
+ roles("user1"),
MetadataObject.Type.TABLE.name(),
"table2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user1",
+ roles("user1"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
// ”user2" has the privilege of group1.
Assertions.assertFalse(
- enforcer.enforce("user2", MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
+ enforcer.enforce(
+ roles("user2"), MetadataObject.Type.METALAKE.name(), "metalake1",
"OWNER"));
Assertions.assertTrue(
enforcer.enforce(
- "user2",
+ roles("user2"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertTrue(
enforcer.enforce(
- "user2",
+ roles("user2"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user2",
+ roles("user2"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user2",
+ roles("user2"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user2",
+ roles("user2"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user2",
+ roles("user2"),
MetadataObject.Type.TABLE.name(),
"table2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user2",
+ roles("user2"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
// "user3" has the privilege of both role1 and group1.
Assertions.assertTrue(
- enforcer.enforce("user3", MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
+ enforcer.enforce(
+ roles("user3"), MetadataObject.Type.METALAKE.name(), "metalake1",
"OWNER"));
Assertions.assertTrue(
enforcer.enforce(
- "user3",
+ roles("user3"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertTrue(
enforcer.enforce(
- "user3",
+ roles("user3"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user3",
+ roles("user3"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user3",
+ roles("user3"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user3",
+ roles("user3"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user3",
+ roles("user3"),
MetadataObject.Type.TABLE.name(),
"table2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user3",
+ roles("user3"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
Assertions.assertFalse(
- enforcer.enforce("user1000", MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
+ enforcer.enforce(
+ roles("user1000"), MetadataObject.Type.METALAKE.name(),
"metalake1", "OWNER"));
// "user1000" has no roles and therefore has no permissions.
Assertions.assertFalse(
enforcer.enforce(
- "user1000",
+ roles("user1000"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user1000",
+ roles("user1000"),
MetadataObject.Type.METALAKE.name(),
"metalake1",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user1000",
+ roles("user1000"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.MODIFY_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user1000",
+ roles("user1000"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.SELECT_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user1000",
+ roles("user1000"),
MetadataObject.Type.METALAKE.name(),
"metalake2",
Privilege.Name.USE_CATALOG.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user1000",
+ roles("user1000"),
MetadataObject.Type.TABLE.name(),
"table2",
Privilege.Name.MODIFY_TABLE.name()));
Assertions.assertFalse(
enforcer.enforce(
- "user1000",
+ roles("user1000"),
MetadataObject.Type.TABLE.name(),
"table1",
Privilege.Name.SELECT_TABLE.name()));
}
+
+ /** Verifies role-set isolation and global deny precedence with the built-in
matcher. */
+ @Test
+ public void testRequestRolesAndDenyPrecedence() throws Exception {
+ Enforcer enforcer = newEnforcer();
+ enforcer.addPolicy("allow-role", "TABLE", "42", "SELECT_TABLE", "allow");
+ enforcer.addPolicy("deny-role", "TABLE", "42", "SELECT_TABLE", "deny");
+ Assertions.assertTrue(enforcer.enforce(Set.of("allow-role"), "TABLE",
"42", "SELECT_TABLE"));
+ Assertions.assertFalse(
+ enforcer.enforce(Set.of("allow-role", "deny-role"), "TABLE", "42",
"SELECT_TABLE"));
+ Assertions.assertFalse(enforcer.enforce(Set.of("unheld-role"), "TABLE",
"42", "SELECT_TABLE"));
+ Assertions.assertFalse(enforcer.enforce(Set.of(), "TABLE", "42",
"SELECT_TABLE"));
+ Assertions.assertFalse(enforcer.enforce("allow-role", "TABLE", "42",
"SELECT_TABLE"));
+ Assertions.assertFalse(enforcer.enforce(Set.of("allow-role"), "TABLE",
"43", "SELECT_TABLE"));
+ Assertions.assertFalse(enforcer.enforce(Set.of("allow-role"), "SCHEMA",
"42", "SELECT_TABLE"));
+ Assertions.assertFalse(enforcer.enforce(Set.of("allow-role"), "TABLE",
"42", "MODIFY_TABLE"));
+ }
+
+ /** Verifies the deny-only enforcer evaluates the request role set. */
+ @Test
+ public void testDenyEnforcerUsesRoleSet() throws Exception {
+ Enforcer deny = newEnforcer();
+ // The deny enforcer represents DENY policies with the allow effect to
answer existence checks.
+ deny.addPolicy("deny-role", "TABLE", "42", "SELECT_TABLE", "allow");
+ Assertions.assertTrue(
+ deny.enforce(Set.of("allow-role", "deny-role"), "TABLE", "42",
"SELECT_TABLE"));
+ Assertions.assertFalse(deny.enforce(Set.of("allow-role"), "TABLE", "42",
"SELECT_TABLE"));
+ }
+
+ private Enforcer newEnforcer() throws Exception {
+ Model model = new Model();
+ try (InputStream input =
getClass().getResourceAsStream("/jcasbin_model.conf")) {
+ model.loadModelFromText(new String(input.readAllBytes(),
StandardCharsets.UTF_8));
+ }
+ Enforcer enforcer = new SyncedEnforcer(model, new GravitinoAdapter());
+ return enforcer;
+ }
}
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 ad84591415..380b557f3d 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
@@ -23,6 +23,7 @@ import static
org.apache.gravitino.authorization.Privilege.Name.USE_SCHEMA;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
@@ -46,10 +47,12 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.Principal;
import java.time.Duration;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -58,18 +61,22 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Function;
import java.util.stream.Collectors;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
import org.apache.gravitino.Entity;
import org.apache.gravitino.EntityStore;
import org.apache.gravitino.GravitinoEnv;
@@ -104,7 +111,10 @@ import org.apache.gravitino.semantic.SemanticModel;
import org.apache.gravitino.semantic.SemanticModelChange;
import org.apache.gravitino.server.ServerConfig;
import org.apache.gravitino.server.authorization.AuthorizationRequestScope;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
+import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
import org.apache.gravitino.server.authorization.MetadataIdConverter;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
import org.apache.gravitino.storage.relational.mapper.GroupMetaMapper;
@@ -132,6 +142,8 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
@@ -202,7 +214,7 @@ public class TestJcasbinAuthorizer {
/**
* Recreated per test in {@link #createAuthorizer()} so each case starts
with empty enforcer state
- * and a fresh cache; the previous static instance leaked g-rows and cache
entries across cases.
+ * and a fresh cache; a shared instance would leak role policies and cache
entries across cases.
*/
private JcasbinAuthorizer jcasbinAuthorizer;
@@ -382,7 +394,7 @@ public class TestJcasbinAuthorizer {
@BeforeEach
public void createAuthorizer() throws Exception {
- // Build a fresh authorizer per test so enforcer g-rows and
version-validated cache state can
+ // Build a fresh authorizer per test so enforcer policies and
version-validated cache state can
// never bleed across cases regardless of the JUnit execution order.
CatalogManager catalogs = mock(CatalogManager.class);
BaseCatalog<?> catalog = mock(BaseCatalog.class);
@@ -479,8 +491,8 @@ public class TestJcasbinAuthorizer {
assertTrue(doAuthorize(currentPrincipal));
// Test role cache.
- // When the user's role changes to one with no privileges, the prune step
removes
- // the stale role's g-rows from the enforcer, so authorization fails
immediately.
+ // When the user's role changes to one with no privileges, the request's
role set no longer
+ // contains the old role, so authorization fails immediately.
Long newRoleId = -1L;
RoleEntity tempNewRole = getRoleEntity(newRoleId, "tempNewRole",
ImmutableList.of());
when(entityStore.get(
@@ -495,7 +507,7 @@ public class TestJcasbinAuthorizer {
.thenReturn(ImmutableList.of(new RoleUpdatedAt(newRoleId,
"tempNewRole", roleVersion2)));
when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
.thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
- // tempNewRole has no privileges; prune step removes stale allowRole
g-row, so authz fails.
+ // tempNewRole has no privileges and the request no longer holds
allowRole, so authz fails.
assertFalse(doAuthorize(currentPrincipal));
// After clearing the role policy cache, the next authorize forces a
reload.
@@ -631,7 +643,7 @@ public class TestJcasbinAuthorizer {
// a roleId whose version probe row is missing (e.g. cache window race,
future
// code path bypassing the fat-JOIN), the fix must still clear that role's
// p-rows from both enforcers and evict its loadedRoles entry so that any
- // residual user → deleted-role g-row grants nothing on subsequent
enforce()s.
+ // request-local membership in the deleted role grants nothing on
subsequent checks.
makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
@@ -1211,9 +1223,10 @@ public class TestJcasbinAuthorizer {
Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer);
String roleIdStr = String.valueOf(ALLOW_ROLE_ID);
+ AuthorizationRequestContext requestContext = new
AuthorizationRequestContext();
assertTrue(
jcasbinAuthorizer.authorize(
- currentPrincipal, METALAKE, catalog, USE_CATALOG, new
AuthorizationRequestContext()));
+ currentPrincipal, METALAKE, catalog, USE_CATALOG, requestContext));
List<List<String>> policyRows = allowEnforcer.getFilteredPolicy(0,
roleIdStr);
assertFalse(policyRows.isEmpty());
@@ -1238,7 +1251,7 @@ public class TestJcasbinAuthorizer {
catalog,
CATALOG_ID,
USE_CATALOG,
- new AuthorizationRequestContext());
+ requestContext);
}));
}
@@ -1313,8 +1326,8 @@ public class TestJcasbinAuthorizer {
jcasbinAuthorizer.authorize(
currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG,
requestContext));
- // TTL expiry or size eviction runs the removal listener, which clears the
role's p-rows but
- // keeps the user's g-row. The request will not run its one-time role load
again.
+ // TTL expiry or size eviction runs the removal listener, which clears the
role's p-rows while
+ // the request still holds the role id. The request will not run its
one-time role load again.
getLoadedRolesCache(jcasbinAuthorizer).invalidate(ALLOW_ROLE_ID);
assertTrue(
getAllowEnforcer(jcasbinAuthorizer)
@@ -2608,8 +2621,8 @@ public class TestJcasbinAuthorizer {
// User is removed from the group at the IdP level -- next token has no
groups.
UserPrincipal noGroupPrincipal = setCurrentPrincipalWithGroup(null);
- // The prune step detects that the group-inherited role is no longer valid
- // (group not in token → role not in desiredRoleIds) and removes the stale
g-rows.
+ // The group is no longer in the token, so the request's role set excludes
the
+ // group-inherited role.
// Access is denied immediately without waiting for cache TTL expiry.
assertFalse(doAuthorize(noGroupPrincipal));
@@ -2947,6 +2960,511 @@ public class TestJcasbinAuthorizer {
getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
}
+ /** Another request with different groups must not grant roles to this
request. */
+ @Test
+ public void testInterleavedGroupRequestsMustNotInjectAllow() throws
Exception {
+ metadataIdConverterMockedStatic
+ .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+ .thenReturn(Optional.of(CATALOG_ID));
+ try {
+ RoleEntity groupRole =
+ mockRoleInStore(901L, "probeGroupAllow",
ImmutableList.of(getAllowSecurableObject()));
+ mockNoDirectUserRoles();
+ mockGroupWithRoles(
+ GROUP_NAME, ImmutableList.of(groupRole.id()),
ImmutableList.of(groupRole.name()));
+
+ UserPrincipal noGroupPrincipal = setCurrentPrincipalWithGroup(null);
+ AuthorizationRequestContext noGroupContext = new
AuthorizationRequestContext();
+ assertFalse(
+ jcasbinAuthorizer.authorize(
+ noGroupPrincipal, METALAKE, metalakeObject(), USE_CATALOG,
noGroupContext));
+ assertTrue(noGroupContext.getBoundRoleIds().isEmpty());
+
+ UserPrincipal groupPrincipal = setCurrentPrincipalWithGroup(GROUP_NAME);
+ assertTrue(
+ jcasbinAuthorizer.authorize(
+ groupPrincipal,
+ METALAKE,
+ catalogObject(),
+ USE_CATALOG,
+ new AuthorizationRequestContext()));
+
+ principalUtilsMockedStatic
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(noGroupPrincipal);
+ assertFalse(
+ jcasbinAuthorizer.authorize(
+ noGroupPrincipal, METALAKE, catalogObject(), USE_CATALOG,
noGroupContext),
+ "request without groups must not inherit another request's group
ALLOW");
+
+ } finally {
+ metadataIdConverterMockedStatic
+ .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+ .thenReturn(Optional.of(CATALOG_ID));
+ restoreDefaultPrincipal();
+ }
+ }
+
+ /** Another request with no groups must not remove this request's group
deny. */
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testInterleavedGroupRequestsMustNotRemoveDeny(boolean narrow)
throws Exception {
+ metadataIdConverterMockedStatic
+ .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+ .thenReturn(Optional.of(CATALOG_ID));
+ try {
+ RoleEntity allowRole =
+ mockRoleInStore(
+ ALLOW_ROLE_ID, "probeDirectAllow",
ImmutableList.of(getAllowSecurableObject()));
+ RoleEntity denyRole =
+ mockRoleInStore(
+ DENY_ROLE_ID, "probeGroupDeny",
ImmutableList.of(getDenySecurableObject()));
+ mockDirectUserRoles(allowRole);
+ mockGroupWithRoles(
+ GROUP_NAME, ImmutableList.of(denyRole.id()),
ImmutableList.of(denyRole.name()));
+
+ UserPrincipal groupPrincipal = setCurrentPrincipalWithGroup(GROUP_NAME);
+ AuthorizationRequestContext groupContext = new
AuthorizationRequestContext();
+ if (narrow) {
+
groupContext.setActiveRoles(ActiveRoles.of(ImmutableList.of("probeDirectAllow")));
+ }
+ assertFalse(
+ jcasbinAuthorizer.authorize(
+ groupPrincipal, METALAKE, metalakeObject(), USE_CATALOG,
groupContext));
+ assertTrue(groupContext.getBoundRoleIds().contains(DENY_ROLE_ID));
+
+ UserPrincipal noGroupPrincipal = setCurrentPrincipalWithGroup(null);
+ assertTrue(
+ jcasbinAuthorizer.authorize(
+ noGroupPrincipal,
+ METALAKE,
+ catalogObject(),
+ USE_CATALOG,
+ new AuthorizationRequestContext()));
+
+ principalUtilsMockedStatic
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(groupPrincipal);
+ assertTrue(
+ jcasbinAuthorizer.deny(
+ groupPrincipal, METALAKE, catalogObject(), USE_CATALOG,
groupContext),
+ "another request must not prune this request's group DENY");
+ assertFalse(
+ jcasbinAuthorizer.authorize(
+ groupPrincipal, METALAKE, catalogObject(), USE_CATALOG,
groupContext));
+ assertTrue(
+ jcasbinAuthorizer.hasDenyPolicy(
+ groupPrincipal, METALAKE, ImmutableSet.of(USE_CATALOG),
groupContext));
+ principalUtilsMockedStatic
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(noGroupPrincipal);
+ assertFalse(
+ jcasbinAuthorizer.hasDenyPolicy(
+ noGroupPrincipal,
+ METALAKE,
+ ImmutableSet.of(USE_CATALOG),
+ new AuthorizationRequestContext()));
+
+ } finally {
+ metadataIdConverterMockedStatic
+ .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+ .thenReturn(Optional.of(CATALOG_ID));
+ restoreDefaultPrincipal();
+ }
+ }
+
+ /**
+ * Parallel list workers share one prefetch outcome, including missing users
and query failures.
+ *
+ * @param failQuery whether the initial query fails
+ */
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testParallelRequestPrefetchIsSingleFlight(boolean failQuery)
throws Exception {
+ UserMetaMapper requestMapper = mock(UserMetaMapper.class);
+ AuthorizationRequestContext context = new AuthorizationRequestContext();
+ AtomicInteger queries = new AtomicInteger();
+ RuntimeException queryFailure = new RuntimeException("auth subject
database unavailable");
+ CountDownLatch queried = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ CountDownLatch started = new CountDownLatch(8);
+ when(requestMapper.batchGetAuthSubjectsForUser(eq(METALAKE), eq(USERNAME),
anyList()))
+ .thenAnswer(
+ invocation -> {
+ queries.incrementAndGet();
+ queried.countDown();
+ assertTrue(release.await(10, TimeUnit.SECONDS));
+ if (failQuery) {
+ throw queryFailure;
+ }
+ return Collections.emptyList();
+ });
+ Method prefetch =
+ JcasbinAuthorizer.class.getDeclaredMethod(
+ "prefetchUserAndGroupInfo",
+ String.class,
+ String.class,
+ List.class,
+ AuthorizationRequestContext.class);
+ prefetch.setAccessible(true);
+ ExecutorService workers = Executors.newFixedThreadPool(8);
+ try {
+ List<Future<?>> results = new ArrayList<>();
+ for (int i = 0; i < 8; i++) {
+ results.add(
+ workers.submit(
+ () -> {
+ // Static mocks are thread-local; each real worker needs its
own mapper bridge.
+ try (MockedStatic<SessionUtils> sessions =
mockStatic(SessionUtils.class)) {
+ sessions
+ .when(() ->
SessionUtils.getWithoutCommit(eq(UserMetaMapper.class), any()))
+ .thenAnswer(
+ invocation -> {
+ Function<UserMetaMapper, ?> query =
invocation.getArgument(1);
+ return query.apply(requestMapper);
+ });
+ started.countDown();
+ if (failQuery) {
+ InvocationTargetException failure =
+ assertThrows(
+ InvocationTargetException.class,
+ () ->
+ prefetch.invoke(
+ jcasbinAuthorizer,
+ METALAKE,
+ USERNAME,
+ Collections.emptyList(),
+ context));
+ assertSame(queryFailure, failure.getCause());
+ } else {
+ assertEquals(
+ Optional.empty(),
+ prefetch.invoke(
+ jcasbinAuthorizer,
+ METALAKE,
+ USERNAME,
+ Collections.emptyList(),
+ context));
+ }
+ }
+ return null;
+ }));
+ }
+ assertTrue(started.await(10, TimeUnit.SECONDS));
+ assertTrue(queried.await(10, TimeUnit.SECONDS));
+ release.countDown();
+ for (Future<?> result : results) {
+ result.get(10, TimeUnit.SECONDS);
+ }
+ assertEquals(1, queries.get());
+ if (failQuery) {
+ assertNull(context.getPrefetchedRoleVersions());
+ // Recover the backend: the failed request must retain its error,
while a new one retries.
+ when(requestMapper.batchGetAuthSubjectsForUser(eq(METALAKE),
eq(USERNAME), anyList()))
+ .thenAnswer(
+ invocation -> {
+ queries.incrementAndGet();
+ return Collections.emptyList();
+ });
+ try {
+ sessionUtilsMockedStatic
+ .when(() ->
SessionUtils.getWithoutCommit(eq(UserMetaMapper.class), any()))
+ .thenAnswer(
+ invocation -> {
+ Function<UserMetaMapper, ?> query =
invocation.getArgument(1);
+ return query.apply(requestMapper);
+ });
+ InvocationTargetException failure =
+ assertThrows(
+ InvocationTargetException.class,
+ () ->
+ prefetch.invoke(
+ jcasbinAuthorizer, METALAKE, USERNAME,
Collections.emptyList(), context));
+ assertSame(queryFailure, failure.getCause());
+ assertEquals(1, queries.get());
+ assertEquals(
+ Optional.empty(),
+ prefetch.invoke(
+ jcasbinAuthorizer,
+ METALAKE,
+ USERNAME,
+ Collections.emptyList(),
+ new AuthorizationRequestContext()));
+ assertEquals(2, queries.get());
+ } finally {
+ sessionUtilsMockedStatic
+ .when(() ->
SessionUtils.getWithoutCommit(eq(UserMetaMapper.class), any()))
+ .thenAnswer(
+ invocation -> {
+ Function<UserMetaMapper, ?> query =
invocation.getArgument(1);
+ return query.apply(userMetaMapper);
+ });
+ }
+ } else {
+ assertEquals(Collections.emptyMap(),
context.getPrefetchedRoleVersions());
+ }
+ } finally {
+ release.countDown();
+ workers.shutdownNow();
+ assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS));
+ }
+ }
+
+ /** Failed prefetch rejects subsequent role checks, while a fresh request
can recover. */
+ @Test
+ public void testFailedPrefetchDeniesUntilNextRequest() throws Exception {
+ RoleEntity allowRole =
+ mockRoleInStore(ALLOW_ROLE_ID, "allowRole",
ImmutableList.of(getAllowSecurableObject()));
+ mockDirectUserRoles(allowRole);
+ List<AuthPrefetchRow> rows =
+ userMetaMapper.batchGetAuthSubjectsForUser(METALAKE, USERNAME,
Collections.emptyList());
+ UserMetaMapper requestMapper = mock(UserMetaMapper.class);
+ RuntimeException failure = new RuntimeException("auth subject database
unavailable");
+ when(requestMapper.batchGetAuthSubjectsForUser(eq(METALAKE), eq(USERNAME),
anyList()))
+ .thenThrow(failure)
+ .thenReturn(rows);
+ sessionUtilsMockedStatic
+ .when(() -> SessionUtils.getWithoutCommit(eq(UserMetaMapper.class),
any()))
+ .thenAnswer(
+ invocation -> {
+ Function<UserMetaMapper, ?> query = invocation.getArgument(1);
+ return query.apply(requestMapper);
+ });
+ try {
+ Principal principal = PrincipalUtils.getCurrentPrincipal();
+ AuthorizationRequestContext context = new AuthorizationRequestContext();
+ assertFalse(
+ jcasbinAuthorizer.authorize(principal, METALAKE, metalakeObject(),
USE_CATALOG, context));
+ // Use another object to avoid reusing the first object's cached denial.
+ assertFalse(
+ jcasbinAuthorizer.authorize(principal, METALAKE, catalogObject(),
USE_CATALOG, context));
+ verify(requestMapper).batchGetAuthSubjectsForUser(eq(METALAKE),
eq(USERNAME), anyList());
+ assertTrue(
+ jcasbinAuthorizer.authorize(
+ principal,
+ METALAKE,
+ catalogObject(),
+ USE_CATALOG,
+ new AuthorizationRequestContext()));
+ verify(requestMapper, Mockito.times(2))
+ .batchGetAuthSubjectsForUser(eq(METALAKE), eq(USERNAME), anyList());
+ } finally {
+ sessionUtilsMockedStatic
+ .when(() -> SessionUtils.getWithoutCommit(eq(UserMetaMapper.class),
any()))
+ .thenAnswer(
+ invocation -> {
+ Function<UserMetaMapper, ?> query = invocation.getArgument(1);
+ return query.apply(userMetaMapper);
+ });
+ }
+ }
+
+ /** Parallel checks share only published request membership, including
inactive-role denies. */
+ @Test
+ public void testParallelNamedActiveRolesUsePublishedRequestMembership()
throws Exception {
+ Enforcer allow = getAllowEnforcer(jcasbinAuthorizer);
+ Enforcer deny = getDenyEnforcer(jcasbinAuthorizer);
+ allow.addPolicy(String.valueOf(ALLOW_ROLE_ID), "TABLE", "77",
"SELECT_TABLE", "allow");
+ allow.addPolicy(String.valueOf(ALLOW_ROLE_ID), "TABLE", "78",
"SELECT_TABLE", "allow");
+ deny.addPolicy(String.valueOf(DENY_ROLE_ID), "TABLE", "78",
"SELECT_TABLE", "allow");
+ AuthorizationRequestContext context = new AuthorizationRequestContext();
+ context.setActiveRoles(ActiveRoles.of(ImmutableList.of("parallelAllow")));
+ Field internalField =
JcasbinAuthorizer.class.getDeclaredField("allowInternalAuthorizer");
+ internalField.setAccessible(true);
+ Object internal = internalField.get(jcasbinAuthorizer);
+ Method authorize =
+ internal
+ .getClass()
+ .getDeclaredMethod(
+ "authorizeByJcasbin",
+ long.class,
+ String.class,
+ MetadataObject.class,
+ Long.class,
+ String.class,
+ AuthorizationRequestContext.class);
+ authorize.setAccessible(true);
+ ExecutorService workers = Executors.newFixedThreadPool(9);
+ CountDownLatch loading = new CountDownLatch(1);
+ CountDownLatch publish = new CountDownLatch(1);
+ CountDownLatch checking = new CountDownLatch(8);
+ try {
+ Future<?> loader =
+ workers.submit(
+ () ->
+ context.loadRole(
+ () -> {
+ loading.countDown();
+ try {
+ assertTrue(publish.await(10, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError(e);
+ }
+ context.setPrefetchedRoleVersions(
+ ImmutableMap.of(
+ ALLOW_ROLE_ID,
+ new RoleUpdatedAt(ALLOW_ROLE_ID,
"parallelAllow", 1L),
+ DENY_ROLE_ID, new RoleUpdatedAt(DENY_ROLE_ID,
"parallelDeny", 1L)));
+
context.setBoundRoleIds(ImmutableList.of(ALLOW_ROLE_ID, DENY_ROLE_ID));
+ }));
+ assertTrue(loading.await(10, TimeUnit.SECONDS));
+ List<Future<?>> checks = new ArrayList<>();
+ for (int i = 0; i < 8; i++) {
+ checks.add(
+ workers.submit(
+ () -> {
+ checking.countDown();
+ context.loadRole(
+ () -> {
+ throw new AssertionError("Role loading must be
single-flight");
+ });
+ for (int n = 0; n < 25; n++) {
+ assertTrue(
+ (boolean)
+ authorize.invoke(
+ internal,
+ 1L,
+ METALAKE,
+ MetadataObjects.of(
+ "testCatalog.testSchema", "visible",
MetadataObject.Type.TABLE),
+ 77L,
+ "SELECT_TABLE",
+ context));
+ assertFalse(
+ (boolean)
+ authorize.invoke(
+ internal,
+ 1L,
+ METALAKE,
+ MetadataObjects.of(
+ "testCatalog.testSchema", "hidden",
MetadataObject.Type.TABLE),
+ 78L,
+ "SELECT_TABLE",
+ context));
+ }
+ return null;
+ }));
+ }
+ assertTrue(checking.await(10, TimeUnit.SECONDS));
+ publish.countDown();
+ loader.get(10, TimeUnit.SECONDS);
+ for (Future<?> check : checks) {
+ check.get(10, TimeUnit.SECONDS);
+ }
+ } finally {
+ publish.countDown();
+ workers.shutdownNow();
+ assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS));
+ }
+ }
+
+ /** The real list filter must not expose a table denied by this request's
group role. */
+ @Test
+ public void testInterleavedGroupRequestsMustNotExposeDeniedTable() throws
Exception {
+ metadataIdConverterMockedStatic
+ .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+ .thenReturn(Optional.of(CATALOG_ID));
+ Field executorField =
MetadataAuthzHelper.class.getDeclaredField("executor");
+ executorField.setAccessible(true);
+ Object previousExecutor = executorField.get(null);
+ executorField.set(null, (Executor) Runnable::run);
+ Config previousConfig = gravitinoEnv.config();
+ Config config = mock(Config.class);
+ when(config.get(eq(Configs.ENABLE_AUTHORIZATION))).thenReturn(true);
+ when(gravitinoEnv.config()).thenReturn(config);
+ try {
+ SecurableObject deniedObject =
+ buildSecurableObject(
+ DENY_ROLE_ID,
+ MetadataObject.Type.TABLE,
+ "testCatalog.testSchema.hidden",
+ SELECT_TABLE,
+ "DENY");
+ metadataIdConverterMockedStatic
+ .when(() -> MetadataIdConverter.getID(eq(deniedObject),
eq(METALAKE)))
+ .thenReturn(Optional.of(42L));
+ RoleEntity allowRole =
+ mockRoleInStore(
+ ALLOW_ROLE_ID,
+ "probeListParentAllow",
+ ImmutableList.of(
+ buildSecurableObject(
+ ALLOW_ROLE_ID,
+ MetadataObject.Type.CATALOG,
+ "testCatalog",
+ SELECT_TABLE,
+ "ALLOW"),
+ buildSecurableObject(
+ ALLOW_ROLE_ID,
+ MetadataObject.Type.CATALOG,
+ "testCatalog",
+ USE_CATALOG,
+ "ALLOW"),
+ buildSecurableObject(
+ ALLOW_ROLE_ID,
+ MetadataObject.Type.SCHEMA,
+ "testCatalog.testSchema",
+ USE_SCHEMA,
+ "ALLOW")));
+ RoleEntity denyRole =
+ mockRoleInStore(DENY_ROLE_ID, "probeListGroupDeny",
ImmutableList.of(deniedObject));
+ mockDirectUserRoles(allowRole);
+ mockGroupWithRoles(
+ GROUP_NAME, ImmutableList.of(denyRole.id()),
ImmutableList.of(denyRole.name()));
+ UserPrincipal groupPrincipal = setCurrentPrincipalWithGroup(GROUP_NAME);
+ AuthorizationRequestContext groupContext = new
AuthorizationRequestContext();
+ assertFalse(
+ jcasbinAuthorizer.authorize(
+ groupPrincipal, METALAKE, metalakeObject(), SELECT_TABLE,
groupContext));
+ assertTrue(
+ jcasbinAuthorizer.hasDenyPolicy(
+ groupPrincipal, METALAKE, ImmutableSet.of(SELECT_TABLE),
groupContext));
+
+ UserPrincipal noGroupPrincipal = setCurrentPrincipalWithGroup(null);
+ assertTrue(
+ jcasbinAuthorizer.authorize(
+ noGroupPrincipal,
+ METALAKE,
+ catalogObject(),
+ SELECT_TABLE,
+ new AuthorizationRequestContext()));
+ principalUtilsMockedStatic
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(groupPrincipal);
+
+ GravitinoAuthorizerProvider provider =
mock(GravitinoAuthorizerProvider.class);
+ when(provider.getGravitinoAuthorizer()).thenReturn(jcasbinAuthorizer);
+ try (MockedStatic<GravitinoAuthorizerProvider> providerMock =
+ mockStatic(GravitinoAuthorizerProvider.class);
+ AuthorizationRequestScope scope = AuthorizationRequestScope.open()) {
+
providerMock.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+ scope.bind(METALAKE, groupContext);
+ NameIdentifier hidden = NameIdentifier.of(METALAKE, "testCatalog",
"testSchema", "hidden");
+ NameIdentifier visible =
+ NameIdentifier.of(METALAKE, "testCatalog", "testSchema",
"visible");
+ NameIdentifier[] filtered =
+ MetadataAuthzHelper.filterByExpression(
+ METALAKE,
+
AuthorizationExpressionConstants.LIST_TABLE_LIKE_AUTHORIZATION_EXPRESSION,
+ Entity.EntityType.TABLE,
+ new NameIdentifier[] {hidden, visible});
+ assertTrue(
+ Arrays.asList(filtered).contains(visible), "the permitted table
must remain visible");
+ assertFalse(
+ Arrays.asList(filtered).contains(hidden),
+ "a concurrent no-group request must not expose the group-denied
table identifier");
+ }
+ } finally {
+ executorField.set(null, previousExecutor);
+ when(gravitinoEnv.config()).thenReturn(previousConfig);
+ metadataIdConverterMockedStatic
+ .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+ .thenReturn(Optional.of(CATALOG_ID));
+ restoreDefaultPrincipal();
+ }
+ }
+
/**
* 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.
@@ -3413,33 +3931,26 @@ public class TestJcasbinAuthorizer {
// Add a role and its policy to the enforcer
Long testRoleId = 300L;
String roleIdStr = String.valueOf(testRoleId);
- String userIdStr = String.valueOf(USER_ID);
- // Add a policy and a user-role binding for this role.
- allowEnforcer.addRoleForUser(userIdStr, roleIdStr);
- denyEnforcer.addRoleForUser(userIdStr, roleIdStr);
+ // Add policies for this role.
allowEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
denyEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
// Add role to cache
loadedRoles.put(testRoleId, System.currentTimeMillis());
- // Verify role exists in enforcer (has policy and grouping).
+ // Verify the role policies exist in both enforcers.
assertTrue(allowEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
assertTrue(denyEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
- assertTrue(allowEnforcer.getRolesForUser(userIdStr).contains(roleIdStr));
- assertTrue(denyEnforcer.getRolesForUser(userIdStr).contains(roleIdStr));
// Invalidate the cache entry - this triggers the synchronous removal
listener
// (using executor(Runnable::run) to ensure synchronous execution)
loadedRoles.invalidate(testRoleId);
// Verify the role's policies have been deleted from enforcers
(synchronous, no need to wait),
- // but user-role bindings are preserved because loadedRoles owns role
policies only.
+ // Request role membership is independent of the policy cache.
assertFalse(allowEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
assertFalse(denyEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
- assertTrue(allowEnforcer.getRolesForUser(userIdStr).contains(roleIdStr));
- assertTrue(denyEnforcer.getRolesForUser(userIdStr).contains(roleIdStr));
}
@Test
@@ -3462,16 +3973,13 @@ public class TestJcasbinAuthorizer {
}
@Test
- public void testInvalidateRolePoliciesPreservesUserRoleBindings() throws
Exception {
+ public void testInvalidateRolePoliciesRemovesPoliciesAndBackoff() throws
Exception {
Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer);
Enforcer denyEnforcer = getDenyEnforcer(jcasbinAuthorizer);
GravitinoCache<Long, Boolean> backoff =
getPartialRoleLoadBackoffCache(jcasbinAuthorizer);
Long testRoleId = 302L;
String roleIdStr = String.valueOf(testRoleId);
- String userIdStr = String.valueOf(USER_ID);
- allowEnforcer.addRoleForUser(userIdStr, roleIdStr);
- denyEnforcer.addRoleForUser(userIdStr, roleIdStr);
allowEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
denyEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
backoff.put(testRoleId, Boolean.TRUE);
@@ -3483,8 +3991,6 @@ public class TestJcasbinAuthorizer {
assertFalse(allowEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
assertFalse(denyEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
- assertTrue(allowEnforcer.getRolesForUser(userIdStr).contains(roleIdStr));
- assertTrue(denyEnforcer.getRolesForUser(userIdStr).contains(roleIdStr));
assertFalse(backoff.getIfPresent(testRoleId).isPresent());
}
diff --git a/server-common/src/test/resources/jcasbin_policy.txt
b/server-common/src/test/resources/jcasbin_policy.txt
index e71a5b6c54..e6b9a0c396 100644
--- a/server-common/src/test/resources/jcasbin_policy.txt
+++ b/server-common/src/test/resources/jcasbin_policy.txt
@@ -20,9 +20,3 @@ p, role4, TABLE, table1, OWNER, allow
p, role5, METALAKE, metalake1, SELECT_TABLE, allow
p, role5, METALAKE, metalake1, USE_CATALOG, allow
p, role5, TABLE, table1, SELECT_TABLE, allow
-
-g, user1, role5
-g, group1, role5
-g, user2, group1
-g, user3, role1
-g, user3, group1