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 9d6f263354 [#11088] feat(authz): Optimize JCasbin cache consistency
and role checks (#11174)
9d6f263354 is described below
commit 9d6f263354a53cedc749c805aa4b0a24a95babc9
Author: Qi Yu <[email protected]>
AuthorDate: Fri May 29 18:35:59 2026 +0800
[#11088] feat(authz): Optimize JCasbin cache consistency and role checks
(#11174)
### What changes were proposed in this pull request?
This PR improves JCasbin authorization cache behavior and reduces DB
access on hot authorization paths.
Main changes:
- Batch the current user, groups, user-role relations, group-role
relations, and role version probes into one mapper query for
`authorize()`.
- Route `isSelf(ROLE)` through the same version-validated user/group
role caches used by `authorize()`.
- Add per-request cache priming in `AuthorizationRequestContext` to
avoid repeated user/group/role probes within one request.
- Add local cache invalidation hooks for user-role and group-role
relation changes.
- Add metadata-id / owner cache lookup helpers and poller-backed
invalidation for multi-node deployments.
- Add mapper/service tests and JCasbin authorizer tests for cache reuse,
invalidation, and authorization behavior.
### Why are the changes needed?
After disabling the previous global cache behavior for multi-node
correctness, JCasbin authorization started doing extra DB probes on hot
paths. This PR restores most of the lost performance by batching version
checks and making `isSelf(ROLE)` reuse the same version-validated caches
as normal authorization.
It also makes local cache invalidation explicit when user-role or
group-role relations change, which avoids stale role bindings after
grant/revoke operations.
Fix: #11088
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
- `./gradlew :core:spotlessApply :server-common:spotlessApply`
- `./gradlew :core:test --tests
org.apache.gravitino.storage.relational.mapper.provider.base.TestAuthMappers
-PskipITs`
- `./gradlew :server-common:test --tests
org.apache.gravitino.server.authorization.jcasbin.TestJcasbinAuthorizer
-PskipITs`
- `./gradlew :core:test --tests
org.apache.gravitino.hook.TestAccessControlHookDispatcher -PskipITs`
- `./gradlew :clients:client-java:test --tests
org.apache.gravitino.client.integration.test.authorization.FunctionAuthorizationIT
-PskipDockerTests=false`
---
.../authorization/AuthorizationRequestContext.java | 28 +++
.../authorization/GravitinoAuthorizer.java | 27 +-
.../hook/AccessControlHookDispatcher.java | 48 ++--
.../storage/relational/mapper/OwnerMetaMapper.java | 8 +-
.../mapper/OwnerMetaSQLProviderFactory.java | 10 +-
.../storage/relational/mapper/UserMetaMapper.java | 34 +++
.../mapper/UserMetaSQLProviderFactory.java | 8 +
.../provider/base/OwnerMetaBaseSQLProvider.java | 77 ++++--
.../provider/base/UserMetaBaseSQLProvider.java | 117 +++++++++
.../postgresql/OwnerMetaPostgreSQLProvider.java | 41 +++-
.../relational/po/auth/AuthPrefetchRow.java | 227 +++++++++++++++++
.../hook/TestAccessControlHookDispatcher.java | 38 +++
.../mapper/provider/base/TestAuthMappers.java | 32 ++-
.../relational/service/TestUserMetaService.java | 98 ++++++++
.../authorization/PassThroughAuthorizer.java | 5 +-
.../AuthorizationExpressionConverter.java | 3 +-
.../jcasbin/JcasbinAuthorizationLookups.java | 42 +++-
.../authorization/jcasbin/JcasbinAuthorizer.java | 244 +++++++++++++++---
.../authorization/jcasbin/JcasbinChangePoller.java | 61 +++--
.../authorization/MockGravitinoAuthorizer.java | 5 +-
.../authorization/TestPassThroughAuthorizer.java | 4 +-
.../jcasbin/TestJcasbinAuthorizationLookups.java | 85 ++++++-
.../jcasbin/TestJcasbinAuthorizer.java | 272 ++++++++++++++++++++-
.../jcasbin/TestJcasbinChangePoller.java | 2 +-
.../filter/TestGravitinoInterceptionService.java | 5 +-
25 files changed, 1376 insertions(+), 145 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 554fed8b52..d30f15bee1 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 lombok.Getter;
import org.apache.gravitino.MetadataObject;
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;
import org.apache.gravitino.storage.relational.po.auth.UserUpdatedAt;
/**
@@ -74,6 +75,13 @@ public class AuthorizationRequestContext {
/** Per-request metadataId→owner cache. Deduplicates isOwner within a single
request. */
private final Map<Long, Optional<OwnerInfo>> ownerCache = new
ConcurrentHashMap<>();
+ /**
+ * Per-request roleId → {@link RoleUpdatedAt} map populated by the fat-JOIN
prefetch on the
+ * authorize hot path. When present, {@code versionCheckAndLoadRoles} can
skip its dedicated
+ * {@code batchGetRoleUpdatedAt} round trip.
+ */
+ private volatile Map<Long, RoleUpdatedAt> prefetchedRoleVersions;
+
private volatile String originalAuthorizationExpression;
/**
@@ -185,6 +193,26 @@ public class AuthorizationRequestContext {
this.originalAuthorizationExpression = originalAuthorizationExpression;
}
+ /**
+ * Returns the prefetched roleId → {@link RoleUpdatedAt} map, or {@code
null} when the fat-JOIN
+ * prefetch has not run for this request.
+ *
+ * @return the prefetched role-versions map or {@code null}
+ */
+ public Map<Long, RoleUpdatedAt> getPrefetchedRoleVersions() {
+ return prefetchedRoleVersions;
+ }
+
+ /**
+ * Sets the prefetched roleId → {@link RoleUpdatedAt} map; called once per
request by the
+ * authorize hot path after the fat-JOIN prefetch.
+ *
+ * @param prefetchedRoleVersions roleId → {@link RoleUpdatedAt} map
+ */
+ public void setPrefetchedRoleVersions(Map<Long, RoleUpdatedAt>
prefetchedRoleVersions) {
+ this.prefetchedRoleVersions = prefetchedRoleVersions;
+ }
+
/**
* Composite key for {@link #allowAuthorizerCache} / {@link
#denyAuthorizerCache}. Immutable —
* mutating any field after construction would silently corrupt the {@link
diff --git
a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
index 12529f74c6..f072c15684 100644
---
a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
+++
b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
@@ -88,9 +88,14 @@ public interface GravitinoAuthorizer extends Closeable {
*
* @param type user or group
* @param nameIdentifier name of user or group
+ * @param requestContext authorization request context; enables per-request
dedup with other
+ * authorization calls in the same request
* @return authorization result
*/
- boolean isSelf(Entity.EntityType type, NameIdentifier nameIdentifier);
+ boolean isSelf(
+ Entity.EntityType type,
+ NameIdentifier nameIdentifier,
+ AuthorizationRequestContext requestContext);
/**
* Determine whether the user is the metalake user.
@@ -156,6 +161,26 @@ public interface GravitinoAuthorizer extends Closeable {
}
}
+ /**
+ * Called when the role assignments of a user change.
+ *
+ * @param metalake the metalake name
+ * @param userName the user name
+ */
+ default void handleUserRoleRelChange(String metalake, String userName) {
+ // default no-op for backward compatibility
+ }
+
+ /**
+ * Called when the role assignments of a group change.
+ *
+ * @param metalake the metalake name
+ * @param groupName the group name
+ */
+ default void handleGroupRoleRelChange(String metalake, String groupName) {
+ // default no-op for backward compatibility
+ }
+
/**
* This method is called to clear the owner relationship in jcasbin when the
owner of the metadata
* changes.
diff --git
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
index 2692330962..a4b2e28007 100644
---
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
+++
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
@@ -121,7 +121,7 @@ public class AccessControlHookDispatcher implements
AccessControlDispatcher {
public User grantRolesToUser(String metalake, List<String> roles, String
user)
throws NoSuchUserException, IllegalRoleException,
NoSuchMetalakeException {
User grantedUser = dispatcher.grantRolesToUser(metalake, roles, user);
- notifyRoleUserRelChange(metalake, roles);
+ notifyUserRoleBindingChange(metalake, roles, user);
return grantedUser;
}
@@ -129,7 +129,7 @@ public class AccessControlHookDispatcher implements
AccessControlDispatcher {
public Group grantRolesToGroup(String metalake, List<String> roles, String
group)
throws NoSuchGroupException, IllegalRoleException,
NoSuchMetalakeException {
Group grantedGroup = dispatcher.grantRolesToGroup(metalake, roles, group);
- notifyRoleGroupRelChange(metalake, roles);
+ notifyGroupRoleBindingChange(metalake, roles, group);
return grantedGroup;
}
@@ -137,7 +137,7 @@ public class AccessControlHookDispatcher implements
AccessControlDispatcher {
public Group revokeRolesFromGroup(String metalake, List<String> roles,
String group)
throws NoSuchGroupException, IllegalRoleException,
NoSuchMetalakeException {
Group revokedGroup = dispatcher.revokeRolesFromGroup(metalake, roles,
group);
- notifyRoleGroupRelChange(metalake, roles);
+ notifyGroupRoleBindingChange(metalake, roles, group);
return revokedGroup;
}
@@ -145,7 +145,7 @@ public class AccessControlHookDispatcher implements
AccessControlDispatcher {
public User revokeRolesFromUser(String metalake, List<String> roles, String
user)
throws NoSuchUserException, IllegalRoleException,
NoSuchMetalakeException {
User revokedUser = dispatcher.revokeRolesFromUser(metalake, roles, user);
- notifyRoleUserRelChange(metalake, roles);
+ notifyUserRoleBindingChange(metalake, roles, user);
return revokedUser;
}
@@ -236,35 +236,49 @@ public class AccessControlHookDispatcher implements
AccessControlDispatcher {
return overriddenRole;
}
- private static void notifyRoleUserRelChange(String metalake, List<String>
roles) {
+ /**
+ * Invalidates both the role-side cache for each of {@code roles} and the
user-side cache for
+ * {@code user}. Used by grant/revoke flows that change the user→roles
binding.
+ */
+ private static void notifyUserRoleBindingChange(
+ String metalake, List<String> roles, String user) {
GravitinoAuthorizer gravitinoAuthorizer =
GravitinoEnv.getInstance().gravitinoAuthorizer();
- if (gravitinoAuthorizer != null) {
- for (String role : roles) {
- gravitinoAuthorizer.handleRolePrivilegeChange(metalake, role);
- }
+ if (gravitinoAuthorizer == null) {
+ return;
+ }
+ for (String role : roles) {
+ gravitinoAuthorizer.handleRolePrivilegeChange(metalake, role);
}
+ gravitinoAuthorizer.handleUserRoleRelChange(metalake, user);
}
- private static void notifyRoleUserRelChange(String metalake, String role) {
+ /**
+ * Invalidates both the role-side cache for each of {@code roles} and the
group-side cache for
+ * {@code group}. Used by grant/revoke flows that change the group→roles
binding.
+ */
+ private static void notifyGroupRoleBindingChange(
+ String metalake, List<String> roles, String group) {
GravitinoAuthorizer gravitinoAuthorizer =
GravitinoEnv.getInstance().gravitinoAuthorizer();
- if (gravitinoAuthorizer != null) {
+ if (gravitinoAuthorizer == null) {
+ return;
+ }
+ for (String role : roles) {
gravitinoAuthorizer.handleRolePrivilegeChange(metalake, role);
}
+ gravitinoAuthorizer.handleGroupRoleRelChange(metalake, group);
}
- private static void notifyRoleUserRelChange(Long role) {
+ private static void notifyRoleUserRelChange(String metalake, String role) {
GravitinoAuthorizer gravitinoAuthorizer =
GravitinoEnv.getInstance().gravitinoAuthorizer();
if (gravitinoAuthorizer != null) {
- gravitinoAuthorizer.handleRolePrivilegeChange(role);
+ gravitinoAuthorizer.handleRolePrivilegeChange(metalake, role);
}
}
- private static void notifyRoleGroupRelChange(String metalake, List<String>
roles) {
+ private static void notifyRoleUserRelChange(Long role) {
GravitinoAuthorizer gravitinoAuthorizer =
GravitinoEnv.getInstance().gravitinoAuthorizer();
if (gravitinoAuthorizer != null) {
- for (String role : roles) {
- gravitinoAuthorizer.handleRolePrivilegeChange(metalake, role);
- }
+ gravitinoAuthorizer.handleRolePrivilegeChange(role);
}
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
index f10571b4fc..e6453f2b66 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
@@ -118,8 +118,10 @@ public interface OwnerMetaMapper {
@Param("metadataObjectType") String metadataObjectType);
@SelectProvider(type = OwnerMetaSQLProviderFactory.class, method =
"selectChangedOwners")
- List<ChangedOwnerInfo> selectChangedOwners(@Param("lastConsumedId") long
lastConsumedId);
+ List<ChangedOwnerInfo> selectChangedOwners(
+ @Param("lastConsumedUpdatedAt") long lastConsumedUpdatedAt,
+ @Param("lastConsumedUpdatedAtId") long lastConsumedUpdatedAtId);
- @SelectProvider(type = OwnerMetaSQLProviderFactory.class, method =
"selectMaxChangeId")
- Long selectMaxChangeId();
+ @SelectProvider(type = OwnerMetaSQLProviderFactory.class, method =
"selectMaxChangedOwner")
+ ChangedOwnerInfo selectMaxChangedOwner();
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
index aac9fa8028..f73f93726f 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
@@ -121,11 +121,13 @@ public class OwnerMetaSQLProviderFactory {
return
getProvider().selectOwnerByMetadataObjectIdAndType(metadataObjectId,
metadataObjectType);
}
- public static String selectChangedOwners(@Param("lastConsumedId") long
lastConsumedId) {
- return getProvider().selectChangedOwners(lastConsumedId);
+ public static String selectChangedOwners(
+ @Param("lastConsumedUpdatedAt") long lastConsumedUpdatedAt,
+ @Param("lastConsumedUpdatedAtId") long lastConsumedUpdatedAtId) {
+ return getProvider().selectChangedOwners(lastConsumedUpdatedAt,
lastConsumedUpdatedAtId);
}
- public static String selectMaxChangeId() {
- return getProvider().selectMaxChangeId();
+ public static String selectMaxChangedOwner() {
+ return getProvider().selectMaxChangedOwner();
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
index 498a90020f..4f16086b84 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
@@ -22,6 +22,7 @@ package org.apache.gravitino.storage.relational.mapper;
import java.util.List;
import org.apache.gravitino.storage.relational.po.ExtendedUserPO;
import org.apache.gravitino.storage.relational.po.UserPO;
+import org.apache.gravitino.storage.relational.po.auth.AuthPrefetchRow;
import org.apache.gravitino.storage.relational.po.auth.UserUpdatedAt;
import org.apache.ibatis.annotations.DeleteProvider;
import org.apache.ibatis.annotations.InsertProvider;
@@ -96,4 +97,37 @@ public interface UserMetaMapper {
@SelectProvider(type = UserMetaSQLProviderFactory.class, method =
"getUserUpdatedAt")
UserUpdatedAt getUserUpdatedAt(
@Param("metalakeName") String metalakeName, @Param("userName") String
userName);
+
+ /**
+ * Single-round-trip auth prefetch for the JCasbin authorize hot path.
Returns every version
+ * sentinel the request needs:
+ *
+ * <ul>
+ * <li>the request user's {@code user_meta} row,
+ * <li>each requested group's {@code group_meta} row,
+ * <li>the user's direct {@code user_role_rel} role ids (joined to {@code
role_meta} for their
+ * {@code updated_at}),
+ * <li>the group-inherited {@code group_role_rel} role ids (also joined
for versions).
+ * </ul>
+ *
+ * <p>Implementation is up to four {@code UNION ALL} branches that map 1:1
to {@link
+ * AuthPrefetchRow.Kind} values — see {@link AuthPrefetchRow}'s class-level
Javadoc for the row
+ * shape and how its fields are interpreted per Kind. Consumers switch on
{@code subjectType} and
+ * bucket each row into the matching collection.
+ *
+ * <p>Empty {@code groupNames} skips the {@code GROUP} and {@code
GROUP_ROLE} branches so the
+ * query degrades to a 2-branch UNION ({@code USER} + {@code USER_ROLE}) for
users without group
+ * membership.
+ *
+ * @param metalakeName the metalake the user and groups belong to
+ * @param userName the user to probe
+ * @param groupNames the group names to probe; may be empty (never null)
+ * @return polymorphic rows for the user, each present group, the user's
direct roles, and each
+ * group's inherited roles
+ */
+ @SelectProvider(type = UserMetaSQLProviderFactory.class, method =
"batchGetAuthSubjectsForUser")
+ List<AuthPrefetchRow> batchGetAuthSubjectsForUser(
+ @Param("metalakeName") String metalakeName,
+ @Param("userName") String userName,
+ @Param("groupNames") List<String> groupNames);
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
index 0c164899dd..27953c35de 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
@@ -20,6 +20,7 @@
package org.apache.gravitino.storage.relational.mapper;
import com.google.common.collect.ImmutableMap;
+import java.util.List;
import java.util.Map;
import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
import
org.apache.gravitino.storage.relational.mapper.provider.base.UserMetaBaseSQLProvider;
@@ -106,4 +107,11 @@ public class UserMetaSQLProviderFactory {
@Param("metalakeName") String metalakeName, @Param("userName") String
userName) {
return getProvider().getUserUpdatedAt(metalakeName, userName);
}
+
+ public static String batchGetAuthSubjectsForUser(
+ @Param("metalakeName") String metalakeName,
+ @Param("userName") String userName,
+ @Param("groupNames") List<String> groupNames) {
+ return getProvider().batchGetAuthSubjectsForUser(metalakeName, userName,
groupNames);
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
index 5c6d85296d..a89c3a1cc3 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
@@ -36,6 +36,11 @@ import org.apache.gravitino.storage.relational.po.OwnerRelPO;
import org.apache.ibatis.annotations.Param;
public class OwnerMetaBaseSQLProvider {
+ protected String currentTimestampMillisExpression() {
+ return "(UNIX_TIMESTAMP() * 1000.0)"
+ + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000";
+ }
+
public String selectUserOwnerMetaByMetadataObjectIdAndType(
@Param("metadataObjectId") Long metadataObjectId,
@Param("metadataObjectType") String metadataObjectType) {
@@ -141,11 +146,14 @@ public class OwnerMetaBaseSQLProvider {
public String batchSoftDeleteOwnerRelByMetadataObjects(
@Param("deletions") List<OwnerRelForDeletion> deletions) {
+ String now = currentTimestampMillisExpression();
return "<script>"
+ "UPDATE "
+ OWNER_TABLE_NAME
- + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
- + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+ + " SET deleted_at = "
+ + now
+ + ", updated_at = "
+ + now
+ " WHERE deleted_at = 0 AND ("
+ "<foreach collection='deletions' item='t' separator=' OR '>"
+ "(metadata_object_id = #{t.metadataObjectId} AND
metadata_object_type = #{t.metadataObjectType})"
@@ -157,35 +165,47 @@ public class OwnerMetaBaseSQLProvider {
public String softDeleteOwnerRelByMetadataObjectIdAndType(
@Param("metadataObjectId") Long metadataObjectId,
@Param("metadataObjectType") String metadataObjectType) {
+ String now = currentTimestampMillisExpression();
return "UPDATE "
+ OWNER_TABLE_NAME
- + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
- + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+ + " SET deleted_at = "
+ + now
+ + ", updated_at = "
+ + now
+ " WHERE metadata_object_id = #{metadataObjectId} AND
metadata_object_type = #{metadataObjectType} AND deleted_at = 0";
}
public String softDeleteOwnerRelByOwnerIdAndType(
@Param("ownerId") Long ownerId, @Param("ownerType") String ownerType) {
+ String now = currentTimestampMillisExpression();
return "UPDATE "
+ OWNER_TABLE_NAME
- + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
- + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+ + " SET deleted_at = "
+ + now
+ + ", updated_at = "
+ + now
+ " WHERE owner_id = #{ownerId} AND owner_type = #{ownerType} AND
deleted_at = 0";
}
public String softDeleteOwnerRelByMetalakeId(@Param("metalakeId") Long
metalakeId) {
+ String now = currentTimestampMillisExpression();
return "UPDATE "
+ OWNER_TABLE_NAME
- + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
- + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+ + " SET deleted_at = "
+ + now
+ + ", updated_at = "
+ + now
+ " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
}
public String softDeleteOwnerRelByCatalogId(@Param("catalogId") Long
catalogId) {
+ String now = currentTimestampMillisExpression();
return "UPDATE "
+ OWNER_TABLE_NAME
- + " ot SET ot.deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
- + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+ + " ot SET ot.deleted_at = "
+ + now
+ + ", ot.updated_at = "
+ + now
+ " WHERE ot.deleted_at = 0 AND EXISTS ("
+ " SELECT ct.catalog_id FROM "
+ CatalogMetaMapper.TABLE_NAME
@@ -230,11 +250,14 @@ public class OwnerMetaBaseSQLProvider {
}
public String softDeleteOwnerRelBySchemaIds(@Param("schemaIds") List<Long>
schemaIds) {
+ String now = currentTimestampMillisExpression();
return "<script>"
+ "UPDATE "
+ OWNER_TABLE_NAME
- + " ot SET ot.deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
- + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+ + " ot SET ot.deleted_at = "
+ + now
+ + ", ot.updated_at = "
+ + now
+ " WHERE ot.deleted_at = 0 AND EXISTS ("
+ " SELECT st.schema_id FROM "
+ SchemaMetaMapper.TABLE_NAME
@@ -316,23 +339,33 @@ public class OwnerMetaBaseSQLProvider {
+ " ORDER BY updated_at DESC, id DESC LIMIT 1";
}
- public String selectChangedOwners(@Param("lastConsumedId") long
lastConsumedId) {
- // Owner changes are broadcast to every server instance because owner
caches are local. Each
- // instance tracks its own last consumed id; re-reading a row is harmless
because cache
- // invalidation is idempotent.
+ public String selectChangedOwners(
+ @Param("lastConsumedUpdatedAt") long lastConsumedUpdatedAt,
+ @Param("lastConsumedUpdatedAtId") long lastConsumedUpdatedAtId) {
+ // Owner changes are broadcast to every server instance because owner
caches are local. Both
+ // inserts and soft-deletes advance owner_meta.updated_at, so a single
(updated_at, id) keyset
+ // cursor catches every change; id is the tiebreaker when multiple rows
share an updated_at
+ // millisecond (batch soft-deletes do that).
return "SELECT id,"
+ " metadata_object_id as metadataObjectId,"
+ " metadata_object_type as metadataObjectType,"
+ " updated_at as updatedAt"
+ " FROM "
+ OWNER_TABLE_NAME
- + " WHERE deleted_at = 0 AND id > #{lastConsumedId}"
- + " ORDER BY id LIMIT 1000";
+ + " WHERE updated_at > #{lastConsumedUpdatedAt}"
+ + " OR (updated_at = #{lastConsumedUpdatedAt} AND id >
#{lastConsumedUpdatedAtId})"
+ + " ORDER BY updated_at, id LIMIT 1000";
}
- public String selectMaxChangeId() {
- // A newly started server has an empty local owner cache. It can start
from the current max id
- // and consume only owner changes that happen after startup.
- return "SELECT COALESCE(MAX(id), 0) FROM " + OWNER_TABLE_NAME + " WHERE
deleted_at = 0";
+ public String selectMaxChangedOwner() {
+ // A newly started server has an empty local owner cache. It can start
from the current tail
+ // tuple and consume only owner changes that happen after startup.
+ return "SELECT id,"
+ + " metadata_object_id as metadataObjectId,"
+ + " metadata_object_type as metadataObjectType,"
+ + " updated_at as updatedAt"
+ + " FROM "
+ + OWNER_TABLE_NAME
+ + " ORDER BY updated_at DESC, id DESC LIMIT 1";
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
index 2842edd332..4bb92e103c 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
@@ -19,10 +19,13 @@
package org.apache.gravitino.storage.relational.mapper.provider.base;
+import static
org.apache.gravitino.storage.relational.mapper.GroupMetaMapper.GROUP_ROLE_RELATION_TABLE_NAME;
+import static
org.apache.gravitino.storage.relational.mapper.GroupMetaMapper.GROUP_TABLE_NAME;
import static
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper.ROLE_TABLE_NAME;
import static
org.apache.gravitino.storage.relational.mapper.UserMetaMapper.USER_ROLE_RELATION_TABLE_NAME;
import static
org.apache.gravitino.storage.relational.mapper.UserRoleRelMapper.USER_TABLE_NAME;
+import java.util.List;
import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
import org.apache.gravitino.storage.relational.po.UserPO;
import org.apache.ibatis.annotations.Param;
@@ -208,4 +211,118 @@ public class UserMetaBaseSQLProvider {
+ " WHERE mm.metalake_name = #{metalakeName} AND um.user_name =
#{userName}"
+ " AND um.deleted_at = 0";
}
+
+ /**
+ * Builds the single-round-trip auth prefetch query for {@code (metalake,
userName, groupNames)}.
+ *
+ * <p>The SQL is up to four {@code UNION ALL} branches that map 1:1 to the
four {@code
+ * org.apache.gravitino.storage.relational.po.auth.AuthPrefetchRow.Kind}
values:
+ *
+ * <ul>
+ * <li>{@code 'USER'} branch — the request user's {@code user_meta} row.
+ * <li>{@code 'USER_ROLE'} branch — roles bound directly to the request
user via {@code
+ * user_role_rel}; {@code parentId} carries the owning {@code user_id}.
+ * <li>{@code 'GROUP'} branch — the request user's groups (one row per
name in {@code
+ * groupNames}).
+ * <li>{@code 'GROUP_ROLE'} branch — roles inherited via group membership
through {@code
+ * group_role_rel}; {@code parentId} carries the owning {@code
group_id}.
+ * </ul>
+ *
+ * <p>The {@code GROUP} / {@code GROUP_ROLE} branches are appended only when
{@code groupNames} is
+ * non-empty, leaving a 2-branch query in the no-group case.
+ *
+ * <p>All branches must SELECT the same column list and aliases ({@code
subjectType}, {@code
+ * entityId}, {@code entityName}, {@code updatedAt}, {@code bindingOwnerId})
because {@code UNION
+ * ALL} requires a uniform row shape. The aliases match {@code
AuthPrefetchRow}'s field names so
+ * MyBatis can reflectively map each row.
+ *
+ * @param metalakeName the metalake the user belongs to
+ * @param userName the request user's name
+ * @param groupNames the user's group memberships; may be empty
+ * @return the SQL string
+ */
+ public String batchGetAuthSubjectsForUser(
+ @Param("metalakeName") String metalakeName,
+ @Param("userName") String userName,
+ @Param("groupNames") List<String> groupNames) {
+ // 'USER' branch → AuthPrefetchRow.Kind.USER.
+ String userBranch =
+ "SELECT 'USER' AS subjectType, um.user_id AS entityId, um.user_name AS
entityName,"
+ + " um.updated_at AS updatedAt, NULL AS bindingOwnerId"
+ + " FROM "
+ + USER_TABLE_NAME
+ + " um"
+ + " JOIN "
+ + MetalakeMetaMapper.TABLE_NAME
+ + " mm ON um.metalake_id = mm.metalake_id AND mm.deleted_at = 0"
+ + " WHERE mm.metalake_name = #{metalakeName} AND um.user_name =
#{userName}"
+ + " AND um.deleted_at = 0";
+
+ // 'USER_ROLE' branch → AuthPrefetchRow.Kind.USER_ROLE.
+ // bindingOwnerId carries the owning user_id so consumers can bucket roles
back to that user.
+ String userRoleBranch =
+ " UNION ALL "
+ + "SELECT 'USER_ROLE' AS subjectType, rm.role_id AS entityId,
rm.role_name AS entityName,"
+ + " rm.updated_at AS updatedAt, ur.user_id AS bindingOwnerId"
+ + " FROM "
+ + USER_TABLE_NAME
+ + " um"
+ + " JOIN "
+ + MetalakeMetaMapper.TABLE_NAME
+ + " mm3 ON um.metalake_id = mm3.metalake_id AND mm3.deleted_at = 0"
+ + " JOIN "
+ + USER_ROLE_RELATION_TABLE_NAME
+ + " ur ON ur.user_id = um.user_id AND ur.deleted_at = 0"
+ + " JOIN "
+ + ROLE_TABLE_NAME
+ + " rm ON rm.role_id = ur.role_id AND rm.deleted_at = 0"
+ + " WHERE mm3.metalake_name = #{metalakeName} AND um.user_name =
#{userName}"
+ + " AND um.deleted_at = 0";
+
+ if (groupNames == null || groupNames.isEmpty()) {
+ // No group memberships → 2-branch query (USER + USER_ROLE only).
+ return "<script>" + userBranch + userRoleBranch + "</script>";
+ }
+
+ // 'GROUP' branch → AuthPrefetchRow.Kind.GROUP.
+ String groupBranch =
+ " UNION ALL "
+ + "SELECT 'GROUP' AS subjectType, gm.group_id AS entityId,
gm.group_name AS entityName,"
+ + " gm.updated_at AS updatedAt, NULL AS bindingOwnerId"
+ + " FROM "
+ + GROUP_TABLE_NAME
+ + " gm"
+ + " JOIN "
+ + MetalakeMetaMapper.TABLE_NAME
+ + " mm2 ON gm.metalake_id = mm2.metalake_id AND mm2.deleted_at = 0"
+ + " WHERE mm2.metalake_name = #{metalakeName}"
+ + " AND gm.group_name IN"
+ + " <foreach item='g' collection='groupNames' open='('
separator=',' close=')'>#{g}</foreach>"
+ + " AND gm.deleted_at = 0";
+
+ // 'GROUP_ROLE' branch → AuthPrefetchRow.Kind.GROUP_ROLE.
+ // bindingOwnerId carries the owning group_id so consumers can bucket
roles by group.
+ String groupRoleBranch =
+ " UNION ALL "
+ + "SELECT 'GROUP_ROLE' AS subjectType, rm.role_id AS entityId,
rm.role_name AS entityName,"
+ + " rm.updated_at AS updatedAt, gr.group_id AS bindingOwnerId"
+ + " FROM "
+ + GROUP_TABLE_NAME
+ + " gm"
+ + " JOIN "
+ + MetalakeMetaMapper.TABLE_NAME
+ + " mm4 ON gm.metalake_id = mm4.metalake_id AND mm4.deleted_at = 0"
+ + " JOIN "
+ + GROUP_ROLE_RELATION_TABLE_NAME
+ + " gr ON gr.group_id = gm.group_id AND gr.deleted_at = 0"
+ + " JOIN "
+ + ROLE_TABLE_NAME
+ + " rm ON rm.role_id = gr.role_id AND rm.deleted_at = 0"
+ + " WHERE mm4.metalake_name = #{metalakeName}"
+ + " AND gm.group_name IN"
+ + " <foreach item='g2' collection='groupNames' open='('
separator=',' close=')'>#{g2}</foreach>"
+ + " AND gm.deleted_at = 0";
+
+ return "<script>" + userBranch + userRoleBranch + groupBranch +
groupRoleBranch + "</script>";
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
index 06791de3b7..beadb38b3a 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
@@ -34,36 +34,57 @@ import
org.apache.gravitino.storage.relational.po.OwnerRelForDeletion;
import org.apache.ibatis.annotations.Param;
public class OwnerMetaPostgreSQLProvider extends OwnerMetaBaseSQLProvider {
+ @Override
+ protected String currentTimestampMillisExpression() {
+ return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
+ }
+
@Override
public String softDeleteOwnerRelByMetadataObjectIdAndType(
Long metadataObjectId, String metadataObjectType) {
+ String now = currentTimestampMillisExpression();
return "UPDATE "
+ OWNER_TABLE_NAME
- + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000
AS BIGINT)"
+ + " SET deleted_at = "
+ + now
+ + ", updated_at = "
+ + now
+ " WHERE metadata_object_id = #{metadataObjectId} AND
metadata_object_type = #{metadataObjectType} AND deleted_at = 0";
}
@Override
public String softDeleteOwnerRelByOwnerIdAndType(Long ownerId, String
ownerType) {
+ String now = currentTimestampMillisExpression();
return "UPDATE "
+ OWNER_TABLE_NAME
- + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000
AS BIGINT)"
+ + " SET deleted_at = "
+ + now
+ + ", updated_at = "
+ + now
+ " WHERE owner_id = #{ownerId} AND owner_type = #{ownerType} AND
deleted_at = 0";
}
@Override
public String softDeleteOwnerRelByMetalakeId(Long metalakeId) {
+ String now = currentTimestampMillisExpression();
return "UPDATE "
+ OWNER_TABLE_NAME
- + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000
AS BIGINT)"
+ + " SET deleted_at = "
+ + now
+ + ", updated_at = "
+ + now
+ " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
}
@Override
public String softDeleteOwnerRelByCatalogId(Long catalogId) {
+ String now = currentTimestampMillisExpression();
return "UPDATE "
+ OWNER_TABLE_NAME
- + " ot SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) *
1000 AS BIGINT)"
+ + " ot SET deleted_at = "
+ + now
+ + ", updated_at = "
+ + now
+ " WHERE ot.deleted_at = 0 AND EXISTS ("
+ " SELECT ct.catalog_id FROM "
+ CatalogMetaMapper.TABLE_NAME
@@ -109,10 +130,14 @@ public class OwnerMetaPostgreSQLProvider extends
OwnerMetaBaseSQLProvider {
@Override
public String softDeleteOwnerRelBySchemaIds(@Param("schemaIds") List<Long>
schemaIds) {
+ String now = currentTimestampMillisExpression();
return "<script>"
+ "UPDATE "
+ OWNER_TABLE_NAME
- + " ot SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) *
1000 AS BIGINT)"
+ + " ot SET deleted_at = "
+ + now
+ + ", updated_at = "
+ + now
+ " WHERE ot.deleted_at = 0 AND EXISTS ("
+ " SELECT st.schema_id FROM "
+ SchemaMetaMapper.TABLE_NAME
@@ -176,10 +201,14 @@ public class OwnerMetaPostgreSQLProvider extends
OwnerMetaBaseSQLProvider {
@Override
public String batchSoftDeleteOwnerRelByMetadataObjects(
@Param("deletions") List<OwnerRelForDeletion> deletions) {
+ String now = currentTimestampMillisExpression();
return "<script>"
+ "UPDATE "
+ OWNER_TABLE_NAME
- + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000
AS BIGINT)"
+ + " SET deleted_at = "
+ + now
+ + ", updated_at = "
+ + now
+ " WHERE deleted_at = 0 AND ("
+ "<foreach collection='deletions' item='t' separator=' OR '>"
+ "(metadata_object_id = #{t.metadataObjectId} AND
metadata_object_type = #{t.metadataObjectType})"
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/po/auth/AuthPrefetchRow.java
b/core/src/main/java/org/apache/gravitino/storage/relational/po/auth/AuthPrefetchRow.java
new file mode 100644
index 0000000000..a62a11daa7
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/po/auth/AuthPrefetchRow.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.po.auth;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * Polymorphic result row returned by {@code
UserMetaMapper#batchGetAuthSubjectsForUser}. That
+ * mapper folds four logically distinct fetches (the request user, the user's
groups, the user's
+ * direct roles, the user's group-inherited roles) into a single round trip
via {@code UNION ALL},
+ * so its result set is a flat list of rows whose meaning depends on a
discriminator. This class is
+ * that row.
+ *
+ * <p>Each row carries the same physical columns ({@code subjectType}, {@code
entityId}, {@code
+ * entityName}, {@code updatedAt}, {@code bindingOwnerId}), but the semantics
of those columns shift
+ * by {@link Kind}:
+ *
+ * <table>
+ * <caption>Per-Kind field semantics</caption>
+ * <tr>
+ * <th>{@link Kind}</th>
+ * <th>{@code entityId}</th>
+ * <th>{@code entityName}</th>
+ * <th>{@code updatedAt}</th>
+ * <th>{@code bindingOwnerId}</th>
+ * </tr>
+ * <tr>
+ * <td>{@link Kind#USER}</td>
+ * <td>{@code user_meta.user_id}</td>
+ * <td>{@code user_meta.user_name}</td>
+ * <td>{@code user_meta.updated_at}</td>
+ * <td>{@code null}</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Kind#GROUP}</td>
+ * <td>{@code group_meta.group_id}</td>
+ * <td>{@code group_meta.group_name}</td>
+ * <td>{@code group_meta.updated_at}</td>
+ * <td>{@code null}</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Kind#USER_ROLE}</td>
+ * <td>{@code role_meta.role_id}</td>
+ * <td>{@code role_meta.role_name}</td>
+ * <td>{@code role_meta.updated_at}</td>
+ * <td>owning {@code user_id}</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Kind#GROUP_ROLE}</td>
+ * <td>{@code role_meta.role_id}</td>
+ * <td>{@code role_meta.role_name}</td>
+ * <td>{@code role_meta.updated_at}</td>
+ * <td>owning {@code group_id}</td>
+ * </tr>
+ * </table>
+ *
+ * <p>The four kinds mirror the four {@code UNION ALL} branches one-for-one;
the discriminator is
+ * not an arbitrary taxonomy. The consumer ({@code
JcasbinAuthorizer#prefetchUserAndGroupInfo})
+ * switches on {@link Kind} and steers each row into the matching collection
(single user, group
+ * map, user role-id set, group role-ids-by-group-id map).
+ *
+ * <p>Why a polymorphic flat row instead of separate POs:
+ *
+ * <ul>
+ * <li>{@code UNION ALL} requires all branches to share the same column
count and types, so the
+ * physical row shape is necessarily uniform.
+ * <li>Splitting into separate per-kind POs would mean separate SQL queries
— losing the single
+ * round trip that is the whole point of this method.
+ * </ul>
+ *
+ * <p>Prefer the {@code forXxx} factory methods over the all-args constructor
when building rows in
+ * Java code (tests, ad-hoc fixtures); they document which {@link Kind} each
shape corresponds to
+ * and avoid mistakes such as passing {@code null} as {@code bindingOwnerId}
for a {@link
+ * Kind#USER_ROLE} row. The all-args constructor is kept public for MyBatis
reflective row mapping.
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuthPrefetchRow {
+
+ /**
+ * Discriminator that determines how the other fields are interpreted. Each
value maps 1:1 to a
+ * branch of the {@code UNION ALL} in {@code
UserMetaBaseSQLProvider#batchGetAuthSubjectsForUser}.
+ * See class-level Javadoc for the full per-Kind field table.
+ */
+ public enum Kind {
+ /** A {@code user_meta} row (the request user). {@code bindingOwnerId} is
{@code null}. */
+ USER,
+ /**
+ * A {@code group_meta} row (one of the request user's groups). {@code
bindingOwnerId} is {@code
+ * null}.
+ */
+ GROUP,
+ /**
+ * A {@code user_role_rel JOIN role_meta} row: a role bound directly to
the request user. {@code
+ * entityId} is the {@code role_id}; {@code bindingOwnerId} is the owning
{@code user_id}.
+ */
+ USER_ROLE,
+ /**
+ * A {@code group_role_rel JOIN role_meta} row: a role inherited via group
membership. {@code
+ * entityId} is the {@code role_id}; {@code bindingOwnerId} is the owning
{@code group_id}.
+ */
+ GROUP_ROLE
+ }
+
+ /**
+ * Discriminator field. Populated from the SQL string literal in each {@code
UNION ALL} branch
+ * ({@code 'USER'}, {@code 'GROUP'}, {@code 'USER_ROLE'}, {@code
'GROUP_ROLE'}). Drives how the
+ * other fields are interpreted by consumers.
+ */
+ private Kind subjectType;
+
+ /**
+ * Primary entity id for this row. Interpretation depends on {@link
#subjectType}:
+ *
+ * <ul>
+ * <li>{@link Kind#USER} → {@code user_meta.user_id}
+ * <li>{@link Kind#GROUP} → {@code group_meta.group_id}
+ * <li>{@link Kind#USER_ROLE}, {@link Kind#GROUP_ROLE} → {@code
role_meta.role_id}
+ * </ul>
+ */
+ private long entityId;
+
+ /**
+ * Primary entity name. Interpretation depends on {@link #subjectType}:
+ *
+ * <ul>
+ * <li>{@link Kind#USER} → {@code user_name}
+ * <li>{@link Kind#GROUP} → {@code group_name}
+ * <li>{@link Kind#USER_ROLE}, {@link Kind#GROUP_ROLE} → {@code role_name}
+ * </ul>
+ */
+ private String entityName;
+
+ /**
+ * {@code updated_at} of the row's primary entity ({@code user_meta} /
{@code group_meta} / {@code
+ * role_meta}). Used as the cache staleness sentinel by the
version-validated caches in {@code
+ * JcasbinAuthorizer}.
+ */
+ private long updatedAt;
+
+ /**
+ * Owning subject id for role-binding rows; {@code null} for identity rows:
+ *
+ * <ul>
+ * <li>{@link Kind#USER_ROLE} → the {@code user_id} the role is directly
bound to
+ * <li>{@link Kind#GROUP_ROLE} → the {@code group_id} the role is
inherited from
+ * <li>{@link Kind#USER}, {@link Kind#GROUP} → {@code null}
+ * </ul>
+ *
+ * Consumers use this to bucket role rows back onto the owning user/group
when populating the
+ * downstream per-subject role caches.
+ */
+ private Long bindingOwnerId;
+
+ /**
+ * Builds a {@link Kind#USER} row.
+ *
+ * @param userId {@code user_meta.user_id}
+ * @param userName {@code user_meta.user_name}
+ * @param updatedAt {@code user_meta.updated_at}
+ * @return a populated row
+ */
+ public static AuthPrefetchRow forUser(long userId, String userName, long
updatedAt) {
+ return new AuthPrefetchRow(Kind.USER, userId, userName, updatedAt, null);
+ }
+
+ /**
+ * Builds a {@link Kind#GROUP} row.
+ *
+ * @param groupId {@code group_meta.group_id}
+ * @param groupName {@code group_meta.group_name}
+ * @param updatedAt {@code group_meta.updated_at}
+ * @return a populated row
+ */
+ public static AuthPrefetchRow forGroup(long groupId, String groupName, long
updatedAt) {
+ return new AuthPrefetchRow(Kind.GROUP, groupId, groupName, updatedAt,
null);
+ }
+
+ /**
+ * Builds a {@link Kind#USER_ROLE} row bound to {@code ownerUserId}.
+ *
+ * @param roleId {@code role_meta.role_id}
+ * @param roleName {@code role_meta.role_name}
+ * @param updatedAt {@code role_meta.updated_at}
+ * @param ownerUserId the {@code user_id} that owns this direct role binding
+ * @return a populated row
+ */
+ public static AuthPrefetchRow forUserRole(
+ long roleId, String roleName, long updatedAt, long ownerUserId) {
+ return new AuthPrefetchRow(Kind.USER_ROLE, roleId, roleName, updatedAt,
ownerUserId);
+ }
+
+ /**
+ * Builds a {@link Kind#GROUP_ROLE} row inherited from {@code ownerGroupId}.
+ *
+ * @param roleId {@code role_meta.role_id}
+ * @param roleName {@code role_meta.role_name}
+ * @param updatedAt {@code role_meta.updated_at}
+ * @param ownerGroupId the {@code group_id} that owns this inherited role
binding
+ * @return a populated row
+ */
+ public static AuthPrefetchRow forGroupRole(
+ long roleId, String roleName, long updatedAt, long ownerGroupId) {
+ return new AuthPrefetchRow(Kind.GROUP_ROLE, roleId, roleName, updatedAt,
ownerGroupId);
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/hook/TestAccessControlHookDispatcher.java
b/core/src/test/java/org/apache/gravitino/hook/TestAccessControlHookDispatcher.java
index d0d1f1519e..6f5ac0d404 100644
---
a/core/src/test/java/org/apache/gravitino/hook/TestAccessControlHookDispatcher.java
+++
b/core/src/test/java/org/apache/gravitino/hook/TestAccessControlHookDispatcher.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.hook;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -28,8 +29,11 @@ import java.util.Collections;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.authorization.AccessControlDispatcher;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.authorization.Group;
import org.apache.gravitino.authorization.OwnerDispatcher;
import org.apache.gravitino.authorization.Role;
+import org.apache.gravitino.authorization.User;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
@@ -40,16 +44,21 @@ public class TestAccessControlHookDispatcher {
private AccessControlHookDispatcher hookDispatcher;
private AccessControlDispatcher mockDispatcher;
private OwnerDispatcher mockOwnerDispatcher;
+ private GravitinoAuthorizer mockAuthorizer;
// Save the original ownerDispatcher before each test and restore it in
tearDown so we do not
// leak null state into the GravitinoEnv singleton across tests.
private OwnerDispatcher savedOwnerDispatcher;
+ private GravitinoAuthorizer savedAuthorizer;
@BeforeEach
public void setUp() throws IllegalAccessException {
mockDispatcher = mock(AccessControlDispatcher.class);
mockOwnerDispatcher = mock(OwnerDispatcher.class);
+ mockAuthorizer = mock(GravitinoAuthorizer.class);
savedOwnerDispatcher = GravitinoEnv.getInstance().ownerDispatcher();
+ savedAuthorizer = GravitinoEnv.getInstance().gravitinoAuthorizer();
FieldUtils.writeField(GravitinoEnv.getInstance(), "ownerDispatcher",
mockOwnerDispatcher, true);
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "gravitinoAuthorizer",
mockAuthorizer, true);
hookDispatcher = new AccessControlHookDispatcher(mockDispatcher);
}
@@ -57,6 +66,7 @@ public class TestAccessControlHookDispatcher {
public void tearDown() throws IllegalAccessException {
FieldUtils.writeField(
GravitinoEnv.getInstance(), "ownerDispatcher", savedOwnerDispatcher,
true);
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "gravitinoAuthorizer",
savedAuthorizer, true);
}
@Test
@@ -77,4 +87,32 @@ public class TestAccessControlHookDispatcher {
Assertions.assertEquals("Set owner failed", thrown.getMessage());
verify(mockDispatcher).createRole(any(), any(), any(), any());
}
+
+ @Test
+ public void testGrantRolesToUserInvalidatesUserRoleRelation() {
+ User mockUser = mock(User.class);
+ when(mockDispatcher.grantRolesToUser(
+ eq("test_metalake"), eq(Collections.singletonList("test_role")),
eq("test_user")))
+ .thenReturn(mockUser);
+
+ hookDispatcher.grantRolesToUser(
+ "test_metalake", Collections.singletonList("test_role"), "test_user");
+
+ verify(mockAuthorizer).handleRolePrivilegeChange("test_metalake",
"test_role");
+ verify(mockAuthorizer).handleUserRoleRelChange("test_metalake",
"test_user");
+ }
+
+ @Test
+ public void testGrantRolesToGroupInvalidatesGroupRoleRelation() {
+ Group mockGroup = mock(Group.class);
+ when(mockDispatcher.grantRolesToGroup(
+ eq("test_metalake"), eq(Collections.singletonList("test_role")),
eq("test_group")))
+ .thenReturn(mockGroup);
+
+ hookDispatcher.grantRolesToGroup(
+ "test_metalake", Collections.singletonList("test_role"), "test_group");
+
+ verify(mockAuthorizer).handleRolePrivilegeChange("test_metalake",
"test_role");
+ verify(mockAuthorizer).handleGroupRoleRelChange("test_metalake",
"test_group");
+ }
}
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
index cb02de4aa1..2175a0a6b5 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
@@ -337,6 +337,19 @@ public class TestAuthMappers {
.withUpdatedAt(0L)
.build();
ownerMetaMapper.insertOwnerRel(ownerRelPO);
+ ownerMetaMapper.insertOwnerRel(
+ OwnerRelPO.builder()
+ .withMetalakeId(1L)
+ .withOwnerId(60L)
+ .withOwnerType("USER")
+ .withMetadataObjectId(201L)
+ .withMetadataObjectType("SCHEMA")
+ .withAuditIfo(auditInfo.toString())
+ .withCurrentVersion(1L)
+ .withLastVersion(0L)
+ .withDeleteAt(200L)
+ .withUpdatedAt(200L)
+ .build());
// Set updated_at = 100 via direct SQL
try (SqlSession sqlSession =
@@ -351,16 +364,27 @@ public class TestAuthMappers {
throw new RuntimeException("Update failed", e);
}
- List<ChangedOwnerInfo> changed = ownerMetaMapper.selectChangedOwners(0L);
- Assertions.assertEquals(1, changed.size());
+ List<ChangedOwnerInfo> changed = ownerMetaMapper.selectChangedOwners(0L,
0L);
+ Assertions.assertEquals(2, changed.size());
Assertions.assertEquals(200L, changed.get(0).getMetadataObjectId());
Assertions.assertEquals("SCHEMA", changed.get(0).getMetadataObjectType());
Assertions.assertEquals(100L, changed.get(0).getUpdatedAt());
+ Assertions.assertEquals(201L, changed.get(1).getMetadataObjectId());
+ Assertions.assertEquals("SCHEMA", changed.get(1).getMetadataObjectType());
+ Assertions.assertEquals(200L, changed.get(1).getUpdatedAt());
- // Polling after the last seen id should not return the same row again.
+ // Polling after the last seen tuple should not return the same row again.
List<ChangedOwnerInfo> sameTimestamp =
- ownerMetaMapper.selectChangedOwners(changed.get(0).getId());
+ ownerMetaMapper.selectChangedOwners(changed.get(1).getUpdatedAt(),
changed.get(1).getId());
Assertions.assertTrue(sameTimestamp.isEmpty());
+
+ ownerMetaMapper.softDeleteOwnerRelByOwnerIdAndType(60L, "USER");
+ List<ChangedOwnerInfo> softDeleted =
+ ownerMetaMapper.selectChangedOwners(changed.get(1).getUpdatedAt(),
changed.get(1).getId());
+ Assertions.assertEquals(1, softDeleted.size());
+ Assertions.assertEquals(200L, softDeleted.get(0).getMetadataObjectId());
+ Assertions.assertEquals("SCHEMA",
softDeleted.get(0).getMetadataObjectType());
+ Assertions.assertTrue(softDeleted.get(0).getUpdatedAt() >
changed.get(1).getUpdatedAt());
}
private AuditInfo buildAuditInfo() {
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
index 6033fc52a1..73345e1340 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
@@ -37,6 +37,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
+import java.util.stream.Collectors;
import org.apache.gravitino.Entity;
import org.apache.gravitino.EntityAlreadyExistsException;
import org.apache.gravitino.Namespace;
@@ -46,6 +47,7 @@ import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.BaseMetalake;
import org.apache.gravitino.meta.CatalogEntity;
import org.apache.gravitino.meta.FilesetEntity;
+import org.apache.gravitino.meta.GroupEntity;
import org.apache.gravitino.meta.RoleEntity;
import org.apache.gravitino.meta.SchemaEntity;
import org.apache.gravitino.meta.TableEntity;
@@ -56,6 +58,7 @@ import
org.apache.gravitino.storage.relational.TestJDBCBackend;
import org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
import org.apache.gravitino.storage.relational.mapper.UserMetaMapper;
import org.apache.gravitino.storage.relational.po.RolePO;
+import org.apache.gravitino.storage.relational.po.auth.AuthPrefetchRow;
import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
import org.apache.gravitino.storage.relational.utils.SessionUtils;
import org.apache.gravitino.utils.NamespaceUtil;
@@ -1162,6 +1165,101 @@ class TestUserMetaService extends TestJDBCBackend {
Assertions.assertEquals(0, deletedCount); // no more to delete
}
+ @TestTemplate
+ void batchGetAuthSubjectsForUser() throws IOException {
+ AuditInfo auditInfo =
+
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+ BaseMetalake metalake =
+ createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), metalakeName,
auditInfo);
+ backend.insert(metalake, false);
+
+ UserMetaService userMetaService = UserMetaService.getInstance();
+ GroupMetaService groupMetaService = GroupMetaService.getInstance();
+
+ UserEntity user =
+ createUserEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofUserNamespace(metalakeName),
+ "batchUser",
+ auditInfo);
+ userMetaService.insertUser(user, false);
+
+ GroupEntity groupA =
+ createGroupEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofGroupNamespace(metalakeName),
+ "batchGroupA",
+ auditInfo,
+ Collections.emptyList(),
+ Collections.emptyList());
+ GroupEntity groupB =
+ createGroupEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofGroupNamespace(metalakeName),
+ "batchGroupB",
+ auditInfo,
+ Collections.emptyList(),
+ Collections.emptyList());
+ groupMetaService.insertGroup(groupA, false);
+ groupMetaService.insertGroup(groupB, false);
+
+ // Case 1: user + multiple groups, no role bindings → one row per subject.
+ List<AuthPrefetchRow> rows =
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ m ->
+ m.batchGetAuthSubjectsForUser(
+ metalakeName, "batchUser",
Lists.newArrayList("batchGroupA", "batchGroupB")));
+ assertEquals(3, rows.size());
+ AuthPrefetchRow userRow =
+ rows.stream()
+ .filter(r -> AuthPrefetchRow.Kind.USER == r.getSubjectType())
+ .findFirst()
+ .orElseThrow();
+ assertEquals(user.id(), userRow.getEntityId());
+ assertEquals("batchUser", userRow.getEntityName());
+ assertTrue(userRow.getUpdatedAt() >= 0);
+ List<AuthPrefetchRow> groupRows =
+ rows.stream()
+ .filter(r -> AuthPrefetchRow.Kind.GROUP == r.getSubjectType())
+ .sorted(Comparator.comparing(AuthPrefetchRow::getEntityName))
+ .collect(Collectors.toList());
+ assertEquals(2, groupRows.size());
+ assertEquals("batchGroupA", groupRows.get(0).getEntityName());
+ assertEquals(groupA.id(), groupRows.get(0).getEntityId());
+ assertEquals("batchGroupB", groupRows.get(1).getEntityName());
+ assertEquals(groupB.id(), groupRows.get(1).getEntityId());
+
+ // Case 2: empty group list → user-only branch (no GROUP / GROUP_ROLE
UNION).
+ List<AuthPrefetchRow> userOnly =
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ m -> m.batchGetAuthSubjectsForUser(metalakeName, "batchUser",
Collections.emptyList()));
+ assertEquals(1, userOnly.size());
+ assertEquals(AuthPrefetchRow.Kind.USER, userOnly.get(0).getSubjectType());
+ assertEquals(user.id(), userOnly.get(0).getEntityId());
+
+ // Case 3: missing user + missing group → only the present group row is
returned.
+ List<AuthPrefetchRow> missing =
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ m ->
+ m.batchGetAuthSubjectsForUser(
+ metalakeName, "noSuchUser",
Lists.newArrayList("noSuchGroup", "batchGroupA")));
+ assertEquals(1, missing.size(), "Only the existing group should be
returned");
+ assertEquals(AuthPrefetchRow.Kind.GROUP, missing.get(0).getSubjectType());
+ assertEquals("batchGroupA", missing.get(0).getEntityName());
+
+ // Case 4: both user and groups missing → empty result.
+ List<AuthPrefetchRow> none =
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ m ->
+ m.batchGetAuthSubjectsForUser(
+ metalakeName, "noSuchUser",
Lists.newArrayList("noSuchGroup")));
+ assertTrue(none.isEmpty());
+ }
+
private Integer countUsers(Long metalakeId) {
int count = 0;
try (SqlSession sqlSession =
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/PassThroughAuthorizer.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/PassThroughAuthorizer.java
index 76de95b0d3..c58ffea12e 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/PassThroughAuthorizer.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/PassThroughAuthorizer.java
@@ -74,7 +74,10 @@ public class PassThroughAuthorizer implements
GravitinoAuthorizer {
}
@Override
- public boolean isSelf(Entity.EntityType type, NameIdentifier nameIdentifier)
{
+ public boolean isSelf(
+ Entity.EntityType type,
+ NameIdentifier nameIdentifier,
+ AuthorizationRequestContext requestContext) {
return true;
}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
index 4a4a58a599..f98ea1ea02 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
@@ -112,7 +112,8 @@ public class AuthorizationExpressionConverter {
} else if (AuthConstants.SELF.equals(privilegeOrExpression)) {
replacement =
String.format(
-
"authorizer.isSelf(@org.apache.gravitino.Entity\\$EntityType@%s,%s_NAME_IDENT)",
+
"authorizer.isSelf(@org.apache.gravitino.Entity\\$EntityType@%s,"
+ + "%s_NAME_IDENT,authorizationContext)",
type, type);
} else {
replacement =
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
index 6a1b30c643..5ff033f5e1 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
@@ -94,8 +94,9 @@ public class JcasbinAuthorizationLookups {
/**
* Two-tier owner lookup: request-level dedup first, then the shared {@code
ownerRelCache}, and
- * finally a single {@code owner_meta} query. A successful DB fetch
populates both tiers so
- * subsequent {@code isOwner} calls — in this request and later ones — hit
the cache.
+ * finally a single {@code owner_meta} query. Positive DB fetches populate
both tiers; missing
+ * owners are cached only for the current request to avoid pinning a
cross-request negative result
+ * through a missed invalidation.
*/
public Optional<OwnerInfo> resolveOwnerId(
Long metadataId,
@@ -103,15 +104,32 @@ public class JcasbinAuthorizationLookups {
AuthorizationRequestContext requestContext) {
return requestContext.computeOwnerIfAbsent(
metadataId,
- id ->
- ownerRelCache.get(
- id,
- ignored -> {
- OwnerInfo ownerInfo =
- SessionUtils.getWithoutCommit(
- OwnerMetaMapper.class,
- m -> m.selectOwnerByMetadataObjectIdAndType(id,
metadataType.name()));
- return ownerInfo == null ? Optional.empty() :
Optional.of(ownerInfo);
- }));
+ id -> {
+ try {
+ // Use the cache's atomic loader so concurrent misses on the same
id collapse to one DB
+ // query. The loader throws for missing owners so only positive
results land in the
+ // long-lived cache; negatives are confined to the per-request map
above.
+ return ownerRelCache.get(id, k -> loadOwner(k, metadataType));
+ } catch (NoSuchOwnerException e) {
+ return Optional.empty();
+ }
+ });
+ }
+
+ private static Optional<OwnerInfo> loadOwner(Long id, MetadataObject.Type
metadataType) {
+ OwnerInfo ownerInfo =
+ SessionUtils.getWithoutCommit(
+ OwnerMetaMapper.class,
+ m -> m.selectOwnerByMetadataObjectIdAndType(id,
metadataType.name()));
+ if (ownerInfo == null) {
+ throw new NoSuchOwnerException();
+ }
+ return Optional.of(ownerInfo);
+ }
+
+ private static final class NoSuchOwnerException extends RuntimeException {
+ private NoSuchOwnerException() {
+ super(null, null, false, false);
+ }
}
}
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 da67d85eea..0af8e33355 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
@@ -25,7 +25,9 @@ import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -43,7 +45,6 @@ import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.MetadataObjects;
import org.apache.gravitino.NameIdentifier;
-import org.apache.gravitino.SupportsRelationOperations;
import org.apache.gravitino.UserGroup;
import org.apache.gravitino.UserPrincipal;
import org.apache.gravitino.auth.AuthConstants;
@@ -61,6 +62,7 @@ import
org.apache.gravitino.storage.relational.mapper.GroupMetaMapper;
import org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
import org.apache.gravitino.storage.relational.mapper.UserMetaMapper;
import org.apache.gravitino.storage.relational.po.RolePO;
+import org.apache.gravitino.storage.relational.po.auth.AuthPrefetchRow;
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;
@@ -359,7 +361,10 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
}
@Override
- public boolean isSelf(Entity.EntityType type, NameIdentifier nameIdentifier)
{
+ public boolean isSelf(
+ Entity.EntityType type,
+ NameIdentifier nameIdentifier,
+ AuthorizationRequestContext requestContext) {
String metalake = nameIdentifier.namespace().level(0);
String currentUserName = PrincipalUtils.getCurrentUserName();
if (Entity.EntityType.USER == type) {
@@ -372,26 +377,24 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
if (!roleId.isPresent()) {
return false;
}
- EntityStore entityStore = GravitinoEnv.getInstance().entityStore();
- NameIdentifier userNameIdentifier =
- NameIdentifierUtil.ofUser(metalake,
PrincipalUtils.getCurrentUserName());
- List<RoleEntity> entities =
- entityStore
- .relationOperations()
- .listEntitiesByRelation(
- SupportsRelationOperations.Type.ROLE_USER_REL,
- userNameIdentifier,
- Entity.EntityType.USER);
long resolvedRoleId = roleId.get();
- // Check direct user-role assignment
- if (entities.stream()
- .anyMatch(roleEntity -> Objects.equals(roleEntity.id(),
resolvedRoleId))) {
+
+ Optional<UserUpdatedAt> userInfoOpt =
+ loadUserInfo(metalake, currentUserName, requestContext);
+ if (!userInfoOpt.isPresent()) {
+ return false;
+ }
+ UserUpdatedAt userInfo = userInfoOpt.get();
+ long userId = userInfo.getUserId();
+
+ List<Long> directRoleIds = loadUserRoles(metalake, currentUserName,
userId, userInfo);
+ if (directRoleIds.contains(resolvedRoleId)) {
return true;
}
- // Check group-role assignments.
- for (GroupEntity groupEntity : resolveCurrentUserGroups(metalake,
entityStore)) {
- List<Long> groupRoleIds = groupEntity.roleIds();
- if (groupRoleIds != null && groupRoleIds.contains(resolvedRoleId)) {
+
+ for (String groupname : currentPrincipalGroupNames()) {
+ List<Long> groupRoleIds = loadGroupRoles(metalake, groupname,
userId, requestContext);
+ if (groupRoleIds.contains(resolvedRoleId)) {
return true;
}
}
@@ -509,6 +512,16 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
loadedRoles.invalidate(roleId);
}
+ @Override
+ public void handleUserRoleRelChange(String metalake, String userName) {
+
userRoleCache.invalidate(JcasbinAuthorizationCacheKeys.userRoleKey(metalake,
userName));
+ }
+
+ @Override
+ public void handleGroupRoleRelChange(String metalake, String groupName) {
+
groupRoleCache.invalidate(JcasbinAuthorizationCacheKeys.groupRoleKey(metalake,
groupName));
+ }
+
@Override
public void handleMetadataOwnerChange(
String metalake, Long oldOwnerId, NameIdentifier nameIdentifier,
Entity.EntityType type) {
@@ -609,12 +622,24 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
MetadataObject metadataObject,
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
+ // privilege has been evaluated yet in this request.
+ boolean ownerOnly =
+ AuthConstants.OWNER.equals(privilege)
+ && requestContext.getPrefetchedRoleVersions() == null;
+
long userId;
UserUpdatedAt userInfo;
try {
- // Step 1a: lightweight query — get userId + user.updated_at (version
sentinel).
- // Per-request dedup: only the first authorize() call for
this user hits DB.
- Optional<UserUpdatedAt> userInfoOpt = loadUserInfo(metalake, username,
requestContext);
+ Optional<UserUpdatedAt> userInfoOpt;
+ if (ownerOnly || requestContext.getPrefetchedRoleVersions() != null) {
+ userInfoOpt = loadUserInfo(metalake, username, requestContext);
+ } else {
+ userInfoOpt =
+ prefetchUserAndGroupInfo(
+ metalake, username, currentPrincipalGroupNames(),
requestContext);
+ }
if (!userInfoOpt.isPresent()) {
LOG.debug("User {} not found in metalake {}", username, metalake);
return false;
@@ -626,8 +651,11 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return false;
}
- // Steps 1b→3: version-validated role loading — pass userInfo to avoid
re-query
- loadRolePrivilege(metalake, username, userId, userInfo, requestContext);
+ if (!ownerOnly) {
+ // Steps 1b→3: version-validated role loading (skipped for OWNER-only
requests since
+ // the enforcer is not consulted on the OWNER short-circuit).
+ loadRolePrivilege(metalake, username, userId, userInfo,
requestContext);
+ }
// For requests such as CREATE SCHEMA, the metadata object may be null.
This method
// performs object-scoped authorization, so without a metadata object it
cannot evaluate
@@ -714,6 +742,123 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
UserMetaMapper.class, m -> m.getUserUpdatedAt(metalake,
username))));
}
+ /**
+ * Fat-JOIN prefetch: collapses {@link #loadUserInfo}, per-group {@link
#loadGroupInfo}, the
+ * per-user/per-group role-list lookups inside {@link #loadUserRoles} /
{@link #loadGroupRoles},
+ * AND the role-version probe inside {@link #versionCheckAndLoadRoles} into
a single SQL round
+ * trip. After this returns, the following caches are primed and the rest of
the authorize hot
+ * path needs zero DB round trips when the cached role policies are still
current:
+ *
+ * <ul>
+ * <li>{@code requestContext.userInfoCache} — user version sentinel.
+ * <li>{@code requestContext.groupInfoCache} — per-group version sentinel;
absent groups are
+ * negative-cached so callers can short-circuit.
+ * <li>{@code userRoleCache} (process-wide) — refreshed with the user's
current direct role ids
+ * at the just-read user version, so the next {@link #loadUserRoles}
call observes a
+ * version-validated cache hit.
+ * <li>{@code groupRoleCache} (process-wide) — same idea per group.
+ * <li>{@code requestContext.prefetchedRoleVersions} — roleId → {@link
RoleUpdatedAt} map
+ * consumed by {@link #versionCheckAndLoadRoles} to skip its dedicated
probe.
+ * </ul>
+ *
+ * <p>The fat prefetch runs at most once per request, gated by {@code
prefetchedRoleVersions}.
+ */
+ private Optional<UserUpdatedAt> prefetchUserAndGroupInfo(
+ String metalake,
+ String username,
+ List<String> groupNames,
+ AuthorizationRequestContext requestContext) {
+
+ String userKey = JcasbinAuthorizationCacheKeys.userRoleKey(metalake,
username);
+ if (requestContext.getPrefetchedRoleVersions() != null) {
+ return loadUserInfo(metalake, username, requestContext);
+ }
+
+ // Single round-trip pulls the request user, its groups, and both direct +
inherited role
+ // bindings as one flat polymorphic list. See AuthPrefetchRow for the
per-Kind field layout.
+ List<AuthPrefetchRow> rows =
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ m -> m.batchGetAuthSubjectsForUser(metalake, username,
groupNames));
+
+ UserUpdatedAt foundUser = null;
+ Map<String, GroupUpdatedAt> foundGroups = new HashMap<>();
+ Map<Long, RoleUpdatedAt> roleVersions = new HashMap<>();
+ LinkedHashSet<Long> userRoleIds = new LinkedHashSet<>();
+ Map<Long, LinkedHashSet<Long>> groupRoleIdsByGroupId = new HashMap<>();
+
+ // Pivot the flat row list into per-Kind buckets. Each branch reads
exactly the fields the
+ // class-level Javadoc of AuthPrefetchRow documents as meaningful for that
Kind.
+ for (AuthPrefetchRow row : rows) {
+ switch (row.getSubjectType()) {
+ case USER:
+ // entityId = user_id, updatedAt = user_meta.updated_at. At most one
row.
+ foundUser = new UserUpdatedAt(row.getEntityId(), row.getUpdatedAt());
+ break;
+ case GROUP:
+ // entityId = group_id, entityName = group_name, updatedAt =
group_meta.updated_at.
+ foundGroups.put(
+ row.getEntityName(), new GroupUpdatedAt(row.getEntityId(),
row.getUpdatedAt()));
+ break;
+ case USER_ROLE:
+ // entityId = role_id, entityName = role_name, updatedAt =
role_meta.updated_at.
+ // bindingOwnerId is the user this role is bound to; not needed here
because the user is
+ // implicit (we already know `username`).
+ userRoleIds.add(row.getEntityId());
+ roleVersions.put(
+ row.getEntityId(),
+ new RoleUpdatedAt(row.getEntityId(), row.getEntityName(),
row.getUpdatedAt()));
+ break;
+ case GROUP_ROLE:
+ // entityId = role_id, entityName = role_name, updatedAt =
role_meta.updated_at.
+ // bindingOwnerId = owning group_id — used to bucket roles back to
their group.
+ Long parentGroupId = row.getBindingOwnerId();
+ if (parentGroupId != null) {
+ groupRoleIdsByGroupId
+ .computeIfAbsent(parentGroupId, p -> new LinkedHashSet<>())
+ .add(row.getEntityId());
+ }
+ roleVersions.put(
+ row.getEntityId(),
+ new RoleUpdatedAt(row.getEntityId(), row.getEntityName(),
row.getUpdatedAt()));
+ break;
+ default:
+ break;
+ }
+ }
+
+ Optional<UserUpdatedAt> foundUserOpt = Optional.ofNullable(foundUser);
+ requestContext.computeUserInfoIfAbsent(userKey, k -> foundUserOpt);
+
+ for (String groupName : groupNames) {
+ String groupKey = JcasbinAuthorizationCacheKeys.groupRoleKey(metalake,
groupName);
+ final Optional<GroupUpdatedAt> groupValue =
Optional.ofNullable(foundGroups.get(groupName));
+ requestContext.computeGroupInfoIfAbsent(groupKey, gk -> groupValue);
+ }
+
+ if (foundUser != null) {
+ userRoleCache.put(
+ JcasbinAuthorizationCacheKeys.userRoleKey(metalake, username),
+ new CachedUserRoleRels(
+ foundUser.getUserId(), foundUser.getUpdatedAt(), new
ArrayList<>(userRoleIds)));
+ }
+
+ for (Map.Entry<String, GroupUpdatedAt> e : foundGroups.entrySet()) {
+ String gname = e.getKey();
+ GroupUpdatedAt ginfo = e.getValue();
+ LinkedHashSet<Long> ridSet =
+ groupRoleIdsByGroupId.getOrDefault(ginfo.getGroupId(), new
LinkedHashSet<>());
+ groupRoleCache.put(
+ JcasbinAuthorizationCacheKeys.groupRoleKey(metalake, gname),
+ new CachedGroupRoleRels(
+ ginfo.getGroupId(), ginfo.getUpdatedAt(), new
ArrayList<>(ridSet)));
+ }
+
+ requestContext.setPrefetchedRoleVersions(roleVersions);
+
+ return foundUserOpt;
+ }
+
/**
* Returns true when the cached owner type and ID match the given principal
or one of the
* principal's groups. The user id is resolved via the version-validated
{@link #loadUserInfo}
@@ -893,8 +1038,8 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
/**
* Resolves GroupEntity objects for the current principal's groups, skipping
any that are stale or
- * not found in the store. Used by {@link #isSelf} (ROLE branch) and owner
checks that need full
- * group entities instead of only group names.
+ * not found in the store. Used by owner checks that need full group
entities instead of only
+ * group names.
*/
private List<GroupEntity> resolveCurrentUserGroups(String metalake,
EntityStore entityStore) {
Principal principal = PrincipalUtils.getCurrentPrincipal();
@@ -914,18 +1059,43 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
private void versionCheckAndLoadRoles(
String metalake, List<Long> roleIds, AuthorizationRequestContext
requestContext) {
- // Step 3: batch fetch (roleId, roleName, updated_at) for all role IDs — 1
query
List<Long> uniqueRoleIds =
roleIds.stream().distinct().collect(Collectors.toList());
- List<RoleUpdatedAt> roleVersions =
- SessionUtils.getWithoutCommit(
- RoleMetaMapper.class, m -> m.batchGetRoleUpdatedAt(uniqueRoleIds));
+
+ Map<Long, RoleUpdatedAt> prefetched =
requestContext.getPrefetchedRoleVersions();
+ List<RoleUpdatedAt> roleVersions = new ArrayList<>(uniqueRoleIds.size());
+ List<Long> missingRoleIds = new ArrayList<>();
+ for (Long rid : uniqueRoleIds) {
+ RoleUpdatedAt rv = prefetched == null ? null : prefetched.get(rid);
+ if (rv != null) {
+ roleVersions.add(rv);
+ } else {
+ missingRoleIds.add(rid);
+ }
+ }
+ if (!missingRoleIds.isEmpty()) {
+ roleVersions.addAll(
+ SessionUtils.getWithoutCommit(
+ RoleMetaMapper.class, m ->
m.batchGetRoleUpdatedAt(missingRoleIds)));
+ }
+
+ // 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.
+ Set<Long> existingRoleIds = new HashSet<>(roleVersions.size());
+ for (RoleUpdatedAt rv : roleVersions) {
+ existingRoleIds.add(rv.getRoleId());
+ }
+ for (Long roleId : uniqueRoleIds) {
+ if (!existingRoleIds.contains(roleId)) {
+ clearRolePolicies(roleId);
+ loadedRoles.invalidate(roleId);
+ }
+ }
List<RoleUpdatedAt> staleRoleVersions = new ArrayList<>();
for (RoleUpdatedAt rv : roleVersions) {
Optional<Long> cachedUpdatedAt =
loadedRoles.getIfPresent(rv.getRoleId());
-
if (cachedUpdatedAt.isPresent() && cachedUpdatedAt.get() >=
rv.getUpdatedAt()) {
- // Role policies are still current
continue;
}
staleRoleVersions.add(rv);
@@ -950,7 +1120,10 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
if (roleEntities == null) {
roleEntities = new ArrayList<>();
}
+ // Some EntityStore implementations don't support batchGet for ROLE and
return empty;
+ // fall back to per-role get so policies still load.
if (roleEntities.isEmpty()) {
+ roleEntities = new ArrayList<>(staleRoleVersions.size());
for (RoleUpdatedAt rv : staleRoleVersions) {
try {
roleEntities.add(
@@ -967,6 +1140,9 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
Map<Long, RoleUpdatedAt> staleRoleVersionById =
staleRoleVersions.stream().collect(Collectors.toMap(RoleUpdatedAt::getRoleId,
rv -> rv));
for (RoleEntity roleEntity : roleEntities) {
+ if (roleEntity == null) {
+ continue;
+ }
RoleUpdatedAt rv = staleRoleVersionById.get(roleEntity.id());
if (rv == null) {
continue;
@@ -975,8 +1151,8 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
long dbUpdatedAt = rv.getUpdatedAt();
Optional<Long> cachedUpdatedAt = loadedRoles.getIfPresent(roleId);
- // Stale or missing: refresh only permission policies. Do not call
deleteRole here because it
- // also removes the current user's freshly bound grouping links.
+ // Refresh only permission policies. deleteRole would also remove the
current user's freshly
+ // bound grouping links.
if (cachedUpdatedAt.isPresent()) {
clearRolePolicies(roleId);
}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java
index 5399e45f24..2786cd52ac 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java
@@ -65,7 +65,13 @@ public class JcasbinChangePoller implements AutoCloseable {
private final long pollIntervalSecs;
private ScheduledExecutorService scheduler;
- private volatile long ownerPollHighWaterId = 0;
+
+ // (ownerPollHighWaterUpdatedAt, ownerPollHighWaterUpdatedAtId) is a single
logical keyset cursor
+ // over owner_meta. Inserts set updated_at = now (in POConverters) and
soft-deletes set it via
+ // SQL, so every write advances updated_at — one cursor catches both. id is
the tiebreaker for
+ // batch soft-deletes (softDeleteOwnerRelByCatalogId etc.) where many rows
share the same ms.
+ private volatile long ownerPollHighWaterUpdatedAt = 0;
+ private volatile long ownerPollHighWaterUpdatedAtId = 0;
private volatile long entityPollHighWaterId = 0;
/**
@@ -87,19 +93,18 @@ public class JcasbinChangePoller implements AutoCloseable {
* Initializes the high-water cursors to the current DB tail (so startup
does not scan historical
* changes) and schedules periodic polling.
*
- * <p>Known trade-off: an id-based high-water mark can miss rows whose id is
allocated before the
- * cursor snapshot but whose commit lands after it. Concretely, if writer A
holds {@code id=N-1}
- * uncommitted while writer B commits {@code id=N}, {@code
selectMaxChangeId()} returns N and the
- * next poll queries {@code id > N} — A's row is never consumed. In that
case the affected cache
- * entry stays stale until either (a) a request-side path catches it on the
next request, or (b)
- * TTL eviction. Acceptable for the eventual-consistency caches targeted
here; revisit if we ever
- * route strong-consistency data through this poller.
+ * <p>The owner poller advances a single {@code (updated_at, id)} keyset
cursor. {@code id} alone
+ * would miss soft-delete updates (which reuse the row id), and {@code
updated_at} alone would
+ * miss same-millisecond rows from batch soft-deletes (which all share one
{@code updated_at}).
*/
public void start() {
- ownerPollHighWaterId =
- getOrDefault(
- SessionUtils.getWithoutCommit(
- OwnerMetaMapper.class, OwnerMetaMapper::selectMaxChangeId));
+ ChangedOwnerInfo maxOwnerChange =
+ SessionUtils.getWithoutCommit(
+ OwnerMetaMapper.class, OwnerMetaMapper::selectMaxChangedOwner);
+ if (maxOwnerChange != null) {
+ ownerPollHighWaterUpdatedAt = maxOwnerChange.getUpdatedAt();
+ ownerPollHighWaterUpdatedAtId = maxOwnerChange.getId();
+ }
entityPollHighWaterId =
getOrDefault(
SessionUtils.getWithoutCommit(
@@ -120,7 +125,10 @@ public class JcasbinChangePoller implements AutoCloseable {
@VisibleForTesting
void pollChanges() {
try {
- LOG.debug("Polling for owner changes after id {}", ownerPollHighWaterId);
+ LOG.debug(
+ "Polling for owner changes after (updated_at={}, id={})",
+ ownerPollHighWaterUpdatedAt,
+ ownerPollHighWaterUpdatedAtId);
pollOwnerChanges();
} catch (Exception e) {
if (handleInterruptIfAny(e, "Owner change poll")) {
@@ -163,15 +171,16 @@ public class JcasbinChangePoller implements AutoCloseable
{
}
/**
- * Drains owner-change rows past {@link #ownerPollHighWaterId} and
invalidates the affected {@code
- * ownerRelCache} entries. Each row carries {@code metadataObjectId}, so
invalidation is a direct
- * key removal — no name resolution needed.
+ * Drains owner-change rows past {@link #ownerPollHighWaterUpdatedAt}/{@link
+ * #ownerPollHighWaterUpdatedAtId} and invalidates the affected {@code
ownerRelCache} entries.
+ * Each row carries {@code metadataObjectId}, so invalidation is a direct
key removal — no name
+ * resolution needed.
*
* <p>The {@code synchronized} modifier is defensive. In production this
method is only invoked
* from the single-threaded scheduler started in {@link #start()}, and {@link
* java.util.concurrent.ScheduledExecutorService#scheduleWithFixedDelay}
guarantees that
- * consecutive runs do not overlap. The cursor field {@link
#ownerPollHighWaterId} is also {@code
- * volatile}, and cache invalidations are now atomic at the cache layer via
{@link
+ * consecutive runs do not overlap. The cursor fields are also {@code
volatile}, and cache
+ * invalidations are now atomic at the cache layer via {@link
* org.apache.gravitino.cache.GravitinoCache#runInvalidationBatch}. The
keyword is kept so that
* future callers — additional schedulers, ad-hoc invocations from tests or
admin tooling — do not
* silently introduce concurrent {@code "select changes → invalidate →
advance cursor"} sequences.
@@ -180,12 +189,14 @@ public class JcasbinChangePoller implements AutoCloseable
{
private synchronized void pollOwnerChanges() {
List<ChangedOwnerInfo> changes =
SessionUtils.getWithoutCommit(
- OwnerMetaMapper.class, m ->
m.selectChangedOwners(ownerPollHighWaterId));
+ OwnerMetaMapper.class,
+ m -> m.selectChangedOwners(ownerPollHighWaterUpdatedAt,
ownerPollHighWaterUpdatedAtId));
if (changes.isEmpty()) {
return;
}
- long[] maxSeenId = {ownerPollHighWaterId};
+ long[] maxSeenUpdatedAt = {ownerPollHighWaterUpdatedAt};
+ long[] maxSeenUpdatedAtId = {ownerPollHighWaterUpdatedAtId};
// Hold the cache's exclusive invalidation lock for the whole batch so
readers never observe
// a half-applied state where some of this batch's entries have been
evicted and others are
// still hot.
@@ -193,12 +204,16 @@ public class JcasbinChangePoller implements AutoCloseable
{
() -> {
for (ChangedOwnerInfo change : changes) {
ownerRelCache.invalidate(change.getMetadataObjectId());
- if (change.getId() > maxSeenId[0]) {
- maxSeenId[0] = change.getId();
+ if (change.getUpdatedAt() > maxSeenUpdatedAt[0]
+ || (change.getUpdatedAt() == maxSeenUpdatedAt[0]
+ && change.getId() > maxSeenUpdatedAtId[0])) {
+ maxSeenUpdatedAt[0] = change.getUpdatedAt();
+ maxSeenUpdatedAtId[0] = change.getId();
}
}
});
- ownerPollHighWaterId = maxSeenId[0];
+ ownerPollHighWaterUpdatedAt = maxSeenUpdatedAt[0];
+ ownerPollHighWaterUpdatedAtId = maxSeenUpdatedAtId[0];
}
/**
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/MockGravitinoAuthorizer.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/MockGravitinoAuthorizer.java
index 51c55ed797..e83e4f4e41 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authorization/MockGravitinoAuthorizer.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/MockGravitinoAuthorizer.java
@@ -88,7 +88,10 @@ public class MockGravitinoAuthorizer implements
GravitinoAuthorizer {
}
@Override
- public boolean isSelf(Entity.EntityType type, NameIdentifier nameIdentifier)
{
+ public boolean isSelf(
+ Entity.EntityType type,
+ NameIdentifier nameIdentifier,
+ AuthorizationRequestContext requestContext) {
return true;
}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPassThroughAuthorizer.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPassThroughAuthorizer.java
index e173491d6a..e6099a6c47 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPassThroughAuthorizer.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPassThroughAuthorizer.java
@@ -62,7 +62,9 @@ public class TestPassThroughAuthorizer {
Assertions.assertTrue(passThroughAuthorizer.isServiceAdmin());
Assertions.assertTrue(
passThroughAuthorizer.isMetalakeUser("metalake", new
AuthorizationRequestContext()));
-
Assertions.assertTrue(passThroughAuthorizer.isSelf(Entity.EntityType.USER,
null));
+ Assertions.assertTrue(
+ passThroughAuthorizer.isSelf(
+ Entity.EntityType.USER, null, new
AuthorizationRequestContext()));
Assertions.assertTrue(
passThroughAuthorizer.hasSetOwnerPermission(
"metalake", "type", "fullName", new
AuthorizationRequestContext()));
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
index b41035c857..de0fa232cf 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
@@ -18,6 +18,12 @@
*/
package org.apache.gravitino.server.authorization.jcasbin;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
import java.util.Arrays;
import java.util.Optional;
import java.util.function.Function;
@@ -25,9 +31,12 @@ import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.MetadataObjects;
import org.apache.gravitino.authorization.AuthorizationRequestContext;
import org.apache.gravitino.cache.GravitinoCache;
+import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
import org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
/** Tests for {@link JcasbinAuthorizationLookups}. */
public class TestJcasbinAuthorizationLookups {
@@ -37,7 +46,7 @@ public class TestJcasbinAuthorizationLookups {
MetadataObject table =
MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"),
MetadataObject.Type.TABLE);
CountingCache<String, Long> metadataIdCache = new CountingCache<>(100L);
- CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new
CountingCache<>(Optional.empty());
+ CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new
CountingCache<>();
JcasbinAuthorizationLookups lookups =
new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache);
AuthorizationRequestContext requestContext = new
AuthorizationRequestContext();
@@ -53,17 +62,59 @@ public class TestJcasbinAuthorizationLookups {
}
@Test
- void testResolveOwnerIdUsesAtomicSharedCacheAndRequestDedup() {
+ void testResolveOwnerIdCachesPositiveOwnerInSharedCache() {
CountingCache<String, Long> metadataIdCache = new CountingCache<>(100L);
- CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new
CountingCache<>(Optional.empty());
+ CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new
CountingCache<>();
+ JcasbinAuthorizationLookups lookups =
+ new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache);
+ OwnerMetaMapper ownerMetaMapper = mock(OwnerMetaMapper.class);
+ OwnerInfo ownerInfo = new OwnerInfo(10L, "USER");
+ when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(
+ 100L, MetadataObject.Type.TABLE.name()))
+ .thenReturn(ownerInfo);
+
+ try (MockedStatic<SessionUtils> sessionUtils =
mockStatic(SessionUtils.class)) {
+ sessionUtils
+ .when(() -> SessionUtils.getWithoutCommit(any(), any()))
+ .thenAnswer(
+ invocation -> {
+ Function<OwnerMetaMapper, OwnerInfo> function =
invocation.getArgument(1);
+ return function.apply(ownerMetaMapper);
+ });
+
+ Assertions.assertEquals(
+ Optional.of(ownerInfo),
+ lookups.resolveOwnerId(
+ 100L, MetadataObject.Type.TABLE, new
AuthorizationRequestContext()));
+ Assertions.assertEquals(
+ Optional.of(ownerInfo),
+ lookups.resolveOwnerId(
+ 100L, MetadataObject.Type.TABLE, new
AuthorizationRequestContext()));
+ }
+
+ Assertions.assertEquals(2, ownerRelCache.getCount);
+ Assertions.assertEquals(0, ownerRelCache.getIfPresentCount);
+ Assertions.assertEquals(1, ownerRelCache.putCount);
+ verify(ownerMetaMapper)
+ .selectOwnerByMetadataObjectIdAndType(100L,
MetadataObject.Type.TABLE.name());
+ }
+
+ @Test
+ void testResolveOwnerIdDoesNotCacheMissingOwnerInSharedCache() {
+ CountingCache<String, Long> metadataIdCache = new CountingCache<>(100L);
+ CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new
CountingCache<>();
JcasbinAuthorizationLookups lookups =
new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache);
AuthorizationRequestContext requestContext = new
AuthorizationRequestContext();
- Assertions.assertFalse(
- lookups.resolveOwnerId(100L, MetadataObject.Type.TABLE,
requestContext).isPresent());
- Assertions.assertFalse(
- lookups.resolveOwnerId(100L, MetadataObject.Type.TABLE,
requestContext).isPresent());
+ try (MockedStatic<SessionUtils> sessionUtils =
mockStatic(SessionUtils.class)) {
+ sessionUtils.when(() -> SessionUtils.getWithoutCommit(any(),
any())).thenReturn(null);
+
+ Assertions.assertFalse(
+ lookups.resolveOwnerId(100L, MetadataObject.Type.TABLE,
requestContext).isPresent());
+ Assertions.assertFalse(
+ lookups.resolveOwnerId(100L, MetadataObject.Type.TABLE,
requestContext).isPresent());
+ }
Assertions.assertEquals(1, ownerRelCache.getCount);
Assertions.assertEquals(0, ownerRelCache.getIfPresentCount);
@@ -72,10 +123,15 @@ public class TestJcasbinAuthorizationLookups {
private static class CountingCache<K, V> implements GravitinoCache<K, V> {
private final V value;
+ private Optional<V> cachedValue = Optional.empty();
private int getCount;
private int getIfPresentCount;
private int putCount;
+ private CountingCache() {
+ this.value = null;
+ }
+
private CountingCache(V value) {
this.value = value;
}
@@ -83,18 +139,29 @@ public class TestJcasbinAuthorizationLookups {
@Override
public Optional<V> getIfPresent(K key) {
getIfPresentCount++;
- return Optional.empty();
+ return cachedValue;
}
@Override
public V get(K key, Function<K, V> loader) {
getCount++;
- return value;
+ if (cachedValue.isPresent()) {
+ return cachedValue.get();
+ }
+ if (value != null) {
+ cachedValue = Optional.of(value);
+ return value;
+ }
+ V loaded = loader.apply(key);
+ putCount++;
+ cachedValue = Optional.of(loaded);
+ return loaded;
}
@Override
public void put(K key, V value) {
putCount++;
+ cachedValue = Optional.of(value);
}
@Override
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 dba8697088..4b3292027f 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
@@ -24,7 +24,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
@@ -78,6 +80,7 @@ import
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
import org.apache.gravitino.storage.relational.mapper.UserMetaMapper;
import org.apache.gravitino.storage.relational.po.RolePO;
import org.apache.gravitino.storage.relational.po.SecurableObjectPO;
+import org.apache.gravitino.storage.relational.po.auth.AuthPrefetchRow;
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;
@@ -96,6 +99,7 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
+import org.mockito.Mockito;
/** Test of {@link JcasbinAuthorizer} */
public class TestJcasbinAuthorizer {
@@ -175,8 +179,9 @@ public class TestJcasbinAuthorizer {
OwnerMetaService ownerMetaService = mock(OwnerMetaService.class);
ownerMetaServiceMockedStatic = mockStatic(OwnerMetaService.class);
ownerMetaServiceMockedStatic.when(OwnerMetaService::getInstance).thenReturn(ownerMetaService);
- when(ownerMetaMapper.selectMaxChangeId()).thenReturn(0L);
-
when(ownerMetaMapper.selectChangedOwners(anyLong())).thenReturn(Collections.emptyList());
+ when(ownerMetaMapper.selectMaxChangedOwner()).thenReturn(null);
+ when(ownerMetaMapper.selectChangedOwners(anyLong(), anyLong()))
+ .thenReturn(Collections.emptyList());
when(entityChangeLogMapper.selectMaxChangeId()).thenReturn(0L);
when(entityChangeLogMapper.selectEntityChanges(anyLong(), anyInt()))
.thenReturn(Collections.emptyList());
@@ -210,6 +215,51 @@ public class TestJcasbinAuthorizer {
when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
.thenReturn(new UserUpdatedAt(USER_ID, 1000L));
+ // Fat-JOIN variant used by the cache-warm path: assemble user + groups +
direct user roles
+ // + group-inherited roles + role versions from the existing per-subject
mocks. Lets tests
+ // continue stubbing at the per-subject granularity.
+ when(userMetaMapper.batchGetAuthSubjectsForUser(anyString(), anyString(),
anyList()))
+ .thenAnswer(
+ invocation -> {
+ String mlk = invocation.getArgument(0);
+ String uname = invocation.getArgument(1);
+ List<String> gNames = invocation.getArgument(2);
+ List<AuthPrefetchRow> rows = new ArrayList<>();
+ UserUpdatedAt u = userMetaMapper.getUserUpdatedAt(mlk, uname);
+ if (u != null) {
+ rows.add(AuthPrefetchRow.forUser(u.getUserId(), uname,
u.getUpdatedAt()));
+ List<RolePO> directRoles =
roleMetaMapper.listRolesByUserId(u.getUserId());
+ if (directRoles != null) {
+ for (RolePO rp : directRoles) {
+ RoleUpdatedAt rv = mockedRoleVersions.get(rp.getRoleId());
+ long roleUpdatedAt = rv != null ? rv.getUpdatedAt() : 0L;
+ rows.add(
+ AuthPrefetchRow.forUserRole(
+ rp.getRoleId(), rp.getRoleName(), roleUpdatedAt,
u.getUserId()));
+ }
+ }
+ }
+ if (gNames != null) {
+ for (String gn : gNames) {
+ GroupUpdatedAt g = groupMetaMapper.getGroupUpdatedAt(mlk,
gn);
+ if (g != null) {
+ rows.add(AuthPrefetchRow.forGroup(g.getGroupId(), gn,
g.getUpdatedAt()));
+ List<RolePO> groupRoles =
roleMetaMapper.listRolesByGroupId(g.getGroupId());
+ if (groupRoles != null) {
+ for (RolePO rp : groupRoles) {
+ RoleUpdatedAt rv =
mockedRoleVersions.get(rp.getRoleId());
+ long roleUpdatedAt = rv != null ? rv.getUpdatedAt() :
0L;
+ rows.add(
+ AuthPrefetchRow.forGroupRole(
+ rp.getRoleId(), rp.getRoleName(),
roleUpdatedAt, g.getGroupId()));
+ }
+ }
+ }
+ }
+ }
+ return rows;
+ });
+
// Default: no roles assigned initially
when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of());
// Default answer pulls versions from mockedRoleVersions, populated by
mockRoleInStore.
@@ -430,6 +480,79 @@ public class TestJcasbinAuthorizer {
verify(roleMetaMapper).listRolesByUserId(eq(recreatedUserId));
}
+ @Test
+ public void testVersionCheckEvictsPoliciesOfRolesMissingFromDb() throws
Exception {
+ // Regression test for the cross-instance role-delete invalidation gap. The
+ // happy path is already handled by the fat-JOIN inside
prefetchUserAndGroupInfo,
+ // which excludes soft-/hard-deleted roles via "role_meta.deleted_at = 0"
and
+ // re-primes userRoleCache before the next loadUserRoles call. This test
covers
+ // the defence-in-depth tier: if versionCheckAndLoadRoles is ever invoked
with
+ // 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.
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+
+ // 1. Authorize once via the normal flow to populate loadedRoles +
enforcer p-rows
+ // for allowRole.
+ RoleEntity allowRole =
+ mockRoleInStore(ALLOW_ROLE_ID, "allowRole",
ImmutableList.of(getAllowSecurableObject()));
+ long userVersion = nextUserVersion();
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, userVersion));
+ when(roleMetaMapper.listRolesByUserId(eq(USER_ID)))
+ .thenReturn(ImmutableList.of(buildRolePO(ALLOW_ROLE_ID,
allowRole.name())));
+ assertTrue(doAuthorize(currentPrincipal));
+
+ // Sanity: loadedRoles now has an entry for ALLOW_ROLE_ID and the
allowEnforcer
+ // contains a p-row whose subject (column 0) equals the role id.
+ Assertions.assertTrue(
+
getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent(),
+ "loadedRoles must be primed before the test");
+ Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer);
+ Assertions.assertFalse(
+ allowEnforcer.getFilteredPolicy(0,
String.valueOf(ALLOW_ROLE_ID)).isEmpty(),
+ "allowEnforcer must hold p-rows for allowRole before the test");
+
+ // 2. Simulate the bug-trigger: batchGetRoleUpdatedAt returns NO row for
the role
+ // (i.e. the role row is gone from role_meta), even though something is
still
+ // asking us to version-check it.
+
when(roleMetaMapper.batchGetRoleUpdatedAt(any())).thenReturn(ImmutableList.of());
+
+ // Invoke versionCheckAndLoadRoles directly with the "deleted" role id and
a
+ // fresh AuthorizationRequestContext that has NO prefetched role versions,
so
+ // the method falls through to the batch probe and observes the empty
result.
+ AuthorizationRequestContext freshCtx = new AuthorizationRequestContext();
+ invokeVersionCheckAndLoadRoles(
+ jcasbinAuthorizer, METALAKE, ImmutableList.of(ALLOW_ROLE_ID),
freshCtx);
+
+ // 3. The fix must have cleared the role's p-rows and evicted loadedRoles.
+ Assertions.assertTrue(
+ allowEnforcer.getFilteredPolicy(0,
String.valueOf(ALLOW_ROLE_ID)).isEmpty(),
+ "allowEnforcer p-rows for the deleted role must be cleared");
+ Assertions.assertFalse(
+
getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent(),
+ "loadedRoles entry for the deleted role must be evicted");
+ }
+
+ /** Reflectively invoke the private versionCheckAndLoadRoles. */
+ private static void invokeVersionCheckAndLoadRoles(
+ JcasbinAuthorizer authorizer,
+ String metalake,
+ List<Long> roleIds,
+ AuthorizationRequestContext requestContext)
+ throws Exception {
+ Method m =
+ JcasbinAuthorizer.class.getDeclaredMethod(
+ "versionCheckAndLoadRoles",
+ String.class,
+ List.class,
+ AuthorizationRequestContext.class);
+ m.setAccessible(true);
+ m.invoke(authorizer, metalake, roleIds, requestContext);
+ }
+
@Test
public void testAuthorizeByOwner() throws Exception {
Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
@@ -462,6 +585,30 @@ public class TestJcasbinAuthorizer {
assertFalse(doAuthorizeOwner(currentPrincipal));
}
+ @Test
+ public void testPrefetchRunsAfterOwnerUserInfoLookup() throws Exception {
+ Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+ RoleEntity allowRole =
+ mockRoleInStore(ALLOW_ROLE_ID, "allowRole",
ImmutableList.of(getAllowSecurableObject()));
+ mockDirectUserRoles(allowRole);
+
+ when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID),
eq("CATALOG")))
+ .thenReturn(new OwnerInfo(USER_ID + 1L, "USER"));
+ getOwnerRelCache(jcasbinAuthorizer).invalidateAll();
+
+ AuthorizationRequestContext requestContext = new
AuthorizationRequestContext();
+ MetadataObject catalog = MetadataObjects.of(null, "testCatalog",
MetadataObject.Type.CATALOG);
+
+ assertFalse(jcasbinAuthorizer.isOwner(currentPrincipal, METALAKE, catalog,
requestContext));
+ Mockito.clearInvocations(userMetaMapper, roleMetaMapper);
+
+ assertTrue(
+ jcasbinAuthorizer.authorize(
+ currentPrincipal, METALAKE, catalog, USE_CATALOG, requestContext));
+ verify(userMetaMapper).batchGetAuthSubjectsForUser(eq(METALAKE),
eq(USERNAME), anyList());
+ verify(roleMetaMapper, Mockito.never()).batchGetRoleUpdatedAt(any());
+ }
+
@Test
public void testAuthorizeByGroupOwner() throws Exception {
// Set up a UserPrincipal whose groups include GROUP_NAME
@@ -613,11 +760,67 @@ public class TestJcasbinAuthorizer {
mockGroupWithRoles(GROUP_NAME, ImmutableList.of(groupRoleId),
ImmutableList.of(groupRoleName));
// isSelf should return true -- role is assigned to user's group
- assertTrue(jcasbinAuthorizer.isSelf(Entity.EntityType.ROLE, roleIdent));
+ assertTrue(
+ jcasbinAuthorizer.isSelf(
+ Entity.EntityType.ROLE, roleIdent, new
AuthorizationRequestContext()));
// A principal with no groups should fail
setCurrentPrincipalWithGroup(null);
- assertFalse(jcasbinAuthorizer.isSelf(Entity.EntityType.ROLE, roleIdent));
+ assertFalse(
+ jcasbinAuthorizer.isSelf(
+ Entity.EntityType.ROLE, roleIdent, new
AuthorizationRequestContext()));
+
+ restoreDefaultPrincipal();
+ }
+
+ @Test
+ public void testIsSelfRoleReusesCacheAcrossCalls() throws Exception {
+ // Acceptance criterion for #11088: repeated isSelf(ROLE) calls in the
same logical request
+ // must not re-issue the role-list DB queries (listRolesByUserId /
listRolesByGroupId).
+ // The version-validated userRoleCache / groupRoleCache are process-wide,
so the second call
+ // hits cache even though each isSelf creates a fresh
AuthorizationRequestContext.
+ //
+ // Use CATALOG_ID so the role id matches the catch-all
MetadataIdConverter.getID mock.
+ Long directRoleId = CATALOG_ID;
+ String directRoleName = "selfDedupRole";
+ NameIdentifier roleIdent = NameIdentifierUtil.ofRole(METALAKE,
directRoleName);
+
+ // Direct user-role assignment via the version-validated cache path.
+ mockUserRoles(directRoleId, directRoleName);
+
+ // Use a fresh authorizer + principal to ensure the userRoleCache starts
cold.
+ setCurrentPrincipalWithGroup(null);
+ Mockito.clearInvocations(roleMetaMapper);
+
+ // 1st call: miss → listRolesByUserId; 2nd call: cache hit → no extra
listRolesByUserId.
+ AuthorizationRequestContext ctx1 = new AuthorizationRequestContext();
+ AuthorizationRequestContext ctx2 = new AuthorizationRequestContext();
+ assertTrue(jcasbinAuthorizer.isSelf(Entity.EntityType.ROLE, roleIdent,
ctx1));
+ assertTrue(jcasbinAuthorizer.isSelf(Entity.EntityType.ROLE, roleIdent,
ctx2));
+
+ Mockito.verify(roleMetaMapper,
Mockito.times(1)).listRolesByUserId(eq(USER_ID));
+
+ restoreDefaultPrincipal();
+ }
+
+ @Test
+ public void testIsSelfRoleDoesNotCallListEntitiesByRelation() throws
Exception {
+ // #11088: isSelf(ROLE) must not bypass the cache by going straight to
+ // entityStore.relationOperations().listEntitiesByRelation(ROLE_USER_REL,
...).
+ Long directRoleId = CATALOG_ID;
+ String directRoleName = "noBypassRole";
+ NameIdentifier roleIdent = NameIdentifierUtil.ofRole(METALAKE,
directRoleName);
+
+ mockUserRoles(directRoleId, directRoleName);
+ setCurrentPrincipalWithGroup(null);
+ Mockito.clearInvocations(supportsRelationOperations);
+
+ assertTrue(
+ jcasbinAuthorizer.isSelf(
+ Entity.EntityType.ROLE, roleIdent, new
AuthorizationRequestContext()));
+
+ Mockito.verify(supportsRelationOperations, Mockito.never())
+ .listEntitiesByRelation(any(), any(), any());
restoreDefaultPrincipal();
}
@@ -773,6 +976,63 @@ public class TestJcasbinAuthorizer {
getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
}
+ @Test
+ public void testUserRoleRelChangeInvalidatesUserRoleCache() throws Exception
{
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+
+ UserPrincipal noGroupPrincipal = setCurrentPrincipalWithGroup(null);
+ long userVersion = nextUserVersion();
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, userVersion));
+
when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of());
+
+ assertFalse(doAuthorize(noGroupPrincipal));
+
+ Long grantedRoleId = 20L;
+ RoleEntity grantedRole =
+ mockRoleInStore(
+ grantedRoleId, "userRelGrantedRole",
ImmutableList.of(getAllowSecurableObject()));
+ when(roleMetaMapper.listRolesByUserId(eq(USER_ID)))
+ .thenReturn(ImmutableList.of(buildRolePO(grantedRoleId,
grantedRole.name())));
+
+ jcasbinAuthorizer.handleUserRoleRelChange(METALAKE, USERNAME);
+
+ assertTrue(doAuthorize(noGroupPrincipal));
+
+ restoreDefaultPrincipal();
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ }
+
+ @Test
+ public void testGroupRoleRelChangeInvalidatesGroupRoleCache() throws
Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+
+ UserPrincipal groupPrincipal = setCurrentPrincipalWithGroup(GROUP_NAME);
+ mockNoDirectUserRoles();
+ long groupVersion = groupVersionCounter.incrementAndGet();
+ when(groupMetaMapper.getGroupUpdatedAt(eq(METALAKE), eq(GROUP_NAME)))
+ .thenReturn(new GroupUpdatedAt(GROUP_ID, groupVersion));
+
when(roleMetaMapper.listRolesByGroupId(eq(GROUP_ID))).thenReturn(ImmutableList.of());
+
+ assertFalse(doAuthorize(groupPrincipal));
+
+ Long grantedRoleId = 21L;
+ RoleEntity grantedRole =
+ mockRoleInStore(
+ grantedRoleId, "groupRelGrantedRole",
ImmutableList.of(getAllowSecurableObject()));
+ when(roleMetaMapper.listRolesByGroupId(eq(GROUP_ID)))
+ .thenReturn(ImmutableList.of(buildRolePO(grantedRoleId,
grantedRole.name())));
+
+ jcasbinAuthorizer.handleGroupRoleRelChange(METALAKE, GROUP_NAME);
+
+ assertTrue(doAuthorize(groupPrincipal));
+
+ restoreDefaultPrincipal();
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ }
+
/**
* When the user is removed from a group at the IdP level (e.g. Azure AD),
the next JWT token
* won't include that group. On the next request the group's roles should no
longer be available.
@@ -1444,6 +1704,10 @@ public class TestJcasbinAuthorizer {
.thenReturn(ImmutableList.of(buildRolePO(roleId, roleName)));
when(roleMetaMapper.batchGetRoleUpdatedAt(any()))
.thenReturn(ImmutableList.of(new RoleUpdatedAt(roleId, roleName,
roleVersion)));
+ // Also register the role in mockedRoleVersions so the fat-JOIN test stub
for
+ // batchGetAuthSubjectsForUser surfaces it; otherwise prefetch's
role-version map would
+ // miss this role and downstream loadPolicyByRoleEntity would never run.
+ mockedRoleVersions.put(roleId, new RoleUpdatedAt(roleId, roleName,
roleVersion));
when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
.thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
index e436fdd9bf..9e9c248167 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
@@ -98,7 +98,7 @@ public class TestJcasbinChangePoller {
EntityChangeLogMapper entityChangeLogMapper =
mock(EntityChangeLogMapper.class);
OwnerMetaMapper ownerMetaMapper = mock(OwnerMetaMapper.class);
-
when(ownerMetaMapper.selectChangedOwners(0L)).thenReturn(Collections.emptyList());
+ when(ownerMetaMapper.selectChangedOwners(0L,
0L)).thenReturn(Collections.emptyList());
when(entityChangeLogMapper.selectEntityChanges(0L, 500))
.thenReturn(
List.of(
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 02d6048c95..d59db13f58 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -313,7 +313,10 @@ public class TestGravitinoInterceptionService {
}
@Override
- public boolean isSelf(Entity.EntityType type, NameIdentifier
nameIdentifier) {
+ public boolean isSelf(
+ Entity.EntityType type,
+ NameIdentifier nameIdentifier,
+ AuthorizationRequestContext requestContext) {
return true;
}