perf(policy): one permission universe per resolution; PolicyResult.replacement #74

Merged
james.maes merged 1 commits from perf/policy-resolution-single-universe into develop 2026-11-11 01:08:28 +00:00
Owner

What was wrong

A host's authentication customizer runs on every request of a session (QQQ's OAuth2 module calls it at each cookie resume and each bearer request). Policy resolution read the live permission universe once for every Allow term:

  • PolicyTerm.Exact.expand, Everything.expand and Prefix.expand each call universe.names().
  • The live universe (PolicyPermissions.universe) rebuilds itself on every names() call: PolicyPermissions.allPermissions → PolicyCatalog.of (recomputes the catalog fingerprint: five sorted copies of the instance's object maps and an 86,000 character text on a 1,400-object instance) → catalog.permissionNames() (walks every resource) → every registered host-name function → a new sorted set.

So a role of nine named permissions built nine universes per request, and a full-access document built one. That is why a limited account was slower than an administrator.

Other places with the same per-term or repeated reading: PolicyRules.withheld (1 + one per term), PolicyParser.parseForSave (one per named permission, prefix and Deny checked), AuthentikGroupPermissionMapper.compute, AccessEditorWidget, AuthentikAdmin (token permission count).

What changed

  • PolicyRules.allowed, resolve and withheld read the universe they are given once per call (new PolicyUniverse.once). Every caller gets this without changing: resultFromClaims, the deprecated fromClaims, both claims mappers, the editor and AuthentikAdmin. PolicyParser.parseForSave reads it once per document. EffectivePermissionResolver uses the same helper in place of its private copy.
  • PolicyCatalog.of still checks on every call that its cached catalog matches the instance, but it compares the instance with the kept fingerprint piece by piece instead of building the text (no sorted copies, no 86,000 character string). The compared content is the same, so every in-place metadata change still gives a new catalog on the next call.
  • The catalog keeps permissionNames and its part of the universe (it never changes after it is built); permissionNames() still returns the caller's own copy.
  • Nothing is cached across resolutions: no universe, no decision. The only cache is the existing per-JVM catalog cache, derived only from that JVM's own QInstance and validated on every call, so several pods need no invalidation between them. QQQ's Memoization or cluster caches were not needed: after this change there is no repeated work left that a cache could safely remove (host-name functions must run at each resolution by contract).
  • New PolicyResult.replacement(): the grant alone (Allow minus Deny; nothing when void or unreadable). apply(otherSources) is unchanged and remains a union; a host that calls apply(session.getPermissions()) keeps whatever the session already held. A host whose permissions come only from the token calls session.setPermissions(result.replacement()).

Behaviour is otherwise identical: the golden vectors (PolicyVectorsTest, 93 tests) are untouched and pass, and PolicyResolutionCostTest.oneReadingGrantsExactlyWhatAReadingPerTermGrants compares the result with a reading per term for eight document shapes.

Measured

One resolution (PolicyPermissions.resultFromClaims(claims, format, PolicyCatalog.of(instance))), warm, median of 201, one workstation core.

Synthetic instance of this repository's test (212 tables, 985 processes, 199 widgets):

Policy Before After
9 named permissions 12.1 ms, 7.9 MB, 9 universes 0.33 ms, 0.12 MB, 1 universe
30 named permissions 28.7 ms, 25.3 MB, 30 universes 0.32 ms, 0.14 MB, 1 universe
Full access 1.19 ms, 1.37 MB, 1 universe 0.77 ms, 0.20 MB, 1 universe
PolicyCatalog.of alone 0.33 ms, 458 KB 0.12 ms, 0.2 KB

A consuming host's composed instance (166 tables, 681 processes, 136 widgets, its own host-name function), the released 0.1.0-RC.12 jar against these classes placed ahead of it on the class path (a throwaway probe, not part of this change):

Policy 0.1.0-RC.12 This change
11 named permissions (two roles) 46.5 ms, 68.3 MB, 11 universes 2.80 ms, 5.3 MB, 1 universe
9 named permissions 38.5 ms, 56.1 MB, 9 universes 2.78 ms, 5.3 MB, 1 universe
Full access 3.88 ms, 7.2 MB 2.78 ms, 5.4 MB

Of the remaining 2.8 ms, about 1.8 ms and 4.5 MB is that host's own host-name function, which the qbit calls once per resolution.

Tests

  • PolicyResolutionCostTest (new, 8 tests): universe builds per resolution are 1 for 1, 9 and 30 named permissions through resultFromClaims, fromClaims and AuthentikPolicyClaimMapper; 1 for a document mixing exact resources, a prefix, named permissions and a Deny; 0 for a void or absent claim; allowed, resolve and withheld read a counting universe once; save-time checks read it once; the equivalence check above; the live universe holds the same names and still follows an instance change; an allocation budget (thirty names allocate less than three times one name, and less than 2 MB).
  • PolicyCatalogTest: seventeen in-place changes (object added or removed, level, base name, custom checker, null rules, supplemental metadata, hidden flag, feature added, feature label) each give a new catalog on the next call, which is then kept and equals a fresh build.
  • PolicyDenyTest: replacement() against apply().

Local: ./mvnw -B verify -DskipITs (the repository's unit gate): 636 tests, 0 failures, 0 errors, 0 skipped; architecture guardrails G1, G3, G4 pass; JaCoCo coverage checks met. The contract ITs start an Authentik stack and were left to this branch's hosted build.

Independence

Touches only policy/, EffectivePermissionResolver, docs/POLICY-FORMAT.md and CHANGELOG.md. A trial merge with feat/session-policy (pull request 73) is clean, so either can merge first.

🤖 Generated with Claude Code

## What was wrong A host's authentication customizer runs on every request of a session (QQQ's OAuth2 module calls it at each cookie resume and each bearer request). Policy resolution read the live permission universe once **for every Allow term**: - `PolicyTerm.Exact.expand`, `Everything.expand` and `Prefix.expand` each call `universe.names()`. - The live universe (`PolicyPermissions.universe`) rebuilds itself on every `names()` call: `PolicyPermissions.allPermissions` → `PolicyCatalog.of` (recomputes the catalog fingerprint: five sorted copies of the instance's object maps and an 86,000 character text on a 1,400-object instance) → `catalog.permissionNames()` (walks every resource) → every registered host-name function → a new sorted set. So a role of nine named permissions built nine universes per request, and a full-access document built one. That is why a limited account was slower than an administrator. Other places with the same per-term or repeated reading: `PolicyRules.withheld` (1 + one per term), `PolicyParser.parseForSave` (one per named permission, prefix and Deny checked), `AuthentikGroupPermissionMapper.compute`, `AccessEditorWidget`, `AuthentikAdmin` (token permission count). ## What changed - `PolicyRules.allowed`, `resolve` and `withheld` read the universe they are given **once per call** (new `PolicyUniverse.once`). Every caller gets this without changing: `resultFromClaims`, the deprecated `fromClaims`, both claims mappers, the editor and `AuthentikAdmin`. `PolicyParser.parseForSave` reads it once per document. `EffectivePermissionResolver` uses the same helper in place of its private copy. - `PolicyCatalog.of` still checks on **every** call that its cached catalog matches the instance, but it compares the instance with the kept fingerprint piece by piece instead of building the text (no sorted copies, no 86,000 character string). The compared content is the same, so every in-place metadata change still gives a new catalog on the next call. - The catalog keeps `permissionNames` and its part of the universe (it never changes after it is built); `permissionNames()` still returns the caller's own copy. - **Nothing is cached across resolutions**: no universe, no decision. The only cache is the existing per-JVM catalog cache, derived only from that JVM's own `QInstance` and validated on every call, so several pods need no invalidation between them. QQQ's `Memoization` or cluster caches were not needed: after this change there is no repeated work left that a cache could safely remove (host-name functions must run at each resolution by contract). - New `PolicyResult.replacement()`: the grant alone (Allow minus Deny; nothing when void or unreadable). `apply(otherSources)` is **unchanged** and remains a union; a host that calls `apply(session.getPermissions())` keeps whatever the session already held. A host whose permissions come only from the token calls `session.setPermissions(result.replacement())`. Behaviour is otherwise identical: the golden vectors (`PolicyVectorsTest`, 93 tests) are untouched and pass, and `PolicyResolutionCostTest.oneReadingGrantsExactlyWhatAReadingPerTermGrants` compares the result with a reading per term for eight document shapes. ## Measured One resolution (`PolicyPermissions.resultFromClaims(claims, format, PolicyCatalog.of(instance))`), warm, median of 201, one workstation core. Synthetic instance of this repository's test (212 tables, 985 processes, 199 widgets): | Policy | Before | After | |---|---|---| | 9 named permissions | 12.1 ms, 7.9 MB, 9 universes | 0.33 ms, 0.12 MB, 1 universe | | 30 named permissions | 28.7 ms, 25.3 MB, 30 universes | 0.32 ms, 0.14 MB, 1 universe | | Full access | 1.19 ms, 1.37 MB, 1 universe | 0.77 ms, 0.20 MB, 1 universe | | `PolicyCatalog.of` alone | 0.33 ms, 458 KB | 0.12 ms, 0.2 KB | A consuming host's composed instance (166 tables, 681 processes, 136 widgets, its own host-name function), the released 0.1.0-RC.12 jar against these classes placed ahead of it on the class path (a throwaway probe, not part of this change): | Policy | 0.1.0-RC.12 | This change | |---|---|---| | 11 named permissions (two roles) | 46.5 ms, 68.3 MB, 11 universes | 2.80 ms, 5.3 MB, 1 universe | | 9 named permissions | 38.5 ms, 56.1 MB, 9 universes | 2.78 ms, 5.3 MB, 1 universe | | Full access | 3.88 ms, 7.2 MB | 2.78 ms, 5.4 MB | Of the remaining 2.8 ms, about 1.8 ms and 4.5 MB is that host's own host-name function, which the qbit calls once per resolution. ## Tests - `PolicyResolutionCostTest` (new, 8 tests): universe builds per resolution are 1 for 1, 9 and 30 named permissions through `resultFromClaims`, `fromClaims` and `AuthentikPolicyClaimMapper`; 1 for a document mixing exact resources, a prefix, named permissions and a Deny; 0 for a void or absent claim; `allowed`, `resolve` and `withheld` read a counting universe once; save-time checks read it once; the equivalence check above; the live universe holds the same names and still follows an instance change; an allocation budget (thirty names allocate less than three times one name, and less than 2 MB). - `PolicyCatalogTest`: seventeen in-place changes (object added or removed, level, base name, custom checker, null rules, supplemental metadata, hidden flag, feature added, feature label) each give a new catalog on the next call, which is then kept and equals a fresh build. - `PolicyDenyTest`: `replacement()` against `apply()`. Local: `./mvnw -B verify -DskipITs` (the repository's unit gate): 636 tests, 0 failures, 0 errors, 0 skipped; architecture guardrails G1, G3, G4 pass; JaCoCo coverage checks met. The contract ITs start an Authentik stack and were left to this branch's hosted build. ## Independence Touches only `policy/`, `EffectivePermissionResolver`, `docs/POLICY-FORMAT.md` and `CHANGELOG.md`. A trial merge with `feat/session-policy` (pull request 73) is clean, so either can merge first. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 1 commit 2026-11-11 00:52:43 +00:00
perf(policy): one permission universe per resolution; PolicyResult.replacement
maven / build (push) Successful in 15m31s
build / maven (push) Successful in 15m31s
537e56d744
A host's authentication customizer runs on every request of a session. Policy
resolution read the live permission universe once for every Allow term, and each
reading recomputed the PolicyCatalog fingerprint, walked every catalog resource
and called every host-name function. A role of nine named permissions therefore
built nine universes per request; a full-access document built one.

- PolicyRules.allowed, resolve and withheld read the universe they are given once
  per call (PolicyUniverse.once), so resultFromClaims, fromClaims, both claims
  mappers, the access editor and AuthentikAdmin cost one universe per resolution
  whatever the policy holds. PolicyParser.parseForSave reads it once per document.
- PolicyCatalog.of compares the instance with the kept fingerprint piece by piece
  instead of building the fingerprint text on every call; the same content is
  compared, so every in-place metadata change still gives a new catalog on the
  next call. The catalog keeps its permission names and its part of the universe.
- Nothing is cached across resolutions: no universe, no decision. The catalog
  cache is per JVM and derived only from that JVM's instance, as before.
- PolicyResult.replacement(): the grant alone (Allow minus Deny), for a host whose
  sessions take their permissions only from the token. apply() is unchanged and
  remains a union with what the host passes.

Measured on a 1,400-object instance (212 tables, 985 processes, 199 widgets), one
resolution: 9 named permissions 12.1 ms and 7.9 MB to 0.33 ms and 0.12 MB; 30
named 28.7 ms and 25.3 MB to 0.32 ms and 0.14 MB; full access 1.19 ms and 1.37 MB
to 0.77 ms and 0.20 MB.

Tests: PolicyResolutionCostTest counts universe builds (1 for 1, 9 and 30 names,
0 for a void or absent claim), pins that one reading grants exactly what a reading
per term grants, and holds an allocation budget; PolicyCatalogTest changes every
fingerprinted aspect in place. The golden vectors are untouched and pass.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
james.maes merged commit 7fcf061455 into develop 2026-11-11 01:08:28 +00:00
Sign in to join this conversation.