From 6a440570c9eaf6dead95b20bdc3590143e5a4321 Mon Sep 17 00:00:00 2001 From: yuqi Date: Fri, 15 May 2026 15:47:05 +0800 Subject: [PATCH 01/23] [#10772] feat(authz): Eventual-consistency invalidation for the JcasbinAuthorizer caches Introduces the HA invalidation infrastructure that will later carry the version-validated role caching (tracked in #10772). This change leaves the existing role-loading path on its current TTL-only behavior. Changes ------- * `JcasbinAuthorizationLookups`: two-tier metadata-id and owner lookup facade (per-request dedup via AuthorizationRequestContext, shared Caffeine-backed GravitinoCache fallback, DB on miss). Owners are now fetched directly from `owner_meta` via OwnerMetaMapper instead of the OWNER_REL relation query, so we have a metadataId-keyed cache that matches the change-log key space. * `JcasbinChangePoller`: scheduled thread that drains `entity_change_log` and `owner_meta` change rows since a high-water cursor and invalidates the affected `metadataIdCache` / `ownerRelCache` keys. Documents the id-cursor in-flight-commit trade-off and the writer-side pre-mutation-name contract. * `JcasbinAuthorizer`: - wires the two GravitinoCaches, the lookups facade, and the poller in `initialize()` / `close()`; - routes `isOwner`, `authorizeByJcasbin` (OWNER path) and `handleMetadataOwnerChange` through the lookups; - drops the old in-class `OwnerInfo` (replaced by `org.apache.gravitino.storage.relational.po.auth.OwnerInfo`) and the `loadOwnerPolicy` / `checkOwnership` pair, collapsed into a single `ownerMatchesUserOrGroups` helper that consumes the resolved `Optional`; - implements `handleEntityStructuralChange` to invalidate (or prefix invalidate) `metadataIdCache` on rename / drop. * `GravitinoAuthorizer`: adds the `handleEntityStructuralChange` default method so callers can wire the new hook without breaking existing implementations. * `Configs`: adds `metadataIdCacheSize` and `changePollIntervalSecs`. The version-validated role caches and the JcasbinRoleLoader rewrite ride on top of this change in a follow-up PR. Test plan --------- `./gradlew :server-common:test --tests 'org.apache.gravitino.server.authorization.*' -PskipITs` `./gradlew :server-common:javadoc :core:javadoc` Co-Authored-By: Claude Opus 4.7 --- .../java/org/apache/gravitino/Configs.java | 18 ++ .../authorization/GravitinoAuthorizer.java | 14 ++ .../jcasbin/JcasbinAuthorizationLookups.java | 157 ++++++++++++ .../jcasbin/JcasbinAuthorizer.java | 197 +++++++-------- .../jcasbin/JcasbinChangePoller.java | 233 ++++++++++++++++++ .../TestJcasbinAuthorizationLookups.java | 111 +++++++++ .../jcasbin/TestJcasbinAuthorizer.java | 109 ++++---- .../jcasbin/TestJcasbinChangePoller.java | 55 +++++ 8 files changed, 747 insertions(+), 147 deletions(-) create mode 100644 server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java create mode 100644 server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java create mode 100644 server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java create mode 100644 server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java diff --git a/core/src/main/java/org/apache/gravitino/Configs.java b/core/src/main/java/org/apache/gravitino/Configs.java index 2f09aa54c80..bdb61abc2c6 100644 --- a/core/src/main/java/org/apache/gravitino/Configs.java +++ b/core/src/main/java/org/apache/gravitino/Configs.java @@ -330,6 +330,24 @@ private Configs() {} .longConf() .createWithDefault(DEFAULT_GRAVITINO_AUTHORIZATION_OWNER_CACHE_SIZE); + public static final long DEFAULT_GRAVITINO_AUTHORIZATION_METADATA_ID_CACHE_SIZE = 100000L; + + public static final ConfigEntry GRAVITINO_AUTHORIZATION_METADATA_ID_CACHE_SIZE = + new ConfigBuilder("gravitino.authorization.jcasbin.metadataIdCacheSize") + .doc("The maximum size of the metadata-id cache for authorization") + .version(ConfigConstants.VERSION_1_3_0) + .longConf() + .createWithDefault(DEFAULT_GRAVITINO_AUTHORIZATION_METADATA_ID_CACHE_SIZE); + + public static final long DEFAULT_GRAVITINO_AUTHORIZATION_CHANGE_POLL_INTERVAL_SECS = 3L; + + public static final ConfigEntry GRAVITINO_AUTHORIZATION_CHANGE_POLL_INTERVAL_SECS = + new ConfigBuilder("gravitino.authorization.jcasbin.changePollIntervalSecs") + .doc("The interval in seconds for polling entity and owner changes") + .version(ConfigConstants.VERSION_1_3_0) + .longConf() + .createWithDefault(DEFAULT_GRAVITINO_AUTHORIZATION_CHANGE_POLL_INTERVAL_SECS); + public static final ConfigEntry> SERVICE_ADMINS = new ConfigBuilder("gravitino.authorization.serviceAdmins") .doc("The admins of Gravitino service") diff --git a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java index 6ab74382adb..6619387b612 100644 --- a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java +++ b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java @@ -166,4 +166,18 @@ default void handleRolePrivilegeChange(String metalake, String roleName) { */ void handleMetadataOwnerChange( String metalake, Long oldOwnerId, NameIdentifier nameIdentifier, Entity.EntityType type); + + /** + * Called when an entity undergoes a structural change (rename or drop) that invalidates cached + * name-to-id mappings in the authorizer. Implementations evict the cache key for the given entity + * and all of its descendants (cascade invalidation). + * + * @param metalake the metalake name + * @param nameIdentifier the entity name identifier + * @param type the entity type + */ + default void handleEntityStructuralChange( + String metalake, NameIdentifier nameIdentifier, Entity.EntityType type) { + // default no-op for backward compatibility + } } 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 new file mode 100644 index 00000000000..9f136c4d453 --- /dev/null +++ b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java @@ -0,0 +1,157 @@ +/* + * 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.google.common.annotations.VisibleForTesting; +import java.util.Optional; +import org.apache.gravitino.MetadataObject; +import org.apache.gravitino.authorization.AuthorizationRequestContext; +import org.apache.gravitino.cache.GravitinoCache; +import org.apache.gravitino.server.authorization.MetadataIdConverter; +import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper; +import org.apache.gravitino.storage.relational.po.auth.OwnerInfo; +import org.apache.gravitino.storage.relational.utils.SessionUtils; + +/** + * Two-tier metadata-id and owner resolution for {@link JcasbinAuthorizer}. + * + *

Each lookup is deduplicated within a single request via {@link AuthorizationRequestContext}, + * falls back to a shared {@link GravitinoCache} on a request miss, and finally issues a single DB + * query on a cache miss. A successful DB fetch populates both tiers so subsequent calls — in this + * request and later ones — hit the cache. The two underlying caches are invalidated externally by + * {@link JcasbinChangePoller} (HA peers) and by the {@link + * org.apache.gravitino.authorization.GravitinoAuthorizer#handleMetadataOwnerChange} / {@link + * org.apache.gravitino.authorization.GravitinoAuthorizer#handleEntityStructuralChange} hooks (local + * mutations). + */ +public class JcasbinAuthorizationLookups { + + /** Key separator for hierarchical cache keys. */ + static final String KEY_SEP = "::"; + + private final GravitinoCache metadataIdCache; + private final GravitinoCache> ownerRelCache; + + /** + * 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 hierarchical {@code metalake::catalog::schema::object::TYPE} → entity id + * @param ownerRelCache {@code metadataObjectId} → {@link Optional} of {@link OwnerInfo} + */ + public JcasbinAuthorizationLookups( + GravitinoCache metadataIdCache, + GravitinoCache> ownerRelCache) { + this.metadataIdCache = metadataIdCache; + this.ownerRelCache = ownerRelCache; + } + + /** + * 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}. + */ + public Long resolveMetadataId( + MetadataObject metadataObject, String metalake, AuthorizationRequestContext requestContext) { + String cacheKey = buildCacheKey(metalake, metadataObject); + return requestContext.computeMetadataIdIfAbsent( + cacheKey, + k -> { + Optional cached = metadataIdCache.getIfPresent(k); + if (cached.isPresent()) { + return cached.get(); + } + Long id = MetadataIdConverter.getID(metadataObject, metalake); + metadataIdCache.put(k, id); + return id; + }); + } + + /** + * Two-tier owner lookup: request-level dedup first, then the shared {@code ownerRelCache}, and + * finally a single {@code owner_meta} query. A successful DB fetch populates both tiers so + * subsequent {@code isOwner} calls — in this request and later ones — hit the cache. + */ + public Optional resolveOwnerId( + Long metadataId, + MetadataObject.Type metadataType, + AuthorizationRequestContext requestContext) { + return requestContext.computeOwnerIfAbsent( + metadataId, + id -> { + Optional> cached = ownerRelCache.getIfPresent(id); + if (cached.isPresent()) { + return cached.get(); + } + OwnerInfo ownerInfo = + SessionUtils.getWithoutCommit( + OwnerMetaMapper.class, + m -> m.selectOwnerByMetadataObjectIdAndType(id, metadataType.name())); + Optional owner = ownerInfo == null ? Optional.empty() : Optional.of(ownerInfo); + ownerRelCache.put(id, owner); + return owner; + }); + } + + /** Underlying metadata-id cache; exposed for invalidation by the change hooks and the poller. */ + public GravitinoCache metadataIdCache() { + return metadataIdCache; + } + + /** Underlying owner cache; exposed for invalidation by the change hooks and the poller. */ + public GravitinoCache> ownerRelCache() { + return ownerRelCache; + } + + /** + * Builds a hierarchical cache key for the metadataIdCache. Non-leaf objects end with "::" to + * enable prefix-based cascade invalidation. + * + *

Examples: {@code metalake::}, {@code metalake::catalog::}, {@code + * metalake::catalog::schema::}, {@code metalake::catalog::schema::table::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 (isNonLeaf(metadataObject.type())) { + // Trailing separator enables prefix-based cascade 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 isNonLeaf(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 3778f8b19f1..0a60ab901d8 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 @@ -56,12 +56,14 @@ import org.apache.gravitino.authorization.GravitinoAuthorizer; 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.utils.MetadataObjectUtil; +import org.apache.gravitino.storage.relational.po.auth.OwnerInfo; import org.apache.gravitino.utils.NameIdentifierUtil; import org.apache.gravitino.utils.PrincipalUtils; import org.casbin.jcasbin.main.Enforcer; @@ -93,7 +95,17 @@ public class JcasbinAuthorizer implements GravitinoAuthorizer { */ private Cache loadedRoles; - private Cache> ownerRel; + /** Hierarchical {@code metalake::catalog::schema::object::TYPE} → entity id. */ + private GravitinoCache metadataIdCache; + + /** {@code metadataObjectId} → {@link Optional} of {@link OwnerInfo}. */ + private GravitinoCache> ownerRelCache; + + /** Two-tier lookup facade (per-request dedup + shared cache + DB fallback). */ + private JcasbinAuthorizationLookups lookups; + + /** Background HA invalidator for {@link #metadataIdCache} and {@link #ownerRelCache}. */ + private JcasbinChangePoller changePoller; private Executor executor = null; @@ -107,6 +119,16 @@ public void initialize() { GravitinoEnv.getInstance().config().get(Configs.GRAVITINO_AUTHORIZATION_ROLE_CACHE_SIZE); long ownerCacheSize = GravitinoEnv.getInstance().config().get(Configs.GRAVITINO_AUTHORIZATION_OWNER_CACHE_SIZE); + long metadataIdCacheSize = + GravitinoEnv.getInstance() + .config() + .get(Configs.GRAVITINO_AUTHORIZATION_METADATA_ID_CACHE_SIZE); + long pollIntervalSecs = + GravitinoEnv.getInstance() + .config() + .get(Configs.GRAVITINO_AUTHORIZATION_CHANGE_POLL_INTERVAL_SECS); + + long ttlMs = cacheExpirationSecs * 1000L; // Initialize enforcers before the caches that reference them in removal listeners allowEnforcer = new SyncedEnforcer(getModel("/jcasbin_model.conf"), new GravitinoAdapter()); @@ -127,11 +149,11 @@ public void initialize() { } }) .build(); - ownerRel = - Caffeine.newBuilder() - .expireAfterAccess(cacheExpirationSecs, TimeUnit.SECONDS) - .maximumSize(ownerCacheSize) - .build(); + metadataIdCache = new CaffeineGravitinoCache<>(ttlMs, metadataIdCacheSize); + ownerRelCache = new CaffeineGravitinoCache<>(ttlMs, ownerCacheSize); + lookups = new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache); + changePoller = new JcasbinChangePoller(metadataIdCache, ownerRelCache, pollIntervalSecs); + changePoller.start(); executor = Executors.newFixedThreadPool( GravitinoEnv.getInstance() @@ -226,9 +248,10 @@ public boolean isOwner( AuthorizationRequestContext requestContext) { boolean result; try { - Long metadataId = MetadataIdConverter.getID(metadataObject, metalake); - loadOwnerPolicy(metalake, metadataObject, metadataId); - result = checkOwnership(principal, metalake, metadataId); + Long metadataId = lookups.resolveMetadataId(metadataObject, metalake, requestContext); + Optional owner = + lookups.resolveOwnerId(metadataId, metadataObject.type(), requestContext); + result = ownerMatchesUserOrGroups(owner, principal, metalake); } catch (Exception e) { LOG.debug("Can not get entity id", e); result = false; @@ -422,17 +445,42 @@ public void handleMetadataOwnerChange( String metalake, Long oldOwnerId, NameIdentifier nameIdentifier, Entity.EntityType type) { MetadataObject metadataObject = NameIdentifierUtil.toMetadataObject(nameIdentifier, type); Long metadataId = MetadataIdConverter.getID(metadataObject, metalake); - ownerRel.invalidate(metadataId); + ownerRelCache.invalidate(metadataId); + // 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)); + } + + @Override + public void handleEntityStructuralChange( + String metalake, NameIdentifier nameIdentifier, Entity.EntityType type) { + MetadataObject metadataObject = NameIdentifierUtil.toMetadataObject(nameIdentifier, type); + String cacheKey = JcasbinAuthorizationLookups.buildCacheKey(metalake, metadataObject); + if (JcasbinAuthorizationLookups.isNonLeaf(metadataObject.type())) { + // Cascade invalidation: metalake::catalog:: prefix removes catalog + all children + metadataIdCache.invalidateByPrefix(cacheKey); + } else { + metadataIdCache.invalidate(cacheKey); + } } @Override public void close() throws IOException { + if (changePoller != null) { + changePoller.close(); + } if (executor != null) { if (executor instanceof ThreadPoolExecutor) { ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor; threadPoolExecutor.shutdown(); } } + if (metadataIdCache != null) { + metadataIdCache.close(); + } + if (ownerRelCache != null) { + ownerRelCache.close(); + } } private class InternalAuthorizer { @@ -464,13 +512,14 @@ private boolean loadPrivilegeAndAuthorize( try { UserEntity userEntity = getUserEntity(username, metalake); userId = userEntity.id(); - metadataId = MetadataIdConverter.getID(metadataObject, metalake); + metadataId = lookups.resolveMetadataId(metadataObject, metalake, requestContext); } catch (Exception e) { LOG.debug("Can not get entity id", e); return false; } loadRolePrivilege(metalake, username, userId, requestContext); - return authorizeByJcasbin(userId, metalake, metadataObject, metadataId, privilege); + return authorizeByJcasbin( + userId, metalake, metadataObject, metadataId, privilege, requestContext); } private boolean authorizeByJcasbin( @@ -478,9 +527,12 @@ private boolean authorizeByJcasbin( String metalake, MetadataObject metadataObject, Long metadataId, - String privilege) { + String privilege, + AuthorizationRequestContext requestContext) { if (AuthConstants.OWNER.equals(privilege)) { - return checkOwnership(PrincipalUtils.getCurrentPrincipal(), metalake, metadataId); + Optional owner = + lookups.resolveOwnerId(metadataId, metadataObject.type(), requestContext); + return ownerMatchesUserOrGroups(owner, PrincipalUtils.getCurrentPrincipal(), metalake); } return enforcer.enforce( String.valueOf(userId), @@ -627,43 +679,6 @@ private void addRoleForUserAndLoadPolicies( loadRoleFutures.add(loadRoleFuture); } - private void loadOwnerPolicy(String metalake, MetadataObject metadataObject, Long metadataId) { - if (ownerRel.getIfPresent(metadataId) != null) { - LOG.debug("Metadata {} OWNER has been loaded.", metadataId); - return; - } - try { - NameIdentifier entityIdent = MetadataObjectUtil.toEntityIdent(metalake, metadataObject); - EntityStore entityStore = GravitinoEnv.getInstance().entityStore(); - List owners = - entityStore - .relationOperations() - .listEntitiesByRelation( - SupportsRelationOperations.Type.OWNER_REL, - entityIdent, - Entity.EntityType.valueOf(metadataObject.type().name())); - if (owners.isEmpty()) { - ownerRel.put(metadataId, Optional.empty()); - } else { - for (Entity ownerEntity : owners) { - if (ownerEntity instanceof UserEntity) { - UserEntity user = (UserEntity) ownerEntity; - ownerRel.put( - metadataId, - Optional.of(new OwnerInfo(user.id(), Entity.EntityType.USER, user.name()))); - } else if (ownerEntity instanceof GroupEntity) { - GroupEntity group = (GroupEntity) ownerEntity; - ownerRel.put( - metadataId, - Optional.of(new OwnerInfo(group.id(), Entity.EntityType.GROUP, group.name()))); - } - } - } - } catch (IOException e) { - LOG.warn("Can not load metadata owner", e); - } - } - private void loadPolicyByRoleEntity(RoleEntity roleEntity) { String metalake = NameIdentifierUtil.getMetalake(roleEntity.nameIdentifier()); List securableObjects = roleEntity.securableObjects(); @@ -702,63 +717,49 @@ private void loadPolicyByRoleEntity(RoleEntity roleEntity) { } /** - * Checks whether the given principal is the owner of the metadata object identified by - * metadataId. Supports both user and group ownership. + * 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 checkOwnership(Principal principal, String metalake, Long metadataId) { - Optional ownerOpt = ownerRel.getIfPresent(metadataId); - if (ownerOpt == null || !ownerOpt.isPresent()) { + private boolean ownerMatchesUserOrGroups( + Optional owner, Principal principal, String metalake) { + if (!owner.isPresent()) { return false; } - OwnerInfo owner = ownerOpt.get(); - // We compare by entity ID rather than name to guard against stale cache entries. - // If a user/group is deleted and recreated with the same name, the cached OwnerInfo - // still holds the old ID. A name-only comparison would incorrectly grant ownership - // to the new entity. The extra IO to fetch the current entity ensures correctness. - if (owner.type == Entity.EntityType.USER) { + OwnerInfo ownerInfo = owner.get(); + if (Entity.EntityType.USER.name().equalsIgnoreCase(ownerInfo.getOwnerType())) { try { UserEntity userEntity = getUserEntity(principal.getName(), metalake); - return Objects.equals(userEntity.id(), owner.id); + return Objects.equals(userEntity.id(), ownerInfo.getOwnerId()); } catch (Exception e) { LOG.debug("Can not get user entity for ownership check", e); return false; } - } else if (owner.type == Entity.EntityType.GROUP) { - if (principal instanceof UserPrincipal) { - List groups = ((UserPrincipal) principal).getGroups(); - if (groups.isEmpty()) { - return false; - } - try { - List groupIdents = - groups.stream() - .map(g -> NameIdentifierUtil.ofGroup(metalake, g.getGroupname())) - .collect(Collectors.toList()); - List groupEntities = - GravitinoEnv.getInstance() - .entityStore() - .batchGet(groupIdents, Entity.EntityType.GROUP, GroupEntity.class); - return groupEntities.stream().anyMatch(ge -> Objects.equals(ge.id(), owner.id)); - } catch (Exception e) { - LOG.debug("Can not get group entities for ownership check", e); - return false; - } - } + } + if (!Entity.EntityType.GROUP.name().equalsIgnoreCase(ownerInfo.getOwnerType())) { return false; } - return false; - } - - /** Holds the owner identity for a metadata object in the owner cache. */ - static class OwnerInfo { - final Long id; - final Entity.EntityType type; - final String name; - - OwnerInfo(Long id, Entity.EntityType type, String name) { - this.id = id; - this.type = type; - this.name = name; + if (!(principal instanceof UserPrincipal)) { + return false; + } + List groups = ((UserPrincipal) principal).getGroups(); + if (groups.isEmpty()) { + return false; + } + try { + List groupIdents = + groups.stream() + .map(g -> NameIdentifierUtil.ofGroup(metalake, g.getGroupname())) + .collect(Collectors.toList()); + List 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 new file mode 100644 index 00000000000..7c7cd2ca3dc --- /dev/null +++ b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java @@ -0,0 +1,233 @@ +/* + * 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.google.common.annotations.VisibleForTesting; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.apache.gravitino.MetadataObject; +import org.apache.gravitino.MetadataObjects; +import org.apache.gravitino.cache.GravitinoCache; +import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper; +import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper; +import org.apache.gravitino.storage.relational.po.auth.ChangedOwnerInfo; +import org.apache.gravitino.storage.relational.po.auth.OwnerInfo; +import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord; +import org.apache.gravitino.storage.relational.utils.SessionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Eventual-consistency invalidator for {@link JcasbinAuthorizer}'s {@code metadataIdCache} and + * {@code ownerRelCache}. + * + *

One scheduled thread 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. + * + *

Both polls run on every tick — a failure in one does not stop the other. + */ +public class JcasbinChangePoller implements AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(JcasbinChangePoller.class); + + /** Max rows to fetch per poller cycle. */ + private static final int POLLER_MAX_ROWS = 500; + + private final GravitinoCache metadataIdCache; + private final GravitinoCache> ownerRelCache; + private final long pollIntervalSecs; + + private ScheduledExecutorService scheduler; + private volatile long ownerPollHighWaterId = 0; + private volatile long entityPollHighWaterId = 0; + + /** + * @param metadataIdCache the metadata-id cache to invalidate on entity changes + * @param ownerRelCache the owner cache to invalidate on owner changes + * @param pollIntervalSecs interval between successive polling cycles + */ + public JcasbinChangePoller( + GravitinoCache metadataIdCache, + GravitinoCache> ownerRelCache, + long pollIntervalSecs) { + this.metadataIdCache = metadataIdCache; + this.ownerRelCache = ownerRelCache; + this.pollIntervalSecs = pollIntervalSecs; + } + + /** + * Initializes the high-water cursors to the current DB tail (so startup does not scan historical + * changes) and schedules periodic polling. + * + *

Known trade-off: an id-based high-water mark can miss rows whose id is allocated before the + * cursor snapshot but whose commit lands after it. Concretely, if writer A holds {@code id=N-1} + * uncommitted while writer B commits {@code id=N}, {@code selectMaxChangeId()} returns N and the + * next poll queries {@code id > N} — A's row is never consumed. In that case the affected cache + * entry stays stale until either (a) a request-side path catches it on the next request, or (b) + * TTL eviction. Acceptable for the eventual-consistency caches targeted here; revisit if we ever + * route strong-consistency data through this poller. + */ + public void start() { + ownerPollHighWaterId = + nullToZero( + SessionUtils.getWithoutCommit( + OwnerMetaMapper.class, OwnerMetaMapper::selectMaxChangeId)); + entityPollHighWaterId = + nullToZero( + SessionUtils.getWithoutCommit( + EntityChangeLogMapper.class, EntityChangeLogMapper::selectMaxChangeId)); + + scheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread t = new Thread(r); + t.setName("GravitinoAuthorizer-ChangePoller"); + t.setDaemon(true); + return t; + }); + scheduler.scheduleWithFixedDelay( + this::pollChanges, pollIntervalSecs, pollIntervalSecs, TimeUnit.SECONDS); + } + + @VisibleForTesting + void pollChanges() { + try { + LOG.debug("Polling for owner changes after id {}", ownerPollHighWaterId); + pollOwnerChanges(); + } catch (Exception e) { + LOG.warn("Owner change poll failed", e); + } + + try { + LOG.debug("Polling for entity changes after id {}", entityPollHighWaterId); + pollEntityChanges(); + } catch (Exception e) { + LOG.warn("Entity change poll failed", e); + } + } + + /** + * Drains owner-change rows past {@link #ownerPollHighWaterId} and invalidates the affected {@code + * ownerRelCache} entries. Each row carries {@code metadataObjectId}, so invalidation is a direct + * key removal — no name resolution needed. + */ + private void pollOwnerChanges() { + List changes = + SessionUtils.getWithoutCommit( + OwnerMetaMapper.class, m -> m.selectChangedOwners(ownerPollHighWaterId)); + + long maxSeenId = ownerPollHighWaterId; + for (ChangedOwnerInfo change : changes) { + ownerRelCache.invalidate(change.getMetadataObjectId()); + if (change.getId() > maxSeenId) { + maxSeenId = change.getId(); + } + } + ownerPollHighWaterId = maxSeenId; + } + + /** + * Drains entity-change rows past {@link #entityPollHighWaterId} and invalidates the affected + * {@code metadataIdCache} keys. + * + *

Contract with the writer side: {@code entity_change_log.full_name} must be the + * pre-mutation name (the name that consumers currently have cached). The writers in {@code + * SchemaMetaService} / {@code TableMetaService} / etc. emit {@code oldFullName} on rename and the + * current name on drop, so the cacheKey we build here resolves to the entry a peer node would + * have populated under that name. If a future change starts emitting the new post-rename name, + * this invalidation will silently miss and stale entries will only clear via LRU eviction. + */ + private void pollEntityChanges() { + List changes = + SessionUtils.getWithoutCommit( + EntityChangeLogMapper.class, + m -> m.selectEntityChanges(entityPollHighWaterId, POLLER_MAX_ROWS)); + + long maxSeenId = entityPollHighWaterId; + for (EntityChangeRecord change : changes) { + String metalake = change.getMetalakeName(); + String entityType = change.getEntityType(); + String fullName = change.getFullName(); + + MetadataObject.Type mdType; + try { + mdType = MetadataObject.Type.valueOf(entityType.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + LOG.warn("Unknown entity type in change log: {}", entityType); + if (change.getId() > maxSeenId) { + maxSeenId = change.getId(); + } + continue; + } + + MetadataObject mdObj = metadataObjectFromChangeLog(metalake, fullName, mdType); + String cacheKey = JcasbinAuthorizationLookups.buildCacheKey(metalake, mdObj); + + if (JcasbinAuthorizationLookups.isNonLeaf(mdType)) { + metadataIdCache.invalidateByPrefix(cacheKey); + } else { + metadataIdCache.invalidate(cacheKey); + } + + if (change.getId() > maxSeenId) { + maxSeenId = change.getId(); + } + } + entityPollHighWaterId = maxSeenId; + } + + @Override + public void close() { + if (scheduler != null) { + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException e) { + scheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } + + @VisibleForTesting + static MetadataObject metadataObjectFromChangeLog( + String metalake, String fullName, MetadataObject.Type type) { + List names = new ArrayList<>(Arrays.asList(fullName.split("\\."))); + if (type != MetadataObject.Type.METALAKE + && !names.isEmpty() + && Objects.equals(names.get(0), metalake)) { + names.remove(0); + } + return MetadataObjects.of(names, type); + } + + private static long nullToZero(Long value) { + return value == null ? 0L : value; + } +} 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 new file mode 100644 index 00000000000..aac6838fb69 --- /dev/null +++ b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java @@ -0,0 +1,111 @@ +/* + * 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 JcasbinAuthorizationLookups} static helpers. */ +public class TestJcasbinAuthorizationLookups { + + // ---------- buildCacheKey ---------- + + @Test + void testBuildCacheKeyMetalake() { + MetadataObject obj = + MetadataObjects.of(Collections.singletonList("ml1"), MetadataObject.Type.METALAKE); + Assertions.assertEquals("ml1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", obj)); + } + + @Test + void testBuildCacheKeyCatalog() { + MetadataObject obj = + MetadataObjects.of(Collections.singletonList("cat1"), MetadataObject.Type.CATALOG); + Assertions.assertEquals("ml1::cat1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", obj)); + } + + @Test + void testBuildCacheKeySchema() { + MetadataObject obj = + MetadataObjects.of(Arrays.asList("cat1", "sch1"), MetadataObject.Type.SCHEMA); + Assertions.assertEquals( + "ml1::cat1::sch1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", obj)); + } + + @Test + void testBuildCacheKeyLeafTypesGetTypeSuffix() { + MetadataObject table = + MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"), MetadataObject.Type.TABLE); + Assertions.assertEquals( + "ml1::cat1::sch1::tbl1::TABLE", JcasbinAuthorizationLookups.buildCacheKey("ml1", table)); + + MetadataObject view = + MetadataObjects.of(Arrays.asList("cat1", "sch1", "v1"), MetadataObject.Type.VIEW); + Assertions.assertEquals( + "ml1::cat1::sch1::v1::VIEW", JcasbinAuthorizationLookups.buildCacheKey("ml1", view)); + + MetadataObject fileset = + MetadataObjects.of(Arrays.asList("cat1", "sch1", "fs1"), MetadataObject.Type.FILESET); + Assertions.assertEquals( + "ml1::cat1::sch1::fs1::FILESET", JcasbinAuthorizationLookups.buildCacheKey("ml1", fileset)); + } + + // ---------- isNonLeaf ---------- + + @Test + void testIsNonLeafContainerTypes() { + Assertions.assertTrue(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.METALAKE)); + Assertions.assertTrue(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.CATALOG)); + Assertions.assertTrue(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.SCHEMA)); + } + + @Test + void testIsNonLeafLeafTypes() { + Assertions.assertFalse(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.TABLE)); + Assertions.assertFalse(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.VIEW)); + Assertions.assertFalse(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.FILESET)); + Assertions.assertFalse(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.TOPIC)); + } + + // ---------- Cascade prefix hierarchy ---------- + + @Test + void testCascadeInvalidationKeyHierarchy() { + // 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)); + } +} 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 afa48efc987..31413a0a420 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 @@ -25,7 +25,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; @@ -37,7 +36,6 @@ import java.io.IOException; import java.lang.reflect.Field; import java.security.Principal; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.Executor; @@ -56,6 +54,7 @@ import org.apache.gravitino.authorization.AuthorizationRequestContext; import org.apache.gravitino.authorization.Privilege; import org.apache.gravitino.authorization.SecurableObject; +import org.apache.gravitino.cache.GravitinoCache; import org.apache.gravitino.meta.AuditInfo; import org.apache.gravitino.meta.BaseMetalake; import org.apache.gravitino.meta.GroupEntity; @@ -64,10 +63,12 @@ import org.apache.gravitino.meta.UserEntity; import org.apache.gravitino.server.ServerConfig; import org.apache.gravitino.server.authorization.MetadataIdConverter; -import org.apache.gravitino.server.authorization.jcasbin.JcasbinAuthorizer.OwnerInfo; +import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper; import org.apache.gravitino.storage.relational.po.SecurableObjectPO; +import org.apache.gravitino.storage.relational.po.auth.OwnerInfo; import org.apache.gravitino.storage.relational.service.OwnerMetaService; import org.apache.gravitino.storage.relational.utils.POConverters; +import org.apache.gravitino.storage.relational.utils.SessionUtils; import org.apache.gravitino.utils.NameIdentifierUtil; import org.apache.gravitino.utils.NamespaceUtil; import org.apache.gravitino.utils.PrincipalUtils; @@ -114,6 +115,10 @@ public class TestJcasbinAuthorizer { private static MockedStatic ownerMetaServiceMockedStatic; + private static MockedStatic sessionUtilsMockedStatic; + + private static OwnerMetaMapper ownerMetaMapper = mock(OwnerMetaMapper.class); + private static JcasbinAuthorizer jcasbinAuthorizer; private static ObjectMapper objectMapper = new ObjectMapper(); @@ -123,6 +128,23 @@ public static void setup() throws IOException { OwnerMetaService ownerMetaService = mock(OwnerMetaService.class); ownerMetaServiceMockedStatic = mockStatic(OwnerMetaService.class); ownerMetaServiceMockedStatic.when(OwnerMetaService::getInstance).thenReturn(ownerMetaService); + + // The change poller probes entity_change_log + owner_meta on startup and owner lookups go via + // SessionUtils; mock SessionUtils to delegate to the mock OwnerMetaMapper so tests can stub + // owner state without opening a real MyBatis session. Other mappers default to null. + sessionUtilsMockedStatic = mockStatic(SessionUtils.class); + sessionUtilsMockedStatic + .when(() -> SessionUtils.getWithoutCommit(any(), any())) + .thenAnswer( + invocation -> { + Class mapperClass = invocation.getArgument(0); + java.util.function.Function func = invocation.getArgument(1); + if (mapperClass == OwnerMetaMapper.class) { + return func.apply(ownerMetaMapper); + } + return null; + }); + gravitinoEnvMockedStatic = mockStatic(GravitinoEnv.class); gravitinoEnvMockedStatic.when(GravitinoEnv::getInstance).thenReturn(gravitinoEnv); when(gravitinoEnv.config()).thenReturn(new ServerConfig()); @@ -170,6 +192,9 @@ public static void stop() { if (ownerMetaServiceMockedStatic != null) { ownerMetaServiceMockedStatic.close(); } + if (sessionUtilsMockedStatic != null) { + sessionUtilsMockedStatic.close(); + } if (gravitinoEnvMockedStatic != null) { gravitinoEnvMockedStatic.close(); } @@ -258,23 +283,23 @@ public void testAuthorize() throws Exception { @Test public void testAuthorizeByOwner() throws Exception { Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal(); - assertFalse(doAuthorizeOwner(currentPrincipal)); NameIdentifier catalogIdent = NameIdentifierUtil.ofCatalog(METALAKE, "testCatalog"); - List owners = ImmutableList.of(getUserEntity()); - doReturn(owners) - .when(supportsRelationOperations) - .listEntitiesByRelation( - eq(SupportsRelationOperations.Type.OWNER_REL), - eq(catalogIdent), - eq(Entity.EntityType.CATALOG)); + + // No owner set — should fail + when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID), eq("CATALOG"))) + .thenReturn(null); + getOwnerRelCache(jcasbinAuthorizer).invalidateAll(); + assertFalse(doAuthorizeOwner(currentPrincipal)); + + // Set owner to current user + when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID), eq("CATALOG"))) + .thenReturn(new OwnerInfo(USER_ID, "USER")); getOwnerRelCache(jcasbinAuthorizer).invalidateAll(); assertTrue(doAuthorizeOwner(currentPrincipal)); - doReturn(new ArrayList<>()) - .when(supportsRelationOperations) - .listEntitiesByRelation( - eq(SupportsRelationOperations.Type.OWNER_REL), - eq(catalogIdent), - eq(Entity.EntityType.CATALOG)); + + // Remove owner via change hook + when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID), eq("CATALOG"))) + .thenReturn(null); jcasbinAuthorizer.handleMetadataOwnerChange( METALAKE, USER_ID, catalogIdent, Entity.EntityType.CATALOG); assertFalse(doAuthorizeOwner(currentPrincipal)); @@ -311,26 +336,18 @@ public void testAuthorizeByGroupOwner() throws Exception { .withAuditInfo(AuditInfo.EMPTY) .build())); - // Mock owner relation returning a GroupEntity - List owners = ImmutableList.of(getGroupEntity()); - doReturn(owners) - .when(supportsRelationOperations) - .listEntitiesByRelation( - eq(SupportsRelationOperations.Type.OWNER_REL), - eq(catalogIdent), - eq(Entity.EntityType.CATALOG)); + // Mock owner_meta returning a GROUP-typed owner with GROUP_ID + OwnerInfo groupOwnerInfo = new OwnerInfo(GROUP_ID, "GROUP"); + when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID), eq("CATALOG"))) + .thenReturn(groupOwnerInfo); getOwnerRelCache(jcasbinAuthorizer).invalidateAll(); // The principal belongs to the owning group, so isOwner should return true assertTrue(doAuthorizeOwner(groupPrincipal)); // Clear owner and verify it returns false - doReturn(new ArrayList<>()) - .when(supportsRelationOperations) - .listEntitiesByRelation( - eq(SupportsRelationOperations.Type.OWNER_REL), - eq(catalogIdent), - eq(Entity.EntityType.CATALOG)); + when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID), eq("CATALOG"))) + .thenReturn(null); jcasbinAuthorizer.handleMetadataOwnerChange( METALAKE, GROUP_ID, catalogIdent, Entity.EntityType.CATALOG); assertFalse(doAuthorizeOwner(groupPrincipal)); @@ -342,13 +359,8 @@ public void testAuthorizeByGroupOwner() throws Exception { principalUtilsMockedStatic .when(PrincipalUtils::getCurrentPrincipal) .thenReturn(nonMemberPrincipal); - // Re-populate the owner cache with the group owner - doReturn(ImmutableList.of(getGroupEntity())) - .when(supportsRelationOperations) - .listEntitiesByRelation( - eq(SupportsRelationOperations.Type.OWNER_REL), - eq(catalogIdent), - eq(Entity.EntityType.CATALOG)); + when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID), eq("CATALOG"))) + .thenReturn(groupOwnerInfo); getOwnerRelCache(jcasbinAuthorizer).invalidateAll(); assertFalse(doAuthorizeOwner(nonMemberPrincipal)); @@ -984,14 +996,13 @@ public void testRoleCacheInvalidation() throws Exception { @Test public void testOwnerCacheInvalidation() throws Exception { - // Get the ownerRel cache via reflection - Cache> ownerRel = getOwnerRelCache(jcasbinAuthorizer); + GravitinoCache> ownerRelCache = getOwnerRelCache(jcasbinAuthorizer); // Manually add an owner relation to the cache - ownerRel.put(CATALOG_ID, Optional.of(new OwnerInfo(USER_ID, Entity.EntityType.USER, USERNAME))); + ownerRelCache.put(CATALOG_ID, Optional.of(new OwnerInfo(USER_ID, "USER"))); // Verify it's in the cache - assertNotNull(ownerRel.getIfPresent(CATALOG_ID)); + assertTrue(ownerRelCache.getIfPresent(CATALOG_ID).isPresent()); // Create a mock NameIdentifier for the metadata object NameIdentifier catalogIdent = NameIdentifierUtil.ofCatalog(METALAKE, "testCatalog"); @@ -1001,7 +1012,7 @@ public void testOwnerCacheInvalidation() throws Exception { METALAKE, USER_ID, catalogIdent, Entity.EntityType.CATALOG); // Verify it's removed from the cache - assertNull(ownerRel.getIfPresent(CATALOG_ID)); + assertFalse(ownerRelCache.getIfPresent(CATALOG_ID).isPresent()); } @Test @@ -1043,10 +1054,10 @@ public void testRoleCacheSynchronousRemovalListenerDeletesPolicy() throws Except public void testCacheInitialization() throws Exception { // Verify that caches are initialized Cache loadedRoles = getLoadedRolesCache(jcasbinAuthorizer); - Cache> ownerRel = getOwnerRelCache(jcasbinAuthorizer); + GravitinoCache> ownerRelCache = getOwnerRelCache(jcasbinAuthorizer); assertNotNull(loadedRoles, "loadedRoles cache should be initialized"); - assertNotNull(ownerRel, "ownerRel cache should be initialized"); + assertNotNull(ownerRelCache, "ownerRelCache should be initialized"); } /** Tests {@link JcasbinAuthorizer#hasMetadataPrivilegePermission} hierarchy walk */ @@ -1199,11 +1210,11 @@ private static Cache getLoadedRolesCache(JcasbinAuthorizer author } @SuppressWarnings("unchecked") - private static Cache> getOwnerRelCache(JcasbinAuthorizer authorizer) - throws Exception { - Field field = JcasbinAuthorizer.class.getDeclaredField("ownerRel"); + private static GravitinoCache> getOwnerRelCache( + JcasbinAuthorizer authorizer) throws Exception { + Field field = JcasbinAuthorizer.class.getDeclaredField("ownerRelCache"); field.setAccessible(true); - return (Cache>) field.get(authorizer); + return (GravitinoCache>) field.get(authorizer); } private static Enforcer getAllowEnforcer(JcasbinAuthorizer authorizer) throws Exception { 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 new file mode 100644 index 00000000000..edcbe33a0bb --- /dev/null +++ b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java @@ -0,0 +1,55 @@ +/* + * 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 org.apache.gravitino.MetadataObject; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Tests for {@link JcasbinChangePoller} static helpers. */ +public class TestJcasbinChangePoller { + + @Test + void testChangeLogFullNameStripsLeadingMetalakeForChildTypes() { + MetadataObject catalog = + JcasbinChangePoller.metadataObjectFromChangeLog( + "ml1", "ml1.cat1", MetadataObject.Type.CATALOG); + Assertions.assertEquals( + "ml1::cat1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", catalog)); + + MetadataObject schema = + JcasbinChangePoller.metadataObjectFromChangeLog( + "ml1", "ml1.cat1.sch1", MetadataObject.Type.SCHEMA); + Assertions.assertEquals( + "ml1::cat1::sch1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", schema)); + + MetadataObject table = + JcasbinChangePoller.metadataObjectFromChangeLog( + "ml1", "ml1.cat1.sch1.tbl1", MetadataObject.Type.TABLE); + Assertions.assertEquals( + "ml1::cat1::sch1::tbl1::TABLE", JcasbinAuthorizationLookups.buildCacheKey("ml1", table)); + } + + @Test + void testChangeLogFullNameForMetalakeKeepsItself() { + MetadataObject metalake = + JcasbinChangePoller.metadataObjectFromChangeLog("ml1", "ml1", MetadataObject.Type.METALAKE); + Assertions.assertEquals("ml1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", metalake)); + } +} From 62ae012e165abb160df71710d4a370d5ba7ee054 Mon Sep 17 00:00:00 2001 From: yuqi Date: Fri, 15 May 2026 17:29:53 +0800 Subject: [PATCH 02/23] [#10772] feat(authz): Version-validated role caching for JcasbinAuthorizer Builds on top of #11117 (eventual-consistency invalidation): adds the strong-consistency role-loading layer. With this PR landed, every cache in JcasbinAuthorizer has an explicit consistency story. What lands here --------------- - `CachedUserRoles` / `CachedGroupRoles`: small immutable snapshots carrying the {role ids, source updated_at} pair used as the staleness sentinel. - `JcasbinLoadedRolesCache`: extracts the previously-inline `LoadedRolesCache` to its own file. Wraps a Caffeine cache with a synchronous removal listener so that `loadedRoles` eviction always flushes the role's JCasbin policies from both enforcers. - `JcasbinAuthorizer` now: - keeps `userRoleCache` and `groupRoleCache` version-validated against `user_meta.updated_at` / `group_meta.updated_at` (probes per request via `loadUserInfo` / `loadGroupInfo`, with per-request dedup through `AuthorizationRequestContext`); - changes `loadedRoles` from `` to `` (storing `role_meta.updated_at`) so role-policy reloads happen on DB-version change, not TTL expiry; - replaces the `Executor` + `CompletableFuture` async role-load path with a synchronous 4-step `loadRolePrivilege`: 1. version-validated user-direct roles 2. version-validated group-inherited roles for every group in the current `UserPrincipal` 3. prune stale g-rows (IdP group removal / role unassignment) 4. batch `role_meta` version check + reload of stale policies - reuses `loadUserInfo`'s per-request cache from `isMetalakeUser` / `isOwner` so a single HTTP request never re-queries `user_meta`. - `Configs.GRAVITINO_AUTHORIZATION_ROLE_CACHE_SIZE` doc now notes that the same value sizes user-role, group-role and loaded-role caches. - Lombok wired in `server-common/build.gradle.kts` for the cached snapshot POJOs. Test plan --------- `./gradlew :server-common:test --tests 'org.apache.gravitino.server.authorization.*' -PskipITs` `./gradlew :server-common:javadoc :core:javadoc` Tests cover: version-validated user-role cache, group-inherited role, stale group skipped, group-role revocation, multi-group partial revocation, IdP group removal pruning g-rows, deny-wins over group-inherited allow, role-shared-by-user-and-group survives single-side revocation, plus the existing owner, isSelf, and hasMetadataPrivilegePermission flows. Co-Authored-By: Claude Opus 4.7 --- .../java/org/apache/gravitino/Configs.java | 5 +- server-common/build.gradle.kts | 6 + .../jcasbin/CachedGroupRoles.java | 37 ++ .../jcasbin/CachedUserRoles.java | 37 ++ .../jcasbin/JcasbinAuthorizer.java | 616 ++++++++++-------- .../jcasbin/JcasbinLoadedRolesCache.java | 93 +++ .../jcasbin/TestJcasbinAuthorizer.java | 497 ++++++++------ .../TestJcasbinAuthorizerCacheHelpers.java | 46 ++ 8 files changed, 866 insertions(+), 471 deletions(-) create mode 100644 server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoles.java create mode 100644 server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoles.java create mode 100644 server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java create mode 100644 server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizerCacheHelpers.java diff --git a/core/src/main/java/org/apache/gravitino/Configs.java b/core/src/main/java/org/apache/gravitino/Configs.java index bdb61abc2c6..7828e1aec49 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 @@ private Configs() {} public static final ConfigEntry 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/server-common/build.gradle.kts b/server-common/build.gradle.kts index fdcdf2f9310..1bfb08efb04 100644 --- a/server-common/build.gradle.kts +++ b/server-common/build.gradle.kts @@ -25,6 +25,9 @@ plugins { } dependencies { + annotationProcessor(libs.lombok) + compileOnly(libs.lombok) + implementation(project(":api")) implementation(project(":catalogs:catalog-common")) implementation(project(":common")) { @@ -55,6 +58,9 @@ dependencies { implementation(libs.prometheus.servlet) implementation(libs.nimbus.jose.jwt) + testAnnotationProcessor(libs.lombok) + testCompileOnly(libs.lombok) + testImplementation(libs.commons.io) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.params) diff --git a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoles.java b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoles.java new file mode 100644 index 00000000000..bf4956c43a8 --- /dev/null +++ b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoles.java @@ -0,0 +1,37 @@ +/* + * 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; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Cached snapshot of a group's role assignments. 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 list is stale and must be reloaded. + */ +@Getter +@AllArgsConstructor +public class CachedGroupRoles { + + private final long groupId; + private final long updatedAt; + private final List roleIds; +} diff --git a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoles.java b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoles.java new file mode 100644 index 00000000000..871604e20ec --- /dev/null +++ b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoles.java @@ -0,0 +1,37 @@ +/* + * 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; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Cached snapshot of a user's direct role assignments. 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 list is stale and must be reloaded. + */ +@Getter +@AllArgsConstructor +public class CachedUserRoles { + + private final long userId; + private final long updatedAt; + private final List roleIds; +} 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 0a60ab901d8..4faf9de8464 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,14 +27,10 @@ import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Locale; 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; import org.apache.commons.lang3.StringUtils; @@ -58,12 +52,18 @@ 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.NameIdentifierUtil; import org.apache.gravitino.utils.PrincipalUtils; import org.casbin.jcasbin.main.Enforcer; @@ -72,11 +72,46 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** The Jcasbin implementation of GravitinoAuthorizer. */ +/** + * The Jcasbin implementation of {@link GravitinoAuthorizer}. + * + *

Cache architecture

+ * + *

Authorization decisions are read-mostly and run on the hot path, so this class layers three + * cache families with different consistency models: + * + *

    + *
  1. Per-request dedup — 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. + *
  2. Version-validated shared caches (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 — {@code expireAfterAccess} only bounds memory. + *
  3. Eventual-consistency caches — {@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. + *
+ * + *

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). + * + *

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); + /** Key separator for hierarchical cache keys. */ + static final String KEY_SEP = "::"; + /** Jcasbin enforcer is used for metadata authorization. */ private Enforcer allowEnforcer; @@ -89,26 +124,42 @@ public class JcasbinAuthorizer implements GravitinoAuthorizer { /** deny internal authorizer */ private InternalAuthorizer denyInternalAuthorizer; + // ---- Version-validated caches (strong consistency) ---- + + /** + * userRoleCache: metalake::userName -> CachedUserRoles. Version-validated per request via + * user_meta.updated_at. + */ + private GravitinoCache 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: metalake::groupName -> CachedGroupRoles. Version-validated per request via + * group_meta.updated_at. */ - private Cache loadedRoles; + private GravitinoCache groupRoleCache; + + /** + * loadedRoles: roleId -> updated_at. If the DB updated_at is newer, evict and reload policies. + */ + private GravitinoCache loadedRoles; + + // ---- Eventual consistency caches (poller-driven) ---- - /** Hierarchical {@code metalake::catalog::schema::object::TYPE} → entity id. */ + /** + * metadataIdCache: hierarchical key (metalake::catalog::schema::table::TYPE) -> entity id. + * Evicted by entity change poller. + */ private GravitinoCache metadataIdCache; - /** {@code metadataObjectId} → {@link Optional} of {@link OwnerInfo}. */ + /** ownerRelCache: metadataObjectId -> Optional(owner). Evicted by owner change poller. */ private GravitinoCache> 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 = @@ -130,40 +181,23 @@ public void initialize() { long ttlMs = cacheExpirationSecs * 1000L; - // 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); metadataIdCache = new CaffeineGravitinoCache<>(ttlMs, metadataIdCacheSize); ownerRelCache = new CaffeineGravitinoCache<>(ttlMs, ownerCacheSize); 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) { @@ -178,6 +212,10 @@ private Model getModel(String modelFilePath) { return model; } + // --------------------------------------------------------------------------- + // Authorize / deny / isOwner + // --------------------------------------------------------------------------- + @Override public boolean authorize( Principal principal, @@ -249,9 +287,15 @@ public boolean isOwner( boolean result; try { Long metadataId = lookups.resolveMetadataId(metadataObject, metalake, requestContext); - Optional owner = - lookups.resolveOwnerId(metadataId, metadataObject.type(), requestContext); - result = ownerMatchesUserOrGroups(owner, principal, metalake); + Optional userInfoOpt = + loadUserInfo(metalake, principal.getName(), requestContext); + if (!userInfoOpt.isPresent()) { + result = false; + } else { + Optional owner = + lookups.resolveOwnerId(metadataId, metadataObject.type(), requestContext); + result = ownerMatchesUserOrGroups(owner, userInfoOpt.get().getUserId(), metalake); + } } catch (Exception e) { LOG.debug("Can not get entity id", e); result = false; @@ -280,14 +324,10 @@ public boolean isMetalakeUser(String metalake, AuthorizationRequestContext reque 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 @@ -342,14 +382,13 @@ public boolean hasSetOwnerPermission( 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, @@ -371,7 +410,6 @@ public boolean hasSetOwnerPermission( || 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, @@ -398,7 +436,6 @@ public boolean hasSetOwnerPermission( } return true; } - // metadata parent owner can set owner. } while ((metadataObject = MetadataObjects.parent(metadataObject)) != null); return false; } @@ -407,17 +444,12 @@ public boolean hasSetOwnerPermission( 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 chain = new ArrayList<>(); for (MetadataObject obj = MetadataObjects.parse(fullName, metadataType); obj != null; @@ -435,6 +467,10 @@ public boolean hasMetadataPrivilegePermission( return hasSetOwnerPermission(metalake, type, fullName, requestContext); } + // --------------------------------------------------------------------------- + // Cache invalidation hooks (called from service layer) + // --------------------------------------------------------------------------- + @Override public void handleRolePrivilegeChange(Long roleId) { loadedRoles.invalidate(roleId); @@ -469,11 +505,14 @@ public void close() throws IOException { if (changePoller != null) { changePoller.close(); } - if (executor != null) { - if (executor instanceof ThreadPoolExecutor) { - ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor; - threadPoolExecutor.shutdown(); - } + if (userRoleCache != null) { + userRoleCache.close(); + } + if (groupRoleCache != null) { + groupRoleCache.close(); + } + if (loadedRoles != null) { + loadedRoles.close(); } if (metadataIdCache != null) { metadataIdCache.close(); @@ -483,6 +522,10 @@ public void close() throws IOException { } } + // --------------------------------------------------------------------------- + // Internal authorizer + // --------------------------------------------------------------------------- + private class InternalAuthorizer { Enforcer enforcer; @@ -497,42 +540,37 @@ private boolean authorizeInternal( MetadataObject metadataObject, String privilege, AuthorizationRequestContext requestContext) { - return loadPrivilegeAndAuthorize( - username, metalake, metadataObject, privilege, requestContext); - } - - private boolean loadPrivilegeAndAuthorize( - String username, - String metalake, - MetadataObject metadataObject, - String privilege, - AuthorizationRequestContext requestContext) { Long metadataId; - 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 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(); + + // Step 2: resolve metadata name → id via metadataIdCache (cache hit when warm) metadataId = lookups.resolveMetadataId(metadataObject, metalake, requestContext); } catch (Exception e) { LOG.debug("Can not get entity id", e); return false; } - loadRolePrivilege(metalake, username, userId, requestContext); - return authorizeByJcasbin( - userId, metalake, metadataObject, metadataId, privilege, requestContext); - } - private boolean authorizeByJcasbin( - Long userId, - String metalake, - MetadataObject metadataObject, - Long metadataId, - String privilege, - AuthorizationRequestContext requestContext) { + // Steps 1b→3: version-validated role loading — pass userInfo to avoid re-query + loadRolePrivilege(metalake, username, userId, userInfo, requestContext); + + // Step 4: JCasbin enforce (pure in-memory) 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 owner = lookups.resolveOwnerId(metadataId, metadataObject.type(), requestContext); - return ownerMatchesUserOrGroups(owner, PrincipalUtils.getCurrentPrincipal(), metalake); + return ownerMatchesUserOrGroups(owner, userId, metalake); } return enforcer.enforce( String.valueOf(userId), @@ -542,86 +580,193 @@ private boolean authorizeByJcasbin( } } - 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 loadUserInfo( + String metalake, String username, AuthorizationRequestContext requestContext) { + String cacheKey = metalake + KEY_SEP + 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 current user or one of the user's + * groups. + */ + private boolean ownerMatchesUserOrGroups( + Optional owner, long userId, String metalake) { + if (!owner.isPresent()) { + return false; + } + OwnerInfo ownerInfo = owner.get(); + if (Entity.EntityType.USER.name().equalsIgnoreCase(ownerInfo.getOwnerType())) { + return ownerInfo.getOwnerId() == userId; + } + 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 entities; - try { - entities = - entityStore - .relationOperations() - .listEntitiesByRelation( - SupportsRelationOperations.Type.ROLE_USER_REL, - userNameIdentifier, - Entity.EntityType.USER); - List> loadRoleFutures = new ArrayList<>(); - Set desiredRoleIds = new HashSet<>(); - for (RoleEntity role : entities) { - desiredRoleIds.add(String.valueOf(role.id())); - addRoleForUserAndLoadPolicies( - userId, metalake, role.id(), role.name(), loadRoleFutures, entityStore); - } + // Step 1a: version-validated user-direct roles via cache. + List userDirectRoleIds = loadUserRoles(metalake, username, userId, userInfo); + + // 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 groupInheritedRoleIds = new ArrayList<>(); + for (String groupname : currentPrincipalGroupNames()) { + groupInheritedRoleIds.addAll( + loadGroupRoles(metalake, groupname, userId, requestContext)); + } - // Load roles inherited from the user's groups. - for (GroupEntity groupEntity : resolveCurrentUserGroups(metalake, entityStore)) { - List roleIds = groupEntity.roleIds(); - List 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); - } + // 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 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); } + } - CompletableFuture.allOf(loadRoleFutures.toArray(new CompletableFuture[0])).join(); - - // 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); - } - } - } 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 allRoleIds = new ArrayList<>(userDirectRoleIds); + allRoleIds.addAll(groupInheritedRoleIds); + if (!allRoleIds.isEmpty()) { + versionCheckAndLoadRoles(metalake, allRoleIds, requestContext); } }); } + private List loadUserRoles( + String metalake, String username, long userId, UserUpdatedAt userInfo) { + String userCacheKey = metalake + KEY_SEP + username; + Optional cachedOpt = userRoleCache.getIfPresent(userCacheKey); + + if (cachedOpt.isPresent() && cachedOpt.get().getUpdatedAt() >= userInfo.getUpdatedAt()) { + // Cache is still valid + CachedUserRoles cached = cachedOpt.get(); + bindUserRoles(userId, cached.getRoleIds()); + return cached.getRoleIds(); + } + + // Cache miss or stale — reload from DB + List rolePOs = + SessionUtils.getWithoutCommit(RoleMetaMapper.class, m -> m.listRolesByUserId(userId)); + List roleIds = rolePOs.stream().map(RolePO::getRoleId).collect(Collectors.toList()); + + userRoleCache.put(userCacheKey, new CachedUserRoles(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 loadGroupInfo( + String metalake, String groupname, AuthorizationRequestContext requestContext) { + String cacheKey = metalake + KEY_SEP + 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}. Uses {@code + * group_meta.updated_at} as the staleness sentinel: if the cached snapshot is at least as fresh + * as the DB version, we reuse it; otherwise we reload from {@code role_meta}. 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 loadGroupRoles( + String metalake, String groupname, long userId, AuthorizationRequestContext requestContext) { + Optional groupInfoOpt = loadGroupInfo(metalake, groupname, requestContext); + if (!groupInfoOpt.isPresent()) { + return new ArrayList<>(); + } + GroupUpdatedAt groupInfo = groupInfoOpt.get(); + long groupId = groupInfo.getGroupId(); + String groupCacheKey = metalake + KEY_SEP + groupname; + Optional cachedOpt = groupRoleCache.getIfPresent(groupCacheKey); + + if (cachedOpt.isPresent() && cachedOpt.get().getUpdatedAt() >= groupInfo.getUpdatedAt()) { + CachedGroupRoles cached = cachedOpt.get(); + bindUserRoles(userId, cached.getRoleIds()); + return cached.getRoleIds(); + } + + List rolePOs = + SessionUtils.getWithoutCommit(RoleMetaMapper.class, m -> m.listRolesByGroupId(groupId)); + List roleIds = rolePOs.stream().map(RolePO::getRoleId).collect(Collectors.toList()); + + groupRoleCache.put( + groupCacheKey, new CachedGroupRoles(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 currentPrincipalGroupNames() { + Principal principal = PrincipalUtils.getCurrentPrincipal(); + if (!(principal instanceof UserPrincipal)) { + return new ArrayList<>(); + } + List 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 both {@link #isSelf} (ROLE branch) and {@link + * #loadRolePrivilege} to discover group-inherited role assignments. */ private List resolveCurrentUserGroups(String metalake, EntityStore entityStore) { Principal principal = PrincipalUtils.getCurrentPrincipal(); @@ -639,127 +784,88 @@ private List resolveCurrentUserGroups(String metalake, EntityStore 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> loadRoleFutures, - EntityStore entityStore) { - allowEnforcer.addRoleForUser(String.valueOf(userId), String.valueOf(roleId)); - denyEnforcer.addRoleForUser(String.valueOf(userId), String.valueOf(roleId)); - if (loadedRoles.getIfPresent(roleId) != null) { - return; + private void versionCheckAndLoadRoles( + String metalake, List roleIds, AuthorizationRequestContext requestContext) { + // Step 3: batch fetch (roleId, roleName, updated_at) for all role IDs — 1 query + List uniqueRoleIds = roleIds.stream().distinct().collect(Collectors.toList()); + List roleVersions = + SessionUtils.getWithoutCommit( + RoleMetaMapper.class, m -> m.batchGetRoleUpdatedAt(uniqueRoleIds)); + + for (RoleUpdatedAt rv : roleVersions) { + long roleId = rv.getRoleId(); + long dbUpdatedAt = rv.getUpdatedAt(); + Optional cachedUpdatedAt = loadedRoles.getIfPresent(roleId); + + if (cachedUpdatedAt.isPresent() && cachedUpdatedAt.get() >= dbUpdatedAt) { + // Role policies are still current + continue; + } + + // Stale or missing — evict old policies and reload + if (cachedUpdatedAt.isPresent()) { + allowEnforcer.deleteRole(String.valueOf(roleId)); + denyEnforcer.deleteRole(String.valueOf(roleId)); + } + + // Load full role entity using roleName from the batch query (no extra DB scan) + try { + EntityStore entityStore = GravitinoEnv.getInstance().entityStore(); + RoleEntity roleEntity = + entityStore.get( + NameIdentifierUtil.ofRole(metalake, rv.getRoleName()), + Entity.EntityType.ROLE, + RoleEntity.class); + loadPolicyByRoleEntity(roleEntity, requestContext); + } catch (Exception e) { + LOG.warn("Failed to load role policies for roleId {}", roleId, e); + continue; + } + + loadedRoles.put(roleId, dbUpdatedAt); + } + } + + private void bindUserRoles(long userId, List roleIds) { + for (Long roleId : roleIds) { + allowEnforcer.addRoleForUser(String.valueOf(userId), String.valueOf(roleId)); + denyEnforcer.addRoleForUser(String.valueOf(userId), String.valueOf(roleId)); } - CompletableFuture 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); - loadedRoles.put(roleId, true); - }, - executor); - loadRoleFutures.add(loadRoleFuture); } - private void loadPolicyByRoleEntity(RoleEntity roleEntity) { + // --------------------------------------------------------------------------- + // Policy loading from role entity + // --------------------------------------------------------------------------- + + private void loadPolicyByRoleEntity( + RoleEntity roleEntity, AuthorizationRequestContext requestContext) { String metalake = NameIdentifierUtil.getMetalake(roleEntity.nameIdentifier()); List securableObjects = roleEntity.securableObjects(); for (SecurableObject securableObject : securableObjects) { + Long securableId = lookups.resolveMetadataId(securableObject, metalake, requestContext); for (Privilege privilege : securableObject.privileges()) { Privilege.Condition condition = privilege.condition(); if (AuthConstants.DENY.equalsIgnoreCase(condition.name())) { denyEnforcer.addPolicy( String.valueOf(roleEntity.id()), securableObject.type().name(), - String.valueOf(MetadataIdConverter.getID(securableObject, metalake)), + String.valueOf(securableId), 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()), securableObject.type().name(), - String.valueOf(MetadataIdConverter.getID(securableObject, metalake)), + String.valueOf(securableId), 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 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 groups = ((UserPrincipal) principal).getGroups(); - if (groups.isEmpty()) { - return false; - } - try { - List groupIdents = - groups.stream() - .map(g -> NameIdentifierUtil.ofGroup(metalake, g.getGroupname())) - .collect(Collectors.toList()); - List 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/JcasbinLoadedRolesCache.java b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java new file mode 100644 index 00000000000..4595f42d620 --- /dev/null +++ b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java @@ -0,0 +1,93 @@ +/* + * 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 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). + * + *

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 { + + private final Cache cache; + + JcasbinLoadedRolesCache(long ttlMs, long maxSize, Enforcer allowEnforcer, Enforcer denyEnforcer) { + this.cache = + Caffeine.newBuilder() + .expireAfterAccess(ttlMs, TimeUnit.MILLISECONDS) + .maximumSize(maxSize) + .executor(Runnable::run) + .removalListener( + (roleId, value, cause) -> { + if (roleId != null) { + allowEnforcer.deleteRole(String.valueOf(roleId)); + denyEnforcer.deleteRole(String.valueOf(roleId)); + } + }) + .build(); + } + + @Override + public Optional 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) { + cache.asMap().keySet().removeIf(k -> k.toString().startsWith(prefix)); + } + + @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/TestJcasbinAuthorizer.java b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java index 31413a0a420..5f8be6859aa 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,27 +20,29 @@ 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.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; 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.security.Principal; +import java.util.ArrayList; +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.atomic.AtomicLong; 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; @@ -63,9 +65,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.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; @@ -74,6 +83,7 @@ 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.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -117,9 +127,32 @@ public class TestJcasbinAuthorizer { private static MockedStatic 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 JcasbinAuthorizer jcasbinAuthorizer; + /** + * Tracks roles registered via {@link #mockRoleInStore} so {@code + * roleMetaMapper.batchGetRoleUpdatedAt} can return their versions on demand. + */ + private static final Map 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); + + /** + * 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(); @@ -129,9 +162,7 @@ public static void setup() throws IOException { ownerMetaServiceMockedStatic = mockStatic(OwnerMetaService.class); ownerMetaServiceMockedStatic.when(OwnerMetaService::getInstance).thenReturn(ownerMetaService); - // The change poller probes entity_change_log + owner_meta on startup and owner lookups go via - // SessionUtils; mock SessionUtils to delegate to the mock OwnerMetaMapper so tests can stub - // owner state without opening a real MyBatis session. Other mappers default to null. + // Mock SessionUtils.getWithoutCommit to delegate to our mock mappers sessionUtilsMockedStatic = mockStatic(SessionUtils.class); sessionUtilsMockedStatic .when(() -> SessionUtils.getWithoutCommit(any(), any())) @@ -139,12 +170,40 @@ public static void setup() throws IOException { invocation -> { Class mapperClass = invocation.getArgument(0); java.util.function.Function 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); } 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 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()); @@ -165,8 +224,6 @@ public static void setup() throws IOException { eq(Entity.EntityType.USER), eq(UserEntity.class))) .thenReturn(getUserEntity()); - jcasbinAuthorizer = new JcasbinAuthorizer(); - jcasbinAuthorizer.initialize(); BaseMetalake baseMetalake = BaseMetalake.builder() .withId(USER_METALAKE_ID) @@ -189,21 +246,23 @@ public static void stop() { 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. @@ -213,13 +272,39 @@ public void resetSharedState() throws Exception { 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 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( @@ -227,14 +312,19 @@ public void testAuthorize() throws Exception { 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 now = System.currentTimeMillis(); + 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", now))); + // Bump user version to invalidate userRoleCache + when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME))) + .thenReturn(new UserUpdatedAt(USER_ID, now)); + 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. @@ -245,24 +335,28 @@ public void testAuthorize() throws Exception { 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 now2 = now + 1; + when(roleMetaMapper.batchGetRoleUpdatedAt(any())) + .thenReturn(ImmutableList.of(new RoleUpdatedAt(newRoleId, "tempNewRole", now2))); + when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME))) + .thenReturn(new UserUpdatedAt(USER_ID, now2)); + // 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 now3 = now2 + 1; + when(roleMetaMapper.batchGetRoleUpdatedAt(any())) + .thenReturn(ImmutableList.of(new RoleUpdatedAt(ALLOW_ROLE_ID, "allowRole", now3))); + when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME))) + .thenReturn(new UserUpdatedAt(USER_ID, now3)); assertTrue(doAuthorize(currentPrincipal)); + // Test deny RoleEntity denyRole = getRoleEntity(DENY_ROLE_ID, "denyRole", ImmutableList.of(getDenySecurableObject())); @@ -271,20 +365,23 @@ public void testAuthorize() throws Exception { 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 now4 = now3 + 1; + when(roleMetaMapper.batchGetRoleUpdatedAt(any())) + .thenReturn( + ImmutableList.of( + new RoleUpdatedAt(ALLOW_ROLE_ID, "allowRole", now4), + new RoleUpdatedAt(DENY_ROLE_ID, "denyRole", now4))); + when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME))) + .thenReturn(new UserUpdatedAt(USER_ID, now4)); assertFalse(doAuthorize(currentPrincipal)); } @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); @@ -292,14 +389,23 @@ public void testAuthorizeByOwner() throws Exception { 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)); @@ -336,7 +442,7 @@ public void testAuthorizeByGroupOwner() throws Exception { .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); @@ -359,6 +465,7 @@ public void testAuthorizeByGroupOwner() throws Exception { 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(); @@ -412,13 +519,7 @@ public void testAuthorizeByDirectAndGroupRoles() throws Exception { 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())); @@ -450,26 +551,6 @@ public void testIsSelfRoleViaGroup() throws Exception { 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); @@ -479,12 +560,8 @@ public void testStaleGroupSkippedWhenNotInStore() throws Exception { 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)); @@ -536,12 +613,7 @@ public void testRoleSharedByUserAndGroupSurvivesGroupRevocation() throws Excepti 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())); @@ -577,24 +649,16 @@ public void testRoleSharedByUserAndGroupSurvivesUserRevocation() throws Exceptio 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 @@ -673,54 +737,17 @@ public void testMultipleGroupsPartialRevocationRetainsAccess() throws Exception 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 @@ -752,12 +779,7 @@ public void testDenyRoleOnUserOverridesAllowFromGroup() throws Exception { 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)); @@ -784,12 +806,7 @@ public void testDenyRoleFromGroupOverridesAllowOnUser() throws Exception { 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)); @@ -819,17 +836,34 @@ private static void restoreDefaultPrincipal() { .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, System.currentTimeMillis())); + } + + /** + * 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 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, System.currentTimeMillis())); } - /** Builds a {@link RoleEntity} and registers it in the mocked entity store. */ + /** + * 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 securableObjects) throws IOException { RoleEntity role = getRoleEntity(roleId, roleName, securableObjects); @@ -838,18 +872,28 @@ private static RoleEntity mockRoleInStore( eq(Entity.EntityType.ROLE), eq(RoleEntity.class))) .thenReturn(role); + mockedRoleVersions.put(roleId, new RoleUpdatedAt(roleId, roleName, System.currentTimeMillis())); 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 roleIds, List roleNames) throws IOException { + return mockGroupWithRoles(GROUP_ID, groupName, roleIds, roleNames); + } + + private static GroupEntity mockGroupWithRoles( + Long groupId, String groupName, List roleIds, List roleNames) + throws IOException { GroupEntity group = GroupEntity.builder() - .withId(GROUP_ID) + .withId(groupId) .withName(groupName) .withNamespace(Namespace.of(METALAKE, "group")) .withAuditInfo(AuditInfo.EMPTY) @@ -861,6 +905,17 @@ private static GroupEntity mockGroupWithRoles( 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 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; } @@ -961,16 +1016,11 @@ private static SecurableObject getDenySecurableObject() { "testCatalog2", getDenySecurableObjectPO(), MetadataObject.Type.CATALOG); } - private static void makeCompletableFutureUseCurrentThread(JcasbinAuthorizer jcasbinAuthorizer) { - try { - Executor currentThread = Runnable::run; - Class 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 @@ -978,31 +1028,32 @@ public void testRoleCacheInvalidation() throws Exception { makeCompletableFutureUseCurrentThread(jcasbinAuthorizer); // Get the loadedRoles cache via reflection - Cache loadedRoles = getLoadedRolesCache(jcasbinAuthorizer); + GravitinoCache 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> ownerRelCache = getOwnerRelCache(jcasbinAuthorizer); + // Get the ownerRel cache via reflection + GravitinoCache> 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"); @@ -1012,7 +1063,7 @@ public void testOwnerCacheInvalidation() throws Exception { 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 @@ -1024,7 +1075,7 @@ public void testRoleCacheSynchronousRemovalListenerDeletesPolicy() throws Except Enforcer denyEnforcer = getDenyEnforcer(jcasbinAuthorizer); // Get the loadedRoles cache - Cache loadedRoles = getLoadedRolesCache(jcasbinAuthorizer); + GravitinoCache loadedRoles = getLoadedRolesCache(jcasbinAuthorizer); // Add a role and its policy to the enforcer Long testRoleId = 300L; @@ -1035,7 +1086,7 @@ public void testRoleCacheSynchronousRemovalListenerDeletesPolicy() throws Except 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")); @@ -1053,25 +1104,20 @@ public void testRoleCacheSynchronousRemovalListenerDeletesPolicy() throws Except @Test public void testCacheInitialization() throws Exception { // Verify that caches are initialized - Cache loadedRoles = getLoadedRolesCache(jcasbinAuthorizer); + GravitinoCache loadedRolesCache = getLoadedRolesCache(jcasbinAuthorizer); GravitinoCache> 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, @@ -1094,11 +1140,7 @@ public void testHasMetadataPrivilegePermission() throws Exception { 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, @@ -1121,11 +1163,7 @@ public void testHasMetadataPrivilegePermission() throws Exception { 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, @@ -1154,11 +1192,7 @@ public void testHasMetadataPrivilegePermission() throws Exception { 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, @@ -1202,11 +1236,11 @@ private static SecurableObject buildManageGrantsSecurableObject( } @SuppressWarnings("unchecked") - private static Cache getLoadedRolesCache(JcasbinAuthorizer authorizer) + private static GravitinoCache getLoadedRolesCache(JcasbinAuthorizer authorizer) throws Exception { Field field = JcasbinAuthorizer.class.getDeclaredField("loadedRoles"); field.setAccessible(true); - return (Cache) field.get(authorizer); + return (GravitinoCache) field.get(authorizer); } @SuppressWarnings("unchecked") @@ -1217,6 +1251,39 @@ private static GravitinoCache> getOwnerRelCache( return (GravitinoCache>) field.get(authorizer); } + /** Mock mapper to assign zero roles. Bumps user version to invalidate cache. */ + private static void mockUserRoles() { + long now = System.currentTimeMillis(); + 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, now)); + } + + /** Mock mapper to assign a single role. Bumps user version to invalidate cache. */ + private static void mockUserRoles(Long roleId, String roleName) { + long now = System.currentTimeMillis(); + when(roleMetaMapper.listRolesByUserId(eq(USER_ID))) + .thenReturn(ImmutableList.of(buildRolePO(roleId, roleName))); + when(roleMetaMapper.batchGetRoleUpdatedAt(any())) + .thenReturn(ImmutableList.of(new RoleUpdatedAt(roleId, roleName, now))); + when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME))) + .thenReturn(new UserUpdatedAt(USER_ID, now)); + } + + 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(); + } + private static Enforcer getAllowEnforcer(JcasbinAuthorizer authorizer) throws Exception { Field field = JcasbinAuthorizer.class.getDeclaredField("allowEnforcer"); field.setAccessible(true); 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 00000000000..a7551bd60e5 --- /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 snapshot classes used by the version-validated role caches. */ +public class TestJcasbinAuthorizerCacheHelpers { + + @Test + void testCachedUserRoles() { + List roleIds = Arrays.asList(10L, 20L, 30L); + CachedUserRoles cur = new CachedUserRoles(1L, 1000L, roleIds); + Assertions.assertEquals(1L, cur.getUserId()); + Assertions.assertEquals(1000L, cur.getUpdatedAt()); + Assertions.assertEquals(roleIds, cur.getRoleIds()); + } + + @Test + void testCachedGroupRoles() { + List roleIds = Arrays.asList(100L, 200L); + CachedGroupRoles cgr = new CachedGroupRoles(5L, 2000L, roleIds); + Assertions.assertEquals(5L, cgr.getGroupId()); + Assertions.assertEquals(2000L, cgr.getUpdatedAt()); + Assertions.assertEquals(roleIds, cgr.getRoleIds()); + } +} From d985bfb55c344ffe8a7ead82ea5df7284d8810a0 Mon Sep 17 00:00:00 2001 From: yuqi Date: Fri, 15 May 2026 19:25:24 +0800 Subject: [PATCH 03/23] fix(authz): address jcasbin cache review comments --- core/src/main/java/org/apache/gravitino/Configs.java | 1 + .../authorization/jcasbin/JcasbinAuthorizer.java | 5 ++++- .../authorization/jcasbin/TestJcasbinAuthorizer.java | 10 +++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/Configs.java b/core/src/main/java/org/apache/gravitino/Configs.java index bdb61abc2c6..1c50d74402e 100644 --- a/core/src/main/java/org/apache/gravitino/Configs.java +++ b/core/src/main/java/org/apache/gravitino/Configs.java @@ -346,6 +346,7 @@ private Configs() {} .doc("The interval in seconds for polling entity and owner changes") .version(ConfigConstants.VERSION_1_3_0) .longConf() + .checkValue(value -> value > 0, ConfigConstants.POSITIVE_NUMBER_ERROR_MSG) .createWithDefault(DEFAULT_GRAVITINO_AUTHORIZATION_CHANGE_POLL_INTERVAL_SECS); public static final ConfigEntry> SERVICE_ADMINS = 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 0a60ab901d8..706879b1d00 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 @@ -128,7 +128,7 @@ public void initialize() { .config() .get(Configs.GRAVITINO_AUTHORIZATION_CHANGE_POLL_INTERVAL_SECS); - long ttlMs = cacheExpirationSecs * 1000L; + long ttlMs = TimeUnit.SECONDS.toMillis(cacheExpirationSecs); // Initialize enforcers before the caches that reference them in removal listeners allowEnforcer = new SyncedEnforcer(getModel("/jcasbin_model.conf"), new GravitinoAdapter()); @@ -149,6 +149,9 @@ public void initialize() { } }) .build(); + // 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. metadataIdCache = new CaffeineGravitinoCache<>(ttlMs, metadataIdCacheSize); ownerRelCache = new CaffeineGravitinoCache<>(ttlMs, ownerCacheSize); lookups = new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache); 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 31413a0a420..fe9741d18af 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 @@ -39,6 +39,7 @@ import java.util.List; import java.util.Optional; import java.util.concurrent.Executor; +import java.util.function.Function; import java.util.stream.Collectors; import org.apache.commons.lang3.reflect.FieldUtils; import org.apache.gravitino.Entity; @@ -138,7 +139,7 @@ public static void setup() throws IOException { .thenAnswer( invocation -> { Class mapperClass = invocation.getArgument(0); - java.util.function.Function func = invocation.getArgument(1); + Function func = invocation.getArgument(1); if (mapperClass == OwnerMetaMapper.class) { return func.apply(ownerMetaMapper); } @@ -183,6 +184,13 @@ public static void setup() throws IOException { @AfterAll public static void stop() { + if (jcasbinAuthorizer != null) { + try { + jcasbinAuthorizer.close(); + } catch (IOException e) { + throw new RuntimeException("Failed to close JcasbinAuthorizer", e); + } + } if (principalUtilsMockedStatic != null) { principalUtilsMockedStatic.close(); } From 7d30c8fcee9aa5826a1198bfa91a971524112f56 Mon Sep 17 00:00:00 2001 From: yuqi Date: Fri, 15 May 2026 20:10:17 +0800 Subject: [PATCH 04/23] test(authz): stabilize jcasbin poller tests --- .../jcasbin/JcasbinChangePoller.java | 6 +++--- .../jcasbin/TestJcasbinAuthorizer.java | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) 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 7c7cd2ca3dc..30bea869e21 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 @@ -54,8 +54,8 @@ public class JcasbinChangePoller implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(JcasbinChangePoller.class); - /** Max rows to fetch per poller cycle. */ - private static final int POLLER_MAX_ROWS = 500; + /** Max entity-change rows to fetch per poller cycle. */ + private static final int ENTITY_CHANGE_POLLER_MAX_ROWS = 500; private final GravitinoCache metadataIdCache; private final GravitinoCache> ownerRelCache; @@ -165,7 +165,7 @@ private void pollEntityChanges() { List changes = SessionUtils.getWithoutCommit( EntityChangeLogMapper.class, - m -> m.selectEntityChanges(entityPollHighWaterId, POLLER_MAX_ROWS)); + m -> m.selectEntityChanges(entityPollHighWaterId, ENTITY_CHANGE_POLLER_MAX_ROWS)); long maxSeenId = entityPollHighWaterId; for (EntityChangeRecord change : changes) { 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 fe9741d18af..1ede20c9de5 100644 --- a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java +++ b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java @@ -24,6 +24,8 @@ 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.mock; import static org.mockito.Mockito.mockStatic; @@ -36,6 +38,7 @@ import java.io.IOException; import java.lang.reflect.Field; import java.security.Principal; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.Executor; @@ -64,6 +67,7 @@ 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.OwnerMetaMapper; import org.apache.gravitino.storage.relational.po.SecurableObjectPO; import org.apache.gravitino.storage.relational.po.auth.OwnerInfo; @@ -120,6 +124,8 @@ public class TestJcasbinAuthorizer { private static OwnerMetaMapper ownerMetaMapper = mock(OwnerMetaMapper.class); + private static EntityChangeLogMapper entityChangeLogMapper = mock(EntityChangeLogMapper.class); + private static JcasbinAuthorizer jcasbinAuthorizer; private static ObjectMapper objectMapper = new ObjectMapper(); @@ -129,10 +135,15 @@ public static void setup() throws IOException { OwnerMetaService ownerMetaService = mock(OwnerMetaService.class); ownerMetaServiceMockedStatic = mockStatic(OwnerMetaService.class); ownerMetaServiceMockedStatic.when(OwnerMetaService::getInstance).thenReturn(ownerMetaService); + when(ownerMetaMapper.selectMaxChangeId()).thenReturn(0L); + when(ownerMetaMapper.selectChangedOwners(anyLong())).thenReturn(Collections.emptyList()); + when(entityChangeLogMapper.selectMaxChangeId()).thenReturn(0L); + when(entityChangeLogMapper.selectEntityChanges(anyLong(), anyInt())) + .thenReturn(Collections.emptyList()); // The change poller probes entity_change_log + owner_meta on startup and owner lookups go via - // SessionUtils; mock SessionUtils to delegate to the mock OwnerMetaMapper so tests can stub - // owner state without opening a real MyBatis session. Other mappers default to null. + // SessionUtils; mock SessionUtils to delegate to mapper mocks so tests can stub owner state + // without opening a real MyBatis session. Poller-only mapper calls return safe empty defaults. sessionUtilsMockedStatic = mockStatic(SessionUtils.class); sessionUtilsMockedStatic .when(() -> SessionUtils.getWithoutCommit(any(), any())) @@ -143,6 +154,9 @@ public static void setup() throws IOException { if (mapperClass == OwnerMetaMapper.class) { return func.apply(ownerMetaMapper); } + if (mapperClass == EntityChangeLogMapper.class) { + return func.apply(entityChangeLogMapper); + } return null; }); From 7a686f1cb94cab618d1298e785d192431581ec9a Mon Sep 17 00:00:00 2001 From: yuqi Date: Fri, 15 May 2026 20:27:15 +0800 Subject: [PATCH 05/23] docs(authz): document jcasbin cache settings --- .../authorization/GravitinoAuthorizer.java | 7 +++-- .../gravitino/cache/GravitinoCache.java | 4 +-- docs/security/access-control.md | 8 ++++++ .../jcasbin/JcasbinAuthorizationLookups.java | 18 ++++++------- .../jcasbin/JcasbinAuthorizer.java | 8 +++--- .../jcasbin/JcasbinChangePoller.java | 2 +- .../TestJcasbinAuthorizationLookups.java | 26 ++++++++++--------- 7 files changed, 41 insertions(+), 32 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java index 6619387b612..1561639be17 100644 --- a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java +++ b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java @@ -168,15 +168,14 @@ void handleMetadataOwnerChange( String metalake, Long oldOwnerId, NameIdentifier nameIdentifier, Entity.EntityType type); /** - * Called when an entity undergoes a structural change (rename or drop) that invalidates cached - * name-to-id mappings in the authorizer. Implementations evict the cache key for the given entity - * and all of its descendants (cascade invalidation). + * Called when an entity name-to-id mapping may have changed because of a rename or drop. + * Implementations evict the cache key for the given entity and all of its descendants. * * @param metalake the metalake name * @param nameIdentifier the entity name identifier * @param type the entity type */ - default void handleEntityStructuralChange( + default void handleEntityNameIdMappingChange( String metalake, NameIdentifier nameIdentifier, Entity.EntityType type) { // default no-op for backward compatibility } diff --git a/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java b/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java index 3771608e3c1..496a6d61038 100644 --- a/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java +++ b/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java @@ -58,8 +58,8 @@ public interface GravitinoCache extends Closeable { /** * Evicts all entries whose key is a String and starts with the given prefix. Only meaningful when - * K = String. Used by metadataIdCache for hierarchical cascade invalidation: dropping a catalog - * evicts the catalog entry plus all schema/table/fileset/... entries beneath it. + * K = String. Used by metadataIdCache for path-based invalidation: dropping a catalog evicts the + * catalog entry plus all schema/table/fileset/... entries under that catalog name path. * * @param prefix the prefix to match against key strings */ diff --git a/docs/security/access-control.md b/docs/security/access-control.md index 76858304c75..4bbb274e1ef 100644 --- a/docs/security/access-control.md +++ b/docs/security/access-control.md @@ -451,6 +451,8 @@ To enable access control in Gravitino, configure the following settings in your | `gravitino.authorization.jcasbin.cacheExpirationSecs` | The expiration time in seconds for authorization cache entries | `3600` | No | 1.1.1 | | `gravitino.authorization.jcasbin.roleCacheSize` | The maximum size of the role cache for authorization | `10000` | No | 1.1.1 | | `gravitino.authorization.jcasbin.ownerCacheSize` | The maximum size of the owner cache for authorization | `100000` | No | 1.1.1 | +| `gravitino.authorization.jcasbin.metadataIdCacheSize` | The maximum size of the metadata ID cache for authorization | `100000` | No | 1.3.0 | +| `gravitino.authorization.jcasbin.changePollIntervalSecs` | The interval in seconds for polling entity and owner changes | `3` | No | 1.3.0 | ### Authorization Cache @@ -462,6 +464,10 @@ Gravitino uses Caffeine caches to improve authorization performance by caching r - **`ownerCacheSize`**: Controls the maximum number of owner relationship entries that can be cached. This cache maps metadata object IDs to their owner IDs. +- **`metadataIdCacheSize`**: Controls the maximum number of metadata name-to-ID mapping entries that can be cached. This cache maps metadata object names to internal metadata IDs used by JCasbin authorization checks. + +- **`changePollIntervalSecs`**: Controls how often a Gravitino server polls persisted entity and owner changes to invalidate local JCasbin authorization caches in multi-node deployments. + :::info When role privileges or ownership are changed through the Gravitino API, the corresponding cache entries are automatically invalidated to ensure authorization decisions reflect the latest state. ::: @@ -488,6 +494,8 @@ gravitino.authorization.serviceAdmins = admin1,admin2 gravitino.authorization.jcasbin.cacheExpirationSecs = 3600 gravitino.authorization.jcasbin.roleCacheSize = 10000 gravitino.authorization.jcasbin.ownerCacheSize = 100000 +gravitino.authorization.jcasbin.metadataIdCacheSize = 100000 +gravitino.authorization.jcasbin.changePollIntervalSecs = 3 ``` ## Migration Guide 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 9f136c4d453..0cde0865f9c 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 @@ -37,12 +37,12 @@ * request and later ones — hit the cache. The two underlying caches are invalidated externally by * {@link JcasbinChangePoller} (HA peers) and by the {@link * org.apache.gravitino.authorization.GravitinoAuthorizer#handleMetadataOwnerChange} / {@link - * org.apache.gravitino.authorization.GravitinoAuthorizer#handleEntityStructuralChange} hooks (local - * mutations). + * org.apache.gravitino.authorization.GravitinoAuthorizer#handleEntityNameIdMappingChange} hooks + * (local mutations). */ public class JcasbinAuthorizationLookups { - /** Key separator for hierarchical cache keys. */ + /** Key separator for path-based cache keys. */ static final String KEY_SEP = "::"; private final GravitinoCache metadataIdCache; @@ -52,7 +52,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 hierarchical {@code metalake::catalog::schema::object::TYPE} → entity id + * @param metadataIdCache path-based {@code metalake::catalog::schema::object::TYPE} → entity id * @param ownerRelCache {@code metadataObjectId} → {@link Optional} of {@link OwnerInfo} */ public JcasbinAuthorizationLookups( @@ -120,8 +120,8 @@ public GravitinoCache> ownerRelCache() { } /** - * Builds a hierarchical cache key for the metadataIdCache. Non-leaf objects end with "::" to - * enable prefix-based cascade invalidation. + * Builds a path-based cache key for the metadataIdCache. Container objects end with "::" so a + * prefix invalidation can remove the container and all entries under the same name path. * *

Examples: {@code metalake::}, {@code metalake::catalog::}, {@code * metalake::catalog::schema::}, {@code metalake::catalog::schema::table::TABLE}. @@ -136,8 +136,8 @@ public static String buildCacheKey(String metalake, MetadataObject metadataObjec // fullName uses '.' as separator, e.g. "catalog1.schema1.table1" String[] parts = metadataObject.fullName().split("\\."); sb.append(String.join(KEY_SEP, parts)); - if (isNonLeaf(metadataObject.type())) { - // Trailing separator enables prefix-based cascade invalidation + if (isContainerType(metadataObject.type())) { + // Trailing separator enables prefix-based invalidation. sb.append(KEY_SEP); } else { // Leaf nodes get the type suffix to avoid collisions @@ -149,7 +149,7 @@ public static String buildCacheKey(String metalake, MetadataObject metadataObjec /** Returns true for entity types that can contain children (metalake, catalog, schema). */ @VisibleForTesting - public static boolean isNonLeaf(MetadataObject.Type type) { + 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 706879b1d00..d21591932ec 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 @@ -95,7 +95,7 @@ public class JcasbinAuthorizer implements GravitinoAuthorizer { */ private Cache loadedRoles; - /** Hierarchical {@code metalake::catalog::schema::object::TYPE} → entity id. */ + /** Path-based {@code metalake::catalog::schema::object::TYPE} → entity id. */ private GravitinoCache metadataIdCache; /** {@code metadataObjectId} → {@link Optional} of {@link OwnerInfo}. */ @@ -455,12 +455,12 @@ public void handleMetadataOwnerChange( } @Override - public void handleEntityStructuralChange( + public void handleEntityNameIdMappingChange( String metalake, NameIdentifier nameIdentifier, Entity.EntityType type) { MetadataObject metadataObject = NameIdentifierUtil.toMetadataObject(nameIdentifier, type); String cacheKey = JcasbinAuthorizationLookups.buildCacheKey(metalake, metadataObject); - if (JcasbinAuthorizationLookups.isNonLeaf(metadataObject.type())) { - // Cascade invalidation: metalake::catalog:: prefix removes catalog + all children + if (JcasbinAuthorizationLookups.isContainerType(metadataObject.type())) { + // Prefix invalidation: metalake::catalog:: removes catalog + all children. metadataIdCache.invalidateByPrefix(cacheKey); } else { metadataIdCache.invalidate(cacheKey); 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 30bea869e21..ef282dcc661 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 @@ -187,7 +187,7 @@ private void pollEntityChanges() { MetadataObject mdObj = metadataObjectFromChangeLog(metalake, fullName, mdType); String cacheKey = JcasbinAuthorizationLookups.buildCacheKey(metalake, mdObj); - if (JcasbinAuthorizationLookups.isNonLeaf(mdType)) { + if (JcasbinAuthorizationLookups.isContainerType(mdType)) { metadataIdCache.invalidateByPrefix(cacheKey); } else { metadataIdCache.invalidate(cacheKey); 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 aac6838fb69..ffe004b0808 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 @@ -70,27 +70,29 @@ void testBuildCacheKeyLeafTypesGetTypeSuffix() { "ml1::cat1::sch1::fs1::FILESET", JcasbinAuthorizationLookups.buildCacheKey("ml1", fileset)); } - // ---------- isNonLeaf ---------- + // ---------- isContainerType ---------- @Test - void testIsNonLeafContainerTypes() { - Assertions.assertTrue(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.METALAKE)); - Assertions.assertTrue(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.CATALOG)); - Assertions.assertTrue(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.SCHEMA)); + 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 testIsNonLeafLeafTypes() { - Assertions.assertFalse(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.TABLE)); - Assertions.assertFalse(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.VIEW)); - Assertions.assertFalse(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.FILESET)); - Assertions.assertFalse(JcasbinAuthorizationLookups.isNonLeaf(MetadataObject.Type.TOPIC)); + 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)); } - // ---------- Cascade prefix hierarchy ---------- + // ---------- Prefix invalidation ---------- @Test - void testCascadeInvalidationKeyHierarchy() { + 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); From e74c005d0db76089f72872a2ad053026cdc71c01 Mon Sep 17 00:00:00 2001 From: yuqi Date: Mon, 18 May 2026 11:20:28 +0800 Subject: [PATCH 06/23] Fix atomic authorization cache loading --- .../cache/CaffeineGravitinoCache.java | 8 ++ .../gravitino/cache/GravitinoCache.java | 20 ++++ .../gravitino/cache/NoOpsGravitinoCache.java | 7 ++ .../utils/HierarchicalSchemaUtil.java | 15 ++- .../gravitino/cache/TestGravitinoCache.java | 73 ++++++++++++ .../utils/TestHierarchicalSchemaUtil.java | 1 + .../jcasbin/JcasbinAuthorizationLookups.java | 49 ++++---- .../TestJcasbinAuthorizationLookups.java | 107 +++++++++++++++++- .../jcasbin/TestJcasbinChangePoller.java | 15 ++- 9 files changed, 252 insertions(+), 43 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java b/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java index f1ed54df754..9647704372e 100644 --- a/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java +++ b/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java @@ -22,8 +22,10 @@ import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Ticker; import com.google.common.annotations.VisibleForTesting; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.function.Function; /** * A Caffeine-backed implementation of {@link GravitinoCache}. Supports configurable TTL and maximum @@ -61,6 +63,12 @@ public Optional getIfPresent(K key) { return Optional.ofNullable(value); } + @Override + public V get(K key, Function loader) { + return cache.get( + key, k -> Objects.requireNonNull(loader.apply(k), "Cache loader must not return null")); + } + @Override public void put(K key, V value) { cache.put(key, value); diff --git a/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java b/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java index 496a6d61038..15be6537e01 100644 --- a/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java +++ b/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java @@ -19,7 +19,9 @@ package org.apache.gravitino.cache; import java.io.Closeable; +import java.util.Objects; import java.util.Optional; +import java.util.function.Function; /** * A general-purpose cache interface used by the authorization subsystem. Implementations include a @@ -38,6 +40,24 @@ public interface GravitinoCache extends Closeable { */ Optional getIfPresent(K key); + /** + * Returns the value associated with the key, loading it if necessary. Implementations with real + * storage should make the load atomic for the same key. + * + * @param key the cache key + * @param loader the loader invoked when the key is absent + * @return the cached or loaded value + */ + default V get(K key, Function loader) { + Optional value = getIfPresent(key); + if (value.isPresent()) { + return value.get(); + } + V loaded = Objects.requireNonNull(loader.apply(key), "Cache loader must not return null"); + put(key, loaded); + return loaded; + } + /** * Associates the value with the key in the cache. * diff --git a/core/src/main/java/org/apache/gravitino/cache/NoOpsGravitinoCache.java b/core/src/main/java/org/apache/gravitino/cache/NoOpsGravitinoCache.java index 8c41eabeefc..8d7bc53edd8 100644 --- a/core/src/main/java/org/apache/gravitino/cache/NoOpsGravitinoCache.java +++ b/core/src/main/java/org/apache/gravitino/cache/NoOpsGravitinoCache.java @@ -18,7 +18,9 @@ */ package org.apache.gravitino.cache; +import java.util.Objects; import java.util.Optional; +import java.util.function.Function; /** * A no-op implementation of {@link GravitinoCache} that never caches anything. Useful for testing @@ -34,6 +36,11 @@ public Optional getIfPresent(K key) { return Optional.empty(); } + @Override + public V get(K key, Function loader) { + return Objects.requireNonNull(loader.apply(key), "Cache loader must not return null"); + } + @Override public void put(K key, V value) { // no-op diff --git a/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java b/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java index 4d6a3fae91c..276c2f5cf83 100644 --- a/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java +++ b/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java @@ -41,8 +41,8 @@ */ public final class HierarchicalSchemaUtil { - /** The internal separator used in EntityStore for HierarchicalSchema names. */ - private static final String PHYSICAL_SEPARATOR = "\u0001"; + /** The internal separator used in EntityStore and internal path-based keys. */ + private static final String INTERNAL_SEPARATOR = "\u0001"; private HierarchicalSchemaUtil() {} @@ -61,9 +61,14 @@ public static String schemaSeparator() { return StringUtils.defaultIfBlank(separator, Configs.SCHEMA_SEPARATOR.getDefaultValue()); } + /** Returns the internal separator used in EntityStore and internal path-based keys. */ + public static String internalSeparator() { + return INTERNAL_SEPARATOR; + } + /** Returns the internal physical separator used in EntityStore. */ public static String physicalSeparator() { - return PHYSICAL_SEPARATOR; + return internalSeparator(); } /** @@ -80,7 +85,7 @@ public static String logicalToPhysical(String logicalPath, String separator) { Preconditions.checkArgument( StringUtils.isNotBlank(logicalPath), "logicalPath must not be blank"); Preconditions.checkArgument(StringUtils.isNotBlank(separator), "separator must not be blank"); - return logicalPath.replace(separator, PHYSICAL_SEPARATOR); + return logicalPath.replace(separator, INTERNAL_SEPARATOR); } /** @@ -97,7 +102,7 @@ public static String physicalToLogical(String physicalName, String separator) { Preconditions.checkArgument( StringUtils.isNotBlank(physicalName), "physicalName must not be blank"); Preconditions.checkArgument(StringUtils.isNotBlank(separator), "separator must not be blank"); - return physicalName.replace(PHYSICAL_SEPARATOR, separator); + return physicalName.replace(INTERNAL_SEPARATOR, separator); } /** 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 132e142002f..34b1c5ca86f 100644 --- a/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java +++ b/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java @@ -19,7 +19,13 @@ package org.apache.gravitino.cache; import com.github.benmanes.caffeine.cache.Ticker; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import org.awaitility.Awaitility; @@ -53,6 +59,56 @@ void testCaffeinePutAndGet() { } } + @Test + void testCaffeineGetLoadsSameKeyAtomically() throws Exception { + CaffeineGravitinoCache cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + ExecutorService executorService = Executors.newFixedThreadPool(8); + try { + AtomicLong loadCount = new AtomicLong(); + CountDownLatch ready = new CountDownLatch(8); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int i = 0; i < 8; i++) { + futures.add( + executorService.submit( + () -> { + ready.countDown(); + start.await(); + return cache.get( + "shared", + key -> { + loadCount.incrementAndGet(); + return 100L; + }); + })); + } + + Assertions.assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + for (Future future : futures) { + Assertions.assertEquals(100L, future.get(5, TimeUnit.SECONDS)); + } + + Assertions.assertEquals(1L, loadCount.get()); + Assertions.assertEquals(1L, cache.size()); + } finally { + executorService.shutdownNow(); + cache.close(); + } + } + + @Test + void testCaffeineGetRejectsNullLoadResult() { + CaffeineGravitinoCache cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + try { + Assertions.assertThrows(NullPointerException.class, () -> cache.get("key", key -> null)); + Assertions.assertFalse(cache.getIfPresent("key").isPresent()); + } finally { + cache.close(); + } + } + @Test void testCaffeineInvalidate() { CaffeineGravitinoCache cache = new CaffeineGravitinoCache<>(60_000L, 1000L); @@ -172,6 +228,23 @@ void testNoOpsCache() { } } + @Test + void testNoOpsGetAlwaysLoadsAndDoesNotCache() { + NoOpsGravitinoCache cache = new NoOpsGravitinoCache<>(); + try { + AtomicLong loadCount = new AtomicLong(); + + Assertions.assertEquals(1L, cache.get("key", key -> loadCount.incrementAndGet())); + Assertions.assertEquals(2L, cache.get("key", key -> loadCount.incrementAndGet())); + + Assertions.assertEquals(2L, loadCount.get()); + Assertions.assertFalse(cache.getIfPresent("key").isPresent()); + Assertions.assertEquals(0, cache.size()); + } finally { + cache.close(); + } + } + @Test void testCaffeineWithNonStringKeys() { CaffeineGravitinoCache cache = new CaffeineGravitinoCache<>(60_000L, 1000L); diff --git a/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java b/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java index e1b65bb66fe..37541c6eab3 100644 --- a/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java +++ b/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java @@ -34,6 +34,7 @@ public class TestHierarchicalSchemaUtil { @Test public void testPhysicalSeparator() { Assertions.assertEquals(PHYS, HierarchicalSchemaUtil.physicalSeparator()); + Assertions.assertEquals(PHYS, HierarchicalSchemaUtil.internalSeparator()); } @Test 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 0cde0865f9c..c82aabe2b7f 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 @@ -27,6 +27,7 @@ import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper; import org.apache.gravitino.storage.relational.po.auth.OwnerInfo; import org.apache.gravitino.storage.relational.utils.SessionUtils; +import org.apache.gravitino.utils.HierarchicalSchemaUtil; /** * Two-tier metadata-id and owner resolution for {@link JcasbinAuthorizer}. @@ -42,8 +43,8 @@ */ public class JcasbinAuthorizationLookups { - /** Key separator for path-based cache keys. */ - static final String KEY_SEP = "::"; + /** Key separator for internal path-based cache keys. */ + static final String KEY_SEP = HierarchicalSchemaUtil.internalSeparator(); private final GravitinoCache metadataIdCache; private final GravitinoCache> ownerRelCache; @@ -72,15 +73,8 @@ public Long resolveMetadataId( String cacheKey = buildCacheKey(metalake, metadataObject); return requestContext.computeMetadataIdIfAbsent( cacheKey, - k -> { - Optional cached = metadataIdCache.getIfPresent(k); - if (cached.isPresent()) { - return cached.get(); - } - Long id = MetadataIdConverter.getID(metadataObject, metalake); - metadataIdCache.put(k, id); - return id; - }); + k -> + metadataIdCache.get(k, ignored -> MetadataIdConverter.getID(metadataObject, metalake))); } /** @@ -94,19 +88,16 @@ public Optional resolveOwnerId( AuthorizationRequestContext requestContext) { return requestContext.computeOwnerIfAbsent( metadataId, - id -> { - Optional> cached = ownerRelCache.getIfPresent(id); - if (cached.isPresent()) { - return cached.get(); - } - OwnerInfo ownerInfo = - SessionUtils.getWithoutCommit( - OwnerMetaMapper.class, - m -> m.selectOwnerByMetadataObjectIdAndType(id, metadataType.name())); - Optional owner = ownerInfo == null ? Optional.empty() : Optional.of(ownerInfo); - ownerRelCache.put(id, owner); - return owner; - }); + id -> + ownerRelCache.get( + id, + ignored -> { + OwnerInfo ownerInfo = + SessionUtils.getWithoutCommit( + OwnerMetaMapper.class, + m -> m.selectOwnerByMetadataObjectIdAndType(id, metadataType.name())); + return ownerInfo == null ? Optional.empty() : Optional.of(ownerInfo); + })); } /** Underlying metadata-id cache; exposed for invalidation by the change hooks and the poller. */ @@ -120,11 +111,13 @@ public GravitinoCache> ownerRelCache() { } /** - * Builds a path-based cache key for the metadataIdCache. Container objects end with "::" so a - * prefix invalidation can remove the container and all entries under the same name path. + * 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. * - *

Examples: {@code metalake::}, {@code metalake::catalog::}, {@code - * metalake::catalog::schema::}, {@code metalake::catalog::schema::table::TABLE}. + *

Examples: {@code metalake}, {@code metalakecatalog}, {@code + * metalakecatalogschema}, {@code + * metalakecatalogschematableTABLE}. */ @VisibleForTesting public static String buildCacheKey(String metalake, MetadataObject metadataObject) { 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 ffe004b0808..9ffb5191392 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 @@ -20,8 +20,14 @@ import java.util.Arrays; import java.util.Collections; +import java.util.Optional; +import java.util.function.Function; import org.apache.gravitino.MetadataObject; import org.apache.gravitino.MetadataObjects; +import org.apache.gravitino.authorization.AuthorizationRequestContext; +import org.apache.gravitino.cache.GravitinoCache; +import org.apache.gravitino.storage.relational.po.auth.OwnerInfo; +import org.apache.gravitino.utils.HierarchicalSchemaUtil; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -34,14 +40,15 @@ public class TestJcasbinAuthorizationLookups { void testBuildCacheKeyMetalake() { MetadataObject obj = MetadataObjects.of(Collections.singletonList("ml1"), MetadataObject.Type.METALAKE); - Assertions.assertEquals("ml1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", obj)); + Assertions.assertEquals(key("ml1", ""), JcasbinAuthorizationLookups.buildCacheKey("ml1", obj)); } @Test void testBuildCacheKeyCatalog() { MetadataObject obj = MetadataObjects.of(Collections.singletonList("cat1"), MetadataObject.Type.CATALOG); - Assertions.assertEquals("ml1::cat1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", obj)); + Assertions.assertEquals( + key("ml1", "cat1", ""), JcasbinAuthorizationLookups.buildCacheKey("ml1", obj)); } @Test @@ -49,7 +56,7 @@ void testBuildCacheKeySchema() { MetadataObject obj = MetadataObjects.of(Arrays.asList("cat1", "sch1"), MetadataObject.Type.SCHEMA); Assertions.assertEquals( - "ml1::cat1::sch1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", obj)); + key("ml1", "cat1", "sch1", ""), JcasbinAuthorizationLookups.buildCacheKey("ml1", obj)); } @Test @@ -57,17 +64,20 @@ void testBuildCacheKeyLeafTypesGetTypeSuffix() { MetadataObject table = MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"), MetadataObject.Type.TABLE); Assertions.assertEquals( - "ml1::cat1::sch1::tbl1::TABLE", JcasbinAuthorizationLookups.buildCacheKey("ml1", table)); + key("ml1", "cat1", "sch1", "tbl1", "TABLE"), + JcasbinAuthorizationLookups.buildCacheKey("ml1", table)); MetadataObject view = MetadataObjects.of(Arrays.asList("cat1", "sch1", "v1"), MetadataObject.Type.VIEW); Assertions.assertEquals( - "ml1::cat1::sch1::v1::VIEW", JcasbinAuthorizationLookups.buildCacheKey("ml1", view)); + key("ml1", "cat1", "sch1", "v1", "VIEW"), + JcasbinAuthorizationLookups.buildCacheKey("ml1", view)); MetadataObject fileset = MetadataObjects.of(Arrays.asList("cat1", "sch1", "fs1"), MetadataObject.Type.FILESET); Assertions.assertEquals( - "ml1::cat1::sch1::fs1::FILESET", JcasbinAuthorizationLookups.buildCacheKey("ml1", fileset)); + key("ml1", "cat1", "sch1", "fs1", "FILESET"), + JcasbinAuthorizationLookups.buildCacheKey("ml1", fileset)); } // ---------- isContainerType ---------- @@ -110,4 +120,89 @@ void testPrefixInvalidationCoversContainerPath() { Assertions.assertTrue(tableKey.startsWith(catalogKey)); Assertions.assertTrue(tableKey.startsWith(schemaKey)); } + + @Test + void testResolveMetadataIdUsesAtomicSharedCacheAndRequestDedup() { + MetadataObject table = + MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"), MetadataObject.Type.TABLE); + CountingCache metadataIdCache = new CountingCache<>(100L); + CountingCache> ownerRelCache = new CountingCache<>(Optional.empty()); + JcasbinAuthorizationLookups lookups = + new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache); + AuthorizationRequestContext requestContext = new AuthorizationRequestContext(); + + Assertions.assertEquals(100L, lookups.resolveMetadataId(table, "ml1", requestContext)); + Assertions.assertEquals(100L, lookups.resolveMetadataId(table, "ml1", requestContext)); + + Assertions.assertEquals(1, metadataIdCache.getCount); + Assertions.assertEquals(0, metadataIdCache.getIfPresentCount); + Assertions.assertEquals(0, metadataIdCache.putCount); + } + + @Test + void testResolveOwnerIdUsesAtomicSharedCacheAndRequestDedup() { + CountingCache metadataIdCache = new CountingCache<>(100L); + CountingCache> ownerRelCache = new CountingCache<>(Optional.empty()); + JcasbinAuthorizationLookups lookups = + new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache); + AuthorizationRequestContext requestContext = new AuthorizationRequestContext(); + + Assertions.assertFalse( + lookups.resolveOwnerId(100L, MetadataObject.Type.TABLE, requestContext).isPresent()); + Assertions.assertFalse( + lookups.resolveOwnerId(100L, MetadataObject.Type.TABLE, requestContext).isPresent()); + + Assertions.assertEquals(1, ownerRelCache.getCount); + Assertions.assertEquals(0, ownerRelCache.getIfPresentCount); + Assertions.assertEquals(0, ownerRelCache.putCount); + } + + private static String key(String... parts) { + return String.join(HierarchicalSchemaUtil.internalSeparator(), parts); + } + + private static class CountingCache implements GravitinoCache { + private final V value; + private int getCount; + private int getIfPresentCount; + private int putCount; + + private CountingCache(V value) { + this.value = value; + } + + @Override + public Optional getIfPresent(K key) { + getIfPresentCount++; + return Optional.empty(); + } + + @Override + public V get(K key, Function loader) { + getCount++; + return value; + } + + @Override + public void put(K key, V value) { + putCount++; + } + + @Override + public void invalidate(K key) {} + + @Override + public void invalidateAll() {} + + @Override + public void invalidateByPrefix(String prefix) {} + + @Override + public long size() { + return 0; + } + + @Override + public void close() {} + } } 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 edcbe33a0bb..6c6cc9527f1 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 @@ -19,6 +19,7 @@ package org.apache.gravitino.server.authorization.jcasbin; import org.apache.gravitino.MetadataObject; +import org.apache.gravitino.utils.HierarchicalSchemaUtil; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -31,25 +32,31 @@ void testChangeLogFullNameStripsLeadingMetalakeForChildTypes() { JcasbinChangePoller.metadataObjectFromChangeLog( "ml1", "ml1.cat1", MetadataObject.Type.CATALOG); Assertions.assertEquals( - "ml1::cat1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", catalog)); + key("ml1", "cat1", ""), JcasbinAuthorizationLookups.buildCacheKey("ml1", catalog)); MetadataObject schema = JcasbinChangePoller.metadataObjectFromChangeLog( "ml1", "ml1.cat1.sch1", MetadataObject.Type.SCHEMA); Assertions.assertEquals( - "ml1::cat1::sch1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", schema)); + key("ml1", "cat1", "sch1", ""), JcasbinAuthorizationLookups.buildCacheKey("ml1", schema)); MetadataObject table = JcasbinChangePoller.metadataObjectFromChangeLog( "ml1", "ml1.cat1.sch1.tbl1", MetadataObject.Type.TABLE); Assertions.assertEquals( - "ml1::cat1::sch1::tbl1::TABLE", JcasbinAuthorizationLookups.buildCacheKey("ml1", table)); + key("ml1", "cat1", "sch1", "tbl1", "TABLE"), + JcasbinAuthorizationLookups.buildCacheKey("ml1", table)); } @Test void testChangeLogFullNameForMetalakeKeepsItself() { MetadataObject metalake = JcasbinChangePoller.metadataObjectFromChangeLog("ml1", "ml1", MetadataObject.Type.METALAKE); - Assertions.assertEquals("ml1::", JcasbinAuthorizationLookups.buildCacheKey("ml1", metalake)); + Assertions.assertEquals( + key("ml1", ""), JcasbinAuthorizationLookups.buildCacheKey("ml1", metalake)); + } + + private static String key(String... parts) { + return String.join(HierarchicalSchemaUtil.internalSeparator(), parts); } } From 73013534b89ce05278baa63945b1abddbdc10aed Mon Sep 17 00:00:00 2001 From: yuqi Date: Mon, 18 May 2026 14:53:54 +0800 Subject: [PATCH 07/23] Address jcasbin cache invalidation review comments --- .../authorization/AuthorizationUtils.java | 17 +++ .../authorization/TestAuthorizationUtils.java | 54 +++++++++ .../jcasbin/JcasbinAuthorizer.java | 16 ++- .../jcasbin/JcasbinChangePoller.java | 38 +++++- .../jcasbin/TestJcasbinAuthorizer.java | 77 ++++++++++++ .../jcasbin/TestJcasbinChangePoller.java | 110 ++++++++++++++++++ 6 files changed, 305 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java index 16642a58557..556792f7421 100644 --- a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java +++ b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java @@ -325,6 +325,7 @@ public static void checkPrivilege( public static void authorizationPluginRemovePrivileges( NameIdentifier ident, Entity.EntityType type, List locations) { + notifyEntityNameIdMappingChange(ident, type); // If we enable authorization, we should remove the privileges about the entity in the // authorization plugin. if (GravitinoEnv.getInstance().accessControlDispatcher() != null) { @@ -363,6 +364,7 @@ public static void authorizationPluginRenamePrivileges( public static void authorizationPluginRenamePrivileges( NameIdentifier ident, Entity.EntityType type, String newName, List locations) { + notifyEntityNameIdMappingChange(ident, type); // If we enable authorization, we should rename the privileges about the entity in the // authorization plugin. if (GravitinoEnv.getInstance().accessControlDispatcher() != null) { @@ -386,6 +388,21 @@ public static void authorizationPluginRenamePrivileges( } } + private static void notifyEntityNameIdMappingChange( + NameIdentifier ident, Entity.EntityType type) { + GravitinoAuthorizer gravitinoAuthorizer = GravitinoEnv.getInstance().gravitinoAuthorizer(); + if (gravitinoAuthorizer == null) { + return; + } + String metalake = + type == Entity.EntityType.METALAKE ? ident.name() : ident.namespace().level(0); + try { + gravitinoAuthorizer.handleEntityNameIdMappingChange(metalake, ident, type); + } catch (RuntimeException e) { + LOG.warn("Failed to notify entity name-id mapping change for {}", ident, e); + } + } + public static Role filterSecurableObjects( RoleEntity role, String metalakeName, String catalogName) { List securableObjects = role.securableObjects(); diff --git a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java index 3071e23f9a6..beb15d36074 100644 --- a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java +++ b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java @@ -289,4 +289,58 @@ void testGetSchemaTypeMetadataObjectLocation() throws IllegalAccessException { Assertions.assertEquals(1, locations.size()); Assertions.assertEquals("schemaLocation", locations.get(0)); } + + @Test + void testRemovePrivilegesNotifiesEntityNameIdMappingChange() throws IllegalAccessException { + GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class); + GravitinoAuthorizer originalAuthorizer = GravitinoEnv.getInstance().gravitinoAuthorizer(); + Object originalAccessControlDispatcher = + FieldUtils.readField(GravitinoEnv.getInstance(), "accessControlDispatcher", true); + try { + FieldUtils.writeField(GravitinoEnv.getInstance(), "gravitinoAuthorizer", authorizer, true); + FieldUtils.writeField(GravitinoEnv.getInstance(), "accessControlDispatcher", null, true); + + NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "schema", "table"); + AuthorizationUtils.authorizationPluginRemovePrivileges( + ident, Entity.EntityType.TABLE, Lists.newArrayList()); + + Mockito.verify(authorizer) + .handleEntityNameIdMappingChange("metalake", ident, Entity.EntityType.TABLE); + } finally { + FieldUtils.writeField( + GravitinoEnv.getInstance(), "gravitinoAuthorizer", originalAuthorizer, true); + FieldUtils.writeField( + GravitinoEnv.getInstance(), + "accessControlDispatcher", + originalAccessControlDispatcher, + true); + } + } + + @Test + void testRenamePrivilegesNotifiesOldEntityNameIdMappingChange() throws IllegalAccessException { + GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class); + GravitinoAuthorizer originalAuthorizer = GravitinoEnv.getInstance().gravitinoAuthorizer(); + Object originalAccessControlDispatcher = + FieldUtils.readField(GravitinoEnv.getInstance(), "accessControlDispatcher", true); + try { + FieldUtils.writeField(GravitinoEnv.getInstance(), "gravitinoAuthorizer", authorizer, true); + FieldUtils.writeField(GravitinoEnv.getInstance(), "accessControlDispatcher", null, true); + + NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "schema", "table"); + AuthorizationUtils.authorizationPluginRenamePrivileges( + ident, Entity.EntityType.TABLE, "new_table"); + + Mockito.verify(authorizer) + .handleEntityNameIdMappingChange("metalake", ident, Entity.EntityType.TABLE); + } finally { + FieldUtils.writeField( + GravitinoEnv.getInstance(), "gravitinoAuthorizer", originalAuthorizer, true); + FieldUtils.writeField( + GravitinoEnv.getInstance(), + "accessControlDispatcher", + originalAccessControlDispatcher, + true); + } + } } 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 d21591932ec..2929bf74157 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 @@ -447,11 +447,15 @@ public void handleRolePrivilegeChange(Long roleId) { public void handleMetadataOwnerChange( String metalake, Long oldOwnerId, NameIdentifier nameIdentifier, Entity.EntityType type) { MetadataObject metadataObject = NameIdentifierUtil.toMetadataObject(nameIdentifier, type); - Long metadataId = MetadataIdConverter.getID(metadataObject, metalake); - ownerRelCache.invalidate(metadataId); // 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)); + try { + Long metadataId = MetadataIdConverter.getID(metadataObject, metalake); + ownerRelCache.invalidate(metadataId); + } catch (RuntimeException e) { + LOG.warn("Failed to resolve metadata id for owner cache invalidation: {}", metadataObject, e); + } } @Override @@ -476,6 +480,14 @@ public void close() throws IOException { 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 (metadataIdCache != null) { 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 ef282dcc661..0a8577e4bee 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 @@ -19,12 +19,15 @@ package org.apache.gravitino.server.authorization.jcasbin; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -74,6 +77,7 @@ public JcasbinChangePoller( GravitinoCache metadataIdCache, GravitinoCache> ownerRelCache, long pollIntervalSecs) { + Preconditions.checkArgument(pollIntervalSecs > 0, "pollIntervalSecs must be positive"); this.metadataIdCache = metadataIdCache; this.ownerRelCache = ownerRelCache; this.pollIntervalSecs = pollIntervalSecs; @@ -93,11 +97,11 @@ public JcasbinChangePoller( */ public void start() { ownerPollHighWaterId = - nullToZero( + getOrDefault( SessionUtils.getWithoutCommit( OwnerMetaMapper.class, OwnerMetaMapper::selectMaxChangeId)); entityPollHighWaterId = - nullToZero( + getOrDefault( SessionUtils.getWithoutCommit( EntityChangeLogMapper.class, EntityChangeLogMapper::selectMaxChangeId)); @@ -168,6 +172,8 @@ private void pollEntityChanges() { m -> m.selectEntityChanges(entityPollHighWaterId, ENTITY_CHANGE_POLLER_MAX_ROWS)); long maxSeenId = entityPollHighWaterId; + Set containerPrefixes = new LinkedHashSet<>(); + Set leafKeys = new LinkedHashSet<>(); for (EntityChangeRecord change : changes) { String metalake = change.getMetalakeName(); String entityType = change.getEntityType(); @@ -188,15 +194,16 @@ private void pollEntityChanges() { String cacheKey = JcasbinAuthorizationLookups.buildCacheKey(metalake, mdObj); if (JcasbinAuthorizationLookups.isContainerType(mdType)) { - metadataIdCache.invalidateByPrefix(cacheKey); + addCoalescedPrefix(containerPrefixes, cacheKey); } else { - metadataIdCache.invalidate(cacheKey); + leafKeys.add(cacheKey); } if (change.getId() > maxSeenId) { maxSeenId = change.getId(); } } + invalidateCoalescedKeys(containerPrefixes, leafKeys); entityPollHighWaterId = maxSeenId; } @@ -227,7 +234,28 @@ static MetadataObject metadataObjectFromChangeLog( return MetadataObjects.of(names, type); } - private static long nullToZero(Long value) { + private static long getOrDefault(Long value) { return value == null ? 0L : value; } + + private static void addCoalescedPrefix(Set prefixes, String candidate) { + for (String prefix : prefixes) { + if (candidate.startsWith(prefix)) { + return; + } + } + prefixes.removeIf(prefix -> prefix.startsWith(candidate)); + prefixes.add(candidate); + } + + private void invalidateCoalescedKeys(Set prefixes, Set leafKeys) { + for (String prefix : prefixes) { + metadataIdCache.invalidateByPrefix(prefix); + } + for (String leafKey : leafKeys) { + if (prefixes.stream().noneMatch(leafKey::startsWith)) { + metadataIdCache.invalidate(leafKey); + } + } + } } 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 1ede20c9de5..571f4c632cc 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 @@ -42,6 +42,10 @@ import java.util.List; 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.function.Function; import java.util.stream.Collectors; import org.apache.commons.lang3.reflect.FieldUtils; @@ -79,6 +83,7 @@ import org.apache.gravitino.utils.PrincipalUtils; import org.casbin.jcasbin.main.Enforcer; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -1037,6 +1042,49 @@ public void testOwnerCacheInvalidation() throws Exception { assertFalse(ownerRelCache.getIfPresent(CATALOG_ID).isPresent()); } + @Test + public void testOwnerChangeBestEffortWhenMetadataIdLookupFails() throws Exception { + GravitinoCache metadataIdCache = getMetadataIdCache(jcasbinAuthorizer); + GravitinoCache> ownerRelCache = getOwnerRelCache(jcasbinAuthorizer); + NameIdentifier catalogIdent = NameIdentifierUtil.ofCatalog(METALAKE, "testCatalog"); + String cacheKey = + JcasbinAuthorizationLookups.buildCacheKey( + METALAKE, NameIdentifierUtil.toMetadataObject(catalogIdent, Entity.EntityType.CATALOG)); + + metadataIdCache.put(cacheKey, CATALOG_ID); + ownerRelCache.put(CATALOG_ID, Optional.of(new OwnerInfo(USER_ID, "USER"))); + metadataIdConverterMockedStatic + .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE))) + .thenThrow(new RuntimeException("lookup failed")); + + try { + Assertions.assertDoesNotThrow( + () -> + jcasbinAuthorizer.handleMetadataOwnerChange( + METALAKE, USER_ID, catalogIdent, Entity.EntityType.CATALOG)); + + assertFalse(metadataIdCache.getIfPresent(cacheKey).isPresent()); + assertTrue(ownerRelCache.getIfPresent(CATALOG_ID).isPresent()); + } finally { + metadataIdConverterMockedStatic + .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE))) + .thenReturn(CATALOG_ID); + } + } + + @Test + public void testCloseAwaitsRoleLoadExecutorTermination() throws Exception { + RecordingThreadPoolExecutor executor = new RecordingThreadPoolExecutor(); + Field field = JcasbinAuthorizer.class.getDeclaredField("executor"); + field.setAccessible(true); + FieldUtils.writeField(field, jcasbinAuthorizer, executor); + + jcasbinAuthorizer.close(); + + assertTrue(executor.shutdownCalled.get()); + assertTrue(executor.awaitTerminationCalled.get()); + } + @Test public void testRoleCacheSynchronousRemovalListenerDeletesPolicy() throws Exception { makeCompletableFutureUseCurrentThread(jcasbinAuthorizer); @@ -1239,6 +1287,14 @@ private static GravitinoCache> getOwnerRelCache( return (GravitinoCache>) field.get(authorizer); } + @SuppressWarnings("unchecked") + private static GravitinoCache getMetadataIdCache(JcasbinAuthorizer authorizer) + throws Exception { + Field field = JcasbinAuthorizer.class.getDeclaredField("metadataIdCache"); + field.setAccessible(true); + return (GravitinoCache) field.get(authorizer); + } + private static Enforcer getAllowEnforcer(JcasbinAuthorizer authorizer) throws Exception { Field field = JcasbinAuthorizer.class.getDeclaredField("allowEnforcer"); field.setAccessible(true); @@ -1250,4 +1306,25 @@ private static Enforcer getDenyEnforcer(JcasbinAuthorizer authorizer) throws Exc 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/TestJcasbinChangePoller.java b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java index 6c6cc9527f1..e9f4e05e6db 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 @@ -18,14 +18,45 @@ */ package org.apache.gravitino.server.authorization.jcasbin; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; import org.apache.gravitino.MetadataObject; +import org.apache.gravitino.cache.GravitinoCache; +import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper; +import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper; +import org.apache.gravitino.storage.relational.po.auth.OwnerInfo; +import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord; +import org.apache.gravitino.storage.relational.po.cache.OperateType; +import org.apache.gravitino.storage.relational.utils.SessionUtils; import org.apache.gravitino.utils.HierarchicalSchemaUtil; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; /** Tests for {@link JcasbinChangePoller} static helpers. */ public class TestJcasbinChangePoller { + @Test + void testRejectsNonPositivePollInterval() { + RecordingCache metadataIdCache = new RecordingCache<>(); + RecordingCache> ownerRelCache = new RecordingCache<>(); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new JcasbinChangePoller(metadataIdCache, ownerRelCache, 0)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new JcasbinChangePoller(metadataIdCache, ownerRelCache, -1)); + } + @Test void testChangeLogFullNameStripsLeadingMetalakeForChildTypes() { MetadataObject catalog = @@ -56,7 +87,86 @@ void testChangeLogFullNameForMetalakeKeepsItself() { key("ml1", ""), JcasbinAuthorizationLookups.buildCacheKey("ml1", metalake)); } + @Test + void testPollEntityChangesCoalescesContainerPrefixes() { + RecordingCache metadataIdCache = new RecordingCache<>(); + RecordingCache> ownerRelCache = new RecordingCache<>(); + EntityChangeLogMapper entityChangeLogMapper = mock(EntityChangeLogMapper.class); + OwnerMetaMapper ownerMetaMapper = mock(OwnerMetaMapper.class); + + when(ownerMetaMapper.selectChangedOwners(0L)).thenReturn(Collections.emptyList()); + when(entityChangeLogMapper.selectEntityChanges(0L, 500)) + .thenReturn( + List.of( + change(1L, MetadataObject.Type.CATALOG, "ml1.cat1"), + change(2L, MetadataObject.Type.SCHEMA, "ml1.cat1.sch1"), + change(3L, MetadataObject.Type.TABLE, "ml1.cat1.sch1.tbl1"), + change(4L, MetadataObject.Type.TABLE, "ml1.cat2.sch1.tbl1"))); + + try (MockedStatic sessionUtils = mockStatic(SessionUtils.class)) { + sessionUtils + .when(() -> SessionUtils.getWithoutCommit(any(), any())) + .thenAnswer( + invocation -> { + Class mapperClass = invocation.getArgument(0); + Function func = invocation.getArgument(1); + if (mapperClass == OwnerMetaMapper.class) { + return func.apply(ownerMetaMapper); + } + if (mapperClass == EntityChangeLogMapper.class) { + return func.apply(entityChangeLogMapper); + } + return null; + }); + + JcasbinChangePoller poller = new JcasbinChangePoller(metadataIdCache, ownerRelCache, 1); + poller.pollChanges(); + } + + Assertions.assertEquals(List.of(key("ml1", "cat1", "")), metadataIdCache.invalidatedPrefixes); + Assertions.assertEquals( + List.of(key("ml1", "cat2", "sch1", "tbl1", "TABLE")), metadataIdCache.invalidatedKeys); + } + private static String key(String... parts) { return String.join(HierarchicalSchemaUtil.internalSeparator(), parts); } + + private static EntityChangeRecord change(long id, MetadataObject.Type type, String fullName) { + return new EntityChangeRecord(id, "ml1", type.name(), fullName, OperateType.ALTER, 0L); + } + + private static class RecordingCache implements GravitinoCache { + private final List invalidatedKeys = new ArrayList<>(); + private final List invalidatedPrefixes = new ArrayList<>(); + + @Override + public Optional getIfPresent(K key) { + return Optional.empty(); + } + + @Override + public void put(K key, V value) {} + + @Override + public void invalidate(K key) { + invalidatedKeys.add(key); + } + + @Override + public void invalidateAll() {} + + @Override + public void invalidateByPrefix(String prefix) { + invalidatedPrefixes.add(prefix); + } + + @Override + public long size() { + return 0; + } + + @Override + public void close() {} + } } From 3718d64a2dcb662c1d7707b7c2d6547812ff3f89 Mon Sep 17 00:00:00 2001 From: yuqi Date: Mon, 18 May 2026 15:10:36 +0800 Subject: [PATCH 08/23] fix --- .../gravitino/utils/HierarchicalSchemaUtil.java | 15 +++++---------- .../utils/TestHierarchicalSchemaUtil.java | 1 - 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java b/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java index 276c2f5cf83..4d6a3fae91c 100644 --- a/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java +++ b/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java @@ -41,8 +41,8 @@ */ public final class HierarchicalSchemaUtil { - /** The internal separator used in EntityStore and internal path-based keys. */ - private static final String INTERNAL_SEPARATOR = "\u0001"; + /** The internal separator used in EntityStore for HierarchicalSchema names. */ + private static final String PHYSICAL_SEPARATOR = "\u0001"; private HierarchicalSchemaUtil() {} @@ -61,14 +61,9 @@ public static String schemaSeparator() { return StringUtils.defaultIfBlank(separator, Configs.SCHEMA_SEPARATOR.getDefaultValue()); } - /** Returns the internal separator used in EntityStore and internal path-based keys. */ - public static String internalSeparator() { - return INTERNAL_SEPARATOR; - } - /** Returns the internal physical separator used in EntityStore. */ public static String physicalSeparator() { - return internalSeparator(); + return PHYSICAL_SEPARATOR; } /** @@ -85,7 +80,7 @@ public static String logicalToPhysical(String logicalPath, String separator) { Preconditions.checkArgument( StringUtils.isNotBlank(logicalPath), "logicalPath must not be blank"); Preconditions.checkArgument(StringUtils.isNotBlank(separator), "separator must not be blank"); - return logicalPath.replace(separator, INTERNAL_SEPARATOR); + return logicalPath.replace(separator, PHYSICAL_SEPARATOR); } /** @@ -102,7 +97,7 @@ public static String physicalToLogical(String physicalName, String separator) { Preconditions.checkArgument( StringUtils.isNotBlank(physicalName), "physicalName must not be blank"); Preconditions.checkArgument(StringUtils.isNotBlank(separator), "separator must not be blank"); - return physicalName.replace(INTERNAL_SEPARATOR, separator); + return physicalName.replace(PHYSICAL_SEPARATOR, separator); } /** diff --git a/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java b/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java index 37541c6eab3..e1b65bb66fe 100644 --- a/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java +++ b/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java @@ -34,7 +34,6 @@ public class TestHierarchicalSchemaUtil { @Test public void testPhysicalSeparator() { Assertions.assertEquals(PHYS, HierarchicalSchemaUtil.physicalSeparator()); - Assertions.assertEquals(PHYS, HierarchicalSchemaUtil.internalSeparator()); } @Test From 3deda80469f45da6a0a07aa6b78fb6b5d923fdd8 Mon Sep 17 00:00:00 2001 From: yuqi Date: Mon, 18 May 2026 15:17:49 +0800 Subject: [PATCH 09/23] fix --- .../authorization/jcasbin/JcasbinAuthorizationLookups.java | 5 ++--- .../jcasbin/TestJcasbinAuthorizationLookups.java | 3 +-- .../authorization/jcasbin/TestJcasbinChangePoller.java | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) 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 c82aabe2b7f..f9171bd72e8 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 @@ -27,7 +27,6 @@ import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper; import org.apache.gravitino.storage.relational.po.auth.OwnerInfo; import org.apache.gravitino.storage.relational.utils.SessionUtils; -import org.apache.gravitino.utils.HierarchicalSchemaUtil; /** * Two-tier metadata-id and owner resolution for {@link JcasbinAuthorizer}. @@ -43,8 +42,8 @@ */ public class JcasbinAuthorizationLookups { - /** Key separator for internal path-based cache keys. */ - static final String KEY_SEP = HierarchicalSchemaUtil.internalSeparator(); + /** Unit Separator for internal path-based cache keys. */ + static final String KEY_SEP = "\u001F"; private final GravitinoCache metadataIdCache; private final GravitinoCache> ownerRelCache; 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 9ffb5191392..cfab7f8010d 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 @@ -27,7 +27,6 @@ import org.apache.gravitino.authorization.AuthorizationRequestContext; import org.apache.gravitino.cache.GravitinoCache; import org.apache.gravitino.storage.relational.po.auth.OwnerInfo; -import org.apache.gravitino.utils.HierarchicalSchemaUtil; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -158,7 +157,7 @@ void testResolveOwnerIdUsesAtomicSharedCacheAndRequestDedup() { } private static String key(String... parts) { - return String.join(HierarchicalSchemaUtil.internalSeparator(), parts); + return String.join(JcasbinAuthorizationLookups.KEY_SEP, parts); } private static class CountingCache implements GravitinoCache { 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 e9f4e05e6db..c99e509b438 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 @@ -36,7 +36,6 @@ import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord; import org.apache.gravitino.storage.relational.po.cache.OperateType; import org.apache.gravitino.storage.relational.utils.SessionUtils; -import org.apache.gravitino.utils.HierarchicalSchemaUtil; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -129,7 +128,7 @@ void testPollEntityChangesCoalescesContainerPrefixes() { } private static String key(String... parts) { - return String.join(HierarchicalSchemaUtil.internalSeparator(), parts); + return String.join(JcasbinAuthorizationLookups.KEY_SEP, parts); } private static EntityChangeRecord change(long id, MetadataObject.Type type, String fullName) { From 07c0ce046b5e1aea93805550cb33a79562783ec3 Mon Sep 17 00:00:00 2001 From: yuqi Date: Mon, 18 May 2026 20:12:53 +0800 Subject: [PATCH 10/23] Fix owner cache invalidation on initial owner set --- .../authorization/GravitinoAuthorizer.java | 8 ++- .../gravitino/authorization/OwnerManager.java | 56 ++++++++++--------- .../authorization/TestOwnerManager.java | 41 ++++++++++++++ 3 files changed, 77 insertions(+), 28 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java index 1561639be17..12529f74c66 100644 --- a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java +++ b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java @@ -19,6 +19,7 @@ import java.io.Closeable; import java.security.Principal; +import javax.annotation.Nullable; import org.apache.gravitino.Entity; import org.apache.gravitino.GravitinoEnv; import org.apache.gravitino.MetadataObject; @@ -160,12 +161,15 @@ default void handleRolePrivilegeChange(String metalake, String roleName) { * changes. * * @param metalake metalake; - * @param oldOwnerId The old owner id; + * @param oldOwnerId The old owner id; null when setting the first owner. * @param nameIdentifier The metadata name identifier; * @param type entity type */ void handleMetadataOwnerChange( - String metalake, Long oldOwnerId, NameIdentifier nameIdentifier, Entity.EntityType type); + String metalake, + @Nullable Long oldOwnerId, + NameIdentifier nameIdentifier, + Entity.EntityType type); /** * Called when an entity name-to-id mapping may have changed because of a rename or drop. diff --git a/core/src/main/java/org/apache/gravitino/authorization/OwnerManager.java b/core/src/main/java/org/apache/gravitino/authorization/OwnerManager.java index 70e10f14886..2655b0f4ba1 100644 --- a/core/src/main/java/org/apache/gravitino/authorization/OwnerManager.java +++ b/core/src/main/java/org/apache/gravitino/authorization/OwnerManager.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.util.List; import java.util.Optional; +import javax.annotation.Nullable; import lombok.Getter; import org.apache.gravitino.Entity; import org.apache.gravitino.EntityStore; @@ -118,7 +119,7 @@ public void setOwner( metadataObject, authorizationPlugin -> authorizationPlugin.onOwnerSet(metadataObject, originOwner.orElse(null), newOwner)); - originOwner.ifPresent(owner -> notifyOwnerChange(owner, metalake, metadataObject)); + notifyOwnerChange(originOwner.orElse(null), metalake, metadataObject); } catch (NoSuchEntityException nse) { LOG.warn( "Metadata object {} or owner {} is not found", metadataObject.fullName(), ownerName, nse); @@ -134,44 +135,47 @@ public void setOwner( } } - private void notifyOwnerChange(Owner oldOwner, String metalake, MetadataObject metadataObject) { + private void notifyOwnerChange( + @Nullable Owner oldOwner, String metalake, MetadataObject metadataObject) { GravitinoAuthorizer gravitinoAuthorizer = GravitinoEnv.getInstance().gravitinoAuthorizer(); if (gravitinoAuthorizer != null) { try { - Long oldOwnerId; - if (oldOwner.type() == Owner.Type.USER) { - UserEntity userEntity = - GravitinoEnv.getInstance() - .entityStore() - .get( - NameIdentifierUtil.ofUser(metalake, oldOwner.name()), - Entity.EntityType.USER, - UserEntity.class); - oldOwnerId = userEntity.id(); - } else if (oldOwner.type() == Owner.Type.GROUP) { - GroupEntity groupEntity = - GravitinoEnv.getInstance() - .entityStore() - .get( - NameIdentifierUtil.ofGroup(metalake, oldOwner.name()), - Entity.EntityType.GROUP, - GroupEntity.class); - oldOwnerId = groupEntity.id(); - } else { - LOG.warn("Unsupported owner type: {}", oldOwner.type()); - return; - } + Long oldOwnerId = oldOwner == null ? null : getOwnerId(metalake, oldOwner); gravitinoAuthorizer.handleMetadataOwnerChange( metalake, oldOwnerId, MetadataObjectUtil.toEntityIdent(metalake, metadataObject), Entity.EntityType.valueOf(metadataObject.type().name())); - } catch (IOException e) { + } catch (Exception e) { LOG.warn(e.getMessage(), e); } } } + private Long getOwnerId(String metalake, Owner owner) throws IOException { + if (owner.type() == Owner.Type.USER) { + UserEntity userEntity = + GravitinoEnv.getInstance() + .entityStore() + .get( + NameIdentifierUtil.ofUser(metalake, owner.name()), + Entity.EntityType.USER, + UserEntity.class); + return userEntity.id(); + } else if (owner.type() == Owner.Type.GROUP) { + GroupEntity groupEntity = + GravitinoEnv.getInstance() + .entityStore() + .get( + NameIdentifierUtil.ofGroup(metalake, owner.name()), + Entity.EntityType.GROUP, + GroupEntity.class); + return groupEntity.id(); + } else { + throw new IllegalArgumentException("Unsupported owner type: " + owner.type()); + } + } + @Override public Optional getOwner(String metalake, MetadataObject metadataObject) { NameIdentifier ident = MetadataObjectUtil.toEntityIdent(metalake, metadataObject); diff --git a/core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java b/core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java index 56dd78f7970..5226e5d13d5 100644 --- a/core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java +++ b/core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java @@ -43,14 +43,17 @@ import java.util.UUID; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.gravitino.Catalog; import org.apache.gravitino.Config; import org.apache.gravitino.Configs; +import org.apache.gravitino.Entity; import org.apache.gravitino.EntityStore; import org.apache.gravitino.EntityStoreFactory; import org.apache.gravitino.GravitinoEnv; import org.apache.gravitino.MetadataObject; import org.apache.gravitino.MetadataObjects; import org.apache.gravitino.NameIdentifier; +import org.apache.gravitino.Namespace; import org.apache.gravitino.catalog.CatalogManager; import org.apache.gravitino.connector.BaseCatalog; import org.apache.gravitino.connector.authorization.AuthorizationPlugin; @@ -59,6 +62,7 @@ import org.apache.gravitino.lock.LockManager; import org.apache.gravitino.meta.AuditInfo; import org.apache.gravitino.meta.BaseMetalake; +import org.apache.gravitino.meta.CatalogEntity; import org.apache.gravitino.meta.GroupEntity; import org.apache.gravitino.meta.SchemaVersion; import org.apache.gravitino.meta.UserEntity; @@ -243,4 +247,41 @@ public void testSetNonExistentGroupAsOwner() { ownerManager.setOwner( METALAKE, metalakeObject, "non-existent-group", Owner.Type.GROUP)); } + + @Test + @Order(4) + public void testInitialOwnerSetNotifiesAuthorizer() throws IllegalAccessException, IOException { + String catalogName = "catalog_initial_owner_notify"; + AuditInfo audit = AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build(); + CatalogEntity catalog = + CatalogEntity.builder() + .withId(idGenerator.nextId()) + .withName(catalogName) + .withNamespace(Namespace.of(METALAKE)) + .withType(Catalog.Type.RELATIONAL) + .withProvider("test") + .withAuditInfo(audit) + .build(); + entityStore.put(catalog, false); + + GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class); + GravitinoAuthorizer originalAuthorizer = GravitinoEnv.getInstance().gravitinoAuthorizer(); + FieldUtils.writeField(GravitinoEnv.getInstance(), "gravitinoAuthorizer", authorizer, true); + try { + MetadataObject catalogObject = + MetadataObjects.of(Lists.newArrayList(catalogName), MetadataObject.Type.CATALOG); + + ownerManager.setOwner(METALAKE, catalogObject, USER, Owner.Type.USER); + + Mockito.verify(authorizer) + .handleMetadataOwnerChange( + Mockito.eq(METALAKE), + Mockito.isNull(), + Mockito.eq(NameIdentifier.of(METALAKE, catalogName)), + Mockito.eq(Entity.EntityType.CATALOG)); + } finally { + FieldUtils.writeField( + GravitinoEnv.getInstance(), "gravitinoAuthorizer", originalAuthorizer, true); + } + } } From 4e2d2a3e2dac98531b7f4259e5cb0effae2368e7 Mon Sep 17 00:00:00 2001 From: yuqi Date: Mon, 18 May 2026 20:58:40 +0800 Subject: [PATCH 11/23] Synchronize jcasbin change poll cursor updates --- .../authorization/jcasbin/JcasbinChangePoller.java | 4 ++-- .../jcasbin/TestJcasbinChangePoller.java | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) 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 0a8577e4bee..0ad7a08a52f 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 @@ -139,7 +139,7 @@ void pollChanges() { * ownerRelCache} entries. Each row carries {@code metadataObjectId}, so invalidation is a direct * key removal — no name resolution needed. */ - private void pollOwnerChanges() { + private synchronized void pollOwnerChanges() { List changes = SessionUtils.getWithoutCommit( OwnerMetaMapper.class, m -> m.selectChangedOwners(ownerPollHighWaterId)); @@ -165,7 +165,7 @@ private void pollOwnerChanges() { * have populated under that name. If a future change starts emitting the new post-rename name, * this invalidation will silently miss and stale entries will only clear via LRU eviction. */ - private void pollEntityChanges() { + private synchronized void pollEntityChanges() { List changes = SessionUtils.getWithoutCommit( EntityChangeLogMapper.class, 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 c99e509b438..867a8626032 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 @@ -23,6 +23,8 @@ import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -127,6 +129,15 @@ void testPollEntityChangesCoalescesContainerPrefixes() { List.of(key("ml1", "cat2", "sch1", "tbl1", "TABLE")), metadataIdCache.invalidatedKeys); } + @Test + void testPollCursorAdvancementIsSynchronized() throws NoSuchMethodException { + Method pollOwnerChanges = JcasbinChangePoller.class.getDeclaredMethod("pollOwnerChanges"); + Method pollEntityChanges = JcasbinChangePoller.class.getDeclaredMethod("pollEntityChanges"); + + Assertions.assertTrue(Modifier.isSynchronized(pollOwnerChanges.getModifiers())); + Assertions.assertTrue(Modifier.isSynchronized(pollEntityChanges.getModifiers())); + } + private static String key(String... parts) { return String.join(JcasbinAuthorizationLookups.KEY_SEP, parts); } From 1a6e163344202fbc0955868e477e784dba786210 Mon Sep 17 00:00:00 2001 From: Qi Yu Date: Tue, 19 May 2026 09:22:22 +0800 Subject: [PATCH 12/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../jcasbin/TestJcasbinAuthorizer.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 571f4c632cc..5319c3fed2b 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 @@ -1077,12 +1077,18 @@ public void testCloseAwaitsRoleLoadExecutorTermination() throws Exception { RecordingThreadPoolExecutor executor = new RecordingThreadPoolExecutor(); Field field = JcasbinAuthorizer.class.getDeclaredField("executor"); field.setAccessible(true); - FieldUtils.writeField(field, jcasbinAuthorizer, executor); + Executor originalExecutor = (Executor) FieldUtils.readField(field, jcasbinAuthorizer, true); - jcasbinAuthorizer.close(); + try { + FieldUtils.writeField(field, jcasbinAuthorizer, executor, true); + + jcasbinAuthorizer.close(); - assertTrue(executor.shutdownCalled.get()); - assertTrue(executor.awaitTerminationCalled.get()); + assertTrue(executor.shutdownCalled.get()); + assertTrue(executor.awaitTerminationCalled.get()); + } finally { + FieldUtils.writeField(field, jcasbinAuthorizer, originalExecutor, true); + } } @Test From 9054927f6cc2064a8001dcbdfc1db1687a17a803 Mon Sep 17 00:00:00 2001 From: yuqi Date: Tue, 19 May 2026 09:52:28 +0800 Subject: [PATCH 13/23] fix --- .../org/apache/gravitino/authorization/AuthorizationUtils.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java index 556792f7421..2f5518ad84b 100644 --- a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java +++ b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java @@ -325,7 +325,6 @@ public static void checkPrivilege( public static void authorizationPluginRemovePrivileges( NameIdentifier ident, Entity.EntityType type, List locations) { - notifyEntityNameIdMappingChange(ident, type); // If we enable authorization, we should remove the privileges about the entity in the // authorization plugin. if (GravitinoEnv.getInstance().accessControlDispatcher() != null) { @@ -364,10 +363,10 @@ public static void authorizationPluginRenamePrivileges( public static void authorizationPluginRenamePrivileges( NameIdentifier ident, Entity.EntityType type, String newName, List locations) { - notifyEntityNameIdMappingChange(ident, type); // If we enable authorization, we should rename the privileges about the entity in the // authorization plugin. if (GravitinoEnv.getInstance().accessControlDispatcher() != null) { + notifyEntityNameIdMappingChange(ident, type); MetadataObject oldMetadataObject = NameIdentifierUtil.toMetadataObject(ident, type); MetadataObject newMetadataObject = NameIdentifierUtil.toMetadataObject(NameIdentifier.of(ident.namespace(), newName), type); From 113a5e4f25c8b3b55c52385a94f9f89ffd0a7460 Mon Sep 17 00:00:00 2001 From: yuqi Date: Tue, 19 May 2026 11:29:44 +0800 Subject: [PATCH 14/23] Isolate GravitinoEnv state in AuthorizationUtils notify tests Co-Authored-By: Claude Opus 4.7 --- .../authorization/TestAuthorizationUtils.java | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java index beb15d36074..83b180f1284 100644 --- a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java +++ b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java @@ -40,6 +40,7 @@ import org.apache.gravitino.rel.Table; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; import org.mockito.Mockito; class TestAuthorizationUtils { @@ -291,14 +292,14 @@ void testGetSchemaTypeMetadataObjectLocation() throws IllegalAccessException { } @Test - void testRemovePrivilegesNotifiesEntityNameIdMappingChange() throws IllegalAccessException { + void testRemovePrivilegesNotifiesEntityNameIdMappingChange() { GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class); - GravitinoAuthorizer originalAuthorizer = GravitinoEnv.getInstance().gravitinoAuthorizer(); - Object originalAccessControlDispatcher = - FieldUtils.readField(GravitinoEnv.getInstance(), "accessControlDispatcher", true); - try { - FieldUtils.writeField(GravitinoEnv.getInstance(), "gravitinoAuthorizer", authorizer, true); - FieldUtils.writeField(GravitinoEnv.getInstance(), "accessControlDispatcher", null, true); + GravitinoEnv envMock = Mockito.mock(GravitinoEnv.class); + Mockito.when(envMock.gravitinoAuthorizer()).thenReturn(authorizer); + Mockito.when(envMock.accessControlDispatcher()).thenReturn(null); + + try (MockedStatic envStatic = Mockito.mockStatic(GravitinoEnv.class)) { + envStatic.when(GravitinoEnv::getInstance).thenReturn(envMock); NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "schema", "table"); AuthorizationUtils.authorizationPluginRemovePrivileges( @@ -306,26 +307,18 @@ void testRemovePrivilegesNotifiesEntityNameIdMappingChange() throws IllegalAcces Mockito.verify(authorizer) .handleEntityNameIdMappingChange("metalake", ident, Entity.EntityType.TABLE); - } finally { - FieldUtils.writeField( - GravitinoEnv.getInstance(), "gravitinoAuthorizer", originalAuthorizer, true); - FieldUtils.writeField( - GravitinoEnv.getInstance(), - "accessControlDispatcher", - originalAccessControlDispatcher, - true); } } @Test - void testRenamePrivilegesNotifiesOldEntityNameIdMappingChange() throws IllegalAccessException { + void testRenamePrivilegesNotifiesOldEntityNameIdMappingChange() { GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class); - GravitinoAuthorizer originalAuthorizer = GravitinoEnv.getInstance().gravitinoAuthorizer(); - Object originalAccessControlDispatcher = - FieldUtils.readField(GravitinoEnv.getInstance(), "accessControlDispatcher", true); - try { - FieldUtils.writeField(GravitinoEnv.getInstance(), "gravitinoAuthorizer", authorizer, true); - FieldUtils.writeField(GravitinoEnv.getInstance(), "accessControlDispatcher", null, true); + GravitinoEnv envMock = Mockito.mock(GravitinoEnv.class); + Mockito.when(envMock.gravitinoAuthorizer()).thenReturn(authorizer); + Mockito.when(envMock.accessControlDispatcher()).thenReturn(null); + + try (MockedStatic envStatic = Mockito.mockStatic(GravitinoEnv.class)) { + envStatic.when(GravitinoEnv::getInstance).thenReturn(envMock); NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "schema", "table"); AuthorizationUtils.authorizationPluginRenamePrivileges( @@ -333,14 +326,6 @@ void testRenamePrivilegesNotifiesOldEntityNameIdMappingChange() throws IllegalAc Mockito.verify(authorizer) .handleEntityNameIdMappingChange("metalake", ident, Entity.EntityType.TABLE); - } finally { - FieldUtils.writeField( - GravitinoEnv.getInstance(), "gravitinoAuthorizer", originalAuthorizer, true); - FieldUtils.writeField( - GravitinoEnv.getInstance(), - "accessControlDispatcher", - originalAccessControlDispatcher, - true); } } } From 540fec79feb4d58a54c2e2d0f59acfd1ed8e96e1 Mon Sep 17 00:00:00 2001 From: yuqi Date: Tue, 19 May 2026 11:36:06 +0800 Subject: [PATCH 15/23] Align AuthorizationUtils notify tests with conditional rename branch Co-Authored-By: Claude Opus 4.7 --- .../authorization/TestAuthorizationUtils.java | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java index 83b180f1284..e27b68bb913 100644 --- a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java +++ b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java @@ -31,8 +31,10 @@ import org.apache.gravitino.Namespace; import org.apache.gravitino.Schema; import org.apache.gravitino.catalog.CatalogDispatcher; +import org.apache.gravitino.catalog.CatalogManager; import org.apache.gravitino.catalog.SchemaDispatcher; import org.apache.gravitino.catalog.TableDispatcher; +import org.apache.gravitino.connector.BaseCatalog; import org.apache.gravitino.exceptions.IllegalNameIdentifierException; import org.apache.gravitino.exceptions.IllegalNamespaceException; import org.apache.gravitino.meta.AuditInfo; @@ -291,31 +293,18 @@ void testGetSchemaTypeMetadataObjectLocation() throws IllegalAccessException { Assertions.assertEquals("schemaLocation", locations.get(0)); } - @Test - void testRemovePrivilegesNotifiesEntityNameIdMappingChange() { - GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class); - GravitinoEnv envMock = Mockito.mock(GravitinoEnv.class); - Mockito.when(envMock.gravitinoAuthorizer()).thenReturn(authorizer); - Mockito.when(envMock.accessControlDispatcher()).thenReturn(null); - - try (MockedStatic envStatic = Mockito.mockStatic(GravitinoEnv.class)) { - envStatic.when(GravitinoEnv::getInstance).thenReturn(envMock); - - NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "schema", "table"); - AuthorizationUtils.authorizationPluginRemovePrivileges( - ident, Entity.EntityType.TABLE, Lists.newArrayList()); - - Mockito.verify(authorizer) - .handleEntityNameIdMappingChange("metalake", ident, Entity.EntityType.TABLE); - } - } - @Test void testRenamePrivilegesNotifiesOldEntityNameIdMappingChange() { GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class); + AccessControlDispatcher accessControlDispatcher = Mockito.mock(AccessControlDispatcher.class); + CatalogManager catalogManager = Mockito.mock(CatalogManager.class); + BaseCatalog baseCatalog = Mockito.mock(BaseCatalog.class); + Mockito.when(catalogManager.loadCatalog(Mockito.any())).thenReturn(baseCatalog); + GravitinoEnv envMock = Mockito.mock(GravitinoEnv.class); Mockito.when(envMock.gravitinoAuthorizer()).thenReturn(authorizer); - Mockito.when(envMock.accessControlDispatcher()).thenReturn(null); + Mockito.when(envMock.accessControlDispatcher()).thenReturn(accessControlDispatcher); + Mockito.when(envMock.catalogManager()).thenReturn(catalogManager); try (MockedStatic envStatic = Mockito.mockStatic(GravitinoEnv.class)) { envStatic.when(GravitinoEnv::getInstance).thenReturn(envMock); From 1566925c753c097c7888f191c3cbef0edc65ac2b Mon Sep 17 00:00:00 2001 From: yuqi Date: Tue, 19 May 2026 15:02:56 +0800 Subject: [PATCH 16/23] Serialize cache reads against invalidations with a fair RW lock Co-Authored-By: Claude Opus 4.7 --- .../cache/CaffeineGravitinoCache.java | 78 ++++++++++++++-- .../gravitino/cache/GravitinoCache.java | 20 +++- .../gravitino/cache/TestGravitinoCache.java | 92 +++++++++++++++++++ .../jcasbin/JcasbinChangePoller.java | 47 +++++++--- 4 files changed, 212 insertions(+), 25 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java b/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java index 9647704372e..a5e7d0b064c 100644 --- a/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java +++ b/core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java @@ -25,12 +25,21 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Function; +import java.util.function.Supplier; /** * A Caffeine-backed implementation of {@link GravitinoCache}. Supports configurable TTL and maximum * size. * + *

A {@link ReentrantReadWriteLock} (fair) serialises invalidations against reads / puts so that + * a batched invalidation cannot be observed in a half-applied state by concurrent readers. Reads, + * loader-backed gets, and puts all hold the shared read lock and can run concurrently with each + * other; {@code invalidate*} operations hold the exclusive write lock and block any reader until + * they complete. Callers that need a multi-step invalidation to appear atomic can run it inside + * {@link #runInvalidationBatch(Runnable)}. + * * @param the key type * @param the value type */ @@ -38,6 +47,14 @@ public class CaffeineGravitinoCache implements GravitinoCache { private final Cache cache; + /** + * Fair mode prevents writer starvation: when an {@code invalidate*} call is parked waiting for + * the write lock, new readers queue behind it instead of slipping in ahead. This matters here + * because reads sit on the authorization hot path and would otherwise starve the rarer + * invalidations. + */ + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true); + /** * Creates a new CaffeineGravitinoCache with the given TTL and maximum size. * @@ -59,41 +76,57 @@ public CaffeineGravitinoCache(long ttlMs, long maxSize) { @Override public Optional getIfPresent(K key) { - V value = cache.getIfPresent(key); - return Optional.ofNullable(value); + return withReadLock(() -> Optional.ofNullable(cache.getIfPresent(key))); } @Override public V get(K key, Function loader) { - return cache.get( - key, k -> Objects.requireNonNull(loader.apply(k), "Cache loader must not return null")); + return withReadLock( + () -> + cache.get( + key, + k -> Objects.requireNonNull(loader.apply(k), "Cache loader must not return null"))); } @Override public void put(K key, V value) { - cache.put(key, value); + // Puts run under the shared read lock so that they may proceed concurrently with reads and + // with other puts; only invalidations need exclusive access. + withReadLock(() -> cache.put(key, value)); } @Override public void invalidate(K key) { - cache.invalidate(key); + withWriteLock(() -> cache.invalidate(key)); } @Override public void invalidateAll() { - cache.invalidateAll(); + withWriteLock(cache::invalidateAll); } @Override public void invalidateByPrefix(String prefix) { // Prefix invalidation scans all keys. It is intended for infrequent structural invalidations // such as dropping or renaming an entity hierarchy, not for per-request hot paths. - cache.asMap().keySet().removeIf(k -> k instanceof String && ((String) k).startsWith(prefix)); + withWriteLock( + () -> + cache + .asMap() + .keySet() + .removeIf(k -> k instanceof String && ((String) k).startsWith(prefix))); + } + + @Override + public void runInvalidationBatch(Runnable batch) { + // The write lock is reentrant, so individual invalidate* calls inside the batch re-acquire it + // without deadlocking. + withWriteLock(batch); } @Override public long size() { - return cache.estimatedSize(); + return withReadLock(cache::estimatedSize); } @VisibleForTesting @@ -106,4 +139,31 @@ public void close() { cache.invalidateAll(); cache.cleanUp(); } + + private T withReadLock(Supplier action) { + lock.readLock().lock(); + try { + return action.get(); + } finally { + lock.readLock().unlock(); + } + } + + private void withReadLock(Runnable action) { + lock.readLock().lock(); + try { + action.run(); + } finally { + lock.readLock().unlock(); + } + } + + private void withWriteLock(Runnable action) { + lock.writeLock().lock(); + try { + action.run(); + } finally { + lock.writeLock().unlock(); + } + } } diff --git a/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java b/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java index 15be6537e01..7eaf839b50c 100644 --- a/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java +++ b/core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java @@ -42,7 +42,9 @@ public interface GravitinoCache extends Closeable { /** * Returns the value associated with the key, loading it if necessary. Implementations with real - * storage should make the load atomic for the same key. + * storage should make the load atomic for the same key, and should serialise concurrent reads + * against any {@link #invalidate(Object) invalidate} call so a reader cannot observe a partially + * invalidated cache. * * @param key the cache key * @param loader the loader invoked when the key is absent @@ -85,6 +87,22 @@ default V get(K key, Function loader) { */ void invalidateByPrefix(String prefix); + /** + * Runs {@code batch} so that all {@link #invalidate}, {@link #invalidateAll}, and {@link + * #invalidateByPrefix} calls it makes appear atomic to concurrent readers — i.e. readers see + * either the state from before the batch or the state after, never an intermediate half-applied + * state. + * + *

Default implementation: just runs the runnable, suitable for caches without real storage + * (e.g. {@link NoOpsGravitinoCache}). Storage-backed implementations should hold an exclusive + * lock for the duration of the batch. + * + * @param batch the invalidation sequence to run atomically + */ + default void runInvalidationBatch(Runnable batch) { + batch.run(); + } + /** * Returns the approximate number of entries in the cache. * 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 34b1c5ca86f..8cbf4e72323 100644 --- a/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java +++ b/core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java @@ -98,6 +98,98 @@ void testCaffeineGetLoadsSameKeyAtomically() throws Exception { } } + @Test + void testCaffeineInvalidateWaitsForInFlightReader() throws Exception { + CaffeineGravitinoCache cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + ExecutorService executorService = Executors.newFixedThreadPool(2); + try { + CountDownLatch readerHoldsLock = new CountDownLatch(1); + CountDownLatch readerMayProceed = new CountDownLatch(1); + + Future reader = + executorService.submit( + () -> + cache.get( + "k", + key -> { + readerHoldsLock.countDown(); + try { + Assertions.assertTrue(readerMayProceed.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + return 42L; + })); + + Assertions.assertTrue(readerHoldsLock.await(5, TimeUnit.SECONDS)); + Future invalidator = executorService.submit(() -> cache.invalidate("k")); + + // Invalidator should be parked on the write lock until the reader releases its read lock. + Thread.sleep(150); + Assertions.assertFalse( + invalidator.isDone(), "invalidate must not proceed while a reader holds the read lock"); + + readerMayProceed.countDown(); + Assertions.assertEquals(42L, reader.get(5, TimeUnit.SECONDS)); + invalidator.get(5, TimeUnit.SECONDS); + + // Reader installed the value, invalidator then removed it. + Assertions.assertFalse(cache.getIfPresent("k").isPresent()); + } finally { + executorService.shutdownNow(); + cache.close(); + } + } + + @Test + void testCaffeineRunInvalidationBatchIsAtomicForReaders() throws Exception { + CaffeineGravitinoCache cache = new CaffeineGravitinoCache<>(60_000L, 1000L); + ExecutorService executorService = Executors.newFixedThreadPool(2); + try { + cache.put("k1", 1L); + cache.put("k2", 2L); + cache.put("k3", 3L); + + CountDownLatch batchEntered = new CountDownLatch(1); + CountDownLatch batchMayProceed = new CountDownLatch(1); + + Future batcher = + executorService.submit( + () -> + cache.runInvalidationBatch( + () -> { + batchEntered.countDown(); + try { + Assertions.assertTrue(batchMayProceed.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + cache.invalidate("k1"); + cache.invalidate("k2"); + cache.invalidate("k3"); + })); + + Assertions.assertTrue(batchEntered.await(5, TimeUnit.SECONDS)); + + // Reader started while the batcher holds the write lock must block until the batch ends. + Future> reader = executorService.submit(() -> cache.getIfPresent("k2")); + Thread.sleep(150); + Assertions.assertFalse(reader.isDone(), "reader must wait for the batch to release the lock"); + + batchMayProceed.countDown(); + batcher.get(5, TimeUnit.SECONDS); + + // Reader observes the post-batch state, never a partially-invalidated cache. + Assertions.assertFalse(reader.get(5, TimeUnit.SECONDS).isPresent()); + Assertions.assertEquals(0, cache.size()); + } finally { + executorService.shutdownNow(); + cache.close(); + } + } + @Test void testCaffeineGetRejectsNullLoadResult() { CaffeineGravitinoCache cache = new CaffeineGravitinoCache<>(60_000L, 1000L); 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 0ad7a08a52f..b8b2a7498ae 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 @@ -143,15 +143,24 @@ private synchronized void pollOwnerChanges() { List changes = SessionUtils.getWithoutCommit( OwnerMetaMapper.class, m -> m.selectChangedOwners(ownerPollHighWaterId)); - - long maxSeenId = ownerPollHighWaterId; - for (ChangedOwnerInfo change : changes) { - ownerRelCache.invalidate(change.getMetadataObjectId()); - if (change.getId() > maxSeenId) { - maxSeenId = change.getId(); - } + if (changes.isEmpty()) { + return; } - ownerPollHighWaterId = maxSeenId; + + long[] maxSeenId = {ownerPollHighWaterId}; + // Hold the cache's exclusive invalidation lock for the whole batch so readers never observe + // a half-applied state where some of this batch's entries have been evicted and others are + // still hot. + ownerRelCache.runInvalidationBatch( + () -> { + for (ChangedOwnerInfo change : changes) { + ownerRelCache.invalidate(change.getMetadataObjectId()); + if (change.getId() > maxSeenId[0]) { + maxSeenId[0] = change.getId(); + } + } + }); + ownerPollHighWaterId = maxSeenId[0]; } /** @@ -249,13 +258,21 @@ private static void addCoalescedPrefix(Set prefixes, String candidate) { } private void invalidateCoalescedKeys(Set prefixes, Set leafKeys) { - for (String prefix : prefixes) { - metadataIdCache.invalidateByPrefix(prefix); - } - for (String leafKey : leafKeys) { - if (prefixes.stream().noneMatch(leafKey::startsWith)) { - metadataIdCache.invalidate(leafKey); - } + if (prefixes.isEmpty() && leafKeys.isEmpty()) { + return; } + // Hold the cache's exclusive invalidation lock for the whole batch so readers never observe + // a half-applied state where some prefix/leaf keys have been evicted and others have not. + metadataIdCache.runInvalidationBatch( + () -> { + for (String prefix : prefixes) { + metadataIdCache.invalidateByPrefix(prefix); + } + for (String leafKey : leafKeys) { + if (prefixes.stream().noneMatch(leafKey::startsWith)) { + metadataIdCache.invalidate(leafKey); + } + } + }); } } From a42462a40bfa29b65de49c81a28ebca9f8734e95 Mon Sep 17 00:00:00 2001 From: yuqi Date: Tue, 19 May 2026 15:26:52 +0800 Subject: [PATCH 17/23] Document defensive synchronized on jcasbin poll methods Co-Authored-By: Claude Opus 4.7 --- .../authorization/jcasbin/JcasbinChangePoller.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 b8b2a7498ae..cfc54599fff 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 @@ -138,6 +138,16 @@ void pollChanges() { * Drains owner-change rows past {@link #ownerPollHighWaterId} and invalidates the affected {@code * ownerRelCache} entries. Each row carries {@code metadataObjectId}, so invalidation is a direct * key removal — no name resolution needed. + * + *

The {@code synchronized} modifier is defensive. In production this method is only invoked + * from the single-threaded scheduler started in {@link #start()}, and {@link + * java.util.concurrent.ScheduledExecutorService#scheduleWithFixedDelay} guarantees that + * consecutive runs do not overlap. The cursor field {@link #ownerPollHighWaterId} is also {@code + * volatile}, and cache invalidations are now atomic at the cache layer via {@link + * org.apache.gravitino.cache.GravitinoCache#runInvalidationBatch}. The keyword is kept so that + * future callers — additional schedulers, ad-hoc invocations from tests or admin tooling — do not + * silently introduce concurrent {@code "select changes → invalidate → advance cursor"} sequences. + * Cost is negligible under no contention thanks to biased / elided locking. */ private synchronized void pollOwnerChanges() { List changes = @@ -173,6 +183,10 @@ private synchronized void pollOwnerChanges() { * current name on drop, so the cacheKey we build here resolves to the entry a peer node would * have populated under that name. If a future change starts emitting the new post-rename name, * this invalidation will silently miss and stale entries will only clear via LRU eviction. + * + *

The {@code synchronized} modifier is defensive — see the note on {@link #pollOwnerChanges()} + * for the rationale. The single-threaded scheduler already prevents overlapping runs in + * production, and the per-batch invalidation atomicity is provided by the cache itself. */ private synchronized void pollEntityChanges() { List changes = From 6833ff32778571833a094ab8228769481e1d7861 Mon Sep 17 00:00:00 2001 From: yuqi Date: Tue, 19 May 2026 15:37:13 +0800 Subject: [PATCH 18/23] Address Copilot review on poller shutdown and owner notify - Isolate JcasbinAuthorizer close-test so it does not tear down the shared poller/caches used by sibling tests. - Preserve interrupt status in JcasbinChangePoller so shutdownNow stops the poll cycle promptly. - Split IOException from RuntimeException in OwnerManager owner-change notification and log full context. Co-Authored-By: Claude Opus 4.7 --- .../gravitino/authorization/OwnerManager.java | 43 ++++++++++++++----- .../jcasbin/JcasbinChangePoller.java | 28 ++++++++++++ .../jcasbin/TestJcasbinAuthorizer.java | 28 +++++++++--- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/authorization/OwnerManager.java b/core/src/main/java/org/apache/gravitino/authorization/OwnerManager.java index 2655b0f4ba1..9e17ef64ec2 100644 --- a/core/src/main/java/org/apache/gravitino/authorization/OwnerManager.java +++ b/core/src/main/java/org/apache/gravitino/authorization/OwnerManager.java @@ -138,17 +138,38 @@ public void setOwner( private void notifyOwnerChange( @Nullable Owner oldOwner, String metalake, MetadataObject metadataObject) { GravitinoAuthorizer gravitinoAuthorizer = GravitinoEnv.getInstance().gravitinoAuthorizer(); - if (gravitinoAuthorizer != null) { - try { - Long oldOwnerId = oldOwner == null ? null : getOwnerId(metalake, oldOwner); - gravitinoAuthorizer.handleMetadataOwnerChange( - metalake, - oldOwnerId, - MetadataObjectUtil.toEntityIdent(metalake, metadataObject), - Entity.EntityType.valueOf(metadataObject.type().name())); - } catch (Exception e) { - LOG.warn(e.getMessage(), e); - } + if (gravitinoAuthorizer == null) { + return; + } + + Long oldOwnerId; + try { + oldOwnerId = oldOwner == null ? null : getOwnerId(metalake, oldOwner); + } catch (IOException e) { + LOG.warn( + "Failed to resolve previous owner id for {} (metalake={}, oldOwner={}); " + + "cache invalidation for this change may be skipped on this node", + metadataObject.fullName(), + metalake, + oldOwner, + e); + return; + } + + try { + gravitinoAuthorizer.handleMetadataOwnerChange( + metalake, + oldOwnerId, + MetadataObjectUtil.toEntityIdent(metalake, metadataObject), + Entity.EntityType.valueOf(metadataObject.type().name())); + } catch (RuntimeException e) { + // Best-effort hook: a failing authorizer must not fail the owner-change operation itself. + LOG.warn( + "Authorizer hook failed for owner change on {} (metalake={}, oldOwnerId={})", + metadataObject.fullName(), + metalake, + oldOwnerId, + e); } } 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 cfc54599fff..611cc9616bb 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 @@ -123,6 +123,9 @@ void pollChanges() { LOG.debug("Polling for owner changes after id {}", ownerPollHighWaterId); pollOwnerChanges(); } catch (Exception e) { + if (handleInterruptIfAny(e, "Owner change poll")) { + return; + } LOG.warn("Owner change poll failed", e); } @@ -130,10 +133,35 @@ void pollChanges() { LOG.debug("Polling for entity changes after id {}", entityPollHighWaterId); pollEntityChanges(); } catch (Exception e) { + if (handleInterruptIfAny(e, "Entity change poll")) { + return; + } LOG.warn("Entity change poll failed", e); } } + /** + * Restores the interrupt flag and returns true if {@code e} carries (or the current thread has + * accumulated) an interruption, so the caller can bail out of this poll cycle quickly during + * {@link #close()}-driven {@code shutdownNow()}. + */ + private static boolean handleInterruptIfAny(Throwable e, String context) { + Throwable t = e; + while (t != null) { + if (t instanceof InterruptedException) { + Thread.currentThread().interrupt(); + LOG.debug("{} interrupted, stopping poll cycle", context); + return true; + } + t = t.getCause(); + } + if (Thread.currentThread().isInterrupted()) { + LOG.debug("{} ran while thread was interrupted, stopping poll cycle", context); + return true; + } + return false; + } + /** * Drains owner-change rows past {@link #ownerPollHighWaterId} and invalidates the affected {@code * ownerRelCache} entries. Each row carries {@code metadataObjectId}, so invalidation is a direct 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 5319c3fed2b..3e65d4fb4a4 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 @@ -1075,19 +1075,37 @@ public void testOwnerChangeBestEffortWhenMetadataIdLookupFails() throws Exceptio @Test public void testCloseAwaitsRoleLoadExecutorTermination() throws Exception { RecordingThreadPoolExecutor executor = new RecordingThreadPoolExecutor(); - Field field = JcasbinAuthorizer.class.getDeclaredField("executor"); - field.setAccessible(true); - Executor originalExecutor = (Executor) FieldUtils.readField(field, jcasbinAuthorizer, true); + + 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(field, jcasbinAuthorizer, executor, true); + 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(field, jcasbinAuthorizer, originalExecutor, true); + FieldUtils.writeField(executorField, jcasbinAuthorizer, originalExecutor, true); + FieldUtils.writeField(changePollerField, jcasbinAuthorizer, originalPoller, true); + FieldUtils.writeField(metadataIdCacheField, jcasbinAuthorizer, originalMetadataIdCache, true); + FieldUtils.writeField(ownerRelCacheField, jcasbinAuthorizer, originalOwnerRelCache, true); } } From 074baad0746f3bd859789cfb5d9912994c16306b Mon Sep 17 00:00:00 2001 From: yuqi Date: Wed, 20 May 2026 17:39:23 +0800 Subject: [PATCH 19/23] refactor(auth): centralize jcasbin cache keys --- .../JcasbinAuthorizationCacheKeys.java | 70 +++++++++ .../jcasbin/JcasbinAuthorizationLookups.java | 56 +------ .../jcasbin/JcasbinAuthorizer.java | 31 ++-- .../jcasbin/JcasbinChangePoller.java | 4 +- .../TestJcasbinAuthorizationCacheKeys.java | 142 ++++++++++++++++++ .../TestJcasbinAuthorizationLookups.java | 94 +----------- .../jcasbin/TestJcasbinAuthorizer.java | 2 +- .../jcasbin/TestJcasbinChangePoller.java | 11 +- 8 files changed, 237 insertions(+), 173 deletions(-) create mode 100644 server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationCacheKeys.java create mode 100644 server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationCacheKeys.java 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 00000000000..28723d3cb4f --- /dev/null +++ b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationCacheKeys.java @@ -0,0 +1,70 @@ +/* + * 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.google.common.annotations.VisibleForTesting; +import org.apache.gravitino.MetadataObject; + +/** Cache key factory for JCasbin authorization caches. */ +final class JcasbinAuthorizationCacheKeys { + + /** Unit Separator for internal cache keys. */ + static final String SEPARATOR = "\u001F"; + + private JcasbinAuthorizationCacheKeys() {} + + /** + * Builds a path-based key for the metadata id cache. + * + *

Container objects end with the internal separator so prefix invalidation can remove both the + * container and entries under the same name path. Leaf objects include the type suffix to avoid + * collisions between objects that share the same name path. + */ + static String metadataObjectKey(String metalake, MetadataObject metadataObject) { + if (metadataObject.type() == MetadataObject.Type.METALAKE) { + return metalake + SEPARATOR; + } + + StringBuilder sb = new StringBuilder(metalake); + sb.append(SEPARATOR); + sb.append(String.join(SEPARATOR, metadataObject.fullName().split("\\."))); + if (isMetadataContainer(metadataObject.type())) { + sb.append(SEPARATOR); + } else { + sb.append(SEPARATOR); + sb.append(metadataObject.type().name()); + } + return sb.toString(); + } + + static String userRoleKey(String metalake, String username) { + return "USER" + SEPARATOR + metalake + SEPARATOR + username; + } + + static String groupRoleKey(String metalake, String groupname) { + return "GROUP" + SEPARATOR + metalake + SEPARATOR + groupname; + } + + @VisibleForTesting + static boolean isMetadataContainer(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/JcasbinAuthorizationLookups.java b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java index f9171bd72e8..00c0c374423 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; @@ -42,9 +41,6 @@ */ public class JcasbinAuthorizationLookups { - /** Unit Separator for internal path-based cache keys. */ - static final String KEY_SEP = "\u001F"; - private final GravitinoCache metadataIdCache; private final GravitinoCache> ownerRelCache; @@ -52,7 +48,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( @@ -69,7 +65,7 @@ public JcasbinAuthorizationLookups( */ public Long resolveMetadataId( MetadataObject metadataObject, String metalake, AuthorizationRequestContext requestContext) { - String cacheKey = buildCacheKey(metalake, metadataObject); + String cacheKey = JcasbinAuthorizationCacheKeys.metadataObjectKey(metalake, metadataObject); return requestContext.computeMetadataIdIfAbsent( cacheKey, k -> @@ -98,52 +94,4 @@ public Optional resolveOwnerId( return ownerInfo == null ? Optional.empty() : Optional.of(ownerInfo); })); } - - /** Underlying metadata-id cache; exposed for invalidation by the change hooks and the poller. */ - public GravitinoCache metadataIdCache() { - return metadataIdCache; - } - - /** Underlying owner cache; exposed for invalidation by the change hooks and the poller. */ - public GravitinoCache> 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. - * - *

Examples: {@code metalake}, {@code metalakecatalog}, {@code - * metalakecatalogschema}, {@code - * metalakecatalogschematableTABLE}. - */ - @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 e57d5f8a1c6..90be5c64d9d 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 @@ -110,9 +110,6 @@ public class JcasbinAuthorizer implements GravitinoAuthorizer { private static final Logger LOG = LoggerFactory.getLogger(JcasbinAuthorizer.class); - /** Key separator for hierarchical cache keys. */ - static final String KEY_SEP = "::"; - /** Jcasbin enforcer is used for metadata authorization. */ private Enforcer allowEnforcer; @@ -128,14 +125,14 @@ public class JcasbinAuthorizer implements GravitinoAuthorizer { // ---- Version-validated caches (strong consistency) ---- /** - * userRoleCache: metalake::userName -> CachedUserRoles. Version-validated per request via + * userRoleCache: per-(metalake, userName) -> CachedUserRoles. Version-validated per request via * user_meta.updated_at. */ private GravitinoCache userRoleCache; /** - * groupRoleCache: metalake::groupName -> CachedGroupRoles. Version-validated per request via - * group_meta.updated_at. + * groupRoleCache: per-(metalake, groupName) -> CachedGroupRoles. Version-validated per request + * via group_meta.updated_at. */ private GravitinoCache groupRoleCache; @@ -146,10 +143,7 @@ public class JcasbinAuthorizer implements GravitinoAuthorizer { // ---- Eventual consistency caches (poller-driven) ---- - /** - * Path-based key {@code metalake::catalog::schema::object::TYPE} -> entity id. Evicted by entity - * change poller. - */ + /** Path-based metadata object key -> entity id. Evicted by entity change poller. */ private GravitinoCache metadataIdCache; /** ownerRelCache: metadataObjectId -> Optional(owner). Evicted by owner change poller. */ @@ -486,7 +480,8 @@ public void handleMetadataOwnerChange( 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.metadataObjectKey(metalake, metadataObject)); try { Long metadataId = MetadataIdConverter.getID(metadataObject, metalake); ownerRelCache.invalidate(metadataId); @@ -499,9 +494,9 @@ public void handleMetadataOwnerChange( 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.metadataObjectKey(metalake, metadataObject); + if (JcasbinAuthorizationCacheKeys.isMetadataContainer(metadataObject.type())) { + // Prefix invalidation removes a container and all children under the same name path. metadataIdCache.invalidateByPrefix(cacheKey); } else { metadataIdCache.invalidate(cacheKey); @@ -598,7 +593,7 @@ private boolean authorizeInternal( */ private Optional loadUserInfo( String metalake, String username, AuthorizationRequestContext requestContext) { - String cacheKey = metalake + KEY_SEP + username; + String cacheKey = JcasbinAuthorizationCacheKeys.userRoleKey(metalake, username); return requestContext.computeUserInfoIfAbsent( cacheKey, k -> @@ -685,7 +680,7 @@ private void loadRolePrivilege( private List loadUserRoles( String metalake, String username, long userId, UserUpdatedAt userInfo) { - String userCacheKey = metalake + KEY_SEP + username; + String userCacheKey = JcasbinAuthorizationCacheKeys.userRoleKey(metalake, username); Optional cachedOpt = userRoleCache.getIfPresent(userCacheKey); if (cachedOpt.isPresent() && cachedOpt.get().getUpdatedAt() >= userInfo.getUpdatedAt()) { @@ -711,7 +706,7 @@ private List loadUserRoles( */ private Optional loadGroupInfo( String metalake, String groupname, AuthorizationRequestContext requestContext) { - String cacheKey = metalake + KEY_SEP + groupname; + String cacheKey = JcasbinAuthorizationCacheKeys.groupRoleKey(metalake, groupname); return requestContext.computeGroupInfoIfAbsent( cacheKey, k -> @@ -735,7 +730,7 @@ private List loadGroupRoles( } GroupUpdatedAt groupInfo = groupInfoOpt.get(); long groupId = groupInfo.getGroupId(); - String groupCacheKey = metalake + KEY_SEP + groupname; + String groupCacheKey = JcasbinAuthorizationCacheKeys.groupRoleKey(metalake, groupname); Optional cachedOpt = groupRoleCache.getIfPresent(groupCacheKey); if (cachedOpt.isPresent() && cachedOpt.get().getUpdatedAt() >= groupInfo.getUpdatedAt()) { 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 611cc9616bb..4b26b3ae405 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 @@ private synchronized void pollEntityChanges() { } MetadataObject mdObj = metadataObjectFromChangeLog(metalake, fullName, mdType); - String cacheKey = JcasbinAuthorizationLookups.buildCacheKey(metalake, mdObj); + String cacheKey = JcasbinAuthorizationCacheKeys.metadataObjectKey(metalake, mdObj); - if (JcasbinAuthorizationLookups.isContainerType(mdType)) { + if (JcasbinAuthorizationCacheKeys.isMetadataContainer(mdType)) { addCoalescedPrefix(containerPrefixes, cacheKey); } else { leafKeys.add(cacheKey); 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 00000000000..b88723231f7 --- /dev/null +++ b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationCacheKeys.java @@ -0,0 +1,142 @@ +/* + * 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 testMetadataObjectKeyMetalake() { + MetadataObject obj = + MetadataObjects.of(Collections.singletonList("ml1"), MetadataObject.Type.METALAKE); + Assertions.assertEquals( + key("ml1", ""), JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", obj)); + } + + @Test + void testMetadataObjectKeyCatalog() { + MetadataObject obj = + MetadataObjects.of(Collections.singletonList("cat1"), MetadataObject.Type.CATALOG); + Assertions.assertEquals( + key("ml1", "cat1", ""), JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", obj)); + } + + @Test + void testMetadataObjectKeySchema() { + MetadataObject obj = + MetadataObjects.of(Arrays.asList("cat1", "sch1"), MetadataObject.Type.SCHEMA); + Assertions.assertEquals( + key("ml1", "cat1", "sch1", ""), + JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", obj)); + } + + @Test + void testMetadataObjectKeyLeafTypesGetTypeSuffix() { + MetadataObject table = + MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"), MetadataObject.Type.TABLE); + Assertions.assertEquals( + key("ml1", "cat1", "sch1", "tbl1", "TABLE"), + JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", table)); + + MetadataObject view = + MetadataObjects.of(Arrays.asList("cat1", "sch1", "v1"), MetadataObject.Type.VIEW); + Assertions.assertEquals( + key("ml1", "cat1", "sch1", "v1", "VIEW"), + JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", view)); + + MetadataObject fileset = + MetadataObjects.of(Arrays.asList("cat1", "sch1", "fs1"), MetadataObject.Type.FILESET); + Assertions.assertEquals( + key("ml1", "cat1", "sch1", "fs1", "FILESET"), + JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", fileset)); + } + + @Test + void testPrincipalRoleKeysAreTyped() { + Assertions.assertEquals( + key("USER", "ml1", "alice"), JcasbinAuthorizationCacheKeys.userRoleKey("ml1", "alice")); + Assertions.assertEquals( + key("GROUP", "ml1", "admins"), JcasbinAuthorizationCacheKeys.groupRoleKey("ml1", "admins")); + } + + @Test + void testPrincipalRoleKeysAreDistinctFromMetadataKeys() { + MetadataObject metalake = + MetadataObjects.of(Collections.singletonList("ml1"), MetadataObject.Type.METALAKE); + String metalakeKey = JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", metalake); + String userKey = JcasbinAuthorizationCacheKeys.userRoleKey("ml1", "alice"); + String groupKey = JcasbinAuthorizationCacheKeys.groupRoleKey("ml1", "alice"); + + Assertions.assertNotEquals(metalakeKey, userKey); + Assertions.assertNotEquals(metalakeKey, groupKey); + Assertions.assertNotEquals(userKey, groupKey); + } + + @Test + void testIsMetadataContainerContainerTypes() { + Assertions.assertTrue( + JcasbinAuthorizationCacheKeys.isMetadataContainer(MetadataObject.Type.METALAKE)); + Assertions.assertTrue( + JcasbinAuthorizationCacheKeys.isMetadataContainer(MetadataObject.Type.CATALOG)); + Assertions.assertTrue( + JcasbinAuthorizationCacheKeys.isMetadataContainer(MetadataObject.Type.SCHEMA)); + } + + @Test + void testIsMetadataContainerLeafTypes() { + Assertions.assertFalse( + JcasbinAuthorizationCacheKeys.isMetadataContainer(MetadataObject.Type.TABLE)); + Assertions.assertFalse( + JcasbinAuthorizationCacheKeys.isMetadataContainer(MetadataObject.Type.VIEW)); + Assertions.assertFalse( + JcasbinAuthorizationCacheKeys.isMetadataContainer(MetadataObject.Type.FILESET)); + Assertions.assertFalse( + JcasbinAuthorizationCacheKeys.isMetadataContainer(MetadataObject.Type.TOPIC)); + } + + @Test + void testPrefixInvalidationCoversContainerPath() { + MetadataObject catalog = + MetadataObjects.of(Collections.singletonList("cat1"), MetadataObject.Type.CATALOG); + String catalogKey = JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", catalog); + + MetadataObject schema = + MetadataObjects.of(Arrays.asList("cat1", "sch1"), MetadataObject.Type.SCHEMA); + String schemaKey = JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", schema); + + MetadataObject table = + MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"), MetadataObject.Type.TABLE); + String tableKey = JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", table); + + Assertions.assertTrue(schemaKey.startsWith(catalogKey)); + Assertions.assertTrue(tableKey.startsWith(catalogKey)); + Assertions.assertTrue(tableKey.startsWith(schemaKey)); + } + + private static String key(String... parts) { + return String.join(JcasbinAuthorizationCacheKeys.SEPARATOR, 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 cfab7f8010d..285d0e1bfe6 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.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 = @@ -156,10 +68,6 @@ void testResolveOwnerIdUsesAtomicSharedCacheAndRequestDedup() { Assertions.assertEquals(0, ownerRelCache.putCount); } - private static String key(String... parts) { - return String.join(JcasbinAuthorizationLookups.KEY_SEP, parts); - } - private static class CountingCache implements GravitinoCache { 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 296f0c2da30..1e6d4d4b980 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 @@ -1111,7 +1111,7 @@ public void testOwnerChangeBestEffortWhenMetadataIdLookupFails() throws Exceptio GravitinoCache> ownerRelCache = getOwnerRelCache(jcasbinAuthorizer); NameIdentifier catalogIdent = NameIdentifierUtil.ofCatalog(METALAKE, "testCatalog"); String cacheKey = - JcasbinAuthorizationLookups.buildCacheKey( + JcasbinAuthorizationCacheKeys.metadataObjectKey( METALAKE, NameIdentifierUtil.toMetadataObject(catalogIdent, Entity.EntityType.CATALOG)); metadataIdCache.put(cacheKey, CATALOG_ID); 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 867a8626032..40e557ad45a 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,21 @@ void testChangeLogFullNameStripsLeadingMetalakeForChildTypes() { JcasbinChangePoller.metadataObjectFromChangeLog( "ml1", "ml1.cat1", MetadataObject.Type.CATALOG); Assertions.assertEquals( - key("ml1", "cat1", ""), JcasbinAuthorizationLookups.buildCacheKey("ml1", catalog)); + key("ml1", "cat1", ""), JcasbinAuthorizationCacheKeys.metadataObjectKey("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", "cat1", "sch1", ""), + JcasbinAuthorizationCacheKeys.metadataObjectKey("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)); + JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", table)); } @Test @@ -85,7 +86,7 @@ void testChangeLogFullNameForMetalakeKeepsItself() { MetadataObject metalake = JcasbinChangePoller.metadataObjectFromChangeLog("ml1", "ml1", MetadataObject.Type.METALAKE); Assertions.assertEquals( - key("ml1", ""), JcasbinAuthorizationLookups.buildCacheKey("ml1", metalake)); + key("ml1", ""), JcasbinAuthorizationCacheKeys.metadataObjectKey("ml1", metalake)); } @Test @@ -139,7 +140,7 @@ void testPollCursorAdvancementIsSynchronized() throws NoSuchMethodException { } private static String key(String... parts) { - return String.join(JcasbinAuthorizationLookups.KEY_SEP, parts); + return String.join(JcasbinAuthorizationCacheKeys.SEPARATOR, parts); } private static EntityChangeRecord change(long id, MetadataObject.Type type, String fullName) { From 17cada93df260ebe618af9b3d486b9ff1badd0f6 Mon Sep 17 00:00:00 2001 From: yuqi Date: Wed, 20 May 2026 19:17:17 +0800 Subject: [PATCH 20/23] fix(auth): remove unused server-common test lombok --- server-common/build.gradle.kts | 3 --- 1 file changed, 3 deletions(-) diff --git a/server-common/build.gradle.kts b/server-common/build.gradle.kts index 1bfb08efb04..0fdbe242953 100644 --- a/server-common/build.gradle.kts +++ b/server-common/build.gradle.kts @@ -58,9 +58,6 @@ dependencies { implementation(libs.prometheus.servlet) implementation(libs.nimbus.jose.jwt) - testAnnotationProcessor(libs.lombok) - testCompileOnly(libs.lombok) - testImplementation(libs.commons.io) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.params) From 166b3fd0fd0298f6bf1b06be10c5ab71080da9f2 Mon Sep 17 00:00:00 2001 From: yuqi Date: Wed, 20 May 2026 20:00:25 +0800 Subject: [PATCH 21/23] fix(auth): refresh jcasbin cached role policies safely --- .../jcasbin/JcasbinAuthorizationLookups.java | 9 ++ .../jcasbin/JcasbinAuthorizer.java | 37 ++++--- .../jcasbin/JcasbinLoadedRolesCache.java | 7 +- .../jcasbin/TestJcasbinAuthorizer.java | 97 +++++++++++++++---- 4 files changed, 111 insertions(+), 39 deletions(-) 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 00c0c374423..1ae9bf4fdbe 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 @@ -72,6 +72,15 @@ public Long resolveMetadataId( metadataIdCache.get(k, ignored -> MetadataIdConverter.getID(metadataObject, metalake))); } + /** + * Resolves the current metadata id directly from the entity store without populating the shared + * metadata-id cache. Use this in invalidation paths that have just removed the name→id mapping + * and only need the id to evict related caches. + */ + public Long resolveFreshMetadataId(MetadataObject metadataObject, String metalake) { + return MetadataIdConverter.getID(metadataObject, metalake); + } + /** * Two-tier owner lookup: request-level dedup first, then the shared {@code ownerRelCache}, and * finally a single {@code owner_meta} query. A successful DB fetch populates both tiers so 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 90be5c64d9d..25356ac9e0c 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 @@ -55,7 +55,6 @@ import org.apache.gravitino.cache.GravitinoCache; import org.apache.gravitino.meta.GroupEntity; import org.apache.gravitino.meta.RoleEntity; -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; @@ -90,7 +89,9 @@ * {@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 — {@code expireAfterAccess} only bounds memory. + * 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}. *

  • Eventual-consistency caches — {@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 @@ -336,9 +337,9 @@ public boolean isSelf(Entity.EntityType type, NameIdentifier nameIdentifier) { return Objects.equals(nameIdentifier.name(), currentUserName); } else if (Entity.EntityType.ROLE == type) { try { + MetadataObject metadataObject = NameIdentifierUtil.toMetadataObject(nameIdentifier, type); Long roleId = - MetadataIdConverter.getID( - NameIdentifierUtil.toMetadataObject(nameIdentifier, type), metalake); + lookups.resolveMetadataId(metadataObject, metalake, new AuthorizationRequestContext()); EntityStore entityStore = GravitinoEnv.getInstance().entityStore(); NameIdentifier userNameIdentifier = NameIdentifierUtil.ofUser(metalake, PrincipalUtils.getCurrentUserName()); @@ -483,7 +484,7 @@ public void handleMetadataOwnerChange( metadataIdCache.invalidate( JcasbinAuthorizationCacheKeys.metadataObjectKey(metalake, metadataObject)); try { - Long metadataId = MetadataIdConverter.getID(metadataObject, metalake); + Long metadataId = lookups.resolveFreshMetadataId(metadataObject, metalake); ownerRelCache.invalidate(metadataId); } catch (RuntimeException e) { LOG.warn("Failed to resolve metadata id for owner cache invalidation: {}", metadataObject, e); @@ -768,8 +769,8 @@ private List currentPrincipalGroupNames() { /** * Resolves GroupEntity objects for the current principal's groups, skipping any that are stale or - * not found in the store. Used by both {@link #isSelf} (ROLE branch) and {@link - * #loadRolePrivilege} to discover group-inherited role assignments. + * 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 resolveCurrentUserGroups(String metalake, EntityStore entityStore) { Principal principal = PrincipalUtils.getCurrentPrincipal(); @@ -805,30 +806,36 @@ private void versionCheckAndLoadRoles( continue; } - // Stale or missing — evict old policies and reload - if (cachedUpdatedAt.isPresent()) { - allowEnforcer.deleteRole(String.valueOf(roleId)); - denyEnforcer.deleteRole(String.valueOf(roleId)); - } - // Load full role entity using roleName from the batch query (no extra DB scan) + RoleEntity roleEntity; try { EntityStore entityStore = GravitinoEnv.getInstance().entityStore(); - RoleEntity roleEntity = + roleEntity = entityStore.get( NameIdentifierUtil.ofRole(metalake, rv.getRoleName()), Entity.EntityType.ROLE, RoleEntity.class); - loadPolicyByRoleEntity(roleEntity, requestContext); } catch (Exception e) { LOG.warn("Failed to load role policies for roleId {}", roleId, e); continue; } + // 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 roleIds) { for (Long roleId : roleIds) { allowEnforcer.addRoleForUser(String.valueOf(userId), String.valueOf(roleId)); diff --git a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java index 4595f42d620..0205ec1608e 100644 --- a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java +++ b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java @@ -20,6 +20,7 @@ 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; @@ -45,8 +46,8 @@ class JcasbinLoadedRolesCache implements GravitinoCache { .maximumSize(maxSize) .executor(Runnable::run) .removalListener( - (roleId, value, cause) -> { - if (roleId != null) { + (Long roleId, Long value, RemovalCause cause) -> { + if (roleId != null && cause != RemovalCause.REPLACED) { allowEnforcer.deleteRole(String.valueOf(roleId)); denyEnforcer.deleteRole(String.valueOf(roleId)); } @@ -76,7 +77,7 @@ public void invalidateAll() { @Override public void invalidateByPrefix(String prefix) { - cache.asMap().keySet().removeIf(k -> k.toString().startsWith(prefix)); + // Role ids are Long keys, so prefix invalidation is not meaningful for this cache. } @Override diff --git a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java index 1e6d4d4b980..d7a788e4446 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 @@ -36,6 +36,7 @@ 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; @@ -156,6 +157,10 @@ public class TestJcasbinAuthorizer { */ 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. @@ -333,14 +338,14 @@ public void testAuthorize() throws Exception { .thenReturn(allowRole); // Mock mapper: user has allowRole - long now = System.currentTimeMillis(); + 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", now))); + .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, now)); + .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion())); assertTrue(doAuthorize(currentPrincipal)); @@ -356,11 +361,11 @@ public void testAuthorize() throws Exception { .thenReturn(tempNewRole); RolePO tempNewRolePO = buildRolePO(newRoleId, "tempNewRole"); when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of(tempNewRolePO)); - long now2 = now + 1; + long roleVersion2 = nextRoleVersion(); when(roleMetaMapper.batchGetRoleUpdatedAt(any())) - .thenReturn(ImmutableList.of(new RoleUpdatedAt(newRoleId, "tempNewRole", now2))); + .thenReturn(ImmutableList.of(new RoleUpdatedAt(newRoleId, "tempNewRole", roleVersion2))); when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME))) - .thenReturn(new UserUpdatedAt(USER_ID, now2)); + .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion())); // tempNewRole has no privileges; prune step removes stale allowRole g-row, so authz fails. assertFalse(doAuthorize(currentPrincipal)); @@ -369,11 +374,11 @@ public void testAuthorize() throws Exception { // Re-assign allowRole, the authorization will succeed when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of(allowRolePO)); - long now3 = now2 + 1; + long roleVersion3 = nextRoleVersion(); when(roleMetaMapper.batchGetRoleUpdatedAt(any())) - .thenReturn(ImmutableList.of(new RoleUpdatedAt(ALLOW_ROLE_ID, "allowRole", now3))); + .thenReturn(ImmutableList.of(new RoleUpdatedAt(ALLOW_ROLE_ID, "allowRole", roleVersion3))); when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME))) - .thenReturn(new UserUpdatedAt(USER_ID, now3)); + .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion())); assertTrue(doAuthorize(currentPrincipal)); // Test deny @@ -387,14 +392,14 @@ public void testAuthorize() throws Exception { RolePO denyRolePO = buildRolePO(DENY_ROLE_ID, "denyRole"); when(roleMetaMapper.listRolesByUserId(eq(USER_ID))) .thenReturn(ImmutableList.of(allowRolePO, denyRolePO)); - long now4 = now3 + 1; + long roleVersion4 = nextRoleVersion(); when(roleMetaMapper.batchGetRoleUpdatedAt(any())) .thenReturn( ImmutableList.of( - new RoleUpdatedAt(ALLOW_ROLE_ID, "allowRole", now4), - new RoleUpdatedAt(DENY_ROLE_ID, "denyRole", now4))); + 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, now4)); + .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion())); assertFalse(doAuthorize(currentPrincipal)); } @@ -882,7 +887,7 @@ private static void restoreDefaultPrincipal() { private static void mockNoDirectUserRoles() throws IOException { when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of()); when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME))) - .thenReturn(new UserUpdatedAt(USER_ID, System.currentTimeMillis())); + .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion())); } /** @@ -896,7 +901,7 @@ private static void mockDirectUserRoles(RoleEntity... roles) { } when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(rolePOs); when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME))) - .thenReturn(new UserUpdatedAt(USER_ID, System.currentTimeMillis())); + .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion())); } /** @@ -911,7 +916,7 @@ private static RoleEntity mockRoleInStore( eq(Entity.EntityType.ROLE), eq(RoleEntity.class))) .thenReturn(role); - mockedRoleVersions.put(roleId, new RoleUpdatedAt(roleId, roleName, System.currentTimeMillis())); + mockedRoleVersions.put(roleId, new RoleUpdatedAt(roleId, roleName, nextRoleVersion())); return role; } @@ -1170,6 +1175,49 @@ public void testRoleCacheSynchronousRemovalListenerDeletesPolicy() throws Except assertFalse(denyEnforcer.hasPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG", "allow")); } + @Test + public void testRoleCacheReplacementDoesNotDeletePolicy() throws Exception { + Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer); + Enforcer denyEnforcer = getDenyEnforcer(jcasbinAuthorizer); + GravitinoCache 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 @@ -1322,22 +1370,29 @@ private static GravitinoCache> getOwnerRelCache( /** Mock mapper to assign zero roles. Bumps user version to invalidate cache. */ private static void mockUserRoles() { - long now = System.currentTimeMillis(); 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, now)); + .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 now = System.currentTimeMillis(); + 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, now))); + .thenReturn(ImmutableList.of(new RoleUpdatedAt(roleId, roleName, roleVersion))); when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME))) - .thenReturn(new UserUpdatedAt(USER_ID, now)); + .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) { From 31b68598de68e729615d95b06a718061c21801a5 Mon Sep 17 00:00:00 2001 From: yuqi Date: Wed, 20 May 2026 20:58:22 +0800 Subject: [PATCH 22/23] fix(auth): validate group id for jcasbin group role cache --- .../jcasbin/JcasbinAuthorizer.java | 19 ++++++----- .../jcasbin/TestJcasbinAuthorizer.java | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) 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 33f5deeb287..2e255f9fe90 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 @@ -833,11 +833,12 @@ private Optional loadGroupInfo( } /** - * Version-validated group-role load, mirroring {@link #loadUserRoles}. Uses {@code - * group_meta.updated_at} as the staleness sentinel: if the cached snapshot is at least as fresh - * as the DB version, we reuse it; otherwise we reload from {@code role_meta}. 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. + * 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 loadGroupRoles( String metalake, String groupname, long userId, AuthorizationRequestContext requestContext) { @@ -850,10 +851,12 @@ private List loadGroupRoles( String groupCacheKey = JcasbinAuthorizationCacheKeys.groupRoleKey(metalake, groupname); Optional cachedOpt = groupRoleCache.getIfPresent(groupCacheKey); - if (cachedOpt.isPresent() && cachedOpt.get().getUpdatedAt() >= groupInfo.getUpdatedAt()) { + if (cachedOpt.isPresent()) { CachedGroupRoles cached = cachedOpt.get(); - bindUserRoles(userId, cached.getRoleIds()); - return cached.getRoleIds(); + if (cached.getGroupId() == groupId && cached.getUpdatedAt() >= groupInfo.getUpdatedAt()) { + bindUserRoles(userId, cached.getRoleIds()); + return cached.getRoleIds(); + } } List rolePOs = 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 4a02844b481..87034edc1f4 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 @@ -645,6 +645,40 @@ public void testGroupRoleRevokedDeniesAccess() throws Exception { 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); From b83440840c8f0a0f3fb7e1df39b09755ab7e5f4d Mon Sep 17 00:00:00 2001 From: yuqi Date: Thu, 21 May 2026 15:58:17 +0800 Subject: [PATCH 23/23] Fix JCasbin user role cache validation --- server-common/build.gradle.kts | 3 --- .../jcasbin/CachedGroupRoles.java | 24 +++++++++++++---- .../jcasbin/CachedUserRoles.java | 24 +++++++++++++---- .../jcasbin/JcasbinAuthorizer.java | 7 +++-- .../jcasbin/TestJcasbinAuthorizer.java | 27 +++++++++++++++++++ 5 files changed, 70 insertions(+), 15 deletions(-) diff --git a/server-common/build.gradle.kts b/server-common/build.gradle.kts index 0fdbe242953..fdcdf2f9310 100644 --- a/server-common/build.gradle.kts +++ b/server-common/build.gradle.kts @@ -25,9 +25,6 @@ plugins { } dependencies { - annotationProcessor(libs.lombok) - compileOnly(libs.lombok) - implementation(project(":api")) implementation(project(":catalogs:catalog-common")) implementation(project(":common")) { diff --git a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoles.java b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoles.java index bf4956c43a8..442f9cdfbad 100644 --- a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoles.java +++ b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoles.java @@ -19,19 +19,33 @@ package org.apache.gravitino.server.authorization.jcasbin; import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Getter; /** * Cached snapshot of a group's role assignments. 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 list is stale and must be reloaded. */ -@Getter -@AllArgsConstructor -public class CachedGroupRoles { +final class CachedGroupRoles { private final long groupId; private final long updatedAt; private final List roleIds; + + CachedGroupRoles(long groupId, long updatedAt, List roleIds) { + this.groupId = groupId; + this.updatedAt = updatedAt; + this.roleIds = roleIds; + } + + long getGroupId() { + return groupId; + } + + long getUpdatedAt() { + return updatedAt; + } + + List getRoleIds() { + return roleIds; + } } diff --git a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoles.java b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoles.java index 871604e20ec..083e7366b86 100644 --- a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoles.java +++ b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoles.java @@ -19,19 +19,33 @@ package org.apache.gravitino.server.authorization.jcasbin; import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Getter; /** * Cached snapshot of a user's direct role assignments. 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 list is stale and must be reloaded. */ -@Getter -@AllArgsConstructor -public class CachedUserRoles { +final class CachedUserRoles { private final long userId; private final long updatedAt; private final List roleIds; + + CachedUserRoles(long userId, long updatedAt, List roleIds) { + this.userId = userId; + this.updatedAt = updatedAt; + this.roleIds = roleIds; + } + + long getUserId() { + return userId; + } + + long getUpdatedAt() { + return updatedAt; + } + + List getRoleIds() { + return roleIds; + } } 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 2e255f9fe90..2c302c20216 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 @@ -800,8 +800,11 @@ private List loadUserRoles( String userCacheKey = JcasbinAuthorizationCacheKeys.userRoleKey(metalake, username); Optional cachedOpt = userRoleCache.getIfPresent(userCacheKey); - if (cachedOpt.isPresent() && cachedOpt.get().getUpdatedAt() >= userInfo.getUpdatedAt()) { - // Cache is still valid + 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. CachedUserRoles cached = cachedOpt.get(); bindUserRoles(userId, cached.getRoleIds()); return cached.getRoleIds(); 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 87034edc1f4..b3bec108973 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 @@ -29,6 +29,7 @@ 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; @@ -403,6 +404,32 @@ public void testAuthorize() throws Exception { 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();