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 5f5e2b346a [#10772] feat(authorization): refactor JcasbinAuthorizer
with version-validated cache (#10996)
5f5e2b346a is described below
commit 5f5e2b346a71ff049b8a7c3044953533d2ebe63d
Author: Qi Yu <[email protected]>
AuthorDate: Fri May 22 09:35:20 2026 +0800
[#10772] feat(authorization): refactor JcasbinAuthorizer with
version-validated cache (#10996)
### What changes were proposed in this pull request?
> **Prerequisite:** #11117 (eventual-consistency invalidation
infrastructure) must land first. This PR will be rebased on `main` after
that, at which point the diff narrows to the role-loading rewrite
described below.
This is the final piece of the #10812 split. It rewrites
`JcasbinAuthorizer`'s strong-consistency caching layer on top of the
lookups + change-poller infrastructure introduced in #11117:
- **Per-request dedup** via `AuthorizationRequestContext`: user
identity, group identity and the role-load step are deduplicated within
a single HTTP request.
- **Version-validated shared caches** (`userRoleCache`,
`groupRoleCache`, `loadedRoles`): cache hits revalidate against
`user_meta.updated_at` / `group_meta.updated_at` /
`role_meta.updated_at` before being trusted, replacing TTL-only
invalidation.
- **`JcasbinRoleLoader`** (new): owns the 4-step role load —
version-checked user-direct roles, version-checked group-inherited
roles, prune stale g-rows, then batch version-check + reload of
`role_meta`.
- **`JcasbinLoadedRolesCache`** (new): wraps a `GravitinoCache` with a
removal listener that synchronously deletes the role's JCasbin policies,
so eviction and policy state stay in sync.
- Removes the `Executor` + `CompletableFuture` async role-load path;
role loading is now synchronous via
`AuthorizationRequestContext.loadRole(Runnable)`.
- `isMetalakeUser` now takes `AuthorizationRequestContext` so the
per-request `UserUpdatedAt` cache populated by `authorize`/`isOwner` is
reused, removing a redundant DB round-trip.
### Why are the changes needed?
Fix: #10772
### Does this PR introduce _any_ user-facing change?
No. New configs (`gravitino.authorization.jcasbin.*`) have safe
defaults.
### How was this patch tested?
- 12 new unit tests across `TestGravitinoCache` (TTL, max-size eviction,
prefix edge cases) and `TestAuthorizationRequestContext` (request-scoped
dedup for user/owner/metadata-id, including absent-result caching).
- New `TestJcasbinAuthorizer` group-role / version-validated cases:
group-inherited role, IdP group removal, multi-group partial revocation,
deny-wins over group-inherited allow, role-shared-by-user-and-group
survives single-side revocation.
- All existing `TestJcasbinAuthorizer`,
`TestJcasbinAuthorizerCacheHelpers` and `TestPassThroughAuthorizer`
suites still pass.
---
.../main/java/org/apache/gravitino/Configs.java | 5 +-
.../apache/gravitino/cache/TestGravitinoCache.java | 2 +-
.../authorization/jcasbin/CachedGroupRoleRels.java | 51 ++
.../authorization/jcasbin/CachedUserRoleRels.java | 51 ++
.../jcasbin/JcasbinAuthorizationCacheKeys.java | 125 ++++
.../jcasbin/JcasbinAuthorizationLookups.java | 62 +-
.../authorization/jcasbin/JcasbinAuthorizer.java | 651 +++++++++++--------
.../authorization/jcasbin/JcasbinChangePoller.java | 4 +-
.../jcasbin/JcasbinLoadedRolesCache.java | 94 +++
.../jcasbin/TestJcasbinAuthorizationCacheKeys.java | 117 ++++
.../jcasbin/TestJcasbinAuthorizationLookups.java | 94 +--
.../jcasbin/TestJcasbinAuthorizer.java | 699 ++++++++++++---------
.../jcasbin/TestJcasbinAuthorizerCacheHelpers.java | 46 ++
.../jcasbin/TestJcasbinChangePoller.java | 22 +-
14 files changed, 1331 insertions(+), 692 deletions(-)
diff --git a/core/src/main/java/org/apache/gravitino/Configs.java
b/core/src/main/java/org/apache/gravitino/Configs.java
index 1c50d74402..a0b4ca5c89 100644
--- a/core/src/main/java/org/apache/gravitino/Configs.java
+++ b/core/src/main/java/org/apache/gravitino/Configs.java
@@ -316,7 +316,10 @@ public class Configs {
public static final ConfigEntry<Long>
GRAVITINO_AUTHORIZATION_ROLE_CACHE_SIZE =
new ConfigBuilder("gravitino.authorization.jcasbin.roleCacheSize")
- .doc("The maximum size of the role cache for authorization")
+ .doc(
+ "The maximum size of the role-related caches used by the
JcasbinAuthorizer. "
+ + "Shared by the user-role, group-role, and loaded-role
caches, so the "
+ + "effective memory footprint is up to roughly 3x this
value.")
.version(ConfigConstants.VERSION_1_1_1)
.longConf()
.createWithDefault(DEFAULT_GRAVITINO_AUTHORIZATION_ROLE_CACHE_SIZE);
diff --git
a/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java
b/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java
index 8cbf4e7232..0f5fa08678 100644
--- a/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java
+++ b/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java
@@ -238,7 +238,7 @@ public class TestGravitinoCache {
void testCaffeineInvalidateByPrefix() {
CaffeineGravitinoCache<String, Long> cache = new
CaffeineGravitinoCache<>(60_000L, 1000L);
try {
- // Simulate hierarchical keys: metalake::catalog::schema::
+ // Simulate name-path keys: metalake::catalog::schema::
cache.put("lake1::cat1::", 1L);
cache.put("lake1::cat1::s1::", 2L);
cache.put("lake1::cat1::s1::t1::TABLE", 3L);
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoleRels.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoleRels.java
new file mode 100644
index 0000000000..38573c96e1
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoleRels.java
@@ -0,0 +1,51 @@
+/*
+ * 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.server.authorization.jcasbin;
+
+import java.util.List;
+
+/**
+ * Cached snapshot of a group's role relations. The {@code updatedAt}
timestamp corresponds to the
+ * {@code group_meta.updated_at} column and is used as a version sentinel: if
the DB value is newer,
+ * the cached role relation list is stale and must be reloaded.
+ */
+final class CachedGroupRoleRels {
+
+ private final long groupId;
+ private final long updatedAt;
+ private final List<Long> roleIds;
+
+ CachedGroupRoleRels(long groupId, long updatedAt, List<Long> roleIds) {
+ this.groupId = groupId;
+ this.updatedAt = updatedAt;
+ this.roleIds = roleIds;
+ }
+
+ long getGroupId() {
+ return groupId;
+ }
+
+ long getUpdatedAt() {
+ return updatedAt;
+ }
+
+ List<Long> getRoleIds() {
+ return roleIds;
+ }
+}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoleRels.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoleRels.java
new file mode 100644
index 0000000000..10e5cf43d3
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoleRels.java
@@ -0,0 +1,51 @@
+/*
+ * 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.server.authorization.jcasbin;
+
+import java.util.List;
+
+/**
+ * Cached snapshot of a user's direct role relations. The {@code updatedAt}
timestamp corresponds to
+ * the {@code user_meta.updated_at} column and is used as a version sentinel:
if the DB value is
+ * newer, the cached role relation list is stale and must be reloaded.
+ */
+final class CachedUserRoleRels {
+
+ private final long userId;
+ private final long updatedAt;
+ private final List<Long> roleIds;
+
+ CachedUserRoleRels(long userId, long updatedAt, List<Long> roleIds) {
+ this.userId = userId;
+ this.updatedAt = updatedAt;
+ this.roleIds = roleIds;
+ }
+
+ long getUserId() {
+ return userId;
+ }
+
+ long getUpdatedAt() {
+ return updatedAt;
+ }
+
+ List<Long> getRoleIds() {
+ return roleIds;
+ }
+}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationCacheKeys.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationCacheKeys.java
new file mode 100644
index 0000000000..c6d0228186
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationCacheKeys.java
@@ -0,0 +1,125 @@
+/*
+ * 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.server.authorization.jcasbin;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.gravitino.MetadataObject;
+
+/** Cache key factory for JCasbin authorization caches. */
+final class JcasbinAuthorizationCacheKeys {
+
+ private static final String KEY_SEPARATOR = "\u001F";
+
+ static final String USER_ROLE_REL = "USER_ROLE_REL";
+ static final String GROUP_ROLE_REL = "GROUP_ROLE_REL";
+
+ private JcasbinAuthorizationCacheKeys() {}
+
+ static String metadataIdCacheKey(String metalake, MetadataObject
metadataObject) {
+ List<String> parts = new ArrayList<>();
+ parts.add(metalake);
+
+ String[] names = metadataObject.fullName().split("\\.");
+ switch (metadataObject.type()) {
+ case METALAKE:
+ parts.add(MetadataObject.Type.METALAKE.name());
+ break;
+ case CATALOG:
+ parts.add(MetadataObject.Type.CATALOG.name());
+ parts.add(names[0]);
+ break;
+ case SCHEMA:
+ appendCatalogAndSchemas(parts, names);
+ break;
+ case TABLE:
+ appendCatalogSchemasAndLeaf(parts, names, MetadataObject.Type.TABLE);
+ break;
+ case COLUMN:
+ appendCatalogAndSchemas(parts, names, names.length - 2);
+ parts.add(MetadataObject.Type.TABLE.name());
+ parts.add(names[names.length - 2]);
+ parts.add(MetadataObject.Type.COLUMN.name());
+ parts.add(names[names.length - 1]);
+ break;
+ case VIEW:
+ case TOPIC:
+ case FILESET:
+ case MODEL:
+ case FUNCTION:
+ appendCatalogSchemasAndLeaf(parts, names, metadataObject.type());
+ break;
+ default:
+ parts.add(metadataObject.type().name());
+ parts.add(metadataObject.fullName());
+ break;
+ }
+
+ String key = String.join(KEY_SEPARATOR, parts);
+ if (hasNestedMetadataObjects(metadataObject.type())) {
+ key += KEY_SEPARATOR;
+ }
+ return key;
+ }
+
+ static String userRoleKey(String metalake, String username) {
+ return String.join(KEY_SEPARATOR, metalake, USER_ROLE_REL, username);
+ }
+
+ static String groupRoleKey(String metalake, String groupname) {
+ return String.join(KEY_SEPARATOR, metalake, GROUP_ROLE_REL, groupname);
+ }
+
+ static boolean hasNestedMetadataObjects(MetadataObject.Type type) {
+ return type == MetadataObject.Type.METALAKE
+ || type == MetadataObject.Type.CATALOG
+ || type == MetadataObject.Type.SCHEMA
+ || type == MetadataObject.Type.TABLE;
+ }
+
+ static String joinKeyParts(String... parts) {
+ return String.join(KEY_SEPARATOR, parts);
+ }
+
+ private static void appendCatalogAndSchemas(List<String> parts, String[]
names) {
+ parts.add(MetadataObject.Type.CATALOG.name());
+ parts.add(names[0]);
+ for (int i = 1; i < names.length; i++) {
+ parts.add(MetadataObject.Type.SCHEMA.name());
+ parts.add(names[i]);
+ }
+ }
+
+ private static void appendCatalogSchemasAndLeaf(
+ List<String> parts, String[] names, MetadataObject.Type leafType) {
+ appendCatalogAndSchemas(parts, names, names.length - 1);
+ parts.add(leafType.name());
+ parts.add(names[names.length - 1]);
+ }
+
+ private static void appendCatalogAndSchemas(
+ List<String> parts, String[] names, int exclusiveEnd) {
+ parts.add(MetadataObject.Type.CATALOG.name());
+ parts.add(names[0]);
+ for (int i = 1; i < exclusiveEnd; i++) {
+ parts.add(MetadataObject.Type.SCHEMA.name());
+ parts.add(names[i]);
+ }
+ }
+}
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 1877e3e1da..6a1b30c643 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
@@ -18,7 +18,6 @@
*/
package org.apache.gravitino.server.authorization.jcasbin;
-import com.google.common.annotations.VisibleForTesting;
import java.util.Optional;
import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.authorization.AuthorizationRequestContext;
@@ -43,9 +42,6 @@ import
org.apache.gravitino.storage.relational.utils.SessionUtils;
*/
public class JcasbinAuthorizationLookups {
- /** Unit Separator for internal path-based cache keys. */
- static final String KEY_SEP = "\u001F";
-
private final GravitinoCache<String, Long> metadataIdCache;
private final GravitinoCache<Long, Optional<OwnerInfo>> ownerRelCache;
@@ -53,7 +49,7 @@ public class JcasbinAuthorizationLookups {
* Creates a new lookups facade around the supplied caches. The caches are
owned by the caller and
* remain accessible for invalidation by other components (poller, change
hooks).
*
- * @param metadataIdCache path-based {@code
metalake::catalog::schema::object::TYPE} → entity id
+ * @param metadataIdCache path-based metadata object key → entity id
* @param ownerRelCache {@code metadataObjectId} → {@link Optional} of
{@link OwnerInfo}
*/
public JcasbinAuthorizationLookups(
@@ -67,12 +63,14 @@ public class JcasbinAuthorizationLookups {
* Two-tier name→id lookup: the per-request map in {@code requestContext}
dedups calls within the
* same HTTP request; on a miss, the long-lived {@code metadataIdCache} is
consulted, and finally
* we fall back to a DB query via {@link MetadataIdConverter#getID}. Returns
{@link
- * Optional#empty()} when the metadata object does not exist so callers can
deny authorization; a
- * missing object is never cached as a negative result.
+ * Optional#empty()} when the metadata object does not exist so callers can
deny authorization.
+ * Missing metadata objects are never cached as negative results: a later
create for the same name
+ * can be observed without waiting for cache eviction. Existing objects are
invalidated by local
+ * name-id mapping hooks and by the change-log poller on peer nodes.
*/
public Optional<Long> resolveMetadataId(
MetadataObject metadataObject, String metalake,
AuthorizationRequestContext requestContext) {
- String cacheKey = buildCacheKey(metalake, metadataObject);
+ String cacheKey =
JcasbinAuthorizationCacheKeys.metadataIdCacheKey(metalake, metadataObject);
try {
// Both cache tiers load atomically and forbid caching null, so a
missing object is signalled
// by throwing through the loaders and translated back to
Optional.empty() here. This caches
@@ -116,52 +114,4 @@ public class JcasbinAuthorizationLookups {
return ownerInfo == null ? Optional.empty() :
Optional.of(ownerInfo);
}));
}
-
- /** Underlying metadata-id cache; exposed for invalidation by the change
hooks and the poller. */
- public GravitinoCache<String, Long> metadataIdCache() {
- return metadataIdCache;
- }
-
- /** Underlying owner cache; exposed for invalidation by the change hooks and
the poller. */
- public GravitinoCache<Long, Optional<OwnerInfo>> ownerRelCache() {
- return ownerRelCache;
- }
-
- /**
- * Builds a path-based cache key for the metadataIdCache. Container objects
end with the internal
- * separator so a prefix invalidation can remove the container and all
entries under the same name
- * path.
- *
- * <p>Examples: {@code metalake<sep>}, {@code metalake<sep>catalog<sep>},
{@code
- * metalake<sep>catalog<sep>schema<sep>}, {@code
- * metalake<sep>catalog<sep>schema<sep>table<sep>TABLE}.
- */
- @VisibleForTesting
- public static String buildCacheKey(String metalake, MetadataObject
metadataObject) {
- if (metadataObject.type() == MetadataObject.Type.METALAKE) {
- return metalake + KEY_SEP;
- }
- StringBuilder sb = new StringBuilder(metalake);
- sb.append(KEY_SEP);
- // fullName uses '.' as separator, e.g. "catalog1.schema1.table1"
- String[] parts = metadataObject.fullName().split("\\.");
- sb.append(String.join(KEY_SEP, parts));
- if (isContainerType(metadataObject.type())) {
- // Trailing separator enables prefix-based invalidation.
- sb.append(KEY_SEP);
- } else {
- // Leaf nodes get the type suffix to avoid collisions
- sb.append(KEY_SEP);
- sb.append(metadataObject.type().name());
- }
- return sb.toString();
- }
-
- /** Returns true for entity types that can contain children (metalake,
catalog, schema). */
- @VisibleForTesting
- public static boolean isContainerType(MetadataObject.Type type) {
- return type == MetadataObject.Type.METALAKE
- || type == MetadataObject.Type.CATALOG
- || type == MetadataObject.Type.SCHEMA;
- }
}
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 1f6b28aa85..da67d85eea 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
@@ -17,8 +17,6 @@
package org.apache.gravitino.server.authorization.jcasbin;
-import com.github.benmanes.caffeine.cache.Cache;
-import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
@@ -29,13 +27,11 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.apache.commons.io.IOUtils;
@@ -58,12 +54,18 @@ import org.apache.gravitino.authorization.Privilege;
import org.apache.gravitino.authorization.SecurableObject;
import org.apache.gravitino.cache.CaffeineGravitinoCache;
import org.apache.gravitino.cache.GravitinoCache;
-import org.apache.gravitino.exceptions.NoSuchUserException;
import org.apache.gravitino.meta.GroupEntity;
import org.apache.gravitino.meta.RoleEntity;
-import org.apache.gravitino.meta.UserEntity;
import org.apache.gravitino.server.authorization.MetadataIdConverter;
+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.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;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
import org.apache.gravitino.utils.HierarchicalSchemaUtil;
import org.apache.gravitino.utils.NameIdentifierUtil;
import org.apache.gravitino.utils.PrincipalUtils;
@@ -73,7 +75,41 @@ import org.casbin.jcasbin.model.Model;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-/** The Jcasbin implementation of GravitinoAuthorizer. */
+/**
+ * The Jcasbin implementation of {@link GravitinoAuthorizer}.
+ *
+ * <h2>Cache architecture</h2>
+ *
+ * <p>Authorization decisions are read-mostly and run on the hot path, so this
class layers three
+ * cache families with different consistency models:
+ *
+ * <ol>
+ * <li><b>Per-request dedup</b> — fields on {@link
AuthorizationRequestContext} (user info, group
+ * info, name→id, owner). A fresh context is created for every HTTP
request; every underlying
+ * DB query runs at most once per request even when the same
authorize/isOwner pair is
+ * evaluated repeatedly for a single authorization expression.
+ * <li><b>Version-validated shared caches</b> (strong consistency) — {@link
#userRoleCache},
+ * {@link #groupRoleCache}, {@link #loadedRoles}. Each cached entry
carries the {@code
+ * *_meta.updated_at} value it was loaded against; every read issues a
lightweight version
+ * probe and discards the entry if the DB sentinel has advanced. No TTL
is relied on for
+ * correctness — TTL eviction only bounds memory. User/group role
snapshots use write-based
+ * TTLs through {@link CaffeineGravitinoCache}; loaded role policies use
access-based TTLs
+ * through {@link JcasbinLoadedRolesCache}.
+ * <li><b>Eventual-consistency caches</b> — {@link #metadataIdCache} and
{@link #ownerRelCache}. A
+ * single background poller ({@link #changePoller}) drains {@code
entity_change_log} and
+ * {@code owner_meta} change rows since a high-water-mark cursor and
invalidates the affected
+ * keys. Other Gravitino nodes therefore observe ALTER/DROP and owner
changes within one poll
+ * interval.
+ * </ol>
+ *
+ * <p>The pollers are best-effort and intentionally cheap; see {@link
JcasbinChangePoller} for the
+ * contracts they rely on (most notably that {@code
entity_change_log.full_name} is the pre-mutation
+ * name).
+ *
+ * <p>JCasbin enforcer state ({@link #allowEnforcer}/{@link #denyEnforcer}) is
kept in sync with
+ * {@link #loadedRoles} via the removal listener inside {@link
JcasbinLoadedRolesCache} — evicting a
+ * role id also deletes that role's policies from both enforcers.
+ */
public class JcasbinAuthorizer implements GravitinoAuthorizer {
private static final Logger LOG =
LoggerFactory.getLogger(JcasbinAuthorizer.class);
@@ -90,26 +126,39 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
/** deny internal authorizer */
private InternalAuthorizer denyInternalAuthorizer;
+ // ---- Version-validated caches (strong consistency) ----
+
+ /**
+ * userRoleCache: per-(metalake, userName) -> CachedUserRoleRels.
Version-validated per request
+ * via user_meta.updated_at.
+ */
+ private GravitinoCache<String, CachedUserRoleRels> userRoleCache;
+
/**
- * loadedRoles is used to cache roles that have loaded permissions. When the
permissions of a role
- * are updated, they should be removed from it.
+ * groupRoleCache: per-(metalake, groupName) -> CachedGroupRoleRels.
Version-validated per request
+ * via group_meta.updated_at.
*/
- private Cache<Long, Boolean> loadedRoles;
+ private GravitinoCache<String, CachedGroupRoleRels> groupRoleCache;
- /** Path-based {@code metalake::catalog::schema::object::TYPE} → entity id.
*/
+ /**
+ * loadedRoles: roleId -> updated_at. If the DB updated_at is newer, evict
and reload policies.
+ */
+ private GravitinoCache<Long, Long> loadedRoles;
+
+ // ---- Eventual consistency caches (poller-driven) ----
+
+ /** Path-based metadata object key -> entity id. Evicted by entity change
poller. */
private GravitinoCache<String, Long> metadataIdCache;
- /** {@code metadataObjectId} → {@link Optional} of {@link OwnerInfo}. */
+ /** ownerRelCache: metadataObjectId -> Optional(owner). Evicted by owner
change poller. */
private GravitinoCache<Long, Optional<OwnerInfo>> ownerRelCache;
- /** Two-tier lookup facade (per-request dedup + shared cache + DB fallback).
*/
+ /** Two-tier lookup facade for metadata-id / owner (per-request dedup +
Caffeine + DB). */
private JcasbinAuthorizationLookups lookups;
/** Background HA invalidator for {@link #metadataIdCache} and {@link
#ownerRelCache}. */
private JcasbinChangePoller changePoller;
- private Executor executor = null;
-
@Override
public void initialize() {
long cacheExpirationSecs =
@@ -131,25 +180,18 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
long ttlMs = TimeUnit.SECONDS.toMillis(cacheExpirationSecs);
- // Initialize enforcers before the caches that reference them in removal
listeners
+ // Initialize enforcers before caches that reference them in removal
listeners
allowEnforcer = new SyncedEnforcer(getModel("/jcasbin_model.conf"), new
GravitinoAdapter());
allowInternalAuthorizer = new InternalAuthorizer(allowEnforcer);
denyEnforcer = new SyncedEnforcer(getModel("/jcasbin_model.conf"), new
GravitinoAdapter());
denyInternalAuthorizer = new InternalAuthorizer(denyEnforcer);
- loadedRoles =
- Caffeine.newBuilder()
- .expireAfterAccess(cacheExpirationSecs, TimeUnit.SECONDS)
- .maximumSize(roleCacheSize)
- .executor(Runnable::run)
- .removalListener(
- (roleId, value, cause) -> {
- if (roleId != null) {
- allowEnforcer.deleteRole(String.valueOf(roleId));
- denyEnforcer.deleteRole(String.valueOf(roleId));
- }
- })
- .build();
+ // loadedRoles: roleId -> updated_at.
+ // When evicted, we must clean up the corresponding JCasbin policies.
+ loadedRoles = new JcasbinLoadedRolesCache(ttlMs, roleCacheSize,
allowEnforcer, denyEnforcer);
+
+ userRoleCache = new CaffeineGravitinoCache<>(ttlMs, roleCacheSize);
+ groupRoleCache = new CaffeineGravitinoCache<>(ttlMs, roleCacheSize);
// The change poller is the primary HA invalidation path. These
write-based TTLs bound the
// stale window if a poll cycle misses a change; access-based TTLs could
keep hot stale entries
// alive indefinitely.
@@ -158,16 +200,6 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
lookups = new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache);
changePoller = new JcasbinChangePoller(metadataIdCache, ownerRelCache,
pollIntervalSecs);
changePoller.start();
- executor =
- Executors.newFixedThreadPool(
- GravitinoEnv.getInstance()
- .config()
- .get(Configs.GRAVITINO_AUTHORIZATION_THREAD_POOL_SIZE),
- runnable -> {
- Thread thread = new Thread(runnable);
- thread.setName("GravitinoAuthorizer-ThreadPool-" +
thread.getId());
- return thread;
- });
}
private Model getModel(String modelFilePath) {
@@ -182,6 +214,10 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return model;
}
+ //
---------------------------------------------------------------------------
+ // Authorize / deny / isOwner
+ //
---------------------------------------------------------------------------
+
@Override
public boolean authorize(
Principal principal,
@@ -300,7 +336,7 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
}
Optional<OwnerInfo> owner =
lookups.resolveOwnerId(metadataId.get(), metadataObject.type(),
requestContext);
- return ownerMatchesUserOrGroups(owner, principal, metalake);
+ return ownerMatchesUserOrGroups(owner, principal, metalake,
requestContext);
}
@Override
@@ -316,14 +352,10 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
if (StringUtils.isBlank(currentUserName)) {
return false;
}
-
- try {
- return
GravitinoEnv.getInstance().accessControlDispatcher().getUser(metalake,
currentUserName)
- != null;
- } catch (NoSuchUserException e) {
- LOG.warn("Can not get user {} in metalake {}", currentUserName,
metalake, e);
- return false;
- }
+ // Reuse the per-request UserUpdatedAt cache populated by
authorize/isOwner. Presence of a
+ // UserUpdatedAt entry for (metalake, user) already implies the user
exists in that metalake,
+ // so we avoid a second accessControlDispatcher().getUser() DB round-trip
per request.
+ return loadUserInfo(metalake, currentUserName, requestContext).isPresent();
}
@Override
@@ -383,14 +415,13 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
if (isOwner(currentPrincipal, metalake, metalakeObject, requestContext)) {
return true;
}
- MetadataObject.Type metadataType =
MetadataObject.Type.valueOf(type.toUpperCase());
+ MetadataObject.Type metadataType =
MetadataObject.Type.valueOf(type.toUpperCase(Locale.ROOT));
MetadataObject metadataObject =
MetadataObjects.of(Arrays.asList(fullName.split("\\.")), metadataType);
do {
if (isOwner(currentPrincipal, metalake, metadataObject, requestContext))
{
MetadataObject.Type tempType = metadataObject.type();
if (tempType == MetadataObject.Type.SCHEMA) {
- // schema owner need use catalog privilege
boolean hasCatalogUseCatalog =
authorize(
currentPrincipal,
@@ -412,7 +443,6 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
|| tempType == MetadataObject.Type.TOPIC
|| tempType == MetadataObject.Type.FILESET
|| tempType == MetadataObject.Type.MODEL) {
- // table owner need use_catalog and use_schema privileges
boolean hasMetalakeUseSchema =
authorize(
currentPrincipal,
@@ -439,7 +469,6 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
}
return true;
}
- // metadata parent owner can set owner.
} while ((metadataObject = MetadataObjects.parent(metadataObject)) !=
null);
return false;
}
@@ -448,17 +477,12 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
public boolean hasMetadataPrivilegePermission(
String metalake, String type, String fullName,
AuthorizationRequestContext requestContext) {
Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
- // Check whether the principal holds MANAGE_GRANTS on the target object or
any ancestor.
- // A grant at a broader level (e.g. CATALOG or SCHEMA) implicitly covers
all objects beneath it.
MetadataObject.Type metadataType;
try {
- metadataType = MetadataObject.Type.valueOf(type.toUpperCase());
+ metadataType =
MetadataObject.Type.valueOf(type.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Unknown metadata object type: " +
type, e);
}
- // Build the full ancestor chain from the target object up to and
including the metalake.
- // MetadataObjects.parent(CATALOG) returns null (CATALOG is a root in the
parent API), so the
- // metalake is appended manually at the end.
List<MetadataObject> chain = new ArrayList<>();
for (MetadataObject obj = MetadataObjects.parse(fullName, metadataType);
obj != null;
@@ -476,6 +500,10 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return hasSetOwnerPermission(metalake, type, fullName, requestContext);
}
+ //
---------------------------------------------------------------------------
+ // Cache invalidation hooks (called from service layer)
+ //
---------------------------------------------------------------------------
+
@Override
public void handleRolePrivilegeChange(Long roleId) {
loadedRoles.invalidate(roleId);
@@ -487,7 +515,8 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
MetadataObject metadataObject =
NameIdentifierUtil.toMetadataObject(nameIdentifier, type);
// Owner mutations may happen after drop/recreate with the same name.
Invalidate the
// name->id mapping as well to prevent using a stale metadataId from
metadataIdCache.
-
metadataIdCache.invalidate(JcasbinAuthorizationLookups.buildCacheKey(metalake,
metadataObject));
+ metadataIdCache.invalidate(
+ JcasbinAuthorizationCacheKeys.metadataIdCacheKey(metalake,
metadataObject));
try {
MetadataIdConverter.getID(metadataObject,
metalake).ifPresent(ownerRelCache::invalidate);
} catch (RuntimeException e) {
@@ -499,9 +528,9 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
public void handleEntityNameIdMappingChange(
String metalake, NameIdentifier nameIdentifier, Entity.EntityType type) {
MetadataObject metadataObject =
NameIdentifierUtil.toMetadataObject(nameIdentifier, type);
- String cacheKey = JcasbinAuthorizationLookups.buildCacheKey(metalake,
metadataObject);
- if (JcasbinAuthorizationLookups.isContainerType(metadataObject.type())) {
- // Prefix invalidation: metalake::catalog:: removes catalog + all
children.
+ String cacheKey =
JcasbinAuthorizationCacheKeys.metadataIdCacheKey(metalake, metadataObject);
+ if
(JcasbinAuthorizationCacheKeys.hasNestedMetadataObjects(metadataObject.type()))
{
+ // Prefix invalidation removes the object and all nested objects under
the same name path.
metadataIdCache.invalidateByPrefix(cacheKey);
} else {
metadataIdCache.invalidate(cacheKey);
@@ -513,19 +542,14 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
if (changePoller != null) {
changePoller.close();
}
- if (executor != null) {
- if (executor instanceof ThreadPoolExecutor) {
- ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
- threadPoolExecutor.shutdown();
- try {
- if (!threadPoolExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
- threadPoolExecutor.shutdownNow();
- }
- } catch (InterruptedException e) {
- threadPoolExecutor.shutdownNow();
- Thread.currentThread().interrupt();
- }
- }
+ if (userRoleCache != null) {
+ userRoleCache.close();
+ }
+ if (groupRoleCache != null) {
+ groupRoleCache.close();
+ }
+ if (loadedRoles != null) {
+ loadedRoles.close();
}
if (metadataIdCache != null) {
metadataIdCache.close();
@@ -557,6 +581,10 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return ImmutableList.copyOf(chain);
}
+ //
---------------------------------------------------------------------------
+ // Internal authorizer
+ //
---------------------------------------------------------------------------
+
private class InternalAuthorizer {
Enforcer enforcer;
@@ -581,15 +609,25 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
MetadataObject metadataObject,
String privilege,
AuthorizationRequestContext requestContext) {
- Long userId;
+ long userId;
+ UserUpdatedAt userInfo;
try {
- UserEntity userEntity = getUserEntity(username, metalake);
- userId = userEntity.id();
+ // 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);
+ if (!userInfoOpt.isPresent()) {
+ LOG.debug("User {} not found in metalake {}", username, metalake);
+ return false;
+ }
+ userInfo = userInfoOpt.get();
+ userId = userInfo.getUserId();
} catch (Exception e) {
LOG.debug("Can not get entity id", e);
return false;
}
- loadRolePrivilege(metalake, username, userId, requestContext);
+
+ // Steps 1b→3: version-validated role loading — pass userInfo to avoid
re-query
+ 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
@@ -618,7 +656,7 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
* {@link #authorizeByJcasbin}. A missing object is treated as "not
authorized".
*/
private boolean authorizeObject(
- Long userId,
+ long userId,
String metalake,
MetadataObject metadataObject,
String privilege,
@@ -633,16 +671,21 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
}
private boolean authorizeByJcasbin(
- Long userId,
+ long userId,
String metalake,
MetadataObject metadataObject,
Long metadataId,
String privilege,
AuthorizationRequestContext requestContext) {
+ // Step 4: JCasbin enforce (pure in-memory) — except OWNER, which is
resolved via the
+ // owner cache rather than g-rows.
if (AuthConstants.OWNER.equals(privilege)) {
+ // Cold-path: resolveOwnerId loads from DB when neither the
per-request nor the shared
+ // Caffeine cache has the entry, ensuring the first OWNER check
doesn't spuriously deny.
Optional<OwnerInfo> owner =
lookups.resolveOwnerId(metadataId, metadataObject.type(),
requestContext);
- return ownerMatchesUserOrGroups(owner,
PrincipalUtils.getCurrentPrincipal(), metalake);
+ return ownerMatchesUserOrGroups(
+ owner, PrincipalUtils.getCurrentPrincipal(), metalake,
requestContext);
}
return enforcer.enforce(
String.valueOf(userId),
@@ -652,93 +695,206 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
}
}
- private static UserEntity getUserEntity(String username, String metalake)
throws IOException {
+ //
---------------------------------------------------------------------------
+ // User info / ownership helpers
+ //
---------------------------------------------------------------------------
+
+ /**
+ * Per-request {@link UserUpdatedAt} lookup. The underlying {@code
user_meta} query is issued at
+ * most once per (metalake, username) within a single request.
+ */
+ private Optional<UserUpdatedAt> loadUserInfo(
+ String metalake, String username, AuthorizationRequestContext
requestContext) {
+ String cacheKey = JcasbinAuthorizationCacheKeys.userRoleKey(metalake,
username);
+ return requestContext.computeUserInfoIfAbsent(
+ cacheKey,
+ k ->
+ Optional.ofNullable(
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class, m -> m.getUserUpdatedAt(metalake,
username))));
+ }
+
+ /**
+ * 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}
+ * cache so back-to-back ownership checks in the same request do not
re-query {@code user_meta}.
+ */
+ private boolean ownerMatchesUserOrGroups(
+ Optional<OwnerInfo> owner,
+ Principal principal,
+ String metalake,
+ AuthorizationRequestContext requestContext) {
+ if (!owner.isPresent()) {
+ return false;
+ }
+ OwnerInfo ownerInfo = owner.get();
+ if
(Entity.EntityType.USER.name().equalsIgnoreCase(ownerInfo.getOwnerType())) {
+ Optional<UserUpdatedAt> userInfo =
+ loadUserInfo(metalake, principal.getName(), requestContext);
+ return userInfo.isPresent() && userInfo.get().getUserId() ==
ownerInfo.getOwnerId();
+ }
+ if
(!Entity.EntityType.GROUP.name().equalsIgnoreCase(ownerInfo.getOwnerType())) {
+ return false;
+ }
EntityStore entityStore = GravitinoEnv.getInstance().entityStore();
- UserEntity userEntity =
- entityStore.get(
- NameIdentifierUtil.ofUser(metalake, username),
- Entity.EntityType.USER,
- UserEntity.class);
- return userEntity;
+ for (GroupEntity groupEntity : resolveCurrentUserGroups(metalake,
entityStore)) {
+ if (Objects.equals(groupEntity.id(), ownerInfo.getOwnerId())) {
+ return true;
+ }
+ }
+ return false;
}
+ //
---------------------------------------------------------------------------
+ // 4-step role loading with version validation
+ //
---------------------------------------------------------------------------
+
private void loadRolePrivilege(
- String metalake, String username, Long userId,
AuthorizationRequestContext requestContext) {
+ String metalake,
+ String username,
+ long userId,
+ UserUpdatedAt userInfo,
+ AuthorizationRequestContext requestContext) {
requestContext.loadRole(
() -> {
- EntityStore entityStore = GravitinoEnv.getInstance().entityStore();
- NameIdentifier userNameIdentifier =
NameIdentifierUtil.ofUser(metalake, username);
- List<RoleEntity> entities;
- try {
- entities =
- entityStore
- .relationOperations()
- .listEntitiesByRelation(
- SupportsRelationOperations.Type.ROLE_USER_REL,
- userNameIdentifier,
- Entity.EntityType.USER);
- List<CompletableFuture<Void>> loadRoleFutures = new ArrayList<>();
- Set<String> desiredRoleIds = new HashSet<>();
- for (RoleEntity role : entities) {
- desiredRoleIds.add(String.valueOf(role.id()));
- addRoleForUserAndLoadPolicies(
- userId,
- metalake,
- role.id(),
- role.name(),
- loadRoleFutures,
- entityStore,
- requestContext);
- }
+ // Step 1a: version-validated user-direct roles via cache.
+ List<Long> userDirectRoleIds = loadUserRoles(metalake, username,
userId, userInfo);
- // Load roles inherited from the user's groups.
- for (GroupEntity groupEntity : resolveCurrentUserGroups(metalake,
entityStore)) {
- List<Long> roleIds = groupEntity.roleIds();
- List<String> roleNames = groupEntity.roleNames();
- if (roleIds == null || roleNames == null) {
- continue;
- }
- if (roleIds.size() != roleNames.size()) {
- LOG.warn(
- "Group {} has mismatched roleIds ({}) and roleNames ({})
-- skipping",
- groupEntity.name(),
- roleIds.size(),
- roleNames.size());
- continue;
- }
- for (int i = 0; i < roleIds.size(); i++) {
- desiredRoleIds.add(String.valueOf(roleIds.get(i)));
- addRoleForUserAndLoadPolicies(
- userId,
- metalake,
- roleIds.get(i),
- roleNames.get(i),
- loadRoleFutures,
- entityStore,
- requestContext);
- }
- }
-
- CompletableFuture.allOf(loadRoleFutures.toArray(new
CompletableFuture[0])).join();
+ // Step 1b: version-validated group-inherited roles via cache. Group
membership comes
+ // from the IdP-pushed UserPrincipal; for each group we load its
roles via the same
+ // version-validated path as users (group_meta.updated_at as the
staleness sentinel).
+ List<Long> groupInheritedRoleIds = new ArrayList<>();
+ for (String groupname : currentPrincipalGroupNames()) {
+ groupInheritedRoleIds.addAll(
+ loadGroupRoles(metalake, groupname, userId, requestContext));
+ }
- // Prune stale g-rows: remove role mappings that are no longer
valid
- // (e.g. user was removed from a group at the IdP level).
- String userIdStr = String.valueOf(userId);
- for (String currentRole :
allowEnforcer.getRolesForUser(userIdStr)) {
- if (!desiredRoleIds.contains(currentRole)) {
- allowEnforcer.deleteRoleForUser(userIdStr, currentRole);
- denyEnforcer.deleteRoleForUser(userIdStr, currentRole);
- }
+ // Prune stale g-rows: any role currently bound but no longer in the
desired
+ // set (e.g. user removed from a group at the IdP, or role
unassigned).
+ Set<String> desiredRoleIds = new HashSet<>();
+ for (Long id : userDirectRoleIds) {
+ desiredRoleIds.add(String.valueOf(id));
+ }
+ for (Long id : groupInheritedRoleIds) {
+ desiredRoleIds.add(String.valueOf(id));
+ }
+ String userIdStr = String.valueOf(userId);
+ for (String currentRole : allowEnforcer.getRolesForUser(userIdStr)) {
+ if (!desiredRoleIds.contains(currentRole)) {
+ allowEnforcer.deleteRoleForUser(userIdStr, currentRole);
+ denyEnforcer.deleteRoleForUser(userIdStr, currentRole);
}
- } catch (IOException e) {
- throw new RuntimeException(e);
+ }
+
+ // Step 3: batch version-check all role IDs (direct +
group-inherited),
+ // load stale ones (1 query for the version probe).
+ List<Long> allRoleIds = new ArrayList<>(userDirectRoleIds);
+ allRoleIds.addAll(groupInheritedRoleIds);
+ if (!allRoleIds.isEmpty()) {
+ versionCheckAndLoadRoles(metalake, allRoleIds, requestContext);
}
});
}
+ private List<Long> loadUserRoles(
+ String metalake, String username, long userId, UserUpdatedAt userInfo) {
+ String userCacheKey = JcasbinAuthorizationCacheKeys.userRoleKey(metalake,
username);
+ Optional<CachedUserRoleRels> cachedOpt =
userRoleCache.getIfPresent(userCacheKey);
+
+ if (cachedOpt.isPresent()
+ && cachedOpt.get().getUserId() == userId
+ && cachedOpt.get().getUpdatedAt() >= userInfo.getUpdatedAt()) {
+ // Cache is still valid. The user id check prevents reusing roles after
deleting and
+ // recreating the same username with a new entity id.
+ CachedUserRoleRels cached = cachedOpt.get();
+ bindUserRoles(userId, cached.getRoleIds());
+ return cached.getRoleIds();
+ }
+
+ // Cache miss or stale — reload from DB
+ List<RolePO> rolePOs =
+ SessionUtils.getWithoutCommit(RoleMetaMapper.class, m ->
m.listRolesByUserId(userId));
+ List<Long> roleIds =
rolePOs.stream().map(RolePO::getRoleId).collect(Collectors.toList());
+
+ userRoleCache.put(
+ userCacheKey, new CachedUserRoleRels(userId, userInfo.getUpdatedAt(),
roleIds));
+ bindUserRoles(userId, roleIds);
+ return roleIds;
+ }
+
+ /**
+ * Per-request {@link GroupUpdatedAt} lookup, mirroring {@link
#loadUserInfo}. The {@code
+ * group_meta} probe runs at most once per (metalake, groupname) within a
single request.
+ */
+ private Optional<GroupUpdatedAt> loadGroupInfo(
+ String metalake, String groupname, AuthorizationRequestContext
requestContext) {
+ String cacheKey = JcasbinAuthorizationCacheKeys.groupRoleKey(metalake,
groupname);
+ return requestContext.computeGroupInfoIfAbsent(
+ cacheKey,
+ k ->
+ Optional.ofNullable(
+ SessionUtils.getWithoutCommit(
+ GroupMetaMapper.class, m -> m.getGroupUpdatedAt(metalake,
groupname))));
+ }
+
+ /**
+ * Version-validated group-role load, mirroring {@link #loadUserRoles}. A
cached snapshot is valid
+ * only when it belongs to the current group id and is at least as fresh as
{@code
+ * group_meta.updated_at}; the group id check prevents reusing stale roles
after a
+ * delete-and-create of the same group name. In both cases the resulting
role IDs are bound to the
+ * user's jcasbin g-rows so that the enforcer sees inherited privileges.
Groups missing from the
+ * DB return an empty list.
+ */
+ private List<Long> loadGroupRoles(
+ String metalake, String groupname, long userId,
AuthorizationRequestContext requestContext) {
+ Optional<GroupUpdatedAt> groupInfoOpt = loadGroupInfo(metalake, groupname,
requestContext);
+ if (!groupInfoOpt.isPresent()) {
+ return new ArrayList<>();
+ }
+ GroupUpdatedAt groupInfo = groupInfoOpt.get();
+ long groupId = groupInfo.getGroupId();
+ String groupCacheKey =
JcasbinAuthorizationCacheKeys.groupRoleKey(metalake, groupname);
+ Optional<CachedGroupRoleRels> cachedOpt =
groupRoleCache.getIfPresent(groupCacheKey);
+
+ if (cachedOpt.isPresent()) {
+ CachedGroupRoleRels cached = cachedOpt.get();
+ if (cached.getGroupId() == groupId && cached.getUpdatedAt() >=
groupInfo.getUpdatedAt()) {
+ bindUserRoles(userId, cached.getRoleIds());
+ return cached.getRoleIds();
+ }
+ }
+
+ List<RolePO> rolePOs =
+ SessionUtils.getWithoutCommit(RoleMetaMapper.class, m ->
m.listRolesByGroupId(groupId));
+ List<Long> roleIds =
rolePOs.stream().map(RolePO::getRoleId).collect(Collectors.toList());
+
+ groupRoleCache.put(
+ groupCacheKey, new CachedGroupRoleRels(groupId,
groupInfo.getUpdatedAt(), roleIds));
+ bindUserRoles(userId, roleIds);
+ return roleIds;
+ }
+
+ /**
+ * Returns the current principal's group names as carried by the IdP-pushed
{@link UserPrincipal}.
+ * Returns an empty list when the principal is not a {@link UserPrincipal}
(e.g. service tokens)
+ * or has no groups.
+ */
+ private List<String> currentPrincipalGroupNames() {
+ Principal principal = PrincipalUtils.getCurrentPrincipal();
+ if (!(principal instanceof UserPrincipal)) {
+ return new ArrayList<>();
+ }
+ List<UserGroup> groups = ((UserPrincipal) principal).getGroups();
+ if (groups.isEmpty()) {
+ return new ArrayList<>();
+ }
+ return
groups.stream().map(UserGroup::getGroupname).collect(Collectors.toList());
+ }
+
/**
* Resolves GroupEntity objects for the current principal's groups, skipping
any that are stale or
- * not found in the store.
+ * not found in the store. Used by {@link #isSelf} (ROLE branch) and owner
checks that need full
+ * group entities instead of only group names.
*/
private List<GroupEntity> resolveCurrentUserGroups(String metalake,
EntityStore entityStore) {
Principal principal = PrincipalUtils.getCurrentPrincipal();
@@ -756,47 +912,96 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return entityStore.batchGet(groupIdents, Entity.EntityType.GROUP,
GroupEntity.class);
}
- /**
- * Adds a role mapping for the given user in both enforcers and
asynchronously loads the role's
- * policies if they are not already cached. When a role needs loading, the
resulting {@link
- * CompletableFuture} is appended to {@code loadRoleFutures} so the caller
can join all futures
- * after processing both direct and group-inherited roles.
- */
- private void addRoleForUserAndLoadPolicies(
- Long userId,
- String metalake,
- Long roleId,
- String roleName,
- List<CompletableFuture<Void>> loadRoleFutures,
- EntityStore entityStore,
- AuthorizationRequestContext requestContext) {
- allowEnforcer.addRoleForUser(String.valueOf(userId),
String.valueOf(roleId));
- denyEnforcer.addRoleForUser(String.valueOf(userId),
String.valueOf(roleId));
- if (loadedRoles.getIfPresent(roleId) != null) {
+ 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));
+
+ 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);
+ }
+
+ if (staleRoleVersions.isEmpty()) {
return;
}
- CompletableFuture<Void> loadRoleFuture =
- CompletableFuture.supplyAsync(
- () -> {
- try {
- return entityStore.get(
- NameIdentifierUtil.ofRole(metalake, roleName),
- Entity.EntityType.ROLE,
- RoleEntity.class);
- } catch (Exception e) {
- throw new RuntimeException("Failed to load role: " +
roleName, e);
- }
- },
- executor)
- .thenAcceptAsync(
- roleEntity -> {
- loadPolicyByRoleEntity(roleEntity, requestContext);
- loadedRoles.put(roleId, true);
- },
- executor);
- loadRoleFutures.add(loadRoleFuture);
+
+ EntityStore entityStore = GravitinoEnv.getInstance().entityStore();
+ List<NameIdentifier> roleIdents =
+ staleRoleVersions.stream()
+ .map(rv -> NameIdentifierUtil.ofRole(metalake, rv.getRoleName()))
+ .collect(Collectors.toList());
+ List<RoleEntity> roleEntities;
+ try {
+ roleEntities = entityStore.batchGet(roleIdents, Entity.EntityType.ROLE,
RoleEntity.class);
+ } catch (Exception e) {
+ LOG.warn("Failed to batch load stale role policies for roleIds {}",
staleRoleVersions, e);
+ roleEntities = new ArrayList<>();
+ }
+ if (roleEntities == null) {
+ roleEntities = new ArrayList<>();
+ }
+ if (roleEntities.isEmpty()) {
+ for (RoleUpdatedAt rv : staleRoleVersions) {
+ try {
+ roleEntities.add(
+ entityStore.get(
+ NameIdentifierUtil.ofRole(metalake, rv.getRoleName()),
+ Entity.EntityType.ROLE,
+ RoleEntity.class));
+ } catch (Exception e) {
+ LOG.warn("Failed to load role policies for roleId {}",
rv.getRoleId(), e);
+ }
+ }
+ }
+
+ Map<Long, RoleUpdatedAt> staleRoleVersionById =
+
staleRoleVersions.stream().collect(Collectors.toMap(RoleUpdatedAt::getRoleId,
rv -> rv));
+ for (RoleEntity roleEntity : roleEntities) {
+ RoleUpdatedAt rv = staleRoleVersionById.get(roleEntity.id());
+ if (rv == null) {
+ continue;
+ }
+ long roleId = rv.getRoleId();
+ 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.
+ if (cachedUpdatedAt.isPresent()) {
+ clearRolePolicies(roleId);
+ }
+ loadPolicyByRoleEntity(roleEntity, requestContext);
+ loadedRoles.put(roleId, dbUpdatedAt);
+ }
+ }
+
+ private void clearRolePolicies(long roleId) {
+ String roleIdStr = String.valueOf(roleId);
+ allowEnforcer.removeFilteredPolicy(0, roleIdStr);
+ denyEnforcer.removeFilteredPolicy(0, roleIdStr);
}
+ private void bindUserRoles(long userId, List<Long> roleIds) {
+ for (Long roleId : roleIds) {
+ allowEnforcer.addRoleForUser(String.valueOf(userId),
String.valueOf(roleId));
+ denyEnforcer.addRoleForUser(String.valueOf(userId),
String.valueOf(roleId));
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // Policy loading from role entity
+ //
---------------------------------------------------------------------------
+
private void loadPolicyByRoleEntity(
RoleEntity roleEntity, AuthorizationRequestContext requestContext) {
String metalake =
NameIdentifierUtil.getMetalake(roleEntity.nameIdentifier());
@@ -818,16 +1023,9 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
String.valueOf(metadataId.get()),
AuthorizationUtils.replaceLegacyPrivilegeName(privilege.name())
.name()
- .toUpperCase(java.util.Locale.ROOT),
+ .toUpperCase(Locale.ROOT),
AuthConstants.ALLOW);
}
- // Since different roles of a user may simultaneously hold both
"allow" and "deny"
- // permissions
- // for the same privilege on a given MetadataObject, the allowEnforcer
must also incorporate
- // the "deny" privilege to ensure that the authorize method correctly
returns false in such
- // cases. For example, if role1 has an "allow" privilege for
SELECT_TABLE on table1, while
- // role2 has a "deny" privilege for the same action on table1, then a
user assigned both
- // roles should receive a false result when calling the authorize
method.
allowEnforcer.addPolicy(
String.valueOf(roleEntity.id()),
@@ -835,56 +1033,9 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
String.valueOf(metadataId.get()),
AuthorizationUtils.replaceLegacyPrivilegeName(privilege.name())
.name()
- .toUpperCase(java.util.Locale.ROOT),
- condition.name().toLowerCase(java.util.Locale.ROOT));
+ .toUpperCase(Locale.ROOT),
+ condition.name().toLowerCase(Locale.ROOT));
}
}
}
-
- /**
- * Returns true when the resolved owner matches the current user (by id) or
one of the user's
- * groups. We compare by entity id rather than name so the cached snapshot
survives a
- * delete-then-recreate-with-same-name scenario without spuriously granting
ownership to the new
- * entity.
- */
- private boolean ownerMatchesUserOrGroups(
- Optional<OwnerInfo> owner, Principal principal, String metalake) {
- if (!owner.isPresent()) {
- return false;
- }
- OwnerInfo ownerInfo = owner.get();
- if
(Entity.EntityType.USER.name().equalsIgnoreCase(ownerInfo.getOwnerType())) {
- try {
- UserEntity userEntity = getUserEntity(principal.getName(), metalake);
- return Objects.equals(userEntity.id(), ownerInfo.getOwnerId());
- } catch (Exception e) {
- LOG.debug("Can not get user entity for ownership check", e);
- return false;
- }
- }
- if
(!Entity.EntityType.GROUP.name().equalsIgnoreCase(ownerInfo.getOwnerType())) {
- return false;
- }
- if (!(principal instanceof UserPrincipal)) {
- return false;
- }
- List<UserGroup> groups = ((UserPrincipal) principal).getGroups();
- if (groups.isEmpty()) {
- return false;
- }
- try {
- List<NameIdentifier> groupIdents =
- groups.stream()
- .map(g -> NameIdentifierUtil.ofGroup(metalake, g.getGroupname()))
- .collect(Collectors.toList());
- List<GroupEntity> groupEntities =
- GravitinoEnv.getInstance()
- .entityStore()
- .batchGet(groupIdents, Entity.EntityType.GROUP,
GroupEntity.class);
- return groupEntities.stream().anyMatch(ge -> Objects.equals(ge.id(),
ownerInfo.getOwnerId()));
- } catch (Exception e) {
- LOG.debug("Can not get group entities for ownership check", e);
- return false;
- }
- }
}
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 611cc9616b..5399e45f24 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
@@ -242,9 +242,9 @@ public class JcasbinChangePoller implements AutoCloseable {
}
MetadataObject mdObj = metadataObjectFromChangeLog(metalake, fullName,
mdType);
- String cacheKey = JcasbinAuthorizationLookups.buildCacheKey(metalake,
mdObj);
+ String cacheKey =
JcasbinAuthorizationCacheKeys.metadataIdCacheKey(metalake, mdObj);
- if (JcasbinAuthorizationLookups.isContainerType(mdType)) {
+ if (JcasbinAuthorizationCacheKeys.hasNestedMetadataObjects(mdType)) {
addCoalescedPrefix(containerPrefixes, cacheKey);
} else {
leafKeys.add(cacheKey);
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java
new file mode 100644
index 0000000000..0205ec1608
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java
@@ -0,0 +1,94 @@
+/*
+ * 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.server.authorization.jcasbin;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.apache.gravitino.cache.GravitinoCache;
+import org.casbin.jcasbin.main.Enforcer;
+
+/**
+ * A {@link GravitinoCache} of {@code roleId -> updated_at} that synchronously
deletes the role's
+ * JCasbin policies from both enforcers when a key is evicted (by TTL, size,
or explicit
+ * invalidate).
+ *
+ * <p>Uses a raw Caffeine cache internally so it can attach a removal listener
with {@code
+ * executor(Runnable::run)} — eviction and policy cleanup must happen on the
same thread, so the
+ * {@link JcasbinAuthorizer} never sees a role bound in the enforcer without a
backing policy.
+ */
+class JcasbinLoadedRolesCache implements GravitinoCache<Long, Long> {
+
+ private final Cache<Long, Long> cache;
+
+ JcasbinLoadedRolesCache(long ttlMs, long maxSize, Enforcer allowEnforcer,
Enforcer denyEnforcer) {
+ this.cache =
+ Caffeine.newBuilder()
+ .expireAfterAccess(ttlMs, TimeUnit.MILLISECONDS)
+ .maximumSize(maxSize)
+ .executor(Runnable::run)
+ .removalListener(
+ (Long roleId, Long value, RemovalCause cause) -> {
+ if (roleId != null && cause != RemovalCause.REPLACED) {
+ allowEnforcer.deleteRole(String.valueOf(roleId));
+ denyEnforcer.deleteRole(String.valueOf(roleId));
+ }
+ })
+ .build();
+ }
+
+ @Override
+ public Optional<Long> getIfPresent(Long key) {
+ return Optional.ofNullable(cache.getIfPresent(key));
+ }
+
+ @Override
+ public void put(Long key, Long value) {
+ cache.put(key, value);
+ }
+
+ @Override
+ public void invalidate(Long key) {
+ cache.invalidate(key);
+ }
+
+ @Override
+ public void invalidateAll() {
+ cache.invalidateAll();
+ }
+
+ @Override
+ public void invalidateByPrefix(String prefix) {
+ // Role ids are Long keys, so prefix invalidation is not meaningful for
this cache.
+ }
+
+ @Override
+ public long size() {
+ cache.cleanUp();
+ return cache.estimatedSize();
+ }
+
+ @Override
+ public void close() {
+ cache.invalidateAll();
+ cache.cleanUp();
+ }
+}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationCacheKeys.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationCacheKeys.java
new file mode 100644
index 0000000000..1bc0afc38d
--- /dev/null
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationCacheKeys.java
@@ -0,0 +1,117 @@
+/*
+ * 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.server.authorization.jcasbin;
+
+import java.util.Arrays;
+import java.util.Collections;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link JcasbinAuthorizationCacheKeys}. */
+public class TestJcasbinAuthorizationCacheKeys {
+
+ @Test
+ void testMetadataIdCacheKey() {
+ MetadataObject metalake =
+ MetadataObjects.of(Collections.singletonList("ml1"),
MetadataObject.Type.METALAKE);
+ Assertions.assertEquals(
+ key("ml1", "METALAKE", ""),
+ JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1", metalake));
+
+ MetadataObject catalog =
+ MetadataObjects.of(Collections.singletonList("cat1"),
MetadataObject.Type.CATALOG);
+ String catalogKey =
JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1", catalog);
+ Assertions.assertEquals(key("ml1", "CATALOG", "cat1", ""), catalogKey);
+
+ MetadataObject schema =
+ MetadataObjects.of(Arrays.asList("cat1", "sch1"),
MetadataObject.Type.SCHEMA);
+ String schemaKey = JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1",
schema);
+ Assertions.assertEquals(key("ml1", "CATALOG", "cat1", "SCHEMA", "sch1",
""), schemaKey);
+
+ MetadataObject table =
+ MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"),
MetadataObject.Type.TABLE);
+ String tableKey = JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1",
table);
+ Assertions.assertEquals(
+ key("ml1", "CATALOG", "cat1", "SCHEMA", "sch1", "TABLE", "tbl1", ""),
tableKey);
+
+ MetadataObject column =
+ MetadataObjects.of(
+ Arrays.asList("cat1", "sch1", "tbl1", "col1"),
MetadataObject.Type.COLUMN);
+ String columnKey = JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1",
column);
+ Assertions.assertEquals(
+ key("ml1", "CATALOG", "cat1", "SCHEMA", "sch1", "TABLE", "tbl1",
"COLUMN", "col1"),
+ columnKey);
+
+ MetadataObject view =
+ MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"),
MetadataObject.Type.VIEW);
+ String viewKey = JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1",
view);
+ Assertions.assertEquals(
+ key("ml1", "CATALOG", "cat1", "SCHEMA", "sch1", "VIEW", "tbl1"),
viewKey);
+
+ Assertions.assertTrue(schemaKey.startsWith(catalogKey));
+ Assertions.assertTrue(tableKey.startsWith(schemaKey));
+ Assertions.assertTrue(columnKey.startsWith(tableKey));
+ Assertions.assertFalse(viewKey.startsWith(tableKey));
+ }
+
+ @Test
+ void testHasNestedMetadataObjects() {
+ Assertions.assertTrue(
+
JcasbinAuthorizationCacheKeys.hasNestedMetadataObjects(MetadataObject.Type.METALAKE));
+ Assertions.assertTrue(
+
JcasbinAuthorizationCacheKeys.hasNestedMetadataObjects(MetadataObject.Type.CATALOG));
+ Assertions.assertTrue(
+
JcasbinAuthorizationCacheKeys.hasNestedMetadataObjects(MetadataObject.Type.SCHEMA));
+ Assertions.assertTrue(
+
JcasbinAuthorizationCacheKeys.hasNestedMetadataObjects(MetadataObject.Type.TABLE));
+
+ Assertions.assertFalse(
+
JcasbinAuthorizationCacheKeys.hasNestedMetadataObjects(MetadataObject.Type.VIEW));
+ Assertions.assertFalse(
+
JcasbinAuthorizationCacheKeys.hasNestedMetadataObjects(MetadataObject.Type.FILESET));
+ Assertions.assertFalse(
+
JcasbinAuthorizationCacheKeys.hasNestedMetadataObjects(MetadataObject.Type.TOPIC));
+ Assertions.assertFalse(
+
JcasbinAuthorizationCacheKeys.hasNestedMetadataObjects(MetadataObject.Type.COLUMN));
+ }
+
+ @Test
+ void testPrincipalRoleKeysAreTyped() {
+ Assertions.assertEquals(
+ key("ml1", JcasbinAuthorizationCacheKeys.USER_ROLE_REL, "alice"),
+ JcasbinAuthorizationCacheKeys.userRoleKey("ml1", "alice"));
+ Assertions.assertEquals(
+ key("ml1", JcasbinAuthorizationCacheKeys.GROUP_ROLE_REL, "admins"),
+ JcasbinAuthorizationCacheKeys.groupRoleKey("ml1", "admins"));
+ }
+
+ @Test
+ void testPrincipalRoleKeysAreDistinct() {
+ String userKey = JcasbinAuthorizationCacheKeys.userRoleKey("ml1", "alice");
+ String groupKey = JcasbinAuthorizationCacheKeys.groupRoleKey("ml1",
"alice");
+
+ Assertions.assertNotEquals(userKey, groupKey);
+ }
+
+ private static String key(String... parts) {
+ return JcasbinAuthorizationCacheKeys.joinKeyParts(parts);
+ }
+}
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 afbf9f3685..b41035c857 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
@@ -19,7 +19,6 @@
package org.apache.gravitino.server.authorization.jcasbin;
import java.util.Arrays;
-import java.util.Collections;
import java.util.Optional;
import java.util.function.Function;
import org.apache.gravitino.MetadataObject;
@@ -30,96 +29,9 @@ import
org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
-/** Tests for {@link JcasbinAuthorizationLookups} static helpers. */
+/** Tests for {@link JcasbinAuthorizationLookups}. */
public class TestJcasbinAuthorizationLookups {
- // ---------- buildCacheKey ----------
-
- @Test
- void testBuildCacheKeyMetalake() {
- MetadataObject obj =
- MetadataObjects.of(Collections.singletonList("ml1"),
MetadataObject.Type.METALAKE);
- Assertions.assertEquals(key("ml1", ""),
JcasbinAuthorizationLookups.buildCacheKey("ml1", obj));
- }
-
- @Test
- void testBuildCacheKeyCatalog() {
- MetadataObject obj =
- MetadataObjects.of(Collections.singletonList("cat1"),
MetadataObject.Type.CATALOG);
- Assertions.assertEquals(
- key("ml1", "cat1", ""),
JcasbinAuthorizationLookups.buildCacheKey("ml1", obj));
- }
-
- @Test
- void testBuildCacheKeySchema() {
- MetadataObject obj =
- MetadataObjects.of(Arrays.asList("cat1", "sch1"),
MetadataObject.Type.SCHEMA);
- Assertions.assertEquals(
- key("ml1", "cat1", "sch1", ""),
JcasbinAuthorizationLookups.buildCacheKey("ml1", obj));
- }
-
- @Test
- void testBuildCacheKeyLeafTypesGetTypeSuffix() {
- MetadataObject table =
- MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"),
MetadataObject.Type.TABLE);
- Assertions.assertEquals(
- key("ml1", "cat1", "sch1", "tbl1", "TABLE"),
- JcasbinAuthorizationLookups.buildCacheKey("ml1", table));
-
- MetadataObject view =
- MetadataObjects.of(Arrays.asList("cat1", "sch1", "v1"),
MetadataObject.Type.VIEW);
- Assertions.assertEquals(
- key("ml1", "cat1", "sch1", "v1", "VIEW"),
- JcasbinAuthorizationLookups.buildCacheKey("ml1", view));
-
- MetadataObject fileset =
- MetadataObjects.of(Arrays.asList("cat1", "sch1", "fs1"),
MetadataObject.Type.FILESET);
- Assertions.assertEquals(
- key("ml1", "cat1", "sch1", "fs1", "FILESET"),
- JcasbinAuthorizationLookups.buildCacheKey("ml1", fileset));
- }
-
- // ---------- isContainerType ----------
-
- @Test
- void testIsContainerTypeContainerTypes() {
- Assertions.assertTrue(
-
JcasbinAuthorizationLookups.isContainerType(MetadataObject.Type.METALAKE));
-
Assertions.assertTrue(JcasbinAuthorizationLookups.isContainerType(MetadataObject.Type.CATALOG));
-
Assertions.assertTrue(JcasbinAuthorizationLookups.isContainerType(MetadataObject.Type.SCHEMA));
- }
-
- @Test
- void testIsContainerTypeLeafTypes() {
-
Assertions.assertFalse(JcasbinAuthorizationLookups.isContainerType(MetadataObject.Type.TABLE));
-
Assertions.assertFalse(JcasbinAuthorizationLookups.isContainerType(MetadataObject.Type.VIEW));
- Assertions.assertFalse(
-
JcasbinAuthorizationLookups.isContainerType(MetadataObject.Type.FILESET));
-
Assertions.assertFalse(JcasbinAuthorizationLookups.isContainerType(MetadataObject.Type.TOPIC));
- }
-
- // ---------- Prefix invalidation ----------
-
- @Test
- void testPrefixInvalidationCoversContainerPath() {
- // Dropping a catalog should use a prefix that covers all schemas and
tables below it.
- MetadataObject catalog =
- MetadataObjects.of(Collections.singletonList("cat1"),
MetadataObject.Type.CATALOG);
- String catalogKey = JcasbinAuthorizationLookups.buildCacheKey("ml1",
catalog);
-
- MetadataObject schema =
- MetadataObjects.of(Arrays.asList("cat1", "sch1"),
MetadataObject.Type.SCHEMA);
- String schemaKey = JcasbinAuthorizationLookups.buildCacheKey("ml1",
schema);
-
- MetadataObject table =
- MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"),
MetadataObject.Type.TABLE);
- String tableKey = JcasbinAuthorizationLookups.buildCacheKey("ml1", table);
-
- Assertions.assertTrue(schemaKey.startsWith(catalogKey));
- Assertions.assertTrue(tableKey.startsWith(catalogKey));
- Assertions.assertTrue(tableKey.startsWith(schemaKey));
- }
-
@Test
void testResolveMetadataIdUsesAtomicSharedCacheAndRequestDedup() {
MetadataObject table =
@@ -158,10 +70,6 @@ public class TestJcasbinAuthorizationLookups {
Assertions.assertEquals(0, ownerRelCache.putCount);
}
- private static String key(String... parts) {
- return String.join(JcasbinAuthorizationLookups.KEY_SEP, parts);
- }
-
private static class CountingCache<K, V> implements GravitinoCache<K, V> {
private final V value;
private int getCount;
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 e1dea19922..dba8697088 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
@@ -20,35 +20,35 @@ package org.apache.gravitino.server.authorization.jcasbin;
import static org.apache.gravitino.authorization.Privilege.Name.USE_CATALOG;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.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.anyLong;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
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 com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
-import com.github.benmanes.caffeine.cache.Cache;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.lang.reflect.Field;
+import java.lang.reflect.Method;
import java.security.Principal;
+import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
-import java.util.concurrent.Executor;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.ThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import java.util.stream.Collectors;
-import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Entity;
import org.apache.gravitino.EntityStore;
import org.apache.gravitino.GravitinoEnv;
@@ -72,9 +72,16 @@ import org.apache.gravitino.meta.UserEntity;
import org.apache.gravitino.server.ServerConfig;
import org.apache.gravitino.server.authorization.MetadataIdConverter;
import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
+import org.apache.gravitino.storage.relational.mapper.GroupMetaMapper;
import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
+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.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;
import org.apache.gravitino.storage.relational.service.OwnerMetaService;
import org.apache.gravitino.storage.relational.utils.POConverters;
import org.apache.gravitino.storage.relational.utils.SessionUtils;
@@ -83,6 +90,7 @@ import org.apache.gravitino.utils.NamespaceUtil;
import org.apache.gravitino.utils.PrincipalUtils;
import org.casbin.jcasbin.main.Enforcer;
import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@@ -127,11 +135,38 @@ public class TestJcasbinAuthorizer {
private static MockedStatic<SessionUtils> sessionUtilsMockedStatic;
+ private static UserMetaMapper userMetaMapper = mock(UserMetaMapper.class);
+
+ private static GroupMetaMapper groupMetaMapper = mock(GroupMetaMapper.class);
+
+ private static RoleMetaMapper roleMetaMapper = mock(RoleMetaMapper.class);
+
private static OwnerMetaMapper ownerMetaMapper = mock(OwnerMetaMapper.class);
private static EntityChangeLogMapper entityChangeLogMapper =
mock(EntityChangeLogMapper.class);
- private static JcasbinAuthorizer jcasbinAuthorizer;
+ /**
+ * Tracks roles registered via {@link #mockRoleInStore} so {@code
+ * roleMetaMapper.batchGetRoleUpdatedAt} can return their versions on demand.
+ */
+ private static final Map<Long, RoleUpdatedAt> mockedRoleVersions = new
HashMap<>();
+
+ /**
+ * Monotonic counter for {@code group_meta.updated_at} mocks so that
successive {@link
+ * #mockGroupWithRoles} calls always advance the version, forcing the
groupRoleCache to miss even
+ * when the wall clock hasn't advanced.
+ */
+ private static final AtomicLong groupVersionCounter = new AtomicLong(1L);
+
+ private static final AtomicLong roleVersionCounter = new AtomicLong(1L);
+
+ private static final AtomicLong userVersionCounter = new AtomicLong(1L);
+
+ /**
+ * Recreated per test in {@link #createAuthorizer()} so each case starts
with empty enforcer state
+ * and a fresh cache; the previous static instance leaked g-rows and cache
entries across cases.
+ */
+ private JcasbinAuthorizer jcasbinAuthorizer;
private static ObjectMapper objectMapper = new ObjectMapper();
@@ -156,7 +191,13 @@ public class TestJcasbinAuthorizer {
invocation -> {
Class<?> mapperClass = invocation.getArgument(0);
Function<Object, Object> func = invocation.getArgument(1);
- if (mapperClass == OwnerMetaMapper.class) {
+ if (mapperClass == UserMetaMapper.class) {
+ return func.apply(userMetaMapper);
+ } else if (mapperClass == GroupMetaMapper.class) {
+ return func.apply(groupMetaMapper);
+ } else if (mapperClass == RoleMetaMapper.class) {
+ return func.apply(roleMetaMapper);
+ } else if (mapperClass == OwnerMetaMapper.class) {
return func.apply(ownerMetaMapper);
}
if (mapperClass == EntityChangeLogMapper.class) {
@@ -165,6 +206,28 @@ public class TestJcasbinAuthorizer {
return null;
});
+ // Default mock: getUserInfo returns a valid user
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, 1000L));
+
+ // Default: no roles assigned initially
+
when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of());
+ // Default answer pulls versions from mockedRoleVersions, populated by
mockRoleInStore.
+ // Use doAnswer to avoid eager invocation of any previous stub when
re-stubbing.
+ doAnswer(
+ invocation -> {
+ List<Long> ids = invocation.getArgument(0);
+ if (ids == null) {
+ return ImmutableList.of();
+ }
+ return ids.stream()
+ .map(mockedRoleVersions::get)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+ })
+ .when(roleMetaMapper)
+ .batchGetRoleUpdatedAt(any());
+
gravitinoEnvMockedStatic = mockStatic(GravitinoEnv.class);
gravitinoEnvMockedStatic.when(GravitinoEnv::getInstance).thenReturn(gravitinoEnv);
when(gravitinoEnv.config()).thenReturn(new ServerConfig());
@@ -185,8 +248,6 @@ public class TestJcasbinAuthorizer {
eq(Entity.EntityType.USER),
eq(UserEntity.class)))
.thenReturn(getUserEntity());
- jcasbinAuthorizer = new JcasbinAuthorizer();
- jcasbinAuthorizer.initialize();
BaseMetalake baseMetalake =
BaseMetalake.builder()
.withId(USER_METALAKE_ID)
@@ -203,34 +264,30 @@ public class TestJcasbinAuthorizer {
@AfterAll
public static void stop() {
- if (jcasbinAuthorizer != null) {
- try {
- jcasbinAuthorizer.close();
- } catch (IOException e) {
- throw new RuntimeException("Failed to close JcasbinAuthorizer", e);
- }
- }
+ // jcasbinAuthorizer is per-test (see @AfterEach); only static mocks need
cleanup here.
if (principalUtilsMockedStatic != null) {
principalUtilsMockedStatic.close();
}
if (metadataIdConverterMockedStatic != null) {
metadataIdConverterMockedStatic.close();
}
- if (ownerMetaServiceMockedStatic != null) {
- ownerMetaServiceMockedStatic.close();
- }
if (sessionUtilsMockedStatic != null) {
sessionUtilsMockedStatic.close();
}
+ if (ownerMetaServiceMockedStatic != null) {
+ ownerMetaServiceMockedStatic.close();
+ }
if (gravitinoEnvMockedStatic != null) {
gravitinoEnvMockedStatic.close();
}
}
@BeforeEach
- public void resetSharedState() throws Exception {
- // Reset shared enforcer and cache state to prevent test ordering
contamination.
- getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ public void createAuthorizer() throws Exception {
+ // Build a fresh authorizer per test so enforcer g-rows and
version-validated cache state can
+ // never bleed across cases regardless of the JUnit execution order.
+ jcasbinAuthorizer = new JcasbinAuthorizer();
+ jcasbinAuthorizer.initialize();
restoreDefaultPrincipal();
// Reset role-user relation mock to return empty list (no roles) by
default; individual tests
// can override as needed.
@@ -240,13 +297,39 @@ public class TestJcasbinAuthorizer {
eq(userNameIdentifier),
eq(Entity.EntityType.USER)))
.thenReturn(ImmutableList.of());
+ // Reset role version map and re-stub the answer to keep tests isolated
from each other.
+ mockedRoleVersions.clear();
+ doAnswer(
+ invocation -> {
+ List<Long> ids = invocation.getArgument(0);
+ if (ids == null) {
+ return ImmutableList.of();
+ }
+ return ids.stream()
+ .map(mockedRoleVersions::get)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+ })
+ .when(roleMetaMapper)
+ .batchGetRoleUpdatedAt(any());
+ }
+
+ @AfterEach
+ public void closeAuthorizer() throws IOException {
+ if (jcasbinAuthorizer != null) {
+ jcasbinAuthorizer.close();
+ jcasbinAuthorizer = null;
+ }
}
@Test
public void testAuthorize() throws Exception {
makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+ // No roles assigned — should fail
assertFalse(doAuthorize(currentPrincipal));
+
+ // Set up allowRole
RoleEntity allowRole =
getRoleEntity(ALLOW_ROLE_ID, "allowRole",
ImmutableList.of(getAllowSecurableObject()));
when(entityStore.get(
@@ -254,14 +337,19 @@ public class TestJcasbinAuthorizer {
eq(Entity.EntityType.ROLE),
eq(RoleEntity.class)))
.thenReturn(allowRole);
- NameIdentifier userNameIdentifier = NameIdentifierUtil.ofUser(METALAKE,
USERNAME);
- // Mock adds roles to users.
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(allowRole));
+
+ // Mock mapper: user has allowRole
+ long roleVersion = nextRoleVersion();
+ RolePO allowRolePO = buildRolePO(ALLOW_ROLE_ID, "allowRole");
+
when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of(allowRolePO));
+ when(roleMetaMapper.batchGetRoleUpdatedAt(any()))
+ .thenReturn(ImmutableList.of(new RoleUpdatedAt(ALLOW_ROLE_ID,
"allowRole", roleVersion)));
+ // Bump user version to invalidate userRoleCache
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
+
assertTrue(doAuthorize(currentPrincipal));
+
// Test role cache.
// When the user's role changes to one with no privileges, the prune step
removes
// the stale role's g-rows from the enforcer, so authorization fails
immediately.
@@ -272,24 +360,28 @@ public class TestJcasbinAuthorizer {
eq(Entity.EntityType.ROLE),
eq(RoleEntity.class)))
.thenReturn(tempNewRole);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(tempNewRole));
+ RolePO tempNewRolePO = buildRolePO(newRoleId, "tempNewRole");
+
when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of(tempNewRolePO));
+ long roleVersion2 = nextRoleVersion();
+ when(roleMetaMapper.batchGetRoleUpdatedAt(any()))
+ .thenReturn(ImmutableList.of(new RoleUpdatedAt(newRoleId,
"tempNewRole", roleVersion2)));
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
+ // tempNewRole has no privileges; prune step removes stale allowRole
g-row, so authz fails.
assertFalse(doAuthorize(currentPrincipal));
- // When the user is re-assigned the correct role, the authorization will
succeed.
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(allowRole));
- when(entityStore.get(
- eq(NameIdentifierUtil.ofRole(METALAKE, allowRole.name())),
- eq(Entity.EntityType.ROLE),
- eq(RoleEntity.class)))
- .thenReturn(allowRole);
+
+ // After clearing the role policy cache, the next authorize forces a
reload.
+ jcasbinAuthorizer.handleRolePrivilegeChange(ALLOW_ROLE_ID);
+
+ // Re-assign allowRole, the authorization will succeed
+
when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of(allowRolePO));
+ long roleVersion3 = nextRoleVersion();
+ when(roleMetaMapper.batchGetRoleUpdatedAt(any()))
+ .thenReturn(ImmutableList.of(new RoleUpdatedAt(ALLOW_ROLE_ID,
"allowRole", roleVersion3)));
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
assertTrue(doAuthorize(currentPrincipal));
+
// Test deny
RoleEntity denyRole =
getRoleEntity(DENY_ROLE_ID, "denyRole",
ImmutableList.of(getDenySecurableObject()));
@@ -298,20 +390,49 @@ public class TestJcasbinAuthorizer {
eq(Entity.EntityType.ROLE),
eq(RoleEntity.class)))
.thenReturn(denyRole);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(allowRole, denyRole));
+ RolePO denyRolePO = buildRolePO(DENY_ROLE_ID, "denyRole");
+ when(roleMetaMapper.listRolesByUserId(eq(USER_ID)))
+ .thenReturn(ImmutableList.of(allowRolePO, denyRolePO));
+ long roleVersion4 = nextRoleVersion();
+ when(roleMetaMapper.batchGetRoleUpdatedAt(any()))
+ .thenReturn(
+ ImmutableList.of(
+ new RoleUpdatedAt(ALLOW_ROLE_ID, "allowRole", roleVersion4),
+ new RoleUpdatedAt(DENY_ROLE_ID, "denyRole", roleVersion4)));
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
+ assertFalse(doAuthorize(currentPrincipal));
+ }
+
+ @Test
+ public void testUserRoleCacheDoesNotReuseRolesAfterUsernameRecreate() throws
Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+
+ RoleEntity allowRole =
+ mockRoleInStore(
+ ALLOW_ROLE_ID,
+ "allowRoleBeforeUserRecreate",
+ ImmutableList.of(getAllowSecurableObject()));
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, 1000L));
+ when(roleMetaMapper.listRolesByUserId(eq(USER_ID)))
+ .thenReturn(ImmutableList.of(buildRolePO(allowRole.id(),
allowRole.name())));
+
+ assertTrue(doAuthorize(currentPrincipal));
+
+ long recreatedUserId = USER_ID + 1000L;
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(recreatedUserId, 0L));
+
when(roleMetaMapper.listRolesByUserId(eq(recreatedUserId))).thenReturn(ImmutableList.of());
assertFalse(doAuthorize(currentPrincipal));
+ verify(roleMetaMapper).listRolesByUserId(eq(recreatedUserId));
}
@Test
public void testAuthorizeByOwner() throws Exception {
Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
- NameIdentifier catalogIdent = NameIdentifierUtil.ofCatalog(METALAKE,
"testCatalog");
-
// No owner set — should fail
when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID),
eq("CATALOG")))
.thenReturn(null);
@@ -319,14 +440,23 @@ public class TestJcasbinAuthorizer {
assertFalse(doAuthorizeOwner(currentPrincipal));
// Set owner to current user
+ OwnerInfo ownerInfo = new OwnerInfo(USER_ID, "USER");
when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID),
eq("CATALOG")))
- .thenReturn(new OwnerInfo(USER_ID, "USER"));
+ .thenReturn(ownerInfo);
getOwnerRelCache(jcasbinAuthorizer).invalidateAll();
assertTrue(doAuthorizeOwner(currentPrincipal));
- // Remove owner via change hook
+ // Matching ID with a GROUP owner type must not grant user ownership.
+ OwnerInfo collidingGroupOwnerInfo = new OwnerInfo(USER_ID, "GROUP");
+ when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID),
eq("CATALOG")))
+ .thenReturn(collidingGroupOwnerInfo);
+ getOwnerRelCache(jcasbinAuthorizer).invalidateAll();
+ assertFalse(doAuthorizeOwner(currentPrincipal));
+
+ // Remove owner
when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID),
eq("CATALOG")))
.thenReturn(null);
+ NameIdentifier catalogIdent = NameIdentifierUtil.ofCatalog(METALAKE,
"testCatalog");
jcasbinAuthorizer.handleMetadataOwnerChange(
METALAKE, USER_ID, catalogIdent, Entity.EntityType.CATALOG);
assertFalse(doAuthorizeOwner(currentPrincipal));
@@ -363,7 +493,7 @@ public class TestJcasbinAuthorizer {
.withAuditInfo(AuditInfo.EMPTY)
.build()));
- // Mock owner_meta returning a GROUP-typed owner with GROUP_ID
+ // Mock owner_meta lookup returning a GROUP-typed OwnerInfo (the owner is
GROUP_ID).
OwnerInfo groupOwnerInfo = new OwnerInfo(GROUP_ID, "GROUP");
when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID),
eq("CATALOG")))
.thenReturn(groupOwnerInfo);
@@ -386,6 +516,7 @@ public class TestJcasbinAuthorizer {
principalUtilsMockedStatic
.when(PrincipalUtils::getCurrentPrincipal)
.thenReturn(nonMemberPrincipal);
+ // Re-populate the owner cache with the group owner
when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID),
eq("CATALOG")))
.thenReturn(groupOwnerInfo);
getOwnerRelCache(jcasbinAuthorizer).invalidateAll();
@@ -424,6 +555,26 @@ public class TestJcasbinAuthorizer {
restoreDefaultPrincipal();
}
+ @Test
+ public void testGroupRoleSkippedWhenRoleIdsAndNamesMismatch() throws
Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+
+ String mismatchGroupName = "mismatchGroup";
+ UserPrincipal groupPrincipal =
setCurrentPrincipalWithGroup(mismatchGroupName);
+
+ mockNoDirectUserRoles();
+ // Mismatched roleIds (1) and roleNames (2) -- the whole group should be
skipped
+ mockGroupWithRoles(
+ mismatchGroupName, ImmutableList.of(101L), ImmutableList.of("roleA",
"roleB"));
+
+ // Authorization denied -- the mismatched group is skipped, so no role is
loaded
+ assertFalse(doAuthorize(groupPrincipal));
+
+ restoreDefaultPrincipal();
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ }
+
@Test
public void testAuthorizeByDirectAndGroupRoles() throws Exception {
makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
@@ -439,13 +590,7 @@ public class TestJcasbinAuthorizer {
mockRoleInStore(
groupRoleId, "groupCatalogRole",
ImmutableList.of(getAllowSecurableObject()));
- NameIdentifier userNameIdentifier = NameIdentifierUtil.ofUser(METALAKE,
USERNAME);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(directRole));
-
+ mockDirectUserRoles(directRole);
mockGroupWithRoles(
GROUP_NAME, ImmutableList.of(groupRoleId),
ImmutableList.of(groupRole.name()));
@@ -477,26 +622,6 @@ public class TestJcasbinAuthorizer {
restoreDefaultPrincipal();
}
- @Test
- public void testGroupRoleSkippedWhenRoleIdsAndNamesMismatch() throws
Exception {
- makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
- getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
-
- String mismatchGroupName = "mismatchGroup";
- UserPrincipal groupPrincipal =
setCurrentPrincipalWithGroup(mismatchGroupName);
-
- mockNoDirectUserRoles();
- // Mismatched roleIds (1) and roleNames (2) -- the whole group should be
skipped
- mockGroupWithRoles(
- mismatchGroupName, ImmutableList.of(101L), ImmutableList.of("roleA",
"roleB"));
-
- // Authorization denied -- the mismatched group is skipped, so no role is
loaded
- assertFalse(doAuthorize(groupPrincipal));
-
- restoreDefaultPrincipal();
- getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
- }
-
@Test
public void testStaleGroupSkippedWhenNotInStore() throws Exception {
makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
@@ -506,12 +631,8 @@ public class TestJcasbinAuthorizer {
UserPrincipal groupPrincipal =
setCurrentPrincipalWithGroup(staleGroupName);
mockNoDirectUserRoles();
- // batchGet silently skips missing entities -- the stale group returns an
empty list
- when(entityStore.batchGet(
- eq(ImmutableList.of(NameIdentifierUtil.ofGroup(METALAKE,
staleGroupName))),
- eq(Entity.EntityType.GROUP),
- eq(GroupEntity.class)))
- .thenReturn(ImmutableList.of());
+ // group_meta lookup returns null -- the stale group has no row in the DB.
+ when(groupMetaMapper.getGroupUpdatedAt(eq(METALAKE),
eq(staleGroupName))).thenReturn(null);
// Authorization denied without throwing -- the stale group is silently
skipped
assertFalse(doAuthorize(groupPrincipal));
@@ -551,6 +672,40 @@ public class TestJcasbinAuthorizer {
getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
}
+ @Test
+ public void testRecreatedGroupWithSameNameDoesNotReuseOldRoleCache() throws
Exception {
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+
+ Long oldGroupId = 201L;
+ Long newGroupId = 202L;
+ Long oldGroupRoleId = 203L;
+ RoleEntity oldGroupRole =
+ mockRoleInStore(
+ oldGroupRoleId, "oldGroupRole",
ImmutableList.of(getAllowSecurableObject()));
+ UserPrincipal groupPrincipal = setCurrentPrincipalWithGroup(GROUP_NAME);
+
+ mockNoDirectUserRoles();
+ mockGroupWithRoles(
+ oldGroupId,
+ GROUP_NAME,
+ ImmutableList.of(oldGroupRoleId),
+ ImmutableList.of(oldGroupRole.name()));
+
+ assertTrue(doAuthorize(groupPrincipal));
+
+ // The group is deleted and recreated with the same name but a new id.
Keep updated_at lower
+ // than the old cache snapshot to verify the group id, not only
updated_at, controls reuse.
+ when(groupMetaMapper.getGroupUpdatedAt(eq(METALAKE), eq(GROUP_NAME)))
+ .thenReturn(new GroupUpdatedAt(newGroupId, 0L));
+
when(roleMetaMapper.listRolesByGroupId(eq(newGroupId))).thenReturn(ImmutableList.of());
+
+ assertFalse(doAuthorize(groupPrincipal));
+
+ restoreDefaultPrincipal();
+ getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+ }
+
@Test
public void testRoleSharedByUserAndGroupSurvivesGroupRevocation() throws
Exception {
makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
@@ -563,12 +718,7 @@ public class TestJcasbinAuthorizer {
RoleEntity sharedRole =
mockRoleInStore(sharedRoleId, "sharedRole",
ImmutableList.of(getAllowSecurableObject()));
- NameIdentifier userNameIdentifier = NameIdentifierUtil.ofUser(METALAKE,
USERNAME);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(sharedRole));
+ mockDirectUserRoles(sharedRole);
mockGroupWithRoles(
GROUP_NAME, ImmutableList.of(sharedRoleId),
ImmutableList.of(sharedRole.name()));
@@ -604,24 +754,16 @@ public class TestJcasbinAuthorizer {
mockRoleInStore(
sharedRoleId, "sharedRoleForUserRevoke",
ImmutableList.of(getAllowSecurableObject()));
- NameIdentifier userNameIdentifier = NameIdentifierUtil.ofUser(METALAKE,
USERNAME);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(sharedRole));
+ mockDirectUserRoles(sharedRole);
mockGroupWithRoles(
GROUP_NAME, ImmutableList.of(sharedRoleId),
ImmutableList.of(sharedRole.name()));
// Authorization succeeds (role via both direct and group)
assertTrue(doAuthorize(groupPrincipal));
- // User loses the role directly; group still has it
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of());
+ // User loses the role directly; group still has it. Bumping the user
version forces the
+ // userRoleCache to miss and reload the (now-empty) direct role list.
+ mockDirectUserRoles();
jcasbinAuthorizer.handleRolePrivilegeChange(sharedRoleId);
// Authorization should still succeed -- role is retained via group
inheritance
@@ -700,54 +842,17 @@ public class TestJcasbinAuthorizer {
mockNoDirectUserRoles();
- GroupEntity groupAEntity =
- GroupEntity.builder()
- .withId(groupAId)
- .withName(groupA)
- .withNamespace(Namespace.of(METALAKE, "group"))
- .withAuditInfo(AuditInfo.EMPTY)
- .withRoleNames(ImmutableList.of(roleA.name()))
- .withRoleIds(ImmutableList.of(roleAId))
- .build();
- GroupEntity groupBEntity =
- GroupEntity.builder()
- .withId(groupBId)
- .withName(groupB)
- .withNamespace(Namespace.of(METALAKE, "group"))
- .withAuditInfo(AuditInfo.EMPTY)
- .withRoleNames(ImmutableList.of(roleB.name()))
- .withRoleIds(ImmutableList.of(roleBId))
- .build();
- when(entityStore.batchGet(
- eq(
- ImmutableList.of(
- NameIdentifierUtil.ofGroup(METALAKE, groupA),
- NameIdentifierUtil.ofGroup(METALAKE, groupB))),
- eq(Entity.EntityType.GROUP),
- eq(GroupEntity.class)))
- .thenReturn(ImmutableList.of(groupAEntity, groupBEntity));
+ mockGroupWithRoles(groupAId, groupA, ImmutableList.of(roleAId),
ImmutableList.of(roleA.name()));
+ mockGroupWithRoles(groupBId, groupB, ImmutableList.of(roleBId),
ImmutableList.of(roleB.name()));
// Authorization succeeds
assertTrue(doAuthorize(multiGroupPrincipal));
- // Revoke roleA from groupA; groupB still has roleB
- GroupEntity groupANoRoles =
- GroupEntity.builder()
- .withId(groupAId)
- .withName(groupA)
- .withNamespace(Namespace.of(METALAKE, "group"))
- .withAuditInfo(AuditInfo.EMPTY)
- .withRoleNames(ImmutableList.of())
- .withRoleIds(ImmutableList.of())
- .build();
- when(entityStore.batchGet(
- eq(
- ImmutableList.of(
- NameIdentifierUtil.ofGroup(METALAKE, groupA),
- NameIdentifierUtil.ofGroup(METALAKE, groupB))),
- eq(Entity.EntityType.GROUP),
- eq(GroupEntity.class)))
- .thenReturn(ImmutableList.of(groupANoRoles, groupBEntity));
+ // Revoke roleA from groupA; groupB still has roleB. Bumping the
group_meta version forces
+ // the groupRoleCache to miss and reload the now-empty role list for
groupA.
+ when(groupMetaMapper.getGroupUpdatedAt(eq(METALAKE), eq(groupA)))
+ .thenReturn(new GroupUpdatedAt(groupAId,
groupVersionCounter.incrementAndGet()));
+
when(roleMetaMapper.listRolesByGroupId(eq(groupAId))).thenReturn(ImmutableList.of());
jcasbinAuthorizer.handleRolePrivilegeChange(roleAId);
// Authorization should still succeed via groupB's role
@@ -779,12 +884,7 @@ public class TestJcasbinAuthorizer {
Long denyRoleId = 19L;
RoleEntity denyRole =
mockRoleInStore(denyRoleId, "userDenyRole",
ImmutableList.of(getDenySecurableObject()));
- NameIdentifier userNameIdentifier = NameIdentifierUtil.ofUser(METALAKE,
USERNAME);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(denyRole));
+ mockDirectUserRoles(denyRole);
// Deny should win -- user has explicit deny even though group provides
allow
assertFalse(doAuthorize(groupPrincipal));
@@ -811,12 +911,7 @@ public class TestJcasbinAuthorizer {
Long allowRoleId = 21L;
RoleEntity allowRole =
mockRoleInStore(allowRoleId, "userAllowRole",
ImmutableList.of(getAllowSecurableObject()));
- NameIdentifier userNameIdentifier = NameIdentifierUtil.ofUser(METALAKE,
USERNAME);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(allowRole));
+ mockDirectUserRoles(allowRole);
// Deny should win -- group provides deny even though user has direct allow
assertFalse(doAuthorize(groupPrincipal));
@@ -846,17 +941,34 @@ public class TestJcasbinAuthorizer {
.thenReturn(new UserPrincipal(USERNAME));
}
- /** Mocks the user as having no direct (ROLE_USER_REL) role assignments. */
+ /**
+ * Mocks the user as having no directly-assigned roles. Bumps
userMetaMapper.getUserUpdatedAt to
+ * force the userRoleCache to miss and re-read from the (empty)
roleMetaMapper.listRolesByUserId.
+ */
private static void mockNoDirectUserRoles() throws IOException {
- NameIdentifier userNameIdentifier = NameIdentifierUtil.ofUser(METALAKE,
USERNAME);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of());
+
when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of());
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
}
- /** Builds a {@link RoleEntity} and registers it in the mocked entity store.
*/
+ /**
+ * Mocks the user as having the given roles directly assigned via the
version-validated cache
+ * path. Bumps {@code userMetaMapper.getUserUpdatedAt} to force a
userRoleCache miss.
+ */
+ private static void mockDirectUserRoles(RoleEntity... roles) {
+ List<RolePO> rolePOs = new ArrayList<>();
+ for (RoleEntity role : roles) {
+ rolePOs.add(buildRolePO(role.id(), role.name()));
+ }
+ when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(rolePOs);
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
+ }
+
+ /**
+ * Builds a {@link RoleEntity}, registers it in the mocked entity store, and
records its version
+ * so {@code batchGetRoleUpdatedAt} returns it during version-check.
+ */
private static RoleEntity mockRoleInStore(
Long roleId, String roleName, List<SecurableObject> securableObjects)
throws IOException {
RoleEntity role = getRoleEntity(roleId, roleName, securableObjects);
@@ -865,18 +977,28 @@ public class TestJcasbinAuthorizer {
eq(Entity.EntityType.ROLE),
eq(RoleEntity.class)))
.thenReturn(role);
+ mockedRoleVersions.put(roleId, new RoleUpdatedAt(roleId, roleName,
nextRoleVersion()));
return role;
}
/**
- * Builds a {@link GroupEntity} with the given role ids/names and registers
it in the mocked
- * entity store via batchGet.
+ * Mocks the group as carrying the given roles via the version-validated
cache path. The {@code
+ * group_meta.updated_at} sentinel is bumped so that the groupRoleCache
misses and re-reads from
+ * {@code roleMetaMapper.listRolesByGroupId}. The {@code
entityStore.batchGet} mock is also wired
+ * because {@code isSelf(ROLE)} and {@code ownerMatchesUserOrGroups} still
resolve full group
+ * entities through the relation store.
*/
private static GroupEntity mockGroupWithRoles(
String groupName, List<Long> roleIds, List<String> roleNames) throws
IOException {
+ return mockGroupWithRoles(GROUP_ID, groupName, roleIds, roleNames);
+ }
+
+ private static GroupEntity mockGroupWithRoles(
+ Long groupId, String groupName, List<Long> roleIds, List<String>
roleNames)
+ throws IOException {
GroupEntity group =
GroupEntity.builder()
- .withId(GROUP_ID)
+ .withId(groupId)
.withName(groupName)
.withNamespace(Namespace.of(METALAKE, "group"))
.withAuditInfo(AuditInfo.EMPTY)
@@ -888,6 +1010,17 @@ public class TestJcasbinAuthorizer {
eq(Entity.EntityType.GROUP),
eq(GroupEntity.class)))
.thenReturn(ImmutableList.of(group));
+
+ // Version-validated path: group_meta.updated_at sentinel +
listRolesByGroupId.
+ // Use a monotonic counter so successive calls always advance the version.
+ when(groupMetaMapper.getGroupUpdatedAt(eq(METALAKE), eq(groupName)))
+ .thenReturn(new GroupUpdatedAt(groupId,
groupVersionCounter.incrementAndGet()));
+ List<RolePO> rolePOs = new ArrayList<>();
+ for (int i = 0; i < roleIds.size(); i++) {
+ String name = i < roleNames.size() ? roleNames.get(i) : "role" +
roleIds.get(i);
+ rolePOs.add(buildRolePO(roleIds.get(i), name));
+ }
+ when(roleMetaMapper.listRolesByGroupId(eq(groupId))).thenReturn(rolePOs);
return group;
}
@@ -988,16 +1121,11 @@ public class TestJcasbinAuthorizer {
"testCatalog2", getDenySecurableObjectPO(),
MetadataObject.Type.CATALOG);
}
- private static void makeCompletableFutureUseCurrentThread(JcasbinAuthorizer
jcasbinAuthorizer) {
- try {
- Executor currentThread = Runnable::run;
- Class<JcasbinAuthorizer> jcasbinAuthorizerClass =
JcasbinAuthorizer.class;
- Field field = jcasbinAuthorizerClass.getDeclaredField("executor");
- field.setAccessible(true);
- FieldUtils.writeField(field, jcasbinAuthorizer, currentThread);
- } catch (Exception e) {
- throw new RuntimeException(e);
- }
+ @SuppressWarnings("UnusedVariable")
+ private static void makeCompletableFutureUseCurrentThread(
+ @SuppressWarnings("unused") JcasbinAuthorizer jcasbinAuthorizer) {
+ // No-op: the executor field was removed during cache refactoring.
+ // Role loading is now synchronous via requestContext.loadRole().
}
@Test
@@ -1005,31 +1133,32 @@ public class TestJcasbinAuthorizer {
makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
// Get the loadedRoles cache via reflection
- Cache<Long, Boolean> loadedRoles = getLoadedRolesCache(jcasbinAuthorizer);
+ GravitinoCache<Long, Long> loadedRoles =
getLoadedRolesCache(jcasbinAuthorizer);
// Manually add a role to the cache
Long testRoleId = 100L;
- loadedRoles.put(testRoleId, true);
+ loadedRoles.put(testRoleId, System.currentTimeMillis());
// Verify it's in the cache
- assertNotNull(loadedRoles.getIfPresent(testRoleId));
+ assertTrue(loadedRoles.getIfPresent(testRoleId).isPresent());
// Call handleRolePrivilegeChange which should invalidate the cache entry
jcasbinAuthorizer.handleRolePrivilegeChange(testRoleId);
// Verify it's removed from the cache
- assertNull(loadedRoles.getIfPresent(testRoleId));
+ assertFalse(loadedRoles.getIfPresent(testRoleId).isPresent());
}
@Test
public void testOwnerCacheInvalidation() throws Exception {
- GravitinoCache<Long, Optional<OwnerInfo>> ownerRelCache =
getOwnerRelCache(jcasbinAuthorizer);
+ // Get the ownerRel cache via reflection
+ GravitinoCache<Long, Optional<OwnerInfo>> ownerRel =
getOwnerRelCache(jcasbinAuthorizer);
// Manually add an owner relation to the cache
- ownerRelCache.put(CATALOG_ID, Optional.of(new OwnerInfo(USER_ID, "USER")));
+ ownerRel.put(CATALOG_ID, Optional.of(new OwnerInfo(USER_ID, "USER")));
// Verify it's in the cache
- assertTrue(ownerRelCache.getIfPresent(CATALOG_ID).isPresent());
+ assertTrue(ownerRel.getIfPresent(CATALOG_ID).isPresent());
// Create a mock NameIdentifier for the metadata object
NameIdentifier catalogIdent = NameIdentifierUtil.ofCatalog(METALAKE,
"testCatalog");
@@ -1039,7 +1168,7 @@ public class TestJcasbinAuthorizer {
METALAKE, USER_ID, catalogIdent, Entity.EntityType.CATALOG);
// Verify it's removed from the cache
- assertFalse(ownerRelCache.getIfPresent(CATALOG_ID).isPresent());
+ assertFalse(ownerRel.getIfPresent(CATALOG_ID).isPresent());
}
@Test
@@ -1048,7 +1177,7 @@ public class TestJcasbinAuthorizer {
GravitinoCache<Long, Optional<OwnerInfo>> ownerRelCache =
getOwnerRelCache(jcasbinAuthorizer);
NameIdentifier catalogIdent = NameIdentifierUtil.ofCatalog(METALAKE,
"testCatalog");
String cacheKey =
- JcasbinAuthorizationLookups.buildCacheKey(
+ JcasbinAuthorizationCacheKeys.metadataIdCacheKey(
METALAKE, NameIdentifierUtil.toMetadataObject(catalogIdent,
Entity.EntityType.CATALOG));
metadataIdCache.put(cacheKey, CATALOG_ID);
@@ -1072,43 +1201,6 @@ public class TestJcasbinAuthorizer {
}
}
- @Test
- public void testCloseAwaitsRoleLoadExecutorTermination() throws Exception {
- RecordingThreadPoolExecutor executor = new RecordingThreadPoolExecutor();
-
- Field executorField = JcasbinAuthorizer.class.getDeclaredField("executor");
- Field changePollerField =
JcasbinAuthorizer.class.getDeclaredField("changePoller");
- Field metadataIdCacheField =
JcasbinAuthorizer.class.getDeclaredField("metadataIdCache");
- Field ownerRelCacheField =
JcasbinAuthorizer.class.getDeclaredField("ownerRelCache");
-
- Executor originalExecutor =
- (Executor) FieldUtils.readField(executorField, jcasbinAuthorizer,
true);
- Object originalPoller = FieldUtils.readField(changePollerField,
jcasbinAuthorizer, true);
- Object originalMetadataIdCache =
- FieldUtils.readField(metadataIdCacheField, jcasbinAuthorizer, true);
- Object originalOwnerRelCache =
- FieldUtils.readField(ownerRelCacheField, jcasbinAuthorizer, true);
-
- try {
- FieldUtils.writeField(executorField, jcasbinAuthorizer, executor, true);
- // Detach shared resources so close() only exercises the executor
shutdown branch and does
- // not leave the shared poller / caches in a closed state for subsequent
tests.
- FieldUtils.writeField(changePollerField, jcasbinAuthorizer, null, true);
- FieldUtils.writeField(metadataIdCacheField, jcasbinAuthorizer, null,
true);
- FieldUtils.writeField(ownerRelCacheField, jcasbinAuthorizer, null, true);
-
- jcasbinAuthorizer.close();
-
- assertTrue(executor.shutdownCalled.get());
- assertTrue(executor.awaitTerminationCalled.get());
- } finally {
- FieldUtils.writeField(executorField, jcasbinAuthorizer,
originalExecutor, true);
- FieldUtils.writeField(changePollerField, jcasbinAuthorizer,
originalPoller, true);
- FieldUtils.writeField(metadataIdCacheField, jcasbinAuthorizer,
originalMetadataIdCache, true);
- FieldUtils.writeField(ownerRelCacheField, jcasbinAuthorizer,
originalOwnerRelCache, true);
- }
- }
-
@Test
public void testRoleCacheSynchronousRemovalListenerDeletesPolicy() throws
Exception {
makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
@@ -1118,7 +1210,7 @@ public class TestJcasbinAuthorizer {
Enforcer denyEnforcer = getDenyEnforcer(jcasbinAuthorizer);
// Get the loadedRoles cache
- Cache<Long, Boolean> loadedRoles = getLoadedRolesCache(jcasbinAuthorizer);
+ GravitinoCache<Long, Long> loadedRoles =
getLoadedRolesCache(jcasbinAuthorizer);
// Add a role and its policy to the enforcer
Long testRoleId = 300L;
@@ -1129,7 +1221,7 @@ public class TestJcasbinAuthorizer {
denyEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
// Add role to cache
- loadedRoles.put(testRoleId, true);
+ loadedRoles.put(testRoleId, System.currentTimeMillis());
// Verify role exists in enforcer (has policy)
assertTrue(allowEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
@@ -1144,28 +1236,66 @@ public class TestJcasbinAuthorizer {
assertFalse(denyEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
}
+ @Test
+ public void testRoleCacheReplacementDoesNotDeletePolicy() throws Exception {
+ Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer);
+ Enforcer denyEnforcer = getDenyEnforcer(jcasbinAuthorizer);
+ GravitinoCache<Long, Long> loadedRoles =
getLoadedRolesCache(jcasbinAuthorizer);
+
+ Long testRoleId = 301L;
+ String roleIdStr = String.valueOf(testRoleId);
+ loadedRoles.put(testRoleId, 1L);
+
+ allowEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
+ denyEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
+
+ loadedRoles.put(testRoleId, 2L);
+
+ assertTrue(allowEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
+ assertTrue(denyEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
+ }
+
+ @Test
+ public void testClearRolePoliciesPreservesUserRoleBindings() throws
Exception {
+ Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer);
+ Enforcer denyEnforcer = getDenyEnforcer(jcasbinAuthorizer);
+
+ Long testRoleId = 302L;
+ String roleIdStr = String.valueOf(testRoleId);
+ String userIdStr = String.valueOf(USER_ID);
+ allowEnforcer.addRoleForUser(userIdStr, roleIdStr);
+ denyEnforcer.addRoleForUser(userIdStr, roleIdStr);
+ allowEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
+ denyEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
+
+ Method clearRolePolicies =
+ JcasbinAuthorizer.class.getDeclaredMethod("clearRolePolicies",
long.class);
+ clearRolePolicies.setAccessible(true);
+ clearRolePolicies.invoke(jcasbinAuthorizer, testRoleId);
+
+ assertFalse(allowEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
+ assertFalse(denyEnforcer.hasPolicy(roleIdStr, "CATALOG", "999",
"USE_CATALOG", "allow"));
+ assertTrue(allowEnforcer.getRolesForUser(userIdStr).contains(roleIdStr));
+ assertTrue(denyEnforcer.getRolesForUser(userIdStr).contains(roleIdStr));
+ }
+
@Test
public void testCacheInitialization() throws Exception {
// Verify that caches are initialized
- Cache<Long, Boolean> loadedRoles = getLoadedRolesCache(jcasbinAuthorizer);
+ GravitinoCache<Long, Long> loadedRolesCache =
getLoadedRolesCache(jcasbinAuthorizer);
GravitinoCache<Long, Optional<OwnerInfo>> ownerRelCache =
getOwnerRelCache(jcasbinAuthorizer);
- assertNotNull(loadedRoles, "loadedRoles cache should be initialized");
- assertNotNull(ownerRelCache, "ownerRelCache should be initialized");
+ assertNotNull(loadedRolesCache, "loadedRoles cache should be initialized");
+ assertNotNull(ownerRelCache, "ownerRel cache should be initialized");
}
/** Tests {@link JcasbinAuthorizer#hasMetadataPrivilegePermission} hierarchy
walk */
@Test
public void testHasMetadataPrivilegePermission() throws Exception {
makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
- NameIdentifier userNameIdentifier = NameIdentifierUtil.ofUser(METALAKE,
USERNAME);
// --- Case 1: no MANAGE_GRANTS anywhere → false ---
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of());
+ mockUserRoles();
assertFalse(
jcasbinAuthorizer.hasMetadataPrivilegePermission(
METALAKE,
@@ -1188,11 +1318,7 @@ public class TestJcasbinAuthorizer {
eq(Entity.EntityType.ROLE),
eq(RoleEntity.class)))
.thenReturn(metalakeGrantRole);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(metalakeGrantRole));
+ mockUserRoles(metalakeGrantRoleId, "metalakeGrantRole");
assertTrue(
jcasbinAuthorizer.hasMetadataPrivilegePermission(
METALAKE,
@@ -1215,11 +1341,7 @@ public class TestJcasbinAuthorizer {
eq(Entity.EntityType.ROLE),
eq(RoleEntity.class)))
.thenReturn(catalogGrantRole);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(catalogGrantRole));
+ mockUserRoles(catalogGrantRoleId, "catalogGrantRole");
assertTrue(
jcasbinAuthorizer.hasMetadataPrivilegePermission(
METALAKE,
@@ -1248,11 +1370,7 @@ public class TestJcasbinAuthorizer {
eq(Entity.EntityType.ROLE),
eq(RoleEntity.class)))
.thenReturn(tableGrantRole);
- when(supportsRelationOperations.listEntitiesByRelation(
- eq(SupportsRelationOperations.Type.ROLE_USER_REL),
- eq(userNameIdentifier),
- eq(Entity.EntityType.USER)))
- .thenReturn(ImmutableList.of(tableGrantRole));
+ mockUserRoles(tableGrantRoleId, "tableGrantRole");
assertTrue(
jcasbinAuthorizer.hasMetadataPrivilegePermission(
METALAKE,
@@ -1296,11 +1414,11 @@ public class TestJcasbinAuthorizer {
}
@SuppressWarnings("unchecked")
- private static Cache<Long, Boolean> getLoadedRolesCache(JcasbinAuthorizer
authorizer)
+ private static GravitinoCache<Long, Long>
getLoadedRolesCache(JcasbinAuthorizer authorizer)
throws Exception {
Field field = JcasbinAuthorizer.class.getDeclaredField("loadedRoles");
field.setAccessible(true);
- return (Cache<Long, Boolean>) field.get(authorizer);
+ return (GravitinoCache<Long, Long>) field.get(authorizer);
}
@SuppressWarnings("unchecked")
@@ -1311,6 +1429,46 @@ public class TestJcasbinAuthorizer {
return (GravitinoCache<Long, Optional<OwnerInfo>>) field.get(authorizer);
}
+ /** Mock mapper to assign zero roles. Bumps user version to invalidate
cache. */
+ private static void mockUserRoles() {
+
when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of());
+
when(roleMetaMapper.batchGetRoleUpdatedAt(any())).thenReturn(ImmutableList.of());
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
+ }
+
+ /** Mock mapper to assign a single role. Bumps user version to invalidate
cache. */
+ private static void mockUserRoles(Long roleId, String roleName) {
+ long roleVersion = nextRoleVersion();
+ when(roleMetaMapper.listRolesByUserId(eq(USER_ID)))
+ .thenReturn(ImmutableList.of(buildRolePO(roleId, roleName)));
+ when(roleMetaMapper.batchGetRoleUpdatedAt(any()))
+ .thenReturn(ImmutableList.of(new RoleUpdatedAt(roleId, roleName,
roleVersion)));
+ when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+ .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
+ }
+
+ private static long nextRoleVersion() {
+ return roleVersionCounter.incrementAndGet();
+ }
+
+ private static long nextUserVersion() {
+ return userVersionCounter.incrementAndGet();
+ }
+
+ private static RolePO buildRolePO(Long roleId, String roleName) {
+ return RolePO.builder()
+ .withRoleId(roleId)
+ .withRoleName(roleName)
+ .withMetalakeId(USER_METALAKE_ID)
+ .withProperties("{}")
+ .withAuditInfo("{}")
+ .withCurrentVersion(1L)
+ .withLastVersion(1L)
+ .withDeletedAt(0L)
+ .build();
+ }
+
@SuppressWarnings("unchecked")
private static GravitinoCache<String, Long>
getMetadataIdCache(JcasbinAuthorizer authorizer)
throws Exception {
@@ -1330,25 +1488,4 @@ public class TestJcasbinAuthorizer {
field.setAccessible(true);
return (Enforcer) field.get(authorizer);
}
-
- private static class RecordingThreadPoolExecutor extends ThreadPoolExecutor {
- private final AtomicBoolean shutdownCalled = new AtomicBoolean();
- private final AtomicBoolean awaitTerminationCalled = new AtomicBoolean();
-
- private RecordingThreadPoolExecutor() {
- super(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
- }
-
- @Override
- public void shutdown() {
- shutdownCalled.set(true);
- super.shutdown();
- }
-
- @Override
- public boolean awaitTermination(long timeout, TimeUnit unit) throws
InterruptedException {
- awaitTerminationCalled.set(true);
- return super.awaitTermination(timeout, unit);
- }
- }
}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizerCacheHelpers.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizerCacheHelpers.java
new file mode 100644
index 0000000000..922039ad43
--- /dev/null
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizerCacheHelpers.java
@@ -0,0 +1,46 @@
+/*
+ * 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.server.authorization.jcasbin;
+
+import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests for the cached role relation snapshot classes used by the
version-validated caches. */
+public class TestJcasbinAuthorizerCacheHelpers {
+
+ @Test
+ void testCachedUserRoleRels() {
+ List<Long> roleIds = Arrays.asList(10L, 20L, 30L);
+ CachedUserRoleRels cur = new CachedUserRoleRels(1L, 1000L, roleIds);
+ Assertions.assertEquals(1L, cur.getUserId());
+ Assertions.assertEquals(1000L, cur.getUpdatedAt());
+ Assertions.assertEquals(roleIds, cur.getRoleIds());
+ }
+
+ @Test
+ void testCachedGroupRoleRels() {
+ List<Long> roleIds = Arrays.asList(100L, 200L);
+ CachedGroupRoleRels cgr = new CachedGroupRoleRels(5L, 2000L, roleIds);
+ Assertions.assertEquals(5L, cgr.getGroupId());
+ Assertions.assertEquals(2000L, cgr.getUpdatedAt());
+ Assertions.assertEquals(roleIds, cgr.getRoleIds());
+ }
+}
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 867a862603..e436fdd9bf 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
@@ -64,20 +64,22 @@ public class TestJcasbinChangePoller {
JcasbinChangePoller.metadataObjectFromChangeLog(
"ml1", "ml1.cat1", MetadataObject.Type.CATALOG);
Assertions.assertEquals(
- key("ml1", "cat1", ""),
JcasbinAuthorizationLookups.buildCacheKey("ml1", catalog));
+ key("ml1", "CATALOG", "cat1", ""),
+ JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1", catalog));
MetadataObject schema =
JcasbinChangePoller.metadataObjectFromChangeLog(
"ml1", "ml1.cat1.sch1", MetadataObject.Type.SCHEMA);
Assertions.assertEquals(
- key("ml1", "cat1", "sch1", ""),
JcasbinAuthorizationLookups.buildCacheKey("ml1", schema));
+ key("ml1", "CATALOG", "cat1", "SCHEMA", "sch1", ""),
+ JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1", schema));
MetadataObject table =
JcasbinChangePoller.metadataObjectFromChangeLog(
"ml1", "ml1.cat1.sch1.tbl1", MetadataObject.Type.TABLE);
Assertions.assertEquals(
- key("ml1", "cat1", "sch1", "tbl1", "TABLE"),
- JcasbinAuthorizationLookups.buildCacheKey("ml1", table));
+ key("ml1", "CATALOG", "cat1", "SCHEMA", "sch1", "TABLE", "tbl1", ""),
+ JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1", table));
}
@Test
@@ -85,7 +87,8 @@ public class TestJcasbinChangePoller {
MetadataObject metalake =
JcasbinChangePoller.metadataObjectFromChangeLog("ml1", "ml1",
MetadataObject.Type.METALAKE);
Assertions.assertEquals(
- key("ml1", ""), JcasbinAuthorizationLookups.buildCacheKey("ml1",
metalake));
+ key("ml1", "METALAKE", ""),
+ JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1", metalake));
}
@Test
@@ -124,9 +127,12 @@ public class TestJcasbinChangePoller {
poller.pollChanges();
}
- Assertions.assertEquals(List.of(key("ml1", "cat1", "")),
metadataIdCache.invalidatedPrefixes);
Assertions.assertEquals(
- List.of(key("ml1", "cat2", "sch1", "tbl1", "TABLE")),
metadataIdCache.invalidatedKeys);
+ List.of(
+ key("ml1", "CATALOG", "cat1", ""),
+ key("ml1", "CATALOG", "cat2", "SCHEMA", "sch1", "TABLE", "tbl1",
"")),
+ metadataIdCache.invalidatedPrefixes);
+ Assertions.assertEquals(List.of(), metadataIdCache.invalidatedKeys);
}
@Test
@@ -139,7 +145,7 @@ public class TestJcasbinChangePoller {
}
private static String key(String... parts) {
- return String.join(JcasbinAuthorizationLookups.KEY_SEP, parts);
+ return JcasbinAuthorizationCacheKeys.joinKeyParts(parts);
}
private static EntityChangeRecord change(long id, MetadataObject.Type type,
String fullName) {