Access control (DAC)
Some cached rows belong to someone — a portfolio, a business relationship, a user.
AbstractDacCache decides on every read whether the caller may see the value, using a
gateway that knows who is calling and what they are granted, and a policy that turns grants into a
decision. The cached value itself stays neutral, so one copy serves every caller.
Concept
Entitlement-scoped data used to be handled in one of two bad ways: cache per user (a copy per caller, low hit rate, expensive eviction) or filter in every service method (easy to forget). DAC — data access control — moves the check into the cache's exit path:
L1 → L2 → loader, exactly as any facade. The value is principal-neutral.
DacGateway.currentPrincipal(), or an explicit principal from getAs(…).
DacGateway.grants(principal) — itself cached (CAST: castUserGrants).
DacPolicy.decide(value, grants, scopes) → allow · deny · replace with a trimmed copy.
Five small contracts make this pluggable. CAST is the first provider; any system that can answer "who is calling" and "what may they see" can be another.
| contract | answers |
|---|---|
| Principal(id, type, attributes) | Who. Principal.of("7e3c…", "jbUid"). |
| PrincipalSource.currentPrincipal() | Who is calling right now — from the security context, a header, a thread-local. |
| Grants.allows(scope, feature, permission) Grants.scopes() | May this principal do permission on feature within scope? And: every scope they hold at all. |
| DacGateway | A PrincipalSource for one domain() that can produce grants(principal) and evict(principal). |
| DacScope<V>.of(value) | What a value belongs to: the set of scope strings such as PF:PF-100, BR:BR-7, ACC:CH93…. |
| DacPolicy<V>.decide(value, grants, scopes) | Decision.allow(), deny(reason) or replace(trimmed). |
A guarded facade
Subclass AbstractDacCache instead of AbstractCache, and pass a gateway and a
policy. Everything else — binding, loader, hooks, reads — is unchanged:
@Component
public class DocumentCache extends AbstractDacCache<String, DocumentMetadataSnapshot> {
private final DocumentMetadataRepository repository;
public DocumentCache(CacheSupport support, CastDacGateway cast, DocumentMetadataRepository repository) {
super(DocumentMetadataSnapshot.BINDING, support, cast,
DacPolicies.anyScope("EDOCS", "READ"), DenyMode.FAIL, DisabledMode.DENY);
this.repository = repository;
}
@Override
protected Optional<CacheLoader<String, DocumentMetadataSnapshot>> loader() {
return Optional.of(id -> repository.findById(id).map(DocumentMetadataSnapshot::from));
}
public List<DocumentMetadataSnapshot> forCaller() {
return listByScopes("byPortfolio");
}
}
The scope of each document comes from the binding, declared on the entity:
@CacheSnapshot(region = "documents", type = RegionKind.TRANSACTIONAL, ttl = "PT24H", domain = "cast",
indexes = @SnapshotIndex(name = "byPortfolio", property = "portfolioNumber", multiValued = true),
scope = @SnapshotScope(prefix = "PF:", property = "portfolioNumber"))
public class DocumentMetadata { … }
Now documentCache.get(id) returns the document only if the caller holds
EDOCS/READ on that portfolio; forCaller() returns every document on any
portfolio the caller is granted, with one index lookup per scope and no filtering code.
Describing what a value belongs to
| form | scopes produced |
|---|---|
| DacScope.single("PF:", V::portfolioNumber) | PF:<number>, or none when the property is null |
| DacScope.single(V::scopeType, V::scopeId) | <type>:<id> — the prefix comes from the value |
| DacScope.none() | no scope — policies that need one deny |
| value → Set.of(…) | any lambda; a value may belong to several scopes |
| @SnapshotScope(prefix | prefixProperty, property) | the same, generated into BINDING |
| .perPrincipal() | the key is the caller: a value is served only to the principal whose id equals its key, before any policy runs |
Scope strings are the shared vocabulary between the data and the grants provider. CAST
grants use BR:, PF:, ACC: and GLOBAL
(CastUserGrants.portfolioScope(…) and friends build them).
Policies
| DacPolicies.… | decision |
|---|---|
| anyScope(feature, permission) | allow if the caller holds the permission on at least one of the value's scopes; deny if the value has no scope |
| allScopes(feature, permission) | allow only if granted on every scope of the value |
| owner(V → ownerId, principal) | allow only the principal whose id equals the value's owner |
| trim((value, grants) → V) | always replace the value with a copy reduced to what the caller may see — e.g. a user record with only the portfolios they hold |
| allowAll() / denyAll(reason) | for tests, migrations and emergency disable switches |
| a.and(b) | both must allow; a replace from a is what b then sees |
A policy is a functional interface, so a domain-specific rule is one lambda:
DacPolicy<PortfolioSnapshot> visibleIfNotClosed = (value, grants, scopes) ->
value.status() == CLOSED && !grants.allows("GLOBAL", "PORTFOLIO", "VIEW_CLOSED")
? Decision.deny("closed portfolio")
: Decision.allow();
super(BINDING, support, cast, DacPolicies.<PortfolioSnapshot>anyScope("PORTFOLIO", "READ").and(visibleIfNotClosed));
What happens on deny, and when the domain is off
| setting | values | meaning |
|---|---|---|
DenyMode | EMPTY (default) · FAIL | A denied single read returns Optional.empty(), or throws DacAccessDeniedException. List reads always drop the denied entries silently. |
DisabledMode | DENY (default) · ALLOW | When the decision cannot be made — the domain flag is off, or there is no caller — hide the value, or hand it back unguarded. Choose ALLOW only where a legacy check still runs downstream. |
The order of checks in afterRead: domain enabled → principal present →
perPrincipal key match → grants present → policy. "No grants for caller" is always a
deny, even with DisabledMode.ALLOW — an unknown user is not an undecidable case.
Reading on behalf of someone
| method | meaning |
|---|---|
| get / getAll / byIndex / listByIndex | as AbstractCache, decided for the current principal |
| getAs(principal, key) getAllAs(principal, keys) listByIndexAs(principal, index, value) | the same, for an explicit principal — batch jobs, admin screens, tests |
| listByScopes(index) listByScopes(index, scopes) | one listByIndex per scope the caller holds (or per given scope), de-duplicated by identity(value) |
| currentPrincipal() / currentGrants() | what the facade would decide with right now |
| AbstractDacCache.as(principal, () → …) | run any block as that principal — implemented with a ScopedValue, so it is inherited by virtual-thread children and cannot leak |
AbstractDacCache.as(Principal.of(jbUid, "jbUid"), () -> documentCache.forCaller());
Rules
- Cache neutral, decide on exit. Never put the caller into the key unless the data really is per caller (
perPrincipal). One copy, many callers, low-cost eviction. - Guard once. Either the data comes through an
AbstractDacCache, or a service method is annotated with@DacAuthorize(next page). Never both for the same data. - Deny is not an error. A hidden value is the normal outcome for a caller who is not entitled; log at debug, return empty.
- The gateway's grants are cached too. CAST's
castUserGrantsregion is evaluated once per user load; a permission check is a key lookup plusset.contains(). - Domain off = source, not bypass. With
dac.domains.cast.enabled=falsethe facade reads from its source and, by default, denies — the legacy check in the calling code must still run.