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.

On this page

    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:

    1Read the value

    L1 → L2 → loader, exactly as any facade. The value is principal-neutral.

    →
    2Who is calling?

    DacGateway.currentPrincipal(), or an explicit principal from getAs(…).

    →
    3What are they granted?

    DacGateway.grants(principal) — itself cached (CAST: castUserGrants).

    →
    4Decide

    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.

    contractanswers
    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.
    DacGatewayA 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

    formscopes 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

    settingvaluesmeaning
    DenyModeEMPTY (default) · FAILA denied single read returns Optional.empty(), or throws DacAccessDeniedException. List reads always drop the denied entries silently.
    DisabledModeDENY (default) · ALLOWWhen 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

    methodmeaning
    get / getAll / byIndex / listByIndexas 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