This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 7e63ac6775 [Cherry-pick to branch-1.3] [#12230] fix(auth): stop a lost
JCasbin role policy load from denying forever (#12231) (#12415)
7e63ac6775 is described below
commit 7e63ac6775fabd5cf91dbe12cc32dd59b3bf98e3
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Aug 11 19:40:33 2026 +0800
[Cherry-pick to branch-1.3] [#12230] fix(auth): stop a lost JCasbin role
policy load from denying forever (#12231) (#12415)
**Cherry-pick Information:**
- Original commit: e85e654d91b297b9e53c5e329717eb5efe39307c
- Target branch: `branch-1.3`
- Status: ✅ **Conflicts resolved**
The branch-1.3 conflicts were resolved in b355c0b3df.
**Validation:**
- `./gradlew spotlessApply`
- `./gradlew :server-common:test -PskipITs`
---------
Co-authored-by: Qi Yu <[email protected]>
---
.../authorization/jcasbin/JcasbinAuthorizer.java | 354 ++++++++++++++++++---
.../jcasbin/JcasbinLoadedRolesCache.java | 17 +-
.../jcasbin/ResolvedRolePolicies.java | 61 ++++
.../jcasbin/TestJcasbinAuthorizer.java | 247 +++++++++++++-
.../jcasbin/TestJcasbinLoadedRolesCache.java | 79 +++++
5 files changed, 709 insertions(+), 49 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 432207e146..7e68172bf3 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
@@ -34,6 +34,7 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
@@ -93,9 +94,9 @@ import org.slf4j.LoggerFactory;
* {@link #groupRoleCache}, {@link #loadedRoles}. Each cached entry
carries the {@code
* *_meta.updated_at} value it was loaded against; every read issues a
lightweight version
* probe and discards the entry if the DB sentinel has advanced. No TTL
is relied on for
- * correctness — TTL eviction only bounds memory. User/group role
snapshots use write-based
- * TTLs through {@link CaffeineGravitinoCache}; loaded role policies use
access-based TTLs
- * through {@link JcasbinLoadedRolesCache}.
+ * correctness — TTL eviction only bounds memory. User/group role
snapshots and loaded role
+ * policies both use write-based TTLs through {@link
CaffeineGravitinoCache} and {@link
+ * JcasbinLoadedRolesCache}, respectively.
* <li><b>Eventual-consistency caches</b> — {@link #metadataIdCache} and
{@link #ownerRelCache}.
* The global entity change log poller dispatches {@code
entity_change_log} batches to {@link
* #changePoller}, while {@link #changePoller} polls {@code owner_meta}.
Other Gravitino nodes
@@ -114,6 +115,43 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
private static final Logger LOG =
LoggerFactory.getLogger(JcasbinAuthorizer.class);
+ /**
+ * Field index of {@code sub} (the role/user/group id) in a jcasbin {@code
p} policy row. See the
+ * {@code policy_definition} in {@code jcasbin_model.conf}: {@code p = sub,
metadataType,
+ * metadataId, act, eft}.
+ */
+ private static final int POLICY_SUBJECT_FIELD_INDEX = 0;
+
+ /**
+ * How long to wait before retrying a role whose last policy load was
incomplete, i.e. at least
+ * one of its securable objects could not be resolved to a metadata id. Such
a role is
+ * deliberately not recorded in {@link #loadedRoles} (see {@link
#versionCheckAndLoadRoles}), so
+ * without this backoff every request carrying the role would re-read the
role entity and re-probe
+ * the missing object. A role can stay unresolvable indefinitely — for
example when it still
+ * references a dropped table — so the retry has to be throttled rather than
left to the version
+ * check, whose {@code role_meta.updated_at} sentinel never moves in that
case.
+ */
+ private static final long PARTIAL_ROLE_LOAD_RETRY_MS = 10_000L;
+
+ /**
+ * Serializes every mutation of role permission policies, including {@link
+ * #invalidateRolePolicies}, {@link #replaceRolePolicies}, and the policy
writes in {@link
+ * #applyRolePolicies}. Both {@code SyncedEnforcer} calls are individually
atomic, but the {@code
+ * clear -> re-add} sequence is not, and neither is it ordered against the
{@link
+ * JcasbinLoadedRolesCache} removal listener, which calls {@link
#clearRolePoliciesOnCacheRemoval}
+ * from whichever thread happens to drain Caffeine's maintenance queue.
Without this lock an
+ * eviction firing between another thread's policy writes and its {@link
#loadedRoles} update
+ * erases the rows that thread just wrote while leaving the marker saying
they are loaded — a
+ * state no subsequent version check can detect or repair.
+ *
+ * <p>The lock guards in-memory enforcer mutations only: metadata ids are
resolved before it is
+ * taken (see {@link #resolveRolePolicies}), so no DB round-trip ever runs
inside the critical
+ * section. It is reentrant because a {@link #loadedRoles} write performed
under the lock can
+ * itself trigger an eviction, and therefore a nested {@link
#clearRolePoliciesOnCacheRemoval}
+ * call.
+ */
+ private final ReentrantLock rolePolicyLock = new ReentrantLock();
+
/** Jcasbin enforcer is used for metadata authorization. */
private Enforcer allowEnforcer;
@@ -145,6 +183,14 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
*/
private GravitinoCache<Long, Long> loadedRoles;
+ /**
+ * partialRoleLoadBackoff: roleId -> marker present while a role whose last
load was incomplete
+ * must not be retried. Policy replacement writes this marker under {@link
#rolePolicyLock}, so a
+ * delayed loaded-role removal callback can also use its presence to
recognize a newer partial
+ * policy state. See {@link #PARTIAL_ROLE_LOAD_RETRY_MS}.
+ */
+ private GravitinoCache<Long, Boolean> partialRoleLoadBackoff;
+
// ---- Eventual consistency caches (poller-driven) ----
/** Path-based metadata object key -> entity id. Evicted by entity change
poller. */
@@ -182,13 +228,16 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
// Initialize enforcers before caches that reference them in removal
listeners
allowEnforcer = new SyncedEnforcer(getModel("/jcasbin_model.conf"), new
GravitinoAdapter());
- allowInternalAuthorizer = new InternalAuthorizer(allowEnforcer);
+ allowInternalAuthorizer = new InternalAuthorizer(allowEnforcer, true);
denyEnforcer = new SyncedEnforcer(getModel("/jcasbin_model.conf"), new
GravitinoAdapter());
- denyInternalAuthorizer = new InternalAuthorizer(denyEnforcer);
+ denyInternalAuthorizer = new InternalAuthorizer(denyEnforcer, false);
// loadedRoles: roleId -> updated_at.
// When evicted, we must clean up the corresponding JCasbin policies.
- loadedRoles = new JcasbinLoadedRolesCache(ttlMs, roleCacheSize,
this::clearRolePolicies);
+ loadedRoles =
+ new JcasbinLoadedRolesCache(ttlMs, roleCacheSize,
this::clearRolePoliciesOnCacheRemoval);
+ partialRoleLoadBackoff =
+ new CaffeineGravitinoCache<>(Math.min(PARTIAL_ROLE_LOAD_RETRY_MS,
ttlMs), roleCacheSize);
userRoleCache = new CaffeineGravitinoCache<>(ttlMs, roleCacheSize);
groupRoleCache = new CaffeineGravitinoCache<>(ttlMs, roleCacheSize);
@@ -561,7 +610,7 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
@Override
public void handleRolePrivilegeChange(Long roleId) {
- loadedRoles.invalidate(roleId);
+ invalidateRolePolicies(roleId);
}
@Override
@@ -620,6 +669,9 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
if (loadedRoles != null) {
loadedRoles.close();
}
+ if (partialRoleLoadBackoff != null) {
+ partialRoleLoadBackoff.close();
+ }
if (metadataIdCache != null) {
metadataIdCache.close();
}
@@ -656,10 +708,12 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
private class InternalAuthorizer {
- Enforcer enforcer;
+ private final Enforcer enforcer;
+ private final boolean diagnoseDenials;
- public InternalAuthorizer(Enforcer enforcer) {
+ private InternalAuthorizer(Enforcer enforcer, boolean diagnoseDenials) {
this.enforcer = enforcer;
+ this.diagnoseDenials = diagnoseDenials;
}
private boolean authorizeInternal(
@@ -771,11 +825,14 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return ownerMatchesUserOrGroups(
owner, PrincipalUtils.getCurrentPrincipal(), metalake,
requestContext);
}
- return enforcer.enforce(
- String.valueOf(userId),
- String.valueOf(metadataObject.type()),
- String.valueOf(metadataId),
- privilege);
+ String metadataType = String.valueOf(metadataObject.type());
+ String metadataIdStr = String.valueOf(metadataId);
+ boolean allowed =
+ enforcer.enforce(String.valueOf(userId), metadataType,
metadataIdStr, privilege);
+ if (!allowed && diagnoseDenials) {
+ diagnoseDenial(userId, metadataType, metadataIdStr, privilege);
+ }
+ return allowed;
}
}
@@ -1127,8 +1184,7 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
}
for (Long roleId : uniqueRoleIds) {
if (!existingRoleIds.contains(roleId)) {
- clearRolePolicies(roleId);
- loadedRoles.invalidate(roleId);
+ invalidateRolePolicies(roleId);
}
}
@@ -1138,6 +1194,13 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
if (cachedUpdatedAt.isPresent() && cachedUpdatedAt.get() >=
rv.getUpdatedAt()) {
continue;
}
+ // A role missing from loadedRoles because its last load was incomplete
would otherwise be
+ // retried on every single request. Its partially loaded policies stay
in the enforcer, so
+ // the privileges that did resolve keep working while the backoff is in
effect.
+ if (!cachedUpdatedAt.isPresent()
+ && partialRoleLoadBackoff.getIfPresent(rv.getRoleId()).isPresent()) {
+ continue;
+ }
staleRoleVersions.add(rv);
}
@@ -1189,19 +1252,124 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
}
long roleId = rv.getRoleId();
long dbUpdatedAt = rv.getUpdatedAt();
- Optional<Long> cachedUpdatedAt = loadedRoles.getIfPresent(roleId);
+
+ // Resolve every securable object to a metadata id before touching the
enforcers, so the
+ // critical section below stays free of DB round-trips.
+ ResolvedRolePolicies resolved = resolveRolePolicies(roleEntity,
requestContext);
+
+ if (!replaceRolePolicies(roleId, dbUpdatedAt, resolved)) {
+ // Another request completed this version (or a newer one) while this
request was resolving
+ // metadata ids. Its policies and marker are authoritative, even if
this request's older
+ // resolution result was partial.
+ continue;
+ }
+
+ if (!resolved.isComplete()) {
+ LOG.warn(
+ "Loaded role {} ({}) with {} of {} securable objects; unresolved
objects: {}. "
+ + "The role is not marked as loaded and will be retried in at
most {} ms.",
+ roleId,
+ rv.getRoleName(),
+ roleEntity.securableObjects().size() -
resolved.getUnresolvedObjects().size(),
+ roleEntity.securableObjects().size(),
+ resolved.getUnresolvedObjects(),
+ PARTIAL_ROLE_LOAD_RETRY_MS);
+ }
+ }
+ }
+
+ /**
+ * Replaces a role's policies if no concurrent loader has already installed
the same or a newer
+ * role version.
+ *
+ * <p>Resolution happens outside {@link #rolePolicyLock}, so two requests
can reach this method
+ * with different results for the same role version. The version check must
therefore be repeated
+ * under the mutation lock. Otherwise, a late partial result can clear a
complete result and leave
+ * the complete loader's marker behind.
+ *
+ * @return {@code true} if {@code resolved} was applied, or {@code false} if
a same-or-newer
+ * complete load was already present
+ */
+ private boolean replaceRolePolicies(
+ long roleId, long dbUpdatedAt, ResolvedRolePolicies resolved) {
+ rolePolicyLock.lock();
+ try {
+ Optional<Long> latestLoadedAt = loadedRoles.getIfPresent(roleId);
+ if (latestLoadedAt.isPresent() && latestLoadedAt.get() >= dbUpdatedAt) {
+ partialRoleLoadBackoff.invalidate(roleId);
+ return false;
+ }
// Refresh only permission policies. deleteRole would also remove the
current user's freshly
- // bound grouping links.
- if (cachedUpdatedAt.isPresent()) {
- clearRolePolicies(roleId);
+ // bound grouping links. If a marker exists, its synchronous removal
callback clears the old
+ // policies. With no marker, rows can still remain from a previous
partial load and must be
+ // cleared explicitly.
+ loadedRoles.invalidate(roleId);
+ if (!latestLoadedAt.isPresent()) {
+ clearRolePoliciesWithoutLock(roleId);
+ }
+ applyRolePolicies(resolved);
+
+ // Only record the role as loaded when the enforcer actually received
everything the role
+ // grants. Marking a partially loaded role as loaded would pin it
forever: role_meta's
+ // updated_at does not change, so the version check above would skip the
reload on every later
+ // request and the missing privileges would never come back.
+ if (resolved.isComplete()) {
+ loadedRoles.put(roleId, dbUpdatedAt);
+ partialRoleLoadBackoff.invalidate(roleId);
+ } else {
+ partialRoleLoadBackoff.put(roleId, Boolean.TRUE);
+ }
+ return true;
+ } finally {
+ rolePolicyLock.unlock();
+ }
+ }
+
+ /** Clears a role's policies and removes its loaded marker as one serialized
operation. */
+ private void invalidateRolePolicies(long roleId) {
+ rolePolicyLock.lock();
+ try {
+ // An explicit invalidation is stronger than the retry throttle. Clear
it under the same lock
+ // before removing the loaded marker so the removal callback cannot
mistake the old partial
+ // state for a newer load and skip policy cleanup.
+ partialRoleLoadBackoff.invalidate(roleId);
+ boolean markerPresent = loadedRoles.getIfPresent(roleId).isPresent();
+ loadedRoles.invalidate(roleId);
+ if (!markerPresent) {
+ // An incomplete load has policies but deliberately has no loadedRoles
marker.
+ clearRolePoliciesWithoutLock(roleId);
}
- loadPolicyByRoleEntity(roleEntity, requestContext);
- loadedRoles.put(roleId, dbUpdatedAt);
+ } finally {
+ rolePolicyLock.unlock();
}
}
- private void clearRolePolicies(long roleId) {
+ /**
+ * Handles a loaded-role cache removal after acquiring the policy mutation
lock.
+ *
+ * <p>Caffeine removes the old marker before invoking its listener. While
the listener waits for
+ * this lock, a concurrent loader may install a new policy state: either a
complete load with a
+ * new loaded marker or a partial load with a retry-backoff marker. In
either case this is a stale
+ * removal event and must not clear the newly installed policies.
+ */
+ private void clearRolePoliciesOnCacheRemoval(long roleId) {
+ rolePolicyLock.lock();
+ try {
+ if (loadedRoles.getIfPresent(roleId).isPresent()
+ || partialRoleLoadBackoff.getIfPresent(roleId).isPresent()) {
+ LOG.debug(
+ "Skip clearing policies for role {} because it was reloaded after
cache removal",
+ roleId);
+ return;
+ }
+ clearRolePoliciesWithoutLock(roleId);
+ } finally {
+ rolePolicyLock.unlock();
+ }
+ }
+
+ private void clearRolePoliciesWithoutLock(long roleId) {
String roleIdStr = String.valueOf(roleId);
allowEnforcer.removeFilteredPolicy(0, roleIdStr);
denyEnforcer.removeFilteredPolicy(0, roleIdStr);
@@ -1218,40 +1386,146 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
// Policy loading from role entity
//
---------------------------------------------------------------------------
- private void loadPolicyByRoleEntity(
+ /**
+ * Resolves a role's securable objects into concrete jcasbin {@code p} rows
without touching the
+ * enforcers. Every metadata-id lookup — the only part of policy loading
that can hit the DB —
+ * happens here, so the caller can apply the result while holding {@link
#rolePolicyLock}.
+ *
+ * <p>A securable object that cannot be resolved to a metadata id is
reported in {@link
+ * ResolvedRolePolicies#getUnresolvedObjects()} rather than silently
dropped. That normally means
+ * the object has been dropped while the role still references it, but it is
indistinguishable
+ * from a transient lookup failure, and the two must not be conflated: the
caller relies on this
+ * flag to decide whether the resulting enforcer state is complete enough to
be cached.
+ */
+ private ResolvedRolePolicies resolveRolePolicies(
RoleEntity roleEntity, AuthorizationRequestContext requestContext) {
String metalake =
NameIdentifierUtil.getMetalake(roleEntity.nameIdentifier());
+ String roleIdStr = String.valueOf(roleEntity.id());
List<SecurableObject> securableObjects = roleEntity.securableObjects();
+ List<String[]> allowRows = new ArrayList<>();
+ List<String[]> denyRows = new ArrayList<>();
+ List<String> unresolvedObjects = new ArrayList<>();
+
for (SecurableObject securableObject : securableObjects) {
Optional<Long> metadataId =
lookups.resolveMetadataId(securableObject, metalake, requestContext);
- // A role may still reference a metadata object that has since been
dropped; skip it.
if (!metadataId.isPresent()) {
+ unresolvedObjects.add(securableObject.type().name() + ":" +
securableObject.fullName());
continue;
}
+ String metadataIdStr = String.valueOf(metadataId.get());
for (Privilege privilege : securableObject.privileges()) {
Privilege.Condition condition = privilege.condition();
+ String action =
+ AuthorizationUtils.replaceLegacyPrivilegeName(privilege.name())
+ .name()
+ .toUpperCase(Locale.ROOT);
if (AuthConstants.DENY.equalsIgnoreCase(condition.name())) {
- denyEnforcer.addPolicy(
- String.valueOf(roleEntity.id()),
+ denyRows.add(
+ new String[] {
+ roleIdStr, securableObject.type().name(), metadataIdStr,
action, AuthConstants.ALLOW
+ });
+ }
+
+ allowRows.add(
+ new String[] {
+ roleIdStr,
securableObject.type().name(),
- String.valueOf(metadataId.get()),
- AuthorizationUtils.replaceLegacyPrivilegeName(privilege.name())
- .name()
- .toUpperCase(Locale.ROOT),
- AuthConstants.ALLOW);
+ metadataIdStr,
+ action,
+ condition.name().toLowerCase(Locale.ROOT)
+ });
+ }
+ }
+ return new ResolvedRolePolicies(allowRows, denyRows, unresolvedObjects);
+ }
+
+ /**
+ * Writes pre-resolved policy rows into both enforcers. Must be called under
{@link
+ * #rolePolicyLock}.
+ */
+ private void applyRolePolicies(ResolvedRolePolicies resolved) {
+ for (String[] row : resolved.getDenyRows()) {
+ denyEnforcer.addPolicy(row);
+ }
+ for (String[] row : resolved.getAllowRows()) {
+ allowEnforcer.addPolicy(row);
+ }
+ }
+
+ /**
+ * Reports the enforcer state behind a denied allow-check, so that a denial
caused by missing
+ * policies can be told apart from a denial the caller genuinely earned.
+ *
+ * <p>Escalates to {@code WARN} when it finds a role that {@link
#loadedRoles} claims is loaded
+ * but that carries no {@code p} row in the allow enforcer. That combination
is the signature of a
+ * lost policy load: the version check consults {@code loadedRoles} alone,
so it would keep
+ * skipping the reload for as long as the entry lives.
+ *
+ * <p>Gated on {@code DEBUG} because it scans the enforcer's policy set per
bound role and denials
+ * are an ordinary outcome on the request path. Enable {@code DEBUG} for
this class on the node
+ * under investigation; the diagnosis itself is then reported at {@code
WARN}.
+ */
+ private void diagnoseDenial(
+ long userId, String metadataType, String metadataIdStr, String
privilege) {
+ if (!LOG.isDebugEnabled()) {
+ return;
+ }
+ try {
+ String userIdStr = String.valueOf(userId);
+ List<String> boundRoles = allowEnforcer.getRolesForUser(userIdStr);
+ if (boundRoles.isEmpty()) {
+ LOG.debug(
+ "Denied [{}, {}, {}, {}]: no role is bound to the user in the
allow enforcer",
+ userIdStr,
+ metadataType,
+ metadataIdStr,
+ privilege);
+ return;
+ }
+
+ List<String> rolesWithoutPolicies = new ArrayList<>();
+ List<String> roleStates = new ArrayList<>(boundRoles.size());
+ for (String roleIdStr : boundRoles) {
+ int policyCount =
+ allowEnforcer.getFilteredNamedPolicy("p",
POLICY_SUBJECT_FIELD_INDEX, roleIdStr).size();
+ Optional<Long> loadedAt =
loadedRoles.getIfPresent(Long.parseLong(roleIdStr));
+ roleStates.add(
+ roleIdStr
+ + "{loadedAt="
+ + (loadedAt.isPresent() ? loadedAt.get() : "absent")
+ + ", allowPolicies="
+ + policyCount
+ + "}");
+ if (loadedAt.isPresent() && policyCount == 0) {
+ rolesWithoutPolicies.add(roleIdStr);
}
+ }
- allowEnforcer.addPolicy(
- String.valueOf(roleEntity.id()),
- securableObject.type().name(),
- String.valueOf(metadataId.get()),
- AuthorizationUtils.replaceLegacyPrivilegeName(privilege.name())
- .name()
- .toUpperCase(Locale.ROOT),
- condition.name().toLowerCase(Locale.ROOT));
+ if (rolesWithoutPolicies.isEmpty()) {
+ LOG.debug(
+ "Denied [{}, {}, {}, {}]: role state {}",
+ userIdStr,
+ metadataType,
+ metadataIdStr,
+ privilege,
+ roleStates);
+ } else {
+ LOG.warn(
+ "Denied [{}, {}, {}, {}] while roles {} are recorded as loaded but
hold no allow "
+ + "policy. This node's enforcer lost their policies; the
version check cannot "
+ + "detect it. Full role state: {}",
+ userIdStr,
+ metadataType,
+ metadataIdStr,
+ privilege,
+ rolesWithoutPolicies,
+ roleStates);
}
+ } catch (Exception e) {
+ // A diagnostic must never change the outcome of an authorization check.
+ LOG.debug("Failed to diagnose denial for user {}", userId, e);
}
}
}
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 07e730761e..3eabb0d6f6 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
@@ -27,14 +27,23 @@ import java.util.function.LongConsumer;
import org.apache.gravitino.cache.GravitinoCache;
/**
- * 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).
+ * A {@link GravitinoCache} of {@code roleId -> updated_at} that synchronously
requests cleanup of
+ * the role's JCasbin policies when a key is evicted (by TTL, size, or
explicit invalidate). The
+ * cleaner may ignore a stale removal when the role was reloaded while the
callback waited for the
+ * authorizer's policy mutation lock.
*
* <p>This cache owns role permission policies only. Therefore, eviction must
clear only {@code
* p(roleId, ...)} policies and must not delete the role itself, because
JCasbin's {@code
* deleteRole(roleId)} also removes {@code g(user/group, roleId)} bindings
that are managed
* separately by {@link JcasbinAuthorizer}.
+ *
+ * <p>The TTL is <b>write-based</b>, matching every other authorization cache.
An access-based TTL
+ * would be renewed by the version probe that {@code versionCheckAndLoadRoles}
performs on every
+ * request, so on a node under steady traffic the entry would never expire.
That matters because the
+ * entry is only a {@code roleId -> updated_at} marker: if the enforcer ever
ends up without the
+ * policies this entry claims are loaded, an access-based TTL turns a
transient inconsistency into a
+ * permanent authorization failure, since each denied request renews the very
entry that suppresses
+ * the reload. A write-based TTL bounds any such state to one TTL.
*/
class JcasbinLoadedRolesCache implements GravitinoCache<Long, Long> {
@@ -43,7 +52,7 @@ class JcasbinLoadedRolesCache implements GravitinoCache<Long,
Long> {
JcasbinLoadedRolesCache(long ttlMs, long maxSize, LongConsumer
rolePolicyCleaner) {
this.cache =
Caffeine.newBuilder()
- .expireAfterAccess(ttlMs, TimeUnit.MILLISECONDS)
+ .expireAfterWrite(ttlMs, TimeUnit.MILLISECONDS)
.maximumSize(maxSize)
.executor(Runnable::run)
.removalListener(
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/ResolvedRolePolicies.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/ResolvedRolePolicies.java
new file mode 100644
index 0000000000..8ef1cc5ed8
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/ResolvedRolePolicies.java
@@ -0,0 +1,61 @@
+/*
+ * 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;
+
+/**
+ * A role's securable objects resolved into concrete jcasbin {@code p} rows,
together with the
+ * objects that could not be resolved to a metadata id.
+ *
+ * <p>Separating resolution from application lets {@code JcasbinAuthorizer}
keep every DB round-trip
+ * outside the lock that serializes enforcer mutations, and lets it tell a
fully loaded role apart
+ * from a partially loaded one — only the former may be recorded as loaded.
+ */
+final class ResolvedRolePolicies {
+
+ private final List<String[]> allowRows;
+ private final List<String[]> denyRows;
+ private final List<String> unresolvedObjects;
+
+ ResolvedRolePolicies(
+ List<String[]> allowRows, List<String[]> denyRows, List<String>
unresolvedObjects) {
+ this.allowRows = allowRows;
+ this.denyRows = denyRows;
+ this.unresolvedObjects = unresolvedObjects;
+ }
+
+ List<String[]> getAllowRows() {
+ return allowRows;
+ }
+
+ List<String[]> getDenyRows() {
+ return denyRows;
+ }
+
+ /** Descriptions of the securable objects whose metadata id could not be
resolved. */
+ List<String> getUnresolvedObjects() {
+ return unresolvedObjects;
+ }
+
+ /** True when every securable object of the role was resolved and turned
into policy rows. */
+ boolean isComplete() {
+ return unresolvedObjects.isEmpty();
+ }
+}
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 740744605b..829fb99e19 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
@@ -19,6 +19,7 @@ package org.apache.gravitino.server.authorization.jcasbin;
import static org.apache.gravitino.authorization.Privilege.Name.USE_CATALOG;
import static org.apache.gravitino.authorization.Privilege.Name.USE_SCHEMA;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -49,7 +50,11 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.gravitino.Entity;
@@ -62,6 +67,7 @@ import org.apache.gravitino.Namespace;
import org.apache.gravitino.SupportsRelationOperations;
import org.apache.gravitino.UserGroup;
import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.authorization.AuthorizationRequestContext;
import org.apache.gravitino.authorization.Privilege;
import org.apache.gravitino.authorization.SecurableObject;
@@ -543,6 +549,199 @@ public class TestJcasbinAuthorizer {
"loadedRoles entry for the deleted role must be evicted");
}
+ @Test
+ public void testPartialPolicyLoadIsNotRecordedAsLoaded() throws Exception {
+ // Regression test for the permanently-denied-role failure mode. When a
securable object cannot
+ // be resolved to a metadata id, loadPolicyByRoleEntity skips it.
Recording the role as loaded
+ // anyway pins the broken state forever: role_meta.updated_at never moves,
so the version check
+ // keeps skipping the reload and the role's privileges never come back on
that node.
+ makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+
+ RoleEntity allowRole =
+ mockRoleInStore(ALLOW_ROLE_ID, "allowRole",
ImmutableList.of(getAllowSecurableObject()));
+ Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer);
+ GravitinoCache<Long, Long> loadedRoles =
getLoadedRolesCache(jcasbinAuthorizer);
+ GravitinoCache<Long, Boolean> backoff =
getPartialRoleLoadBackoffCache(jcasbinAuthorizer);
+
+ // 1. The role's only securable object does not resolve, so nothing can be
loaded.
+ metadataIdConverterMockedStatic
+ .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+ .thenReturn(Optional.empty());
+ try {
+ invokeVersionCheckAndLoadRoles(
+ jcasbinAuthorizer,
+ METALAKE,
+ ImmutableList.of(ALLOW_ROLE_ID),
+ new AuthorizationRequestContext());
+
+ assertFalse(
+ loadedRoles.getIfPresent(ALLOW_ROLE_ID).isPresent(),
+ "a role whose policies could not be loaded must not be recorded as
loaded");
+ assertTrue(
+ allowEnforcer.getFilteredPolicy(0,
String.valueOf(ALLOW_ROLE_ID)).isEmpty(),
+ "no p-row can exist when the securable object did not resolve");
+ assertTrue(
+ backoff.getIfPresent(ALLOW_ROLE_ID).isPresent(),
+ "the incomplete load must arm the retry backoff");
+ } finally {
+ metadataIdConverterMockedStatic
+ .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+ .thenReturn(Optional.of(CATALOG_ID));
+ }
+
+ // 2. While the backoff is armed the role is not re-read, so a request
cannot turn into a DB
+ // round-trip per call for a role that stays unresolvable.
+ invokeVersionCheckAndLoadRoles(
+ jcasbinAuthorizer,
+ METALAKE,
+ ImmutableList.of(ALLOW_ROLE_ID),
+ new AuthorizationRequestContext());
+ assertTrue(
+ allowEnforcer.getFilteredPolicy(0,
String.valueOf(ALLOW_ROLE_ID)).isEmpty(),
+ "the backoff must suppress the immediate retry");
+
+ // 3. Once the backoff lapses the role is retried and, now that the object
resolves, loads
+ // fully and is recorded — this is the self-healing the old code could
never reach.
+ backoff.invalidate(ALLOW_ROLE_ID);
+ invokeVersionCheckAndLoadRoles(
+ jcasbinAuthorizer,
+ METALAKE,
+ ImmutableList.of(ALLOW_ROLE_ID),
+ new AuthorizationRequestContext());
+
+ assertFalse(
+ allowEnforcer.getFilteredPolicy(0,
String.valueOf(allowRole.id())).isEmpty(),
+ "the retry must load the role's p-rows");
+ assertTrue(
+ loadedRoles.getIfPresent(ALLOW_ROLE_ID).isPresent(),
+ "a fully loaded role must be recorded as loaded");
+ assertFalse(
+ backoff.getIfPresent(ALLOW_ROLE_ID).isPresent(),
+ "a successful load must disarm the retry backoff");
+ }
+
+ @Test
+ public void testStaleRemovalDoesNotClearReloadedPolicies() throws Exception {
+ Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer);
+ GravitinoCache<Long, Long> loadedRoles =
getLoadedRolesCache(jcasbinAuthorizer);
+ ReentrantLock rolePolicyLock = getRolePolicyLock(jcasbinAuthorizer);
+ String roleIdStr = String.valueOf(ALLOW_ROLE_ID);
+ String[] policyRow =
+ new String[] {
+ roleIdStr,
+ MetadataObject.Type.CATALOG.name(),
+ String.valueOf(CATALOG_ID),
+ USE_CATALOG.name(),
+ AuthConstants.ALLOW
+ };
+ allowEnforcer.addPolicy(policyRow);
+ loadedRoles.put(ALLOW_ROLE_ID, 1L);
+
+ CountDownLatch invalidationStarted = new CountDownLatch(1);
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+ rolePolicyLock.lock();
+ Thread invalidator =
+ new Thread(
+ () -> {
+ invalidationStarted.countDown();
+ try {
+ loadedRoles.invalidate(ALLOW_ROLE_ID);
+ } catch (Throwable t) {
+ failure.set(t);
+ }
+ });
+ invalidator.setDaemon(true);
+ try {
+ invalidator.start();
+ assertTrue(invalidationStarted.await(5, TimeUnit.SECONDS));
+
+ // Caffeine removes the marker before its listener waits for
rolePolicyLock. Wait until that
+ // ordering is visible, then simulate a loader installing a fresh policy
set and marker while
+ // the old removal callback is still blocked.
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (loadedRoles.getIfPresent(ALLOW_ROLE_ID).isPresent() &&
System.nanoTime() < deadline) {
+ Thread.yield();
+ }
+ assertFalse(
+ loadedRoles.getIfPresent(ALLOW_ROLE_ID).isPresent(),
+ "the invalidation must remove the old marker before its listener
acquires the lock");
+
+ allowEnforcer.removeFilteredPolicy(0, roleIdStr);
+ allowEnforcer.addPolicy(policyRow);
+ loadedRoles.put(ALLOW_ROLE_ID, 2L);
+ } finally {
+ rolePolicyLock.unlock();
+ }
+
+ invalidator.join(5000L);
+ assertFalse(invalidator.isAlive(), "the invalidation thread must finish");
+ Assertions.assertNull(failure.get(), "the invalidation thread must not
fail");
+ assertEquals(2L, loadedRoles.getIfPresent(ALLOW_ROLE_ID).orElse(null));
+ assertFalse(
+ allowEnforcer.getFilteredPolicy(0, roleIdStr).isEmpty(),
+ "a stale removal callback must not clear policies installed by a later
load");
+ }
+
+ @Test
+ public void testStaleRemovalDoesNotClearNewPartialPolicies() throws
Exception {
+ Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer);
+ GravitinoCache<Long, Boolean> backoff =
getPartialRoleLoadBackoffCache(jcasbinAuthorizer);
+ String roleIdStr = String.valueOf(ALLOW_ROLE_ID);
+ allowEnforcer.addPolicy(
+ roleIdStr,
+ MetadataObject.Type.CATALOG.name(),
+ String.valueOf(CATALOG_ID),
+ USE_CATALOG.name(),
+ AuthConstants.ALLOW);
+ backoff.put(ALLOW_ROLE_ID, Boolean.TRUE);
+
+ // A partial loader records its backoff marker under rolePolicyLock before
an older cache
+ // removal callback can resume. The callback must treat that marker as a
newer policy state even
+ // though partial loads deliberately have no loadedRoles marker.
+ invokeClearRolePoliciesOnCacheRemoval(jcasbinAuthorizer, ALLOW_ROLE_ID);
+
+ assertFalse(
+ allowEnforcer.getFilteredPolicy(0, roleIdStr).isEmpty(),
+ "a stale removal callback must not clear a newer partial policy set");
+ }
+
+ @Test
+ public void testPartialResultCannotOverwriteCompletedConcurrentLoad() throws
Exception {
+ String roleIdStr = String.valueOf(ALLOW_ROLE_ID);
+ String[] policyRow =
+ new String[] {
+ roleIdStr,
+ MetadataObject.Type.CATALOG.name(),
+ String.valueOf(CATALOG_ID),
+ USE_CATALOG.name(),
+ AuthConstants.ALLOW
+ };
+ ResolvedRolePolicies complete =
+ new ResolvedRolePolicies(
+ ImmutableList.<String[]>of(policyRow),
+ Collections.emptyList(),
+ Collections.emptyList());
+ ResolvedRolePolicies partial =
+ new ResolvedRolePolicies(
+ Collections.emptyList(),
+ Collections.emptyList(),
+ ImmutableList.of("CATALOG:testCatalog"));
+ long roleVersion = 42L;
+
+ // Both results may be resolved before either request acquires
rolePolicyLock. Once the complete
+ // result wins, a late partial result for the same version must not clear
its policies or leave
+ // its loaded marker attached to a partial policy set.
+ assertTrue(invokeReplaceRolePolicies(jcasbinAuthorizer, ALLOW_ROLE_ID,
roleVersion, complete));
+ assertFalse(invokeReplaceRolePolicies(jcasbinAuthorizer, ALLOW_ROLE_ID,
roleVersion, partial));
+
+ assertEquals(
+ roleVersion,
+
getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).orElse(null));
+ assertFalse(
+ getAllowEnforcer(jcasbinAuthorizer).getFilteredPolicy(0,
roleIdStr).isEmpty(),
+ "the completed load's policies must survive a late partial result");
+ }
+
/** Reflectively invoke the private versionCheckAndLoadRoles. */
private static void invokeVersionCheckAndLoadRoles(
JcasbinAuthorizer authorizer,
@@ -560,6 +759,27 @@ public class TestJcasbinAuthorizer {
m.invoke(authorizer, metalake, roleIds, requestContext);
}
+ private static boolean invokeReplaceRolePolicies(
+ JcasbinAuthorizer authorizer,
+ long roleId,
+ long updatedAt,
+ ResolvedRolePolicies resolvedRolePolicies)
+ throws Exception {
+ Method method =
+ JcasbinAuthorizer.class.getDeclaredMethod(
+ "replaceRolePolicies", long.class, long.class,
ResolvedRolePolicies.class);
+ method.setAccessible(true);
+ return (boolean) method.invoke(authorizer, roleId, updatedAt,
resolvedRolePolicies);
+ }
+
+ private static void invokeClearRolePoliciesOnCacheRemoval(
+ JcasbinAuthorizer authorizer, long roleId) throws Exception {
+ Method method =
+
JcasbinAuthorizer.class.getDeclaredMethod("clearRolePoliciesOnCacheRemoval",
long.class);
+ method.setAccessible(true);
+ method.invoke(authorizer, roleId);
+ }
+
@Test
public void testAuthorizeByOwner() throws Exception {
Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
@@ -1613,9 +1833,10 @@ public class TestJcasbinAuthorizer {
}
@Test
- public void testClearRolePoliciesPreservesUserRoleBindings() throws
Exception {
+ public void testInvalidateRolePoliciesPreservesUserRoleBindings() throws
Exception {
Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer);
Enforcer denyEnforcer = getDenyEnforcer(jcasbinAuthorizer);
+ GravitinoCache<Long, Boolean> backoff =
getPartialRoleLoadBackoffCache(jcasbinAuthorizer);
Long testRoleId = 302L;
String roleIdStr = String.valueOf(testRoleId);
@@ -1624,16 +1845,18 @@ public class TestJcasbinAuthorizer {
denyEnforcer.addRoleForUser(userIdStr, roleIdStr);
allowEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
denyEnforcer.addPolicy(roleIdStr, "CATALOG", "999", "USE_CATALOG",
"allow");
+ backoff.put(testRoleId, Boolean.TRUE);
- Method clearRolePolicies =
- JcasbinAuthorizer.class.getDeclaredMethod("clearRolePolicies",
long.class);
- clearRolePolicies.setAccessible(true);
- clearRolePolicies.invoke(jcasbinAuthorizer, testRoleId);
+ Method invalidateRolePolicies =
+ JcasbinAuthorizer.class.getDeclaredMethod("invalidateRolePolicies",
long.class);
+ invalidateRolePolicies.setAccessible(true);
+ invalidateRolePolicies.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));
+ assertFalse(backoff.getIfPresent(testRoleId).isPresent());
}
@Test
@@ -2066,6 +2289,20 @@ public class TestJcasbinAuthorizer {
return (GravitinoCache<Long, Long>) field.get(authorizer);
}
+ private static ReentrantLock getRolePolicyLock(JcasbinAuthorizer authorizer)
throws Exception {
+ Field field = JcasbinAuthorizer.class.getDeclaredField("rolePolicyLock");
+ field.setAccessible(true);
+ return (ReentrantLock) field.get(authorizer);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static GravitinoCache<Long, Boolean> getPartialRoleLoadBackoffCache(
+ JcasbinAuthorizer authorizer) throws Exception {
+ Field field =
JcasbinAuthorizer.class.getDeclaredField("partialRoleLoadBackoff");
+ field.setAccessible(true);
+ return (GravitinoCache<Long, Boolean>) field.get(authorizer);
+ }
+
@SuppressWarnings("unchecked")
private static GravitinoCache<Long, Optional<OwnerInfo>> getOwnerRelCache(
JcasbinAuthorizer authorizer) throws Exception {
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinLoadedRolesCache.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinLoadedRolesCache.java
new file mode 100644
index 0000000000..77ad844669
--- /dev/null
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinLoadedRolesCache.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.server.authorization.jcasbin;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests for the {@code roleId -> updated_at} cache that owns role permission
policies. */
+public class TestJcasbinLoadedRolesCache {
+
+ private static final long ROLE_ID = 42L;
+
+ @Test
+ public void testTtlIsWriteBasedSoReadsCannotKeepAnEntryAlive() throws
Exception {
+ // versionCheckAndLoadRoles probes this cache on every request that
carries the role. Under an
+ // access-based TTL those probes renew the entry, so on a node under
steady traffic the entry
+ // never expires. That is what turns a lost policy load into a permanent
authorization failure:
+ // each denied request renews the very entry that tells the version check
to skip the reload.
+ long ttlMs = 150L;
+ List<Long> cleaned = new ArrayList<>();
+ JcasbinLoadedRolesCache cache = new JcasbinLoadedRolesCache(ttlMs, 100L,
cleaned::add);
+ cache.put(ROLE_ID, 1L);
+
+ // Read the entry far more often than the TTL, the way a hot role is
probed in production.
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(ttlMs *
4);
+ while (System.nanoTime() < deadline) {
+ cache.getIfPresent(ROLE_ID);
+ Thread.sleep(10L);
+ }
+
+ Assertions.assertFalse(
+ cache.getIfPresent(ROLE_ID).isPresent(),
+ "repeated reads must not keep the entry alive past its TTL");
+ // size() runs Caffeine's maintenance, which delivers any pending removal
notification.
+ cache.size();
+ Assertions.assertTrue(
+ cleaned.contains(ROLE_ID), "expiring the entry must clear the role's
policies");
+ cache.close();
+ }
+
+ @Test
+ public void testReplacingAnEntryDoesNotClearPolicies() {
+ // A refresh writes the new version over the old one. Treating that as a
removal would delete
+ // the policies the refresh just loaded.
+ List<Long> cleaned = new ArrayList<>();
+ JcasbinLoadedRolesCache cache = new JcasbinLoadedRolesCache(60_000L, 100L,
cleaned::add);
+
+ cache.put(ROLE_ID, 1L);
+ cache.put(ROLE_ID, 2L);
+ cache.size();
+
+ Assertions.assertTrue(cleaned.isEmpty(), "replacing a value must not clear
the role policies");
+ Assertions.assertEquals(2L, cache.getIfPresent(ROLE_ID).orElse(null));
+
+ cache.invalidate(ROLE_ID);
+ Assertions.assertTrue(
+ cleaned.contains(ROLE_ID), "explicit invalidation must clear the role
policies");
+ cache.close();
+ }
+}