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>
27 KiB
QQQ policy format
Version 1.1.0 of the policy documents that qbit-authentik reads and writes. The design is in DESIGN.md §12.
A policy grants QQQ permissions to the members of an Authentik group. It is stored on the group as an attribute. At sign-in, the policies of all the user's groups, including inherited parent groups, become QQQ session permissions.
Document
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.1.0",
"Statement": [
{
"Effect": "Allow",
"Action": ["hasAccess"],
"Resource": ["contentApp", "publishPage"]
},
{
"Effect": "Allow",
"Action": ["read", "insert", "edit"],
"Resource": ["cms_page", "cms_media"]
}
]
}
| Field | Type | Rule |
|---|---|---|
PolicySpec |
string | Must equal the app's configured spec name (policySpec, default qqq-iam) |
PolicyVersion |
string, semver | Must equal the supported version (policyVersion, default 1.1.0) |
Statement |
array of statements | May be empty (grants nothing) |
Statement[].Effect |
string | Allow or Deny |
Statement[].Action |
array of strings | Each one of hasAccess, read, insert, edit, delete, *, or a table feature name registered for every listed resource (for a prefix resource: for at least one match) |
Statement[].Resource |
array of strings | * with exactly Action: ["*"]; a prefix such as utils_*; or a QQQ permission base name matching [A-Za-z0-9_]+: a table, process, app, report or widget name (or its custom permissionBaseName) |
Statement[].Permission |
array of strings | Instead of Action and Resource: exact permission names, dot-separated segments of [A-Za-z0-9_-] (hyphens allowed here, not in Resource; a bare name with no dot is allowed). Dotted names such as authentik.users.manage.hasAccess, cdn.purge or document-generation.print are granted only this way |
Meaning
Each statement grants {resource}.{action} for every resource and action it lists, that exists (see "One universe" below): a standard action the resource does not have is silently dropped, so Action ["hasAccess", "read"] on a process grants only hasAccess. Action: ["*"] on a named resource stands for the five standard actions only; it never includes table features. The full-access statement below is the explicit exception. The result is the union over all statements, and over all of the user's groups.
| Action | QQQ permission | Applies to |
|---|---|---|
hasAccess |
{name}.hasAccess |
apps, processes, reports, widgets, and tables with HAS_ACCESS permissions |
read |
{name}.read |
tables (query, get) |
insert |
{name}.insert |
tables |
edit |
{name}.edit |
tables |
delete |
{name}.delete |
tables |
registered feature (e.g. analyze, comment) |
{name}.{feature} |
tables that register that feature, including HAS_ACCESS and READ_WRITE tables |
Examples:
{"Action": ["read"], "Resource": ["cms_page"]}grantscms_page.read.{"Action": ["*"], "Resource": ["cms_page"]}grantscms_page.hasAccess,cms_page.read,cms_page.insert,cms_page.editandcms_page.delete.{"Action": ["analyze", "comment"], "Resource": ["person"]}grantsperson.analyzeandperson.commentwhen both features are registered forperson.
Features are generic strings, discovered by matching PermissionsHelper.getAllAvailablePermissions(qInstance) entries to the table's effective permission base name. Examples include analyze from qbit-data-analysis and comment, attach, tag, share, task, assign from qbit-collaboration. The feature label comes from AvailablePermission.permissionType. For named resources every feature grant is explicit, so registering a new feature never broadens an existing Action: ["*"] statement on a named resource. This matching rule also exposes the available write permission of READ_WRITE tables as an explicit named action; it is not part of *.
A grant that QQQ never checks (for example hasAccess on a table with insert/edit/delete permissions) is harmless.
Not in 1.1.0: conditions and field-level rules. Any statement key other than Effect, Action, Resource and Permission rejects the document, because a parser that silently dropped a Condition would turn a conditional statement into an unconditional one. Named permissions such as authentik.users.manage cannot be written as a resource, because they contain dots; use the Permission key (next section). Explicit named grants may also stay in the group attribute qqq.io/permissions; full access includes them in session form.
Deny, prefixes and named permissions (1.1.0)
Evaluation. Across all of a user's groups (direct and inherited), the result is the union of every Allow expansion minus the union of every Deny expansion. A Deny anywhere wins. There is no ordering, no specificity and no per-group precedence, so any result is explained by listing the statements that matched. Both sets are expanded against the live QQQ instance at sign-in, so a Deny on billing_* also covers a billing table added later.
Prefix resources. utils_* matches every resource whose base name starts with utils_: the part before the final .action of a permission name, containing no dot. It does not match utilsboard. An Allow prefix grants only names whose base has no dot. A Deny of a prefix (or of a named resource) is wider: it also covers the dotted host names that resource owns, those whose first dot-separated segment matches, so Deny * billing_* removes billing_audit.export.run that full access would otherwise grant. A prefix must have at least two characters before the *, end its literal part in _, and have the * as the last character; u*, utils* and *_log reject the document. Action: ["*"] on a prefix means the five standard actions for each match, exactly as on a named resource. A feature action grants only on matches that register it, and a statement whose feature action matches no resource is rejected at save.
Deny and *. For Allow, Action: ["*"] is the five standard actions only. For Deny it means every action, including table features and write, for each match. Otherwise denying utils_log under full access would leave utils_log.analyze granted. A Deny is therefore always at least as broad as the Allow it is written to cancel. Resource: ["*"] still requires Action: ["*"], for Deny as for Allow.
Named permissions. A statement may carry Permission: [...] instead of Action and Resource, giving exact permission names. It works under Allow and Deny, and is the only way to deny a dotted permission such as authentik.users.manage.hasAccess or a host name such as cdn.purge that full access includes. Mixing Permission with Action or Resource in one statement rejects the document.
One universe. Every Allow expansion (an exact Resource and Action, a Permission name, a prefix, and full access) is computed against the same set of permission names: core getAllAvailablePermissions, plus the qbit catalog including hidden tables and processes, plus host-declared names, plus every IdentityPermission session name. A prefix matches the base name (the text before the final .action) of any entry in that set. An Allow of a name that is not in the set grants nothing at runtime. A Deny is a predicate over names and needs no universe entry to apply.
Host-declared names are not features. Action grants only the standard actions and the table features registered for that resource. Dotted service names (alerts.admin, email.send, echo.use) are never features; they are granted only through Permission. A host declares them with PolicyPermissions.registerHostPermissions(qInstance -> names), which needs no Authentik runtime (so a host that runs only the policy mapper keeps its names), or with AuthentikQBitConfig.withFullAccessHostPermissions. The two are unioned and evaluated at every expansion.
Claim shape. The claim is ONE merged document, a JSON object or a JSON string, exactly what the claim expression emits. An array of documents is rejected.
Zero matches. At sign-in a prefix or named permission that matches nothing expands to nothing. When an operator saves a document, any prefix, feature or named permission that matches nothing in the current instance is rejected ("matches nothing"), for Allow and for Deny. A Deny of a named resource none of whose listed actions exists is rejected the same way (this is stricter than the golden vectors require). A mistyped Deny that silently does nothing is the dangerous case, so it is refused at the point where someone can fix it.
Editing. Adding a Deny is always allowed to an operator with policy management. Removing or narrowing a Deny is a grant: the operator must hold every permission it was withholding and must not be denied any of them themselves. Removing a person from a group, or a group from a parent, releases the Deny statements of that group and above it, so it needs the same check. Saving a policy that contains a Deny also runs the last-administrator check.
What a Deny subtracts. ONE rule, in SessionPermissionRules.compute, serves sign-in, automated sessions and the refreshing mapper, so they cannot disagree. By what is known about the user's policies:
- Valid (known). A
Denyremoves what the user's groups grant through policy documents andqqq.io/permissions, the mirror's stored effective permissions, the static group fallbacks, the host permission source, and the permissions an earlier customizer already put on the session. That holds whether the cut-over guard is enforcing or blocking: while it blocks, sign-in grants today's access (static floor plus host source) minus every validDeny. ADenyonly removes access, so the guard never switches it off. The ONLY exemption is the bootstrap-operator identity permissions (break-glass). ADenythat removes a permission from a guard-group member is a deliberate removal, so it never trips the guard by itself. If the guard cannot run (an Authentik error) its last known state stands (never OFF, with its original time, for at most 15 minutes); otherwise it blocks. - Void. An invalid document, or a group or parent missing from the graph or mirror, leaves ONLY the bootstrap identity permissions: the floors, the host source, the mirror's stored grants and the session's earlier permissions are cleared, even while the guard blocks (the log carries an ERROR naming the groups).
- Unavailable. If the group graph (or mirror) cannot be read and the token carries no policy claim, no
Denycan be known: the host source, the session's earlier permissions and any directory grants are withheld, and only the explicitly configured outage fallbacks (static group permissions) plus break-glass remain. If the claim is present it carries every group's merged policy and applies on its own.
A token with no groups claim at all and no policy claim says nothing about the user's groups, so it is treated as unavailable; an empty groups array is a real answer (no groups) and is known-empty.
Operators and break-glass. For the subset rule, the bootstrap identity permissions are added to an operator AFTER the Deny filter, so a void or denying group cannot lock a bootstrap operator out of repairing policies.
AuthentikPolicyClaimMapper differs on purpose. It is the claim-only mapper: it has no bootstrap, static or host floors, so it does not go through the shared rule. With no policy claim it leaves the session's permissions untouched; with a valid claim it adds the claim's grants and removes every Deny from them and from the session's earlier permissions; with an invalid claim it clears all of them. Hosts that need floors or the cut-over guard use AuthentikGroupPermissionMapper. The helpers AuthentikGroupPermissionMapper.resolve/resolveResult and PolicyResult.apply are for a host customizer's own assembly, not for sign-in assembly, which the mappers do themselves.
PolicyResult reports isVoid(), isUnavailable() and isAbsent() separately, and apply() returns nothing when void or unavailable.
Freshness of the group graph. Sign-in reads the group graph through a cache (groupCacheTtlSeconds): a Deny added in the Authentik admin UI is not seen at sign-in until the TTL elapses, unless the claim carries it. The refreshing mapper reads the mirrored group rows, so it sees a Deny as of the last mirror checkpoint (at most 15 minutes old), read on the same connection as the user row and refused unless every group row carries that checkpoint's refresh time and the table is under its bound.
Void is not empty. An invalid document voids the user: they get no permissions. Because a void group might have held a Deny, un-voiding is a release: removing a member from a void group, removing a parent link to it, or saving a valid document over an invalid stored one requires the operator to hold every permission that exists. Generate Policy refuses to run over an invalid stored document instead of discarding its Deny.
Operators. For every subset-rule check an operator is credited with a permission only if their own policies grant it, or a host floor (static fallback, host permission source, bootstrap) offers it AND their own policies do not deny it. An operator denied a permission can neither grant it nor release a Deny on it. Save Policy must carry the expectedFingerprint of the group as reviewed; the fingerprint covers qqq.io/ attributes and the configured policy attribute even when it lives outside qqq.io/.
Host customizers. PolicyPermissions.fromClaims and AuthentikGroupPermissionMapper.resolve return plain sets and are deprecated: a set cannot tell void from absent or carry a Deny to the host's other sources. Use PolicyPermissions.resultFromClaims(claims, format, catalog) (or AuthentikGroupPermissionMapper.resolveResult), which returns a PolicyResult (isVoid(), isAbsent(), the typed rules and what they grant), and add the host's own service permissions through result.apply(otherPermissions): it subtracts every Deny from them and returns nothing when the policy is void. apply is a union: apply(session.getPermissions()) keeps whatever the session already held that no Deny covers. A host whose sessions take their permissions from the policies alone (QQQ runs the customizer again on every request of a session, and the session must end with exactly what the current token grants) calls session.setPermissions(result.replacement()) instead: the grant alone, nothing carried over, nothing when the policy is void or unreadable.
The golden vectors in src/test/resources/policy-vectors-1.1.0.json define these rules over a small fixed permission universe. Every implementation of the grammar must pass them: this qbit's parser, the Authentik claim validator in the identity CD repository, and Forge's catalog tests.
Full access
The exact full-access statement is:
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
It grants Full access (all current and future resources). A complete document is:
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.1.0",
"Statement": [
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
]
}
Full access expands when permissions are computed (sign-in, effective-permission resolution, access comparison and member preview), using the current QQQ instance. The document and cached group graph retain a symbolic marker; no expanded permission list is stored in the document or cached for full access. Adding a table, process, app, report, widget or table feature therefore requires no policy edit. Existing signed-in sessions receive the new set when their permissions are computed again.
The expansion is the union of:
- Every
AvailablePermission.namereturned byPermissionsHelper.getAllAvailablePermissions(qInstance), without limiting names to the editor's resource syntax. Core uses effective permission base names and permission levels: tablehasAccess,read,insert,edit,delete, READ_WRITEwrite, registered table feature names, and process/app/report/protected-widgethasAccess. Names supplied by custom permission checkers are included when those checkers expose them through this API; arbitrary permissions that core does not enumerate are not inferred. - The qbit permission catalog, including policy-catalog permissions for hidden tables and processes (core skips hidden tables/processes), plus any additional catalog names supplied to the resolver.
- Host-declared permission names from
AuthentikQBitConfig.withFullAccessHostPermissions(qInstance -> names), for each runtime whose API backend is in the instance. Use it for names that core cannot enumerate: custom permission checkers and named permissions such ascdn.purgeoremail.send. The function runs at every expansion, so names the host adds later are covered without a policy edit. Without it, full access does not grant those names. - Every
IdentityPermissionsession name, such asauthentik.users.manage.hasAccess,authentik.permissions.manage.hasAccessandauthentik.policies.manage.hasAccess. This covers the entire qbit identity catalog, even when a particular feature is not enabled.
This grants QQQ permissions, not Authentik superuser status. Existing scope, protected-account, audit, approval, last-admin and cut-over checks still apply.
Examples and boundaries:
{"Effect":"Allow","Action":["*"],"Resource":["*"]}includes table features, READ_WRITEwrite, named identity permissions and future resources.{"Effect":"Allow","Action":["*"],"Resource":["cms_page"]}still grants only the five standard actions forcms_page; it does not grant features orwrite.{"Effect":"Allow","Action":["read"],"Resource":["*"]}is invalid. So isAction: ["*", "read"]withResource: ["*"]. The error says thatResource "*" requires Action ["*"].- A full-access statement may coexist with other valid statements (or named resources in the same statement). Canonicalization drops the redundant grants. Every statement and resource is validated first: an invalid statement anywhere still rejects the entire document, even beside full access.
Saving a full-access document requires the operator to hold every permission in its current expansion, even when the target already has full access. An operator whose own effective policy is full access meets that check. Review and audit summaries use Full access (all current and future resources). Removing full access cannot remove the last qualifying identity administrator.
Parsing fails closed
A document grants nothing, and the parser logs a warning, when any of these is true:
- it is missing, blank, not JSON, or not a JSON object
PolicySpecis missing or is not the configured namePolicyVersionis missing or is not the supported versionStatementis present but not an array, or a statement is not an object- a statement's
Effectis neitherAllownorDeny - a statement has a key other than
Effect,Action,ResourceandPermission, or mixesPermissionwithAction/Resource - a wildcard resource is not a valid prefix
ActionorResourceis missing or not an array of strings (a statement withPermissionhas neither)- the document has a top-level key other than
PolicySpec,PolicyVersionandStatement(a misspelledStatementwould otherwise read as an empty policy), orStatementis missing (an empty array is fine) - a
Permissionname is not dot-separated segments of[A-Za-z0-9_-] - a statement has an empty
Resource,ActionorPermissionarray (it would do nothing, which for aDenyis the dangerous case) - the claim is not one JSON object or JSON string (an array of documents is rejected)
- an action is neither a standard action,
*, nor a feature registered for every listed resource - a resource does not match
[A-Za-z0-9_]+, except*with exactlyAction: ["*"]
There is no partial credit: one bad statement voids the whole document. A user's documents are evaluated together, and one invalid document among a user's groups voids all of them: the user gets no permissions. This is deliberate. With Deny in the format, dropping only the bad group could drop a Deny while other groups' Allow statements survive, which would silently escalate access. The same rule applies to the merged token claim: one bad statement voids the claim.
Canonical form
Canonicalization never drops a Deny. A document containing full access canonicalizes to one full-access Allow statement followed by its Deny statements (and no other Allow); the redundant Allow grants are dropped, the Deny statements are kept. A generated permission set produces full access only when it explicitly contains the internal full-access marker; enumerating all current permissions never creates future grants.
For other documents the qbit writes the existing canonical form:
- one statement per action that is granted, in the order
hasAccess,read,insert,edit,delete, then feature names sorted alphabetically (existing documents retain their byte-for-byte canonical form) - each statement lists its resources sorted
- no
* - stored on the group as a compact JSON string
Before every save, the qbit checks the round trip: parsing the generated document must give back exactly the permissions it was generated from. A document that fails this check is never written.
Where it lives
| What | Default | Config |
|---|---|---|
| Group attribute holding a group's document | qqq.io/policy |
policyAttribute |
| Token claim holding the merged document | qqq_policy |
policyClaim |
| Spec name | qqq-iam |
policySpec |
| Version | 1.1.0 |
policyVersion |
An app that already stores documents of this shape under its own spec name, attribute and claim sets those four values and keeps its data unchanged. The parser accepts the attribute or claim as a JSON string or as a JSON object.
Getting policies into the token
Authentik puts the merged document into the token through a scope mapping. The example blueprint voids the whole claim (it emits a document the qbit rejects) when any document found under the configured attribute is unparsable, has a duplicate key, is not an object, carries a PolicySpec that is not exactly the configured one (missing, mistyped, another case, a trailing space), has another version or a non-array Statement. Skipping documents of another spec is an explicit opt-in (skip_other_specs = True, for an attribute shared with other applications) and is off by default. Known difference: the qbit reads JSON with org.json (which accepts some lenient syntax Python rejects, and rejects duplicate keys) and the blueprint with Python json (strict syntax, duplicate keys rejected, also nested); a document only one of them accepts voids the claim on the stricter side. BlueprintClaimExpressionTest runs the blueprint expression against these cases. Steps:
- Apply the example blueprint,
src/test/resources/blueprints/qqq-policy-claim.yaml. Edit its spec, version, attribute and claim name to match your config. - Add the mapping to the OAuth2 provider's
property_mappings. It uses the scopeprofile, which every QQQ sign-in requests. - Keep
include_claims_in_id_token: trueon the provider (the default). - Register the customizer in your app:
AuthentikPolicyClaimMapperon its own, orAuthentikGroupPermissionMapper, which also reads the claim and the group graph, or- your own customizer, calling
PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance)).
The expanded catalog is cached per QInstance (the last few instances, keyed by a digest of table permission rules, supplemental metadata and object names); PolicyCatalog.clearCache() forgets them. The cache lives in each JVM and is derived only from that JVM's own instance, so nodes of a cluster need no invalidation between them. Every PolicyCatalog.of call compares the instance with the digest, so an in-place metadata change is seen by the next call. The permission universe is never cached: it is built from the catalog's names, the host-declared names and the identity names at each resolution, once per resolution however many terms the policy has (PolicyRules reads the universe it is given once per call; PolicyUniverse.once does the same for a caller's own loop). No decision is cached: the same claim is resolved again on every request. Host-declared names are never cached, and a host-name function runs once per resolution, so it should be cheap. Host names must be dot-separated segments of [A-Za-z0-9_-]; a function that returns anything else fails the expansion loudly. The cache key also covers the permission rules of processes, apps, reports and widgets, hidden flags and the registered table-feature list; a feature's applies-to predicate is opaque, so a host that changes only that in place calls clearCache().
The catalog-aware overloads of PolicyParser, PolicyGenerator, PolicyPermissions, PolicyStore.fingerprint and GroupGraph.fromGroups support features. The older overloads without a catalog accept standard actions and full access; PolicyPermissions.fromClaims uses the current QContext instance for full-access expansion when no catalog is supplied. Raw PolicyParser results are symbolic and must be resolved before entering a session. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the available-permission API from the pinned RC.6 core; registered TableFeaturePermissionsMetaData features appear in that API.
The mapping merges the statements of every group in request.user.all_groups() (direct groups and their ancestors) whose document has the right spec and version. It always emits a document, with an empty Statement array when the user has no policies.
The contract test PolicyClaimMappingIT runs this blueprint on Authentik 2026.8.3. It puts generated policies on a parent and a child group (one as a string, one as a JSON object), then runs the mapping through Authentik's property-mapping test endpoint for a member of the child group. It checks that the parser turns the claim into the union of the groups' permissions, that a document with another spec is skipped, and that a user without policies gets a valid empty document.
Freshness
The claim is computed when the token is issued, so a policy change reaches a signed-in user at their next token refresh. In kof22 that is within 1 hour. To apply a change at once, revoke the user's sessions (Revoke Sessions).
Record locks (not yet)
Row-level security keys are not part of the format. AuthentikPolicyClaimMapper.applySecurityKeys(QSession, JSONObject) is the extension point. A host overrides it to set security key values from its own explicit claims, and must never infer them from group names.