Compare commits

..
Author SHA1 Message Date
james.maes 7fcf061455 Merge pull request 'perf(policy): one permission universe per resolution; PolicyResult.replacement' (#74) from perf/policy-resolution-single-universe into develop
build / maven (push) Successful in 13m5s
maven / build (push) Successful in 13m5s
2026-11-11 01:08:27 +00:00
james.maesandClaude Fable 5.2 537e56d744 perf(policy): one permission universe per resolution; PolicyResult.replacement
build / maven (push) Successful in 15m31s
maven / build (push) Successful in 15m31s
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>
2026-11-10 18:51:54 -06:00
james.maes a3fa59efe7 Merge pull request 'fix(health): token_broker health check only when tokens are enabled' (#72) from fix/health-token-broker-develop into develop
build / maven (push) Successful in 11m23s
maven / build (push) Successful in 11m23s
2026-11-09 18:46:33 +00:00
james.maesandClaude Sonnet 5.5 82c79d69be fix(health): register the token_broker health check only when tokens are enabled
build / maven (push) Successful in 11m34s
maven / build (push) Successful in 11m34s
With AUTHENTIK_QBIT_TOKENS off the check could only ever report UNKNOWN
"not configured", and core has no way to remove a registered check, so the
health page showed a permanent UNKNOWN row. Register it only when
getTokensEnabled() is true; the other checks are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 12:24:34 -06:00
james.maes 4c2ea3b889 Merge pull request 'feat(policy): format 1.1.0 with Deny, prefix wildcards and named permissions' (#69) from feat/policy-1-1-deny-prefix into develop
build / maven (push) Successful in 11m28s
maven / build (push) Successful in 11m28s
2026-11-09 03:01:04 +00:00
james.maesandClaude Sonnet 5.5 2c5baa336d ci: re-run (the previous run hit a runner Docker port-binding error in Testcontainers)
build / maven (push) Successful in 11m49s
maven / build (push) Successful in 11m49s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 20:48:19 -06:00
james.maesandClaude Sonnet 5.5 e2a3cd3021 test(policy): skip the python blueprint run when the CI image has no python
build / maven (push) Failing after 1m43s
maven / build (push) Failing after 1m43s
The shared CI runner image has no python3, so the 'fail without python' rule made
every build red at the unit stage. The python run now skips unless
BLUEPRINT_PYTHON_REQUIRED=true; a pure-Java test keeps the blueprint's safety
shape checked everywhere.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 20:38:27 -06:00
james.maesandClaude Sonnet 5.5 49905f8e86 fix(policy): break-glass operators keep identity permissions; absent groups claim is unavailable
build / maven (push) Failing after 1m29s
maven / build (push) Failing after 1m29s
Bootstrap identity permissions are added to an operator after the Deny filter, so
a void or denying group cannot lock them out of repairing policies. A token with
no groups claim and no policy claim is unavailable (an empty array stays known).
The mirror restores a policy attribute verbatim only when it parses. Docs mark the
resolve/resolveResult/apply helpers as not for sign-in assembly. More mapper tests
(void claim alone, bootstrap under void, guard error at sign-in, specific
QAuthenticationException on refused mirror snapshots).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 20:32:00 -06:00
james.maesandClaude Sonnet 5.5 a76843d622 docs(policy): break-glass operators, absent groups claim, claim-only mapper
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 20:31:59 -06:00
james.maesandClaude Sonnet 5.5 cd1ebc0f73 fix(policy): one shared session-permission rule; Deny beats the mirror; void/unavailable
build / maven (push) Failing after 1m29s
maven / build (push) Failing after 1m29s
SessionPermissionRules.compute serves sign-in, automated sessions and the
refreshing mapper: void leaves break-glass only even while the guard blocks;
unavailable leaves only configured outage fallbacks; known applies every Deny to
policy grants, mirror permissions, static and host floors and earlier session
permissions. The refreshing mapper reads Deny rules from the mirrored group rows
on the same connection (checkpoint-consistent, bounded). Automated sessions void
on an unknown group pk and no longer keep earlier permissions when unresolved.
The guard keeps only a real last state (never OFF, original time, 15 min bound).
The mirror stores the policy document unredacted. Blueprint rejects extra
top-level keys and a missing Statement.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:56:28 -06:00
james.maesandClaude Sonnet 5.5 81fa5b53bf docs(policy): one shared session rule, void/unavailable, blueprint shape
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:56:26 -06:00
james.maesandClaude Sonnet 5.5 bad06d806a fix(policy): guard-blocked and floor Deny, unavailable state, strict documents
build / maven (push) Failing after 1m32s
maven / build (push) Failing after 1m32s
The cut-over guard's blocked branch applies valid Deny rules (claim and graph) to
floor, host source and existing permissions; a guard error keeps the last known
state. Static and host floors are subject to Deny everywhere (bootstrap exempt),
including the refreshing mapper, which now reads Deny rules from the mirror
without an API call. PolicyResult gains UNAVAILABLE; a failed graph read with no
claim voids earlier permissions; resolveResult reports the full rules. Missing
parents void. Documents with unknown top-level keys or no Statement are
rejected. The group fingerprint covers the configured policy attribute. Stronger
tests for release, grandparent Deny, and the blueprint (fails without python).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:24:01 -06:00
james.maesandClaude Sonnet 5.5 4a375848f2 docs(policy): vectors and spec for document shape, blocked-guard and floors
Documents with an unknown top-level key or no Statement are rejected (new
rawDocuments vectors); a Deny subtracts static and host floors, also while the
guard blocks; unreadable and void states; fingerprint covers the policy attribute.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:23:59 -06:00
james.maesandClaude Sonnet 5.5 a6ac960f3a fix(policy): close fail-open edges found in review
build / maven (push) Failing after 1m25s
maven / build (push) Failing after 1m26s
Operator credit never includes a name their own policies deny; the configured
policy attribute is reserved against attribute edits; un-voiding an invalid
policy is a release; a deliberate Deny no longer trips the cut-over guard and
void is shown; a group missing from the graph voids; PolicyResult typed API
with apply(); Deny of a prefix covers dotted host names; empty arrays and bad
host names reject; Save Policy requires expectedFingerprint; the catalog cache
key covers all rule sources and the runtime shares the cached catalog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 18:54:02 -06:00
james.maesandClaude Sonnet 5.5 86aa863c7e docs(policy): vectors, spec and blueprint for review fixes
Deny of a prefix or named resource covers its dotted host names; empty
Resource/Action/Permission arrays reject; the blueprint voids on any document
under the attribute that is not exactly our spec (skipping other specs is an
opt-in) and on duplicate keys. Spec: what a Deny subtracts, void is not empty,
operators, host customizer API, blueprint known differences.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 18:54:02 -06:00
james.maesandClaude Sonnet 5.5 33e75b6d45 test(policy): golden vectors, Deny properties, consumer tests; blueprint, changelog, upgrade notes
build / maven (push) Failing after 1m43s
maven / build (push) Failing after 1m43s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 18:09:24 -06:00
james.maesandClaude Sonnet 5.5 1ca28ecce6 feat(policy): format 1.1.0 typed rules (Deny, prefix resources, named permissions)
PolicyParser returns typed PolicyRules (Allow and Deny terms, or void) instead
of permission names, so a Deny or prefix cannot be read as a grant. Result =
union(Allow) minus union(Deny) across all of a user's groups; one invalid
policy voids every group of the user. Allow expands against one live universe;
Deny is a predicate. Canonicalization never drops a Deny. Editor: save-time
zero-match checks, effective-permission subset rule, release of Deny on
removal, last-admin check. Host names register without an Authentik runtime;
PolicyCatalog is cached per QInstance. Default PolicyVersion is 1.1.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 18:09:24 -06:00
james.maesandClaude Sonnet 5.5 9c76ff389e docs(policy): vectors and spec for hyphenated names, one universe, host names, claim shape
Permission names are dot-separated segments of [A-Za-z0-9_-] (bare names
allowed); Resource stays [A-Za-z0-9_]+ and utils_* prefixes. Every Allow
expands against one universe, host names are not features, a standard action
a resource lacks is dropped, and the claim is one merged document.
New vectors: hyphenated and bare Permission names, invalid names, hyphenated
Resource, host name as Action, dropped action, not-in-universe name.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 18:01:26 -06:00
james.maesandClaude Sonnet 5.5 6f9ec41f35 docs(policy): specify format 1.1.0 (Deny, prefix wildcards, named permissions)
build / maven (push) Failing after 45s
maven / build (push) Failing after 45s
Spec and shared golden vectors only; no parser change yet.

- Effect Allow|Deny; result = union(Allow) minus union(Deny) across all of
  a user's groups, Deny wins, no ordering, expanded against the live
  instance so a Deny also covers future resources.
- Prefix resources (utils_*): at least two characters, literal part ends in
  an underscore, wildcard last. Deny with Action * means every action
  including features; Allow with Action * stays the five standard actions.
- Permission[] key for exact dotted/host permission names under Allow and
  Deny, so Deny can remove what full access includes.
- Zero-match prefix, feature or named permission is rejected at save for
  both effects. Unknown statement keys (Condition) reject the document.
- One invalid document among a user's groups voids all of them: dropping a
  bad group could drop a Deny and escalate access.
- Canonicalization never drops a Deny.
- policy-vectors-1.1.0.json: 27 vectors over a 16-name universe, to be run
  by this parser, the authentik-cd claim validator and Forge's tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:28:33 -06:00
james.maes e342a39f8e Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#67) from deps/align-gitea into develop
build / maven (push) Successful in 12m3s
maven / build (push) Successful in 12m3s
2026-11-07 18:20:43 +00:00
james.maesandClaude Opus 5.5 dcd0645f72 build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Successful in 14m48s
maven / build (push) Successful in 14m49s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.21 -> 4.1.0-RC.26
pom.xml: qqq-frontend-next: 1.0.0-RC.37 -> 1.0.0-RC.38
pom.xml: removed Central snapshot repository
pom.xml: added qqq.version=4.1.0-RC.26 (parent BOM import)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:03:06 +00:00
james.maes 67d1ec0eec Merge pull request 'ci: build on container-build-large' (#66) from ci/large-runner into develop
build / maven (push) Failing after 1m37s
maven / build (push) Failing after 1m37s
2026-11-07 13:58:14 +00:00
james.maesandClaude Opus 5.5 d49e79f4ac ci: build on container-build-large (Authentik stack per build)
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 12m53s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 12m53s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:54:05 +00:00
james.maes e521f54018 Merge pull request 'ci: pin shared workflow to v1' (#62) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Failing after 3m9s
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 7s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Failing after 3m9s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-07 00:28:46 +00:00
James MaesandClaude Opus 5.5 d87ada10d2 Merge develop into ci/pin-v1-develop
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 19m31s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 19m31s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:08:38 -06:00
james.maes 914b3ac58f Merge pull request 'test(authentik): fix slow contract stack startup in CI (Postgres JIT)' (#65) from ci/authentik-ci-stability into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 13m0s
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 7s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 13m0s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
2026-11-07 00:08:27 +00:00
James MaesandClaude Opus 5.5 d3f334cd42 fix(test): turn off Postgres JIT in the contract stack
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 13m21s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 13m21s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Root cause of the slow/failed Authentik contract stack on busy CI runners:
Authentik validates every policy binding in a blueprint by loading
PolicyBindingModel.select_subclasses(), a ~20-way LEFT JOIN whose planner
estimate on a fresh database (cost ~1e8) is far above Postgres' JIT
thresholds. Postgres JIT-compiled it on every call: 7.6 s instead of 77 ms
at 0.5 CPU, with the Postgres container pegged and the worker idle.
The fixtures' nested metaapplyblueprint chain (tokens -> bootstrap ->
oauth2-apps) runs oauth2-apps 8 times per tokens apply, so one apply
outran the worker's 10-minute task limit, was killed and retried, and
the stack never became ready (runs 5754, 6277, 6363).

With jit=off the full contract suite passes locally with every
container capped at 0.5 CPU; blueprint startup drops from >14 minutes
to ~1.5 minutes and BlueprintReapplyIT from 228 s to 34 s.

Also give waits on worker-delivered results (emails, notifications,
events) a shared 3-minute budget and blueprint re-applies 10 minutes;
the waits still return as soon as the condition holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:36:47 -06:00
James MaesandClaude Opus 5.5 7adc25bf7e fix(test): drain Authentik's first-boot blueprint work before re-applying ours
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Failing after 24m34s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Failing after 24m34s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Root cause of the flaky contract stack (diagnostics from run 6277): on
first boot the worker (2 threads) applies ~40 built-in blueprints plus
the three kof22 fixtures concurrently. On a slow shared runner ours can
validate before the built-in objects they reference exist (managed
OAuth2 scope mappings, default flows) and end in "error", which
Authentik never retries for an unchanged file; concurrent applies also
deadlocked in Postgres. The earlier throttled re-apply made it worse:
it enqueued three applies every 30s regardless of pending work (69
queued apply_blueprint tasks, applies running for 6+ minutes), and the
backlog then starved the tests' own worker tasks (blueprint applies,
recovery/invitation emails, notification rules) and held locks that
timed out plain API writes.

Now startup waits until no blueprint discovery/apply task is queued or
in progress, then re-applies the first non-successful kof22 blueprint
in dependency order, one at a time, never enqueueing while an apply is
pending. The startup budget is 15 minutes (with a comment) because a
cold shared runner can need well over 8 minutes for the built-in storm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:00:01 -06:00
James MaesandClaude Opus 5.5 2f6a83db27 test(authentik): print the worker task queue on contract-test timeouts; restore seed state in BlueprintReapplyIT
build / maven (push) Failing after 19m29s
maven / build (push) Failing after 19m29s
Most contract-test waits depend on Authentik worker tasks (blueprint
applies, recovery/invitation emails, notification rules). When a wait
times out, print the global task status and the running, queued,
warning and error tasks with their logs, so CI shows whether the worker
was stuck, backlogged or failing.

BlueprintReapplyIT now restores the seed groups and the service-account
role's view_user permission in a finally block. A failed re-apply used
to leave those edits behind and cascade into unrelated failures
(QbitReadContractIT.groups saw "authentik.*, cms.publish";
TokenContractIT got 403 on /core/users/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:38:25 -06:00
james.maes 4d45796651 Merge pull request 'fix(admin): queue two-person approval when Create Group grants authentik.*' (#63) from fix/create-time-approval-20261106 into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Failing after 33m23s
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 8s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 11s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 8s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Failing after 33m23s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
2026-11-06 15:31:54 +00:00
james.maesandClaude Fable 5.1 53bc414490 fix(admin): queue two-person approval when Create Group grants authentik.*
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Failing after 31m34s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 6s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Failing after 31m34s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
With twoPersonApproval=true, the native Create Group action persisted a
new group carrying authentik.* permissions (for example
authentik.users.read) immediately, while Edit Group queued an
authentik_approval_request for a second person. Apply the same
identity-permission expansion and second-person condition at creation
time: the plan becomes NEEDS_APPROVAL and nothing is written until a
distinct operator holding authentik.approve approves it; the stored
request keeps the native requester/approver and live revalidation
checks.

Regression test: creating a group with authentik.users.read queues one
approval request and writes nothing, self-approval is refused, a second
operator with approve + groups.manage completes the creation, the
persisted group carries exactly the requested permission, the request is
APPROVED, and a repeat approval is refused.

Carried locally by GG Forge as qbit-authentik-rc8-create-approval.patch
since 0.1.0-RC.8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 08:40:17 -06:00
james.maesandClaude Opus 5.5 c657eb7e28 ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Failing after 35m34s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 7s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Failing after 35m34s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:04:27 +00:00
james.maes d928575104 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#61) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 18m45s
QRun-IO/ci-policy: native / native-result (push) Successful in 3s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 5s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 18m45s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 00:48:15 +00:00
James MaesandClaude Opus 5.5 ea5c5ab65e test(authentik): surface Authentik error logs when the contract stack fails to start
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 18m14s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 18m14s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
When the blueprint wait times out, also print error-level worker lines
and error/warning server lines (API-triggered re-applies run in the
server), so the failing blueprint's cause is visible in CI logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:28:50 -06:00
James MaesandClaude Opus 5.5 38660be0f5 test(authentik): also re-apply never-applied kof22 blueprints at startup
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Failing after 17m52s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Failing after 17m52s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Run 5488: with errored blueprints re-applied, kof22-oauth2-apps and
kof22-qqq-tokens succeeded but kof22-identity-bootstrap stayed
"unknown" (never applied) until the wait timed out. Re-apply blueprints
in "error" or "unknown" state after a 60s grace period, every 30s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:30:04 -06:00
James MaesandClaude Opus 5.5 8b52f583e9 test(authentik): re-apply errored kof22 blueprints during contract stack startup
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Failing after 16m37s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Failing after 16m37s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
On a cold runner the built-in Authentik blueprints can race our custom
ones on first boot, leaving kof22-oauth2-apps in "error" until the
8-minute wait times out (run 5465). The local-review path already
re-applied errored blueprints; do the same (throttled to every 10s) in
the shared contract-stack wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:11:53 -06:00
James MaesandClaude Opus 5.5 ba5a688525 test(authentik): make contract ITs run against a non-loopback Docker host
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Failing after 15m48s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 23s
maven / build (push) Failing after 15m48s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Canceled after 19m53s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Canceled after 19m54s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
- SchemaDriftIT/SchemaDriftTest: quote OpenAPI path templates before
  turning {key} into a regex; other placeholders such as {slug} were
  compiled as regex repetition and threw PatternSyntaxException.
- Contract ITs: set allowInsecureHttp on the test qbit config; the
  Authentik test container is plain http and is reached via the Docker
  host address (e.g. 172.17.0.1 in CI), not loopback. Production https
  validation is unchanged.
- Postgres ITs: bind the mapped port to 127.0.0.1 only when the Docker
  daemon is local; with a remote daemon the loopback-only binding made
  the database unreachable (connection refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:52:46 -06:00
CI c9806e2b82 ci: rerun shared workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Failing after 19m42s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 4s
maven / build (push) Failing after 19m42s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Failing after 23m38s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Canceled after 24m57s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
2026-11-05 16:23:54 -06:00
CI 8f91c08e3e ci: rerun shared workflow
build / maven (push) Failing after 8s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / node-8 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / original-business (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / original-voyage (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / original-cli (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / node-10 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / android-0 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / original-mcp (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / native-result (pull_request) Canceled after 0s
maven / build (push) Failing after 9s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / node-9 (pull_request) Canceled after 0s
2026-11-05 16:15:15 -06:00
james.maesandClaude Opus 5.5 021a9ae446 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Failing after 3m33s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 23s
maven / build (push) Failing after 3m33s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Canceled after 13m49s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Canceled after 13m49s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:02:07 +00:00
james.maes 98275d42ea Merge pull request 'feat(authentik): application access and synchronization RC checkpoint' (#59) from james.maes/authentik-rc-checkpoint-20261105 into develop
Shared Java Maven CI with Docker / verify (push) Failing after 18m5s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Failing after 1h24m15s
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 13053bb397bf07f3eaacd6e51ef0ffad7f9f1eb3] (push) Successful in 9s
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 46s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 10s
Shared Java Maven CI with Docker / secrets (push) Successful in 37s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
qrun-release / release (push) Failing after 1h14m33s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
Shared Java Maven CI with Docker / gate (push) Failing after 19s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Failing after 28s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Failing after 17m23s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-05 11:40:19 +00:00
james.maesandGPT-6 2e356b8117 feat(authentik): checkpoint application access and identity synchronization RC
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
qbit release candidate / cut-rc (pull_request) Failing after 18s
Release candidate / release-rc (pull_request) Failing after 18s
Shared Java Maven CI with Docker / verify (push) Failing after 17m19s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 38s
qbit release candidate / tag-and-release (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 10s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow f1d46718955078331817c6206b822a94bc7d769f] (pull_request) Successful in 9s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 24s
qrun-release / release (push) Failing after 1h16m30s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 38s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Failing after 15m39s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Failing after 1h14m34s
Shared Java Maven CI with Docker / gate (push) Failing after 24s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Add user and group QQQ application-role access editing, bounded catalogs,
webhook inbox and SQL mirrors, refreshed session permissions, and deferred
invitation assignments. Include the isolated local review host, browser
acceptance scripts, documentation and the token-widget column mapping fix.

User approved this RC checkpoint. Full 1.0 gates remain documented, including
the schema-drift matcher and PostgreSQL integration errors from the full gate.
Companion UI: QRun-IO/qqq-frontend-next#205.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-11-05 05:29:54 -06:00
forge-recovery fe6f716e87 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 24s
QRun-IO/ci-policy: ci / java-docker-3 (push) Failing after 30m24s
Shared Java Maven CI with Docker / verify (push) Failing after 30m40s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
Shared Java Maven CI with Docker / secrets (push) Successful in 26s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Failing after 4s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
qrun-release / release (push) Failing after 30m49s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Failing after 8s
Shared Java Maven CI with Docker / gate (push) Failing after 8s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Failing after 30m13s
2026-11-03 22:07:34 +00:00
forge-recoveryandCodex 6290b23a32 ci: restore Auth fixture startup order and shared lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
Shared Java Maven CI with Docker / verify (push) Successful in 19s
qrun-release / postpublish (push) Skipped
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
Shared Java Maven CI with Docker / secrets (push) Successful in 25s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
qrun-release / release (push) Successful in 49s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 35s
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
Shared Java Maven CI with Docker / gate (push) Successful in 11s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 23m26s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 23m37s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>

Co-Authored-By: Codex <noreply@openai.com>
2026-11-03 17:57:26 +00:00
james.maesandCodex b22ed15719 ci: adopt managed orb 0.8 patch track (#51)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:39:11 -05:00
james.maesandCodex 366d55b9ec ci: adopt shared orb 0.7.18 (#50)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:52:21 -05:00
james.maesandCodex f1ed82a92f ci: adopt shared orb 0.7.17 (#49)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:25:22 -05:00
james.maesandCodex dd55a61105 ci: use the shared branch-aware CodeQL worker (#47)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:56:39 -05:00
james.maesandCodex 17486b88d2 ci: adopt central orb 0.7.12 and stable gates (#45)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 09:03:12 -05:00
james.maesandCodex 7d1227adf5 ci: centralize rc release completion in qqq orb (#44)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:35:07 -05:00
james.maesandCodex 7df05f2324 ci: align canonical shared orb workers (#43)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:39:22 -05:00
james.maesandCodex 28cb654d13 ci: adopt shared orb 0.7.11 (#42)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:10:02 -05:00
james.maes 33cdaa1141 Merge pull request #41 from QRun-IO/feature/central-ci
ci: adopt verified central QQQ orb 0.7.1
2026-10-20 18:56:04 -05:00
james.maesandCodex 44a46a3cfc ci: adopt verified central QQQ orb 0.7.2
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:42:36 -05:00
james.maes dbae77da5b Merge pull request #38 from QRun-IO/feature/maintenance-service-principal
feat(maintenance): applyPolicy runs as the maintenance service principal
2026-10-16 13:53:54 -05:00
james.maesandClaude Opus 5.5 1d72ce3cec Merge origin/develop into feature/maintenance-service-principal
Keeps both CHANGELOG entries (#38 maintenance principal; #37/#39 health fixes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:34:32 -05:00
james.maesandClaude Opus 5.5 b784bfd097 build: QQQ 4.1.0-RC.19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:19:05 -05:00
james.maes da92be126d Merge pull request #39 from QRun-IO/feature/mapper-group-attributes-warning
fix(health): name flagged groups and reasons in the mapper check
2026-10-16 11:53:04 -05:00
james.maesandClaude Opus 5.5 86ff924ebd fix(health): name flagged groups and reasons in the mapper check
A degraded authentik.mapper check said only "Mapper drift detected" with
findingCodes=group_attributes.warning, so nobody could tell which group
was wrong or why without opening the drift report.

Group-attribute findings now carry a short, safe reason
(DriftFinding.reason): "grants nothing: <permission>", "malformed
<attribute>" or "superuser group carries qqq.io/permissions". The health
message and the new flaggedGroups and summary details name each flagged
group with its reasons, within the 500-character limits. Typed attribute
values (malformed entries, unprintable permission names) and
service-account activity subjects (people) stay out of the outcome.

The "grants nothing" drift message also notes that a permission an app
checks only in code, not in its metadata, is not in the catalog.

A group with no qqq.io/* attributes (a group relying on the host's
static floor) is not drift; a test pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:37:41 -05:00
james.maesandClaude Opus 5.5 1f12334f4f fix(maintenance): never change a service principal's role group
Maintenance (itself a principal) refuses groups named service:... or under
qqq-service-accounts: only the owner changes principal grants (security
review H3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:15:22 -05:00
james.maes 995a5900aa Merge pull request #37 from QRun-IO/feature/broker-probe-token-exchange
fix(health): token_broker probe uses token exchange (stops 400s every 5 min)
2026-10-16 08:48:26 -05:00
james.maesandClaude Opus 5.5 4d6140f7be feat(maintenance): applyPolicy runs as the maintenance service principal
Drop the self-made MaintenanceSession that granted itself
policies.manage and the document's grants (rejected under OAuth2). Run
as the host's declared maintenance principal: its Authentik service
account's grants decide; the subset rule still applies. The audit
operator is service:{principal} and the job name prefixes the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:21:41 -05:00
james.maes 265dbb0468 Merge pull request #36 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:04:32 -05:00
james.maesandClaude Opus 5.5 bc92be338f fix(health): probe the token broker with token exchange, not client_credentials
The broker provider allows only token exchange, so the client_credentials
probe always got 400 (Invalid grant_type for provider). Probe with token
exchange and no subject. Authentik checks grant type, then secret, then
subject, so invalid_request means the credentials passed. invalid_client
and invalid_grant are DOWN. Refusals carry their OAuth2 error code
(BrokerRefusedException).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:55:32 -05:00
james.maesandClaude Opus 5.5 69830a095e ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:21:07 -05:00
james.maes 33e53263c5 Merge pull request #35 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:15:07 -05:00
james.maesandClaude Opus 5.5 1d8a2a85c7 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:59:43 -05:00
james.maes eba93e68ca Merge pull request #31 from QRun-IO/feature/health-checks
feat(health): register Authentik API, token, mapper and broker checks with core central health
2026-10-14 21:27:00 -05:00
james.maesandClaude Opus 5.5 0df7bb49e2 Merge origin/develop into feature/health-checks; pin QQQ 4.1.0-RC.8
RC.8 ships the central health kernel (QRun-IO/qqq#1207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:15:11 -05:00
james.maes 8c8ef9fd28 Merge pull request #34 from QRun-IO/feature/help-bundle
feat(help): qbit-authentik help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:29:38 -05:00
james.maesandClaude Opus 5.5 5cf668f8e3 feat(help): ship the qbit-authentik help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:17:00 -05:00
james.maes 590788adf8 Merge pull request #33 from QRun-IO/feature/tables-populated-defaults
feat(help): guided empty states for every authentik table
2026-10-14 17:31:47 -05:00
james.maesandClaude Opus 5.5 a6daba12df docs: add removeBy to the STAND-IN markers in the help bundle and changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:10:26 -05:00
james.maesandClaude Opus 5.5 f0648d1ef2 feat(help): guided empty states for every authentik table
Add help/qbit-authentik.yaml (QQQ help-bundle format) for the Identity app and
all 15 tables, applied by AuthentikHelp (STAND-IN for core #1198). API-backed
tables say rows are read live and what an empty list means. Add
AuthentikTablesGuardrailTest (used at runtime, explained, documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:44:21 -05:00
james.maes a5db01a071 Merge pull request #32 from QRun-IO/feature/policy-full-access
feat(policy): full access ("*") statement and maintenance policy API
2026-10-14 15:42:07 -05:00
james.maesandClaude Opus 5.5 c9e5506cdf feat(policy): host-declared permission names join the full-access expansion
Custom-checker and named permissions (for example cdn.purge) are not listed
by PermissionsHelper.getAllAvailablePermissions, so full access missed them.
AuthentikQBitConfig.withFullAccessHostPermissions lets the host declare them;
they are computed at use time and only for the instance holding the runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:24:21 -05:00
james.maesandClaude Opus 5.5 b196109d84 feat(admin): maintenance API to apply a policy through the audited Save Policy path
AuthentikMaintenance.applyPolicy(backend, groupName, policyJson,
operatorName, reason) lets reviewed host code (a run-once job) set a
group's policy as a named maintenance operator (run-once:/maintenance:),
never as a system session. It runs the same Save Policy plan and
execute: audit row before the Authentik call, custom event, guardrails
(protected and superuser groups, scope, readOnly, POLICIES), stale check,
optional two-person approval. An equivalent policy is a NO_CHANGE no-op.
Plain Java API only; nothing is exposed over HTTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:17:28 -05:00
james.maesandClaude Opus 5.5 4d2bd07e4b feat(policy): full-access statement (Resource "*" + Action "*")
Owner request (2026-10-14): a "*" access policy for full access. The
statement {"Effect":"Allow","Action":["*"],"Resource":["*"]} grants
every permission the instance defines, expanded when permissions are
computed (sign-in, resolver, access comparison, member preview), so new
tables, processes, apps, reports, widgets and features are covered with
no edit. Resource "*" with any other action rejects the document. The
canonical form is the single statement; saving needs the operator to
hold the full set. Editor payload: fullAccess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:09:34 -05:00
james.maesandClaude Opus 5.5 24a97823a9 docs: Authentik health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:18 -05:00
james.maesandClaude Opus 5.5 fee7e2ad20 refactor(authentik): Identity home shows central health instead of direct probes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:17 -05:00
james.maesandClaude Opus 5.5 3b117cfff4 feat(health): register Authentik API, token, mapper and broker checks with core central health
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:16 -05:00
james.maesandClaude Opus 5.5 35d9886bbf feat(health): deadline-bounded Authentik client calls for health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:15 -05:00
james.maes 1c1d9bc594 Merge pull request #30 from QRun-IO/feature/log-effective-features
fix(config): startup log lists the effective features (TOKENS on by default)
2026-10-14 13:01:16 -05:00
james.maes cb20aecf18 Merge pull request #29 from QRun-IO/feature/policy-editor-preview
feat(policy): member preview payload for the policy editor
2026-10-14 13:01:11 -05:00
james.maesandClaude Opus 5.5 d0c5c2971e fix(config): startup log lists the effective features (TOKENS on by default)
The "Authentik QBit produced" line logged the configured feature set, so
TOKENS (on by default whenever writes are enabled) never appeared.
getEffectiveFeatures() reports what hasFeature() says is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:42:58 -05:00
james.maesandClaude Opus 5.5 612307bde8 feat(policy): member preview payload for the policy editor
The editor widget now lists the group's members (direct and through
descendant groups, active and in scope, capped at 200) with each
person's sign-in permissions WITHOUT this group's policy
(basePermissions), plus mapperEnabled. The Next widget computes the
live effective permissions for a chosen member and what the edit adds
or removes. EffectivePermissionResolver gains an exclude-one-policy
resolution; loading requires authentik.policies.manage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:13:13 -05:00
james.maes 3bbb073ec7 Merge pull request #28 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:56:34 -05:00
james.maesandClaude Opus 5.5 a977b7a0da ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:06:04 -05:00
james.maes 1b058b617d Merge pull request #26 from QRun-IO/feature/token-scopes
feat(tokens): scopes, last used, reminders and admin views (phase 6c)
2026-10-14 08:11:50 -05:00
james.maesandClaude Opus 5.5 99a302c7ce refactor(tokens): token notifications call core QAlerts directly (RC.6)
QQQ 4.1.0-RC.6 (now the qbit's baseline) ships the notification SPI
(qqq#1125), so the reflection stand-in is gone: guard incidents use
QAlerts.raise, owner reminders QAlerts.notify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:59:08 -05:00
james.maes 65aa816660 Merge remote-tracking branch 'origin/develop' into feature/token-scopes 2026-10-14 07:57:21 -05:00
james.maesandClaude Opus 5.5 3722380db0 feat(tokens): owner decision A (add_token); alarm on service-account creation; core notify SPI
- DESIGN.md decision 31 (owner, 2026-10-14): the qbit role holds
  add_token (needed by service_account and by set_key); view_token_key
  stays not granted.
- TokenContractIT pins 403 without add_token and 200/204 with it, and
  that the alarm notifies identity-admins when the qbit's account creates
  a service account (fixture = cluster-gitops#103 blueprint, verbatim).
- Reminders go through core QAlerts.notify (qqq#1125) by reflection,
  never another qbit's classes (G1); guard alerts through core QAlerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:57:19 -05:00
james.maes 89fcdec2ab Merge pull request #27 from QRun-IO/feature/guardrails-rc6
build: QQQ 4.1.0-RC.6, core table feature permissions, guardrails (#20)
2026-10-14 07:51:11 -05:00
james.maes f78aaa3626 Merge pull request #24 from QRun-IO/feature/token-management
feat(tokens): access-token management (DESIGN.md §13, phase 6b)
2026-10-14 07:44:03 -05:00
james.maesandClaude Opus 5.5 02c18de8c1 build: QQQ 4.1.0-RC.6, real table feature permissions, guardrails enforced
Move to the released 4.1.0-RC.6, which includes core
TableFeaturePermissionsMetaData (QRun-IO/qqq#1136). The policy catalog
discovers features through core only; the test-only injection overload
is removed. The policy feature tests register real metadata instead of
the synthetic catalog, so the expired STAND-IN marker is gone.

Adopt qbit-build-parent 2.1.0 with the guardrails enforced and strict
raw-route mode.

Closes #20 (architecture review QRun-IO/qqq#1150, finding F19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:43:51 -05:00
james.maes eb74cd12db Merge remote-tracking branch 'origin/feature/token-management' into feature/token-scopes 2026-10-14 07:21:06 -05:00
james.maes 9dfef79929 Merge remote-tracking branch 'origin/develop' into feature/token-management
# Conflicts:
#	CHANGELOG.md
2026-10-14 07:20:40 -05:00
james.maes eb5d019fe7 Merge pull request #23 from QRun-IO/feature/arch-rc5
chore(deps): build on QQQ 4.1.0-RC.5 (#20)
2026-10-14 07:09:09 -05:00
james.maesandClaude Opus 5.5 6c56a24960 docs(spec): no client secret for the PAT exchange; async one-time hand-off
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:28:57 -05:00
james.maesandClaude Opus 5.5 3e18458fb3 test(tokens): PAT exchange without a client secret, as users send it
TokenContractIT now exchanges app passwords exactly like the generated
usage text (no client_secret); green on Authentik 2026.8.3. Also logs the
cause when a token scope cannot be verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:18:35 -05:00
james.maesandClaude Opus 5.5 5a3802193f fix(tokens): token-scope lookup runs as the internal system session
The lookup runs while a bearer session is still being built (no user), so
the guard refused the Authentik read and the scope failed closed for
every token. Read Authentik and the scope row as QSystemUserSession, then
restore the caller's context. Found by the kof22 adoption test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:11 -05:00
james.maesandClaude Opus 5.5 8698d627a9 test(tokens): widget type is the core TABLE type
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:00:33 -05:00
james.maesandClaude Opus 5.5 32741508e8 fix(tokens): explicit last_reminder_7d/_1d column names for authentik_token_scope
A host whose RDBMS backend maps camelCase to snake_case got
last_reminder7d, which does not match docs/schema.sql (found by the
kof22 adoption's changeset test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:59:28 -05:00
james.maesandClaude Opus 5.5 2061677e16 fix(tokens): access-token widget uses the core TABLE widget type ("table")
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:54:10 -05:00
james.maesandClaude Opus 5.5 7f8e742120 feat(tokens): scopes, last used, reminders and admin views (DESIGN.md section 13, phase 6c)
- Scopes: Create Token takes read only, apps and tables when
  tokenScopesEnabled (validated against the instance and the operator's
  own access). AuthentikTokenScopes reads the signed qqq_token claim in
  both mappers (and AuthentikTokenScopes.apply for hosts with their own
  customizer): no row = unscoped, deleted token = no permissions, any
  verification failure = no permissions. STAND-IN for core
  QCredentialScope (qqq#1168, RC.7): permissions are intersected; the
  core scope is attached by reflection when present (empty dimension =
  null = unrestricted).
- Last used from Authentik login events (method token, 90 days, cached
  5 minutes); createdAt and scopeSummary from the scope row.
- Daily expiry reminders (7 days, 1 day) through the optional Alerts
  producer, deduplicated on the scope row; guard-bypass alert.
- Admin widget on the app home (authentik.tokens.manage): by owner, by
  age, unused for tokenUnusedDays, deactivated owners; bulk Revoke Token
  on the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:26:53 -05:00
james.maesandClaude Opus 5.5 26a449c9e6 test(tokens): contract tests on Authentik 2026.8.3 and service-account lifetime on the user
- TokenContractIT (9 tests) against the production token blueprint
  (verbatim copy of cluster-gitops kof22-qqq-tokens.yaml): caller-owned
  create, PATCH defaults, expiry cap (brand, groups, user), token
  exchange as the user via the broker, the deny policy for protected
  users, the qqq_token claim (profile only), service_account + set_key +
  PATCH, the detective alarm, and the qbit lifecycle with the one-time
  key absent from audit rows and events.
- Findings now in DESIGN.md: service_account and set_key need add_token
  (safe: a non-superuser create is always the caller's); set_key events
  use the model reference shape.
- Service-account lifetime cap is a user attribute
  (serviceAccountTokenLifetime, default days=365), not a group.
- Test fixtures carry the production role; SuperuserFloorIT accepts the
  400 from /recovery/ with reset_user_password (the qbit never calls it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:59:00 -05:00
james.maesandClaude Opus 5.5 de7c015081 feat(tokens): access-token management (DESIGN.md section 13, phase 6b)
TOKENS feature, on by default with writes (AUTHENTIK_QBIT_TOKENS=false
opts out). Create, rotate, revoke and extend tokens, and create service
accounts, as record and table actions with the standard Review/Result.

- People's tokens are created as that person through the token broker
  (RFC 8693 exchange); service accounts through service_account, set_key
  and PATCH (always intent, user and expires, with a race guard).
- Guardrails: protected owners (superusers, authentik Admins,
  identity-admins, qqq.io/protected, the qbit account, configurable
  groups), self-only for people, required capped expiry, optional
  two-person approval for service-account tokens.
- One-time key via core one-time process output (stand-in until
  qqq#1162 ships; refuses at Review when unavailable), never in tables,
  audit, events, logs or process state; usage text from config.
- authentik_token_scope table; authentik.tokens.manage permission.
- In-memory Authentik models the 2026.8.3 token semantics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:58:46 -05:00
james.maes 38d9776f25 Merge remote-tracking branch 'origin/develop' into feature/arch-rc5
# Conflicts:
#	CHANGELOG.md
2026-10-13 20:18:53 -05:00
james.maes 9eece9cde5 Merge pull request #21 from QRun-IO/feature/token-spec
docs(spec): access tokens as QQQ's personal access tokens (revision 6)
2026-10-13 20:01:45 -05:00
james.maes b1d9df5370 Merge pull request #22 from QRun-IO/feature/mapper-automated-sessions
feat(permissions): Authentik permissions for automated sessions
2026-10-13 19:55:16 -05:00
james.maesandClaude Opus 5.5 d138c5c911 chore(deps): build on QQQ 4.1.0-RC.5
Move the core baseline from 4.1.0-RC.2 to the released 4.1.0-RC.5. No
source changes were needed. Comments now reflect core's opt-in permission
enforcement in 4.1. The synthetic feature-permission catalog in tests is
tagged STAND-IN(core=QRun-IO/qqq#1136, removeBy=4.1.0-RC.6), because
TableFeaturePermissionsMetaData is on develop but not in RC.5.

Refs #20 (architecture review QRun-IO/qqq#1150, finding F19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:51:47 -05:00
james.maesandClaude Opus 5.5 2aaedacffd docs(spec): token lifetime cap on the brand, claim bound to profile, separate broker blueprint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:42 -05:00
james.maesandClaude Opus 5.5 f151a7344a feat(permissions): Authentik permissions for automated sessions
AuthentikGroupPermissionMapper implements customizeAutomatedSessionForUser.
Server-minted sessions (scheduled saved reports, SSH-key users of
qbit-file-gateway) carry no token, so they got no Authentik permissions.
The email idReference must match exactly one active Authentik user; that
user's direct groups and ancestors go through the same resolver, static
fallbacks and cut-over guard as sign-in. Anything else, or an unreadable
API, grants only the static fallbacks. customizeSession is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:33 -05:00
james.maesandClaude Opus 5.5 d9431e7e6b docs(spec): access tokens as QQQ's personal access tokens (revision 6)
Owner decisions (2026-10-13) 23-30: tokens managed in the app and stored
in Authentik, shown once, self-service through Preferences, on by default
with an opt-out, guardrails (a)-(f), optional per-token scopes enforced
through core QCredentialScope (qqq#1153), reminders, last used, admin
views and bulk revoke.

Decision 30 records the owner-approved mechanism revision: Authentik
2026.8.3 always creates a token for the caller unless it is a superuser,
so people's tokens are created as that person through RFC 8693 token
exchange (token broker with a deny policy), service accounts through
service_account, set_key and PATCH. The qbit role gains change_token and
set_token_key and never holds add_token or view_token_key.

Adds docs/ACCESS-TOKENS.md ("Using access tokens").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:42:28 -05:00
james.maes 7fb96d7d2a Merge pull request #19 from QRun-IO/feature/policy-feature-permissions
feat(policy): grant table feature permissions (analyze, comment, ...)
2026-10-13 15:39:36 -05:00
james.maesandClaude Opus 5.5 d3b740785f feat(policy): grant table feature permissions (analyze, comment, ...)
The catalog lists each table's feature permissions, read generically from
PermissionsHelper.getAllAvailablePermissions (core
TableFeaturePermissionsMetaData), as a features array per table resource.
Policies grant them by name; the * wildcard still means the five
standard actions only, so existing policies grant nothing new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:30:15 -05:00
james.maes 0e981bc4f7 Merge pull request #18 from QRun-IO/feature/policy-cutover
feat(policy): safe cut-over to policies (status, compare, guard, generate)
2026-10-13 14:48:20 -05:00
james.maes 716a40df1b Merge pull request #17 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 14:42:55 -05:00
james.maesandClaude Opus 5.5 af970d32db feat(policy): safe cut-over to policies (status, compare, guard, generate)
- AuthentikHostPermissionSource SPI (today's access) and config
  mapperEnabled, cutoverGuardGroups, mapperOverride, staticGroupPermissions.
- Enforcement status OFF/ACTIVE/OVERRIDDEN/BLOCKED: banner widget on group
  and policy records and the Edit Policy step ("Policies are saved but NOT
  enforced at sign-in on this instance (mapper off)"), and a row in the home's
  Connection card.
- Compare Access widget (authentik.admin, read only): per active person,
  today vs the mapper, added/removed/unchanged, guard result.
- Cut-over guard: while a guarded-group member would lose a permission (no
  override), the mapper grants today's access plus the static fallback and
  bootstrap subjects instead of the policies.
- Generate Policy From Current Access group action; operator's access today
  counts for the subset rule.
- DESIGN.md 12.8 with the one-switch rollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:39:05 -05:00
james.maesandClaude Opus 5.5 0a827955b8 ci: automate RC releases; hotfix/* pushes no longer publish
Removes publish_hotfix_release: a hotfix/* branch push published a GA
release to Maven Central with no tag or review (QRun-IO/qbit-cdn#12,
QRun-IO/qqq#1121). hotfix/* branches now run test_only; a hotfix
release publishes from a reviewed vX.Y.Z tag through publish_release.
Adds .github/workflows/release-rc.yml (reusable QRun-IO/.github
qbit-release-rc): a manual dispatch, or merging a develop PR labelled
release:rc, merges develop into the release line and the release/* push
runs the existing CircleCI RC publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:36:57 -05:00
james.maes a5a74af0e2 Merge pull request #16 from QRun-IO/feature/user-group-crud
feat(crud): phase 5c full user and group CRUD
2026-10-13 14:12:24 -05:00
james.maesandClaude Opus 5.5 a79f2d2d4b feat(crud): phase 5c full user and group CRUD
- Edit User: username (validated, unique) and other attributes (JSON object;
  qqq.io/ and goauthentik.io/ keys kept and refused; blank keeps them; needs
  authentik.audit.read, which also gates showing them). Edit Group: other
  attributes the same way.
- Delete User and Delete Group behind the new DELETE feature (off by default;
  Authentik delete_user/delete_group): typed confirmation, never yourself, a
  protected account, a superuser group or the last identity administrator; a
  group needs no members and no child groups; its policy row goes with it.
- Tests on the in-memory Authentik and a delete contract test on 2026.8.3;
  README, STATUS, CHANGELOG, CLAUDE.md invariant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:56:23 -05:00
james.maes a2fbb2516e Merge pull request #15 from QRun-IO/feature/policy-editor
feat(policy): phase 5b policy editor and save to Authentik
2026-10-13 13:54:58 -05:00
james.maesandClaude Opus 5.5 639dc38ca1 feat(policy): phase 5b policy editor and save to Authentik
- Edit Policy record action on groups and policies (feature POLICIES,
  permission authentik.policies.manage): streamed-ETL with the
  AUTHENTIK_POLICY_EDITOR widget on its step; Review parses fail-closed,
  regenerates canonically with the round-trip check, refuses new resources the
  app does not define, applies the subset rule to added permissions and lists
  the changes; Result merge-patches qqq.io/policy into the group's current
  attributes (removed when empty). Optional policyTwoPersonApproval.
- PolicyCatalog: apps, tables, processes (hidden record actions included),
  reports and protected widgets with the actions QQQ checks, grouped by app;
  joins the permission catalog.
- authentik_policy local table (one row per group, field-level audit as the
  change history), nav and child list on the group; DDL in docs/schema.sql.
- Widget authentikPolicyEditor: catalog plus the live document, flagged when
  changed in Authentik since the last QQQ save.
- Tests: catalog, admin rules, process, widget, gating, approval, and a
  savePolicy contract test on Authentik 2026.8.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:47:23 -05:00
james.maes 9751296d6c Merge pull request #14 from QRun-IO/feature/policy-model
feat(policy): phase 5a policy model, parser, generator and runtime mapper
2026-10-13 13:44:04 -05:00
james.maesandClaude Opus 5.5 616e3a8135 feat(policy): phase 5a policy model, parser, generator and runtime mapper
- PolicyParser (fail closed), PolicyGenerator (canonical form, round-trip
  check), PolicyFormat and config (policySpec/Version/Attribute/Claim,
  policyTwoPersonApproval).
- AuthentikPolicyClaimMapper and PolicyPermissions for the token claim, with
  an applySecurityKeys hook for record locks; AuthentikGroupPermissionMapper
  adds the claim too.
- GroupGraph and EffectivePermissionResolver read each group's qqq.io/policy,
  so effective permissions, the subset rule and the last-admin check see
  policy grants; an invalid policy is listed as malformed.
- PolicyClaimMappingIT pins the scope-mapping blueprint example on Authentik
  2026.8.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:34:38 -05:00
james.maes 93d3afa869 Merge pull request #13 from QRun-IO/feature/policy-spec
docs(spec): policies (IAM-style statements) and full user/group CRUD
2026-10-13 13:28:00 -05:00
215 changed files with 39671 additions and 463 deletions

No files matched your search

+11 -68
View File
@@ -1,71 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only: [/hotfix\/.*/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+10
View File
@@ -0,0 +1,10 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
with:
# Starts a full Authentik stack per build: use the large runners.
runner: container-build-large
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+74
View File
@@ -0,0 +1,74 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+6 -32
View File
@@ -1,42 +1,16 @@
# Weekly: does the newest Authentik release still have every operation and
# query parameter qbit-authentik uses? Downloads the release's schema.yml from
# goauthentik/authentik and runs SchemaDriftTest against it (no Docker, no
# credentials). A failure means: review the release notes before upgrading.
name: authentik-schema-drift
on:
'on':
schedule:
- cron: "17 6 * * 1"
- cron: 17 6 * * 1
workflow_dispatch:
inputs:
version:
description: "Authentik version (e.g. 2026.10.0); empty = latest release"
description: Authentik version (e.g. 2026.10.0); empty = latest release
required: false
permissions:
contents: read
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
cache: maven
- name: Resolve version and fetch its schema
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ inputs.version }}
run: |
version="${INPUT_VERSION}"
if [ -z "$version" ]; then
version=$(gh release view --repo goauthentik/authentik --json tagName -q .tagName | sed 's#^version/##')
fi
echo "Authentik $version"
curl -fsSL "https://raw.githubusercontent.com/goauthentik/authentik/version/${version}/schema.yml" -o "$RUNNER_TEMP/schema.yml"
echo "SCHEMA=$RUNNER_TEMP/schema.yml" >> "$GITHUB_ENV"
- name: Drift test
run: ./mvnw -B -q test -Dtest=SchemaDriftTest -Dsurefire.failIfNoSpecifiedTests=false -Dauthentik.schema.file="$SCHEMA"
uses: QRun-IO/qqq-orb/.github/workflows/qbit-schema-drift.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
with:
version: ${{ inputs.version || '' }}
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+26 -14
View File
@@ -1,10 +1,10 @@
# AGENTS.md
# AGENTS.md / CLAUDE.md
This file provides guidance to Codex and other agents when working with code in this repository. It mirrors CLAUDE.md; keep them in sync.
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md in sync.
## Project Overview
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and (from phase 2) writes through the Authentik API. Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = read-only (phases 0-1).
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and writes through the Authentik API (never its DB). Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = the full user lifecycle and group/permission management (phases 0-3), read-only by default.
## Build Commands
@@ -12,6 +12,7 @@ This file provides guidance to Codex and other agents when working with code in
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
./mvnw test -Dtest=ClassName
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
@@ -29,24 +30,30 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
- Writes go to the guard, which refuses them in 0.1.
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
- `security/AuthentikGuard` handles read and write permission checks, the user scope, and protected-account detection. `security/IdentityPermission` is the `identity.*` catalog (session form `<name>.hasAccess`).
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `authentik.admin`.
## Invariants (do not break)
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
- Never filter client-side; reject what Authentik cannot evaluate.
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions.
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
## Code Style
@@ -54,13 +61,18 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
## Dependencies
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
Java 21. Parent `qbit-build-parent:2.1.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.21` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
## Release
CircleCI qqq-orb 0.6.8:
- `develop` publishes SNAPSHOTs
- `release/*` publishes RCs
- `v*` tags publish releases
Gitea is primary. `.gitea/workflows/qrun-release.yaml` delegates to the shared
`qrun-release-v1` lifecycle; `.qqq-ci.yml` identifies `develop` as the default
feature branch and enables snapshot, RC and release publication. Do not infer
feature completeness from `main`, which still contains the older baseline.
Cut `release/0.1` only after phase 1 is green and reviewed.
## Local review
Run `./scripts/Start-Review.ps1` on Windows with Java 21 and Docker Desktop.
The Next UI is at `http://localhost:18300/app/authentikAdmin`; startup URLs for
Authentik and Mailpit are in `target/review-server.log`. All application ports
bind to loopback. See `docs/LOCAL-REVIEW.md` for scope, reset and limitations.
+72 -1
View File
@@ -4,6 +4,77 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document under the configured attribute, a differently-specced one included (skipping other specs is an explicit opt-in). Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). One shared rule (`SessionPermissionRules`) for sign-in, automated sessions and the refreshing mapper: a Deny subtracts policy grants, the mirror's stored permissions, static fallbacks, the host source and earlier session permissions, also while the cut-over guard blocks (only bootstrap identity permissions are exempt); an invalid policy or a missing group/parent leaves break-glass only even while blocked; an unreadable graph/mirror with no claim leaves only the configured outage fallbacks; a guard error keeps the last known real state (never OFF, original time, 15 minute bound); the refreshing mapper reads Deny rules from the mirrored group rows on the same connection and refuses an inconsistent or truncated snapshot; the mirror stores a valid policy document unredacted (an invalid one is redacted like any attribute); bootstrap operators keep their identity permissions even when their own group is void or denies them; a token with no groups claim and no policy claim is unavailable, an empty groups array is known-empty; a missing parent voids; documents with an unknown top-level key or no `Statement` are rejected; the group fingerprint covers the configured policy attribute; the refreshing mapper reads Deny rules from the mirror (no API call). Review fixes: operators are credited with a floor permission only if their own policies do not deny it; the configured policy attribute is reserved against Edit Group/User attributes; un-voiding an invalid policy is a release; a Deny a person keeps deliberately no longer trips the cut-over guard; a Deny of a prefix or named resource also covers its dotted host names; empty Resource/Action/Permission arrays and malformed host names are rejected; Save Policy requires `expectedFingerprint`; `PolicyResult` / `resultFromClaims` / `resolveResult` replace the deprecated plain-set APIs. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`.
- Fix: native **Create Group** now obeys the two-person gate. With `twoPersonApproval=true`, creating a group whose permissions expand to any `authentik.*` permission queues an `authentik_approval_request` for a second person instead of persisting the grant immediately; the same identity-permission expansion and second-person condition as Edit Group apply, nothing is written before approval, self-approval is refused and a decided request cannot be approved again. Previously a new group with `authentik.users.read` was created without approval.
- Fix (performance): one policy resolution builds the permission universe once, whatever the policy holds. `PolicyRules.allowed`, `resolve` and `withheld` read the universe they are given once per call (they read it once per Allow term), so `PolicyPermissions.resultFromClaims`, `fromClaims`, both claims mappers and the editor's save-time checks no longer pay one `PolicyCatalog` fingerprint, one walk of every resource and one call of every host-name function for each named permission. A host customizer runs on every request of a session, so a role of nine named permissions paid nine universes per request: on a 1,400-object instance one resolution went from 12 ms and 7.9 MB to 0.3 ms and 0.12 MB, and it no longer grows with the number of names. `PolicyCatalog.of` checks that its cached catalog is current without building the fingerprint text, and the catalog keeps its permission names. Grants are unchanged (the golden vectors pass as they were); the universe and the catalog still follow in-place instance changes on the next call; nothing is cached across resolutions. New: `PolicyUniverse.once(universe)`.
- `PolicyResult.replacement()`: the complete permission set of a session whose only source is the policies (Allow minus Deny, nothing carried over, nothing when void or unreadable). `apply(otherSources)` is unchanged and remains a union; a host that calls `apply(session.getPermissions())` keeps what the session already held, so a host whose permissions come only from the token calls `session.setPermissions(result.replacement())`.
- **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255).
- Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: <permission>`, `malformed <attribute>` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was.
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
- Replace expired token-delivery and credential-scope reflection bridges with the RC.7 APIs; retain conservative permission narrowing and explicit unavailable-delivery tests.
- Ship the expanded, host-neutral QBIT help bundle using QQQ 4.1.0-RC.7 registration and validation, with coverage for optional features and live references. Preserve inline app/table help for hosts without help storage; document HOST bundle overrides.
- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied inline by `AuthentikHelp` as well as registered with core, which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes.
- `AuthentikQBitConfig.fullAccessHostPermissions` lets a host declare permission names core cannot enumerate (custom checkers, named permissions); full access expands to them at use time, for the instance holding that runtime's backend only.
- `AuthentikMaintenance.applyPolicy` lets reviewed host run-once jobs apply a policy to an exact group name through the existing audited Save Policy path. Dedicated maintenance identities, full-access expansion, canonical no-ops, context restoration, stale checks and optional two-person approval are covered; system-session writes remain forbidden. No HTTP route or process is registered.
- Explicit full-access policy statement (`Action: ["*"], Resource: ["*"]`) resolves against all current core and qbit permissions, including features, READ_WRITE `write`, identity names and future resources. Added canonical round trips, complete-set save authorization, last-admin removal protection and the editor `fullAccess` payload contract; named-resource wildcards retain their five-action meaning.
- Edit Policy supplies per-member preview data for up to 200 active, in-scope direct and descendant members, with relevant direct group names, base permissions excluding only the edited policy, a truncation flag, and the configured mapper-enabled flag. The Next UI can compute effective permissions and additions/removals locally while preserving inherited, named, static and bootstrap grants.
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
### Added (central health)
- `authentik.api`, `authentik.api_token`, `authentik.mapper`, and `authentik.token_broker` dependency checks registered by the producer, with safe details and bounded requests through the existing client.
- Client-credentials broker probe using the existing token transport; its bearer is discarded.
### Removed (central health)
- The Identity home Connection health card, its direct version/token-expiry probes, and its inline mapper status. A single central component status links to System Health. The configuration drift-report widget is retained.
### Added (access tokens 6c)
- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153.
- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked.
- Daily expiry reminders with persistent per-window deduplication and optional Alerts delivery; high-severity guard-bypass alerts.
- A standard Access tokens TABLE widget with owner counts, age buckets and cleanup links, plus the existing record/list bulk revoke action.
### Added (access tokens, DESIGN.md section 13)
- Default-on `TOKENS` with personal create/rotate/revoke/extend, service-account creation and rotation, protected-owner guardrails and optional two-person approval.
- Action-scoped OAuth2 token exchange, response-only one-time key delivery (fail closed on older QQQ), generated usage instructions and non-secret token scope records.
- Token-manager read access, expanded token metadata, secret redaction and in-memory process/persistence tests.
### Changed (core baseline, #20 / F19)
- Build against the released QQQ BOM `4.1.0-RC.6` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. Adopt `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
- Core actions now enforce permissions for non-HTTP callers. Authentik-specific guards remain for direct util calls and domain rules. See [UPGRADE.md](UPGRADE.md) for host impact and feature registration requirements.
- Replace the expired feature-catalog stand-in with real `TableFeaturePermissionsMetaData` registrations and remove the test-only `PolicyCatalog` permission-source overload. Feature discovery uses the core catalog without static mocking.
### Added (automated sessions)
- `AuthentikGroupPermissionMapper` resolves tokenless automated sessions by a unique active Authentik email match, applying direct and inherited group permissions, static fallbacks and the sign-in cut-over guard while preserving existing session permissions.
### Added (table feature permissions)
- Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData
### Added (policy cut-over, DESIGN.md section 12.8)
- `AuthentikHostPermissionSource` SPI (today's access) and config `mapperEnabled`, `cutoverGuardGroups`, `mapperOverride`, `staticGroupPermissions`.
- **Enforcement status**: banner widget `authentikPolicyEnforcement` (first on group and policy records and on the Edit Policy step) and a "Policies at sign-in" row in the home's Connection card: OFF, ACTIVE, OVERRIDDEN or BLOCKED.
- **Compare Access** widget `authentikCompareAccess` on the app home (`authentik.admin`, read only): per active person, today vs the mapper, with added, removed and unchanged, and the guard result.
- **Cut-over guard**: while a member of a guarded group would lose a permission (and no override), the mapper grants today's access plus the static fallback and bootstrap subjects instead of the policies.
- **Generate Policy From Current Access** group action: the expressible part of today's access (joined with the current policy) becomes the policy, named permissions go to `qqq.io/permissions`, the rest is listed; Review shows the document and diff.
### Added (full user and group CRUD, phase 5c: DESIGN.md section 12.5)
- **Edit User** also changes the username (validated, unique) and the other attributes (a JSON object; reserved `qqq.io/` and `goauthentik.io/` keys are kept and cannot be set; blank leaves them as they are; editing attributes needs `authentik.audit.read`, which is also what shows them on the form). **Edit Group** edits other attributes the same way.
- **Delete User** and **Delete Group** record actions behind the new `DELETE` feature (off by default; needs Authentik's `delete_user` / `delete_group`). Typed confirmation; never yourself, a protected account, a superuser group or the last identity administrator; a group must have no members and no child groups first; the group's `authentik_policy` row goes with it. Deactivate stays the default for people.
### Added (policy editor, phase 5b: DESIGN.md section 12.4)
- **Edit Policy** record action on groups and on policies (feature `POLICIES`, permission `authentik.policies.manage`): a standard streamed-ETL action whose step shows the `AUTHENTIK_POLICY_EDITOR` widget (Next UI). Review parses the submitted document (fail closed), regenerates it in canonical form with the round-trip check, refuses new resources this app does not define, applies the subset rule to the permissions it adds, and lists what is added and removed. Result merge-patches `qqq.io/policy` into the group's current attributes (removed when empty). Optional second-person approval (`policyTwoPersonApproval`).
- **`PolicyCatalog`**: every app, table, process (hidden record actions included), report and protected widget a policy can grant, with the actions QQQ checks, grouped by app. Its permission names join the permission catalog.
- **`authentik_policy`** local table (one row per group; field-level audit is the change history; DDL in `docs/schema.sql`), in the nav under Access and as a child list on the group.
- Widget `authentikPolicyEditor` (type `AUTHENTIK_POLICY_EDITOR`): the catalog and the group's live document, with a flag when it was changed in Authentik since the last save in QQQ.
### Added (policies, phase 5a: DESIGN.md section 12, docs/POLICY-FORMAT.md)
- **Policy documents** (IAM-style statements) on Authentik groups: `PolicyParser` (fails closed: unknown spec or version, non-Allow effect, unknown action, bad resource or malformed JSON grant nothing), `PolicyGenerator` (canonical form with a round-trip check), `PolicyFormat` and the config `policySpec`, `policyVersion`, `policyAttribute`, `policyClaim`, `policyTwoPersonApproval`.
- **Runtime**: `AuthentikPolicyClaimMapper` (reads the `qqq_policy` claim from the ID token or access token; `applySecurityKeys` hook for record locks) and `PolicyPermissions.fromClaims` for a host's own customizer. `AuthentikGroupPermissionMapper` also adds the claim, and the group graph and `EffectivePermissionResolver` read each group's `qqq.io/policy`, so effective permissions, the subset rule and the last-admin check see policy grants. An invalid policy on a group is listed as malformed.
- **Blueprint example** for the scope mapping that puts the merged policies into the token, pinned by `PolicyClaimMappingIT` on Authentik 2026.8.3.
### Added (v1: full user lifecycle and group/permission management, phases 2-3)
- **Writes through the Authentik API**, behind the features `USERS`, `GROUPS`, `MEMBERSHIP`, `CREDENTIALS` and `SESSIONS` (read-only stays the default; `readOnly` is a hard kill switch). Every change goes through `AuthentikAdmin`: it plans against live Authentik data (the guardrails of DESIGN.md 5.4), audits first, calls Authentik, posts a `custom_qqq_admin_action` event naming the operator, and finishes the audit row. `AuthentikActionUtil.write` needs a `WriteAuthorization`, which only `AuthentikGuard.authorize` can mint.
- **Actions** (table and record actions with Review and Result steps; none in the nav): Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject.
@@ -26,7 +97,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel
### Changed
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
- **Table names are lowercase snake_case with the `authentik_` prefix** (owner naming rule, 2026-10-12): `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event` (were `authentikUser`, ...). New `AuthentikQBitConfig.applyTablePrefix` and `toSnakeCase`; apps, widgets, the backend and processes keep camelCase names (`authentikAdmin`, `authentikApi`). The old names are kept as `@Deprecated` `AuthentikTables.LEGACY_*_TABLE_NAME` constants. See [UPGRADE.md](UPGRADE.md).
### Documentation
+19 -12
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
# AGENTS.md / CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md in sync.
## Project Overview
@@ -12,6 +12,7 @@ This file provides guidance to Claude Code when working with code in this reposi
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
./mvnw test -Dtest=ClassName
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
@@ -29,13 +30,14 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. The only way to change Authentik is `write(WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins that only `AuthentikAdmin` calls it).
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
@@ -43,15 +45,15 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
## Invariants (do not break)
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
- Never filter client-side; reject what Authentik cannot evaluate.
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
- Nothing but `AuthentikAdmin` may call `AuthentikActionUtil.write`. Every write needs a reason, passes the guard, and is audited first.
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions.
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
## Code Style
@@ -59,13 +61,18 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
## Dependencies
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
Java 21. Parent `qbit-build-parent:2.1.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.21` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
## Release
CircleCI qqq-orb 0.6.8:
- `develop` publishes SNAPSHOTs
- `release/*` publishes RCs
- `v*` tags publish releases
Gitea is primary. `.gitea/workflows/qrun-release.yaml` delegates to the shared
`qrun-release-v1` lifecycle; `.qqq-ci.yml` identifies `develop` as the default
feature branch and enables snapshot, RC and release publication. Do not infer
feature completeness from `main`, which still contains the older baseline.
Cut `release/0.1` only after phase 1 is green and reviewed.
## Local review
Run `./scripts/Start-Review.ps1` on Windows with Java 21 and Docker Desktop.
The Next UI is at `http://localhost:18300/app/authentikAdmin`; startup URLs for
Authentik and Mailpit are in `target/review-server.log`. All application ports
bind to loopback. See `docs/LOCAL-REVIEW.md` for scope, reset and limitations.
+94 -11
View File
@@ -2,17 +2,19 @@
A QQQ QBit to manage [Authentik](https://goauthentik.io) identities from a QQQ app: **the full user lifecycle, and groups with the permissions they grant**. Authentik stays the source of truth. The qbit reads Authentik live and writes through the Authentik API, never its database. Changes made in the Authentik admin UI are equally valid, and the qbit never overwrites them blindly.
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065). Status: [`docs/STATUS.md`](docs/STATUS.md). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Table DDL: [`docs/schema.sql`](docs/schema.sql).
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065). Access tokens (PATs): [`docs/ACCESS-TOKENS.md`](docs/ACCESS-TOKENS.md). Status: [`docs/STATUS.md`](docs/STATUS.md). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Table DDL: [`docs/schema.sql`](docs/schema.sql).
## What you get
| | |
|---|---|
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. No hard delete. |
| **Groups and permissions** | Create and edit groups (parents), add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
| **Access tokens** | Create and rotate your personal access tokens, revoke (including bulk), extend expiry, and create service accounts. Enabled with any write feature; opt out with `tokensEnabled=false` or `AUTHENTIK_QBIT_TOKENS=false`. Keys are response-only and shown once; key-producing actions require QQQ 4.1.0-RC.7+ one-time output support. See [Access tokens](docs/ACCESS-TOKENS.md). |
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. Edit covers username, name, email, path and other attributes. Delete is a separate, explicit action behind the `DELETE` feature (off by default); deactivate stays the default. |
| **Groups and permissions** | Create, edit (parents, other attributes) and delete groups, add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, connection), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, central health), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
Everything is a standard QQQ table, action and widget, so it works in any QQQ UI and over the QQQ API.
@@ -23,7 +25,7 @@ AuthentikQBitConfig config = new AuthentikQBitConfig()
.withBaseUrl("https://auth.example.com") // host root; /api/v3 is appended
.withApiToken("${env.AUTHENTIK_API_TOKEN}") // service-account token, resolved by the qbit
.withAppKey("my-app-admin") // service account: qqq-my-app-admin-idadmin
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS))
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS, POLICIES)) // + DELETE for delete actions
.withLocalBackendName("rdbms") // audit trail and approval requests (see docs/schema.sql)
.withRecoveryEmailStage("my-recovery-email") // Authentik email stage (name or uuid)
.withInvitationFlow("my-enrollment"); // optional: enables invitations
@@ -38,10 +40,19 @@ Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill s
| | |
|---|---|
| API backend | `authentikApi` |
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_policy` (with `POLICIES`), `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
| Child lists (on their parents, not in the nav) | `authentik_group_member`, `authentik_session`, `authentik_token` (metadata only, never a key), `authentik_event`, `authentik_policy_binding`, `authentik_role`, `authentik_blueprint` |
| App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report |
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject |
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group |
### Tables, starter data and empty screens
Nothing is seeded: there are no starter defaults to include in a changelog. Setup comes from `AuthentikQBitConfig` (URL, API token, features, read-only mode), and the Identity home's "Needs attention" card lists what is left. The tables are of two kinds:
- **API-backed (live):** `authentik_user`, `_group`, `_group_member` (virtual), `_session`, `_event`, `_application`, `_policy_binding`, `_blueprint`, `_role`, `_token`, `_invitation`. Every row is read from Authentik on each view and nothing is stored locally. An empty list means Authentik returned nothing to you: your role lacks the permission, the qbit's feature or invitation flow is not enabled, or Authentik has no such records. Read-only mode only blocks changes.
- **Local (the qbit's own records):** `authentik_admin_action` (audit trail), `authentik_approval_request`, `authentik_policy` and `authentik_token_scope`. They fill as people run actions, and are written only by the qbit.
Every table, and the app, ships help (`help/qbit-authentik.yaml`, the QQQ help-bundle format) that says what the table is for, how its rows get created and, for the live tables, what an empty list means. The Next UI shows it as the empty state. A host's own help on a table is never replaced, and a host's `tableNamePrefix` is applied to the bundle's table names. `AuthentikTablesGuardrailTest` fails when a table is added without runtime use, a documented reason it starts empty, and this help.
### Permissions
@@ -54,6 +65,7 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
| `authentik.groups.manage` | create and edit groups |
| `authentik.memberships.manage` | add and remove members (subset rule) |
| `authentik.permissions.manage` | edit a group's permissions and app access (subset rule) |
| `authentik.policies.manage` | edit a group's policy (subset rule) |
| `authentik.credentials.reset` | password reset email, reset MFA, revoke app passwords |
| `authentik.sessions.revoke` | revoke sessions |
| `authentik.audit.read` | sessions, tokens, events, the audit trail, sensitive fields |
@@ -63,11 +75,15 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
`authentik.*` grants all of them.
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
The editor discovers registered features and labels through core RC.6's `PermissionsHelper.getAllAvailablePermissions`. The build uses `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
### Authentik side
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, token-key, impersonate or delete permissions. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, `view_token_key` or impersonate permissions, and holds user/group delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. The contract tests grant exactly this list.
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. Saving a policy uses `change_group`. Token management additionally needs `change_token`, `set_token_key` and `add_token` (Authentik requires `add_token` for `service_account` and `set_key`; for a non-superuser it can only create the caller's own token); never grant `view_token_key`. Personal token operations use an exchanged user bearer from the confidential token broker, never the qbit bearer. The `DELETE` feature also needs `delete_user` and `delete_group`. The contract fixtures include the token broker, claim mapping, detective rule and token write permissions.
## Adopting in another app
@@ -76,9 +92,9 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
| Item | What to do |
|---|---|
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
| QQQ version | QQQ 4.1 (see `pom.xml` for the exact RC). Java 21. |
| QQQ version | QQQ 4.1.0-RC.21 (released BOM, including the core health SPI). Java 21. |
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. |
| Tables | Create `authentik_admin_action` and `authentik_approval_request` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
| Tables | Create `authentik_admin_action`, `authentik_approval_request` and `authentik_token_scope` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. |
| Required config | `baseUrl`, `apiToken`, `appKey`. For writes: `features`, `localBackendName`, `recoveryEmailStage` (users and credentials). Optional: `invitationFlow`, `twoPersonApproval` (default on), `userScopePathStartsWith`, `protectedUsernames`, `applicationsInScope`, `linkPrefix` (default `/app`), `throttleRequestsPerSecond` (default 10), `bulkPacingMillis`. |
| Secrets / env | `apiToken` as a `${env.X}` reference; `bootstrapOperatorSubjects` (the lockout escape hatch) defaults to `${env.AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS}`: immutable ids only (the Authentik user pk or uuid, or the token `sub`), never emails, because users can change their own email. Empty it once real group grants exist. Keep secrets in the host's secret store. |
@@ -86,6 +102,12 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
| Known limitations | Not in this release: the Authentik webhook, the local mirror tables and reconcile, the access-matrix widget, snapshot export, RBAC role management (phase 4), Alerts producer integration. Invitations add people but not their groups (add them to groups after they sign up). Group and user lists are read live (cached 30 s). |
| Upgrading | See `UPGRADE.md`. |
## Local review
Run `./scripts/Start-Review.ps1` with Java 21 and Docker Desktop, then open
`http://localhost:18300/app/authentikAdmin`. See [Local review](docs/LOCAL-REVIEW.md)
for startup, disposable data, and the authentication limitations.
## Build
```bash
@@ -100,3 +122,64 @@ Contract tests start Authentik (server and worker), Postgres and Mailpit, and ta
## License
Apache 2.0
Access-token metadata is readable with `authentik.audit.read` or `authentik.tokens.manage`. Personal actions have no additional process permission; ownership is checked live. Managing service-account tokens needs `authentik.tokens.manage`, and creating a service account also needs `authentik.users.manage`. Configure `tokenBrokerClientId`, `tokenBrokerClientSecret` (environment references supported), `appClientId`, `appPublicUrl` and `authentikPublicUrl` for exchange and usage instructions. Scope fields remain unavailable until `tokenScopesEnabled=true`.
| Access-token setting | Default | Purpose |
|---|---|---|
| `tokenScopesEnabled` | `false` | Offer read-only, apps and tables fields after the signed claim mapping is installed. Names must exist and the operator must currently have read/hasAccess permission. |
| `tokenScopeClaim` | `qqq_token` | Signed JWT claim containing the Authentik token identifier. Claims are enforced even when offering new scopes is disabled. |
| `tokenUnusedDays` | `60` | Inactivity threshold for cleanup suggestions. With no login found, a known creation date and a threshold at most 90 days are required. |
| `tokenReminderSchedulerName` | First registered scheduler | Run `authentikTokenReminders` daily. Without a scheduler the hidden process is available for a host to invoke. Register the scheduler before producing the qbit. |
The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The qbit conservatively narrows session permissions for direct consumers and attaches core's `QCredentialScope` for framework enforcement. It does not add permissions.
The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`.
Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added.
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
## Health checks
The producer registers these central checks when Authentik is configured. All use
component `authentik`, category `IDENTITY`, and a 10-second timeout. They reuse the
API client and drift diagnostics, honor the runner deadline, and record only safe
version, expiry-day, and finding-code details.
| Name | Kind | Interval |
| --- | --- | --- |
| `authentik.api` | DEPENDENCY | 300 seconds |
| `authentik.api_token` | DEPENDENCY | 21600 seconds |
| `authentik.mapper` | DEPENDENCY | 21600 seconds |
| `authentik.token_broker` | DEPENDENCY | 300 seconds |
The broker check uses a client-credentials grant and discards the returned bearer;
it reports UNKNOWN when broker credentials are not configured. Token metadata must
identify one API token; ambiguous tokens or an unavailable expiry report UNKNOWN.
The Identity home links its central status to System Health. The configuration
`driftReport` widget remains available. Disable checks centrally with
`health_check.enabled`; no Authentik table fields are added.
These registrations use the core health SPI released in QQQ 4.1.0-RC.8; no
SNAPSHOT override or enforcer skip is needed.
## In-app help
The qbit ships `src/main/resources/help/qbit-authentik.yaml` and registers it with
QQQ's help bundle registry (QQQ 4.1.0-RC.7). Registration is metadata-only; core
skips startup synchronization when the host has no `helpContent` table. Inline
app and table help remains available without help storage.
To override wording, register a separate bundle with `layer: HOST`, its own bundle
ID and version, and entries with the same keys and roles. HOST entries take
precedence over QBIT entries; do not edit the dependency's resource. Keep deployment
names, navigation and authentication timing in the host bundle.
`AuthentikHelpBundleTest` enables every supported feature and uses
`HelpBundleAssertions` to require coverage and validate keys, summaries and live
references. Run it with `./mvnw test -Dtest=AuthentikHelpBundleTest`.
The bundle version is the literal project version; update it when releasing.
+34
View File
@@ -1,5 +1,39 @@
# Upgrading qbit-authentik
## Policy format 1.1.0 (unreleased)
1. The default `PolicyVersion` is `1.1.0`. Documents stored as `1.0.0` stop parsing (they grant nothing, and one invalid document voids ALL of a user's policies). Either rewrite the stored documents and the claim expression to `1.1.0`, or keep `policyVersion("1.0.0")` in the config and the claim expression.
2. The example claim blueprint now emits a document the qbit rejects when any of a user's documents under your spec is unparsable, has another version or has a non-array `Statement`. Update the blueprint copy in your identity repository, and run `src/test/resources/policy-vectors-1.1.0.json` against any claim validator you own.
3. Code that read `PolicyParser.parse`/`parseStrict(...).permissions()` or `GroupGraph.Node.policyPermissions()` must move to `PolicyRules` (`resolve(universe)`, `hasFullAccess()`, `denyLabels()`); there is deliberately no plain-name view of a Deny.
4. Hosts that keep custom or service permission names (`email.send`, `echo.use`) declare them with `PolicyPermissions.registerHostPermissions(qInstance -> names)`; no Authentik runtime is needed. They are granted only through `Permission: [...]`.
5. The Next UI policy editor payloads gain `denied` (policy editor widget) and `policyFullAccess`, `policyDeny`, `policyInvalid` (access editor widget); `policyPermissions` is now the resolved list (Allow minus Deny), never the internal full-access marker.
## Table help bundle (unreleased)
Nothing to do. The qbit now applies `help/qbit-authentik.yaml` to the Identity app and its tables at the end of `produce()`, filling only help slots the host has not set. There is no schema change and nothing to seed or include in a Liquibase changelog: the API-backed tables are live Authentik data, and the four local tables fill as people use the actions.
## QQQ 4.1.0-RC.6 baseline (#20 / F19)
Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.6`
and use Java 21. The historical `qqq-snapshot` profile and `qqq.snapshot.version`
property names remain, but the imported core version is a release, not a SNAPSHOT.
Core actions now enforce permissions and field security locks for non-HTTP callers
(QRun-IO/qqq#1061, #1062); callers must supply an appropriately authorized session.
The qbit keeps its guards, scope checks and sensitive-field stripping because they
also protect direct backend calls and implement Authentik-specific rules.
No qbit configuration, stored policy format or schema changes are introduced.
The build adopts `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
The expired #1136 test stand-in is removed: tests now register features through
`TableFeaturePermissionsMetaData` and discover them through the real
`PermissionsHelper.getAllAvailablePermissions`, as production hosts do. The
unused explicit permission-source overload in `PolicyCatalog` is removed.
Duplicate shared-base entries, replacement registrations and malformed suffixes
remain covered. Hosts should register feature suffixes, labels and table
applicability through the core metadata API; no synthetic catalog is needed.
Client-channel attribution and identity/credential channel restrictions remain
separate follow-up work.
## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release)
The qbit's permissions follow the same owner naming rule (2026-10-12) and now
+71
View File
@@ -0,0 +1,71 @@
# Using access tokens
An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6).
## Create a token
1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**.
2. Choose **Create token** and fill in:
- a description, for example "nightly export script";
- an expiry. It's required: at most 90 days for people, and 365 for service accounts;
- optionally, a **scope**: read only, and/or only some apps and tables. A scoped token can never do more than you can, and loses access as soon as you do.
3. Copy the key from the dialog. **It is shown once.** The app does not keep it, and nobody can show it again. If you lose it, rotate the token.
The dialog also shows ready-to-run examples for this app, with its real URLs.
## Use a token
Tokens are exchanged for a short-lived access token (a JWT) at the Authentik token endpoint. You then call the app with that JWT.
```sh
# 1. Exchange the token for a JWT (valid for the app's access-token lifetime)
JWT=$(curl -s -X POST "https://auth.example.com/application/o/token/" \
-d grant_type=client_credentials \
-d client_id="$APP_CLIENT_ID" \
-d username="$YOUR_USERNAME" \
-d password="$QQQ_TOKEN" \
-d scope="openid profile email" | jq -r .access_token)
# 2. Call the app
curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData"
```
- Keep the token in a secret store or an environment variable. Never commit it.
- When the JWT expires (the API answers 401), repeat step 1.
- Every exchange is recorded as a sign-in in Authentik. The app shows the token's **last used** time from those records.
## Manage your tokens
From **Preferences → Access tokens**:
| Action | What happens |
|---|---|
| **Rotate** | A new key replaces the old one. The description, expiry and scope stay the same. The old key stops working at once. |
| **Extend** | Moves the expiry. The limit counts from today. |
| **Revoke** | Deletes the token in Authentik. Anything using it stops working at once. |
When the host has configured a scheduler and the optional Alerts delivery adapter, the daily job sends owner reminders in the 7-day and 1-day expiry windows. Delivery channels (bell/email) follow the host’s Alerts configuration. Each window sends once; if first seen within one day, only the urgent window sends. Extending a token through the qbit resets its reminder windows. Without delivery support, the job logs and leaves the reminder pending.
## For administrators
People with `authentik.tokens.manage` see the **Access tokens** TABLE widget on the Identity home page, with a link to **all access tokens**. From there they can:
- filter by person, intent, expiring status or token identifier; view computed creation time, scope and last use (these computed columns cannot be filtered or sorted by the Authentik API);
- revoke tokens one at a time or in bulk, for example the tokens the home page lists as unused for 60 days, or as belonging to deactivated people;
- create a **service account** for an integration (**Users → Create Service Account**). The account gets one app password, shown once, and the groups you choose. Rotate and extend it from its token record.
The widget shows the top ten owners plus a total, age buckets (`<30d`, `30–90d`, `90–365d`, `>365d`, and unknown), unused tokens and tokens with deactivated owners. Cleanup rows include owner and token links; age buckets link to the explicit token identifiers. Creation dates are known only for tokens created by the qbit. External tokens can receive reminders without acquiring a scope or an invented creation date.
Last use comes from app-password login events over the last 90 days, cached for five minutes. The pinned Authentik schema has no date-range parameter for this endpoint, so the qbit pages newest-first to the window boundary. API-intent tokens show **not tracked** and never appear as unused. A token with no recorded login is suggested as unused only if its known creation date is older than `tokenUnusedDays` and that threshold is at most 90 days.
Create Token offers read-only, apps and tables scopes only when `tokenScopesEnabled` is enabled after installing the signed claim mapping. Enter one metadata name per line. Review checks existence and the operator’s current read/hasAccess permission and shows the scope. The claims mappers intersect permissions after resolving them; scopes never grant access the owner lacks. A deleted token receives no permissions even if its local scope row remains, subject to the 60-second verification cache.
Administrators can never create, rotate or extend a token for **another person**. Each person manages their own.
Accounts that can administer Authentik, the break-glass account and the app's own service account can't get tokens from the app at all. Use the Authentik admin UI for them if you really need one.
## Security notes
- A token can do what its owner can do, or less if it's scoped. Prefer a scoped token and a short expiry.
- Tokens for people are created as that person, through Authentik. The app's own service account cannot create a token for a person.
- Every create, rotate, extend and revoke is audited in the app (`authentik_admin_action`, with the channel it came from) and in Authentik's event log.
- The key exists only in Authentik and in the one response that showed it to you.
+45
View File
@@ -0,0 +1,45 @@
# Application access editor implementation plan
Updated: 2026-11-04. User-approved scope: QQQ application roles only; Authentik administrative RBAC is excluded.
## Acceptance
From a user, group or application role, an operator can view effective QQQ access, identify each grant's source, edit direct grants using a checkbox matrix, and assign/remove users, groups and roles with searchable controls. Every save has Review and Result, live authorization and an audit record. Inherited grants cannot be removed by unchecking a direct grant. Permission removal must be verified after fresh sign-in.
## Model
- Authentik remains authoritative; all mutations go through the existing guarded admin API path.
- Application roles are reusable QQQ permission-bearing Authentik groups, distinguished by a reserved qqq.io attribute. User-role assignment is membership; group-role assignment is parent inheritance. This reuses the existing mapper, policy claims and subset/last-admin rules.
- Explicitly label Authentik administrative roles separately and do not expose them as editable QQQ application roles.
- Direct user grants use a dedicated group tagged with app key and immutable user ID. The group has exactly one member, no parents/children or Authentik administrative roles. The user action enforces protection, self-edit, stale-state, subset, last-admin and approval checks; generic group edits, membership changes and parent assignments cannot repurpose it. Existing group-based sign-in mapping remains compatible.
- Preserve grants from other sources; unknown existing permission names remain visible and are never silently dropped.
## Work sequence
1. Backend role classification, server-side role queries, named permission catalog and grant provenance; regression tests.
2. Reusable checkbox permission editor for groups/roles and searchable membership/parent-role selectors; remove the normal workflow's raw name/JSON fields.
3. User permissions and assignments, with direct/inherited sources and explicit save semantics; mapper and safety tests.
4. Review host with two operators and representative application resources. Verify role creation, direct and inherited grants, assignment/removal, protected targets, approval, fresh-sign-in enforcement and persistence behavior.
5. Finish unit/coverage/frontend and real-Authentik contract gates; resolve the previously observed bootstrap reapplication issue instead of counting an assisted run as clean.
## Working locations
- Backend: R:/Git.Local/QRunIO/qbit-authentik, review/local-latest (preserve existing local-review work).
- Frontend: C:/Users/james/.cache/qqq-authentik-access-ui, feature/authentik-access-ui, based on refreshed origin/main d04cfa4b.
- Existing review UI remains at http://localhost:18300/app/authentikAdmin while implementation proceeds.
No commit, push, merge or deployment is authorized by this plan.
## Implementation checkpoint
Implemented the backend and the frontend controls, including single-field server searches compatible with Authentik filters. Backend verification: 330 tests and JaCoCo gate passed; frontend access/process widgets: 46 tests passed, typecheck and changed-file lint passed. Live API checks found and drove the picker-search fix. Final real-Authentik write smoke and frontend proxy checks passed; the uniquely owned review instance is running. This is not a 1.0 release acceptance claim.
The manual review stack has unique qbit-authentik-review instance names and ownership labels. It uses explicit recorded-ID cleanup because an idle Testcontainers reaper connection removed earlier manual stacks while their JVMs were still running. Automated contract tests retain the ordinary reaper lifecycle.
## Large-catalog follow-up (2026-11-04)
Direct permission editing now requests 25-resource pages from the guarded widget endpoint. Search and application/resource-type filters run against QQQ metadata on the server. The UI renders action columns, preserves the complete direct-grant selection separately from catalog pages, and exposes bulk select/clear only for the displayed page with an explicit permission count. Named grants cover existing resources; bulk selection never creates wildcard/future grants.
Selected direct grants and inherited/policy grant sources have separate searchable, paged views. Unknown direct grants remain selected and removable. Directory assignments retain stable IDs and server-backed searches; their visible choices are bounded to 25.
Scale verification: a generated 1,000-table catalog is walked across all 40 pages, checking completeness, uniqueness, metadata filters, label search and out-of-range pages. Frontend tests exercise bulk page changes, off-page/unknown preservation and request failures. This is functional scale coverage, not a production latency or concurrency benchmark. Initial selected/inherited grant data still transfers as a complete baseline; the separate Edit Policy screen retains its existing catalog implementation.
+370 -25
View File
@@ -1,6 +1,6 @@
# Authentik qbit: Design Spec
Status: Approved, revision 5 (adds §12 policies and full user/group CRUD; owner decisions of 2026-10-13) · 2026-10-13 · Author: James Maes (drafted with Claude)
Status: Approved, revision 6 (adds §13 access tokens; revision 5 added §12 policies and full user/group CRUD; owner decisions of 2026-10-13) · 2026-10-13 · Author: James Maes (drafted with Claude)
Targets: QQQ 4.1.x, QQQ Next UI 1.0.x, Authentik 2026.8.x (researched against the live `auth.kof22.com` schema, `info.version: 2026.8.3`), new repo `QRun-IO/qbit-authentik`
## 0. Summary
@@ -24,7 +24,7 @@ Seven design rules shape everything below:
- A group's QQQ permissions live in the group attribute `qqq.io/permissions`.
- The reusable `AuthentikGroupPermissionMapper` turns a user's groups, including inherited parents, into QQQ session permissions.
- Who can operate the qbit is decided by those same permissions (`authentik.*`). No operator group is hard-coded.
4. **The qbit checks permissions itself.** QQQ core actions do not enforce permissions (QRun-IO/qqq#1061), and `FieldSecurityLock` is not enforced (#1062). An `AuthentikGuard` in the backend action layer checks every read and write.
4. **The qbit checks permissions itself.** The original core gaps (QRun-IO/qqq#1061, #1062) are fixed in the RC.5 baseline. Direct backend calls and Authentik-specific rules still require local checks. An `AuthentikGuard` in the backend action layer checks every read and write.
5. **No escalation.**
- Operators can only grant permissions they hold themselves, whether through memberships or permission attributes.
- Operators cannot edit themselves.
@@ -33,7 +33,8 @@ Seven design rules shape everything below:
- Authentik also enforces a hard floor: the service account cannot touch superuser groups, token keys or impersonation.
6. **Credentials never pass through QQQ.**
- New users and password resets go through Authentik's own emails (enrollment or recovery).
- QQQ never sets passwords, never shows recovery links and never displays token keys.
- QQQ never sets passwords and never shows recovery links.
- Token keys are the one exception (§13): a new or rotated access-token key is shown **once**, in the response that created it, and is never stored, logged or audited in QQQ.
7. **Every change is audited on both sides.** QQQ writes a local append-only `authentik_admin_action` row. Authentik receives a `custom_qqq_admin_action` event naming the human operator.
### 0.1 Owner decisions (2026-10-11)
@@ -60,6 +61,25 @@ Seven design rules shape everything below:
| 21 | **Delete is allowed as an explicit action** (supersedes decision 4's "no hard delete"): deactivate stays the default; *Delete User* and *Delete Group* are separate typed-confirmation actions behind the new `DELETE` feature (off by default), which also needs Authentik's `delete_user` / `delete_group`. |
| 22 | **Write permissions split as read / users / groups / policies**: `authentik.users.read`, `authentik.users.manage`, `authentik.groups.manage` (+ `authentik.memberships.manage`), and the new `authentik.policies.manage`. Two-person approval for policy saves is optional (`policyTwoPersonApproval`, off by default). |
**Owner decisions (2026-10-13): access tokens (revision 6).** The owner asked: "I want to manage tokens in app, like we want to do with users and groups and permissions - all through authentik still, but exposed in app to make it easier", then: "it should be on by default. if this is gonna be our PAT solution, it needs to be first class." Authentik tokens, through this qbit, are **the** personal access token (PAT) solution for QQQ apps that sign in with Authentik. Full design in §13.
| # | Decision |
|---|---|
| 23 | **Tokens are managed in QQQ, stored in Authentik.** The `authentik_token` table lists them (owner, identifier, intent, expiry, last used, description, scope). Record actions: *Create Token*, *Rotate Token*, *Revoke Token*, *Extend Expiry*, and *Create Service Account*. |
| 24 | **The key is shown exactly once**, in the response of the create or rotate, with copy and usage examples, and is never stored in QQQ: not in a table, the audit, logs, process state, ESB, notifications or the assistant (core `OneTimeSecret`, §13.6). |
| 25 | **Self-service.** Every signed-in user manages their **own** tokens in the Preferences section "Access tokens" (core Preferences contract, QRun-IO/qqq#1162), also reachable from the user menu. |
| 26 | **On by default** whenever the qbit runs with writes enabled (feature `TOKENS`). A host can opt out (`tokensEnabled=false`, kof22 env `AUTHENTIK_QBIT_TOKENS=false`). No opt-in flag. |
| 27 | **Guardrails** (§13.4): (a) no minting or rotating for privileged accounts, in the qbit and again in Authentik; (b) self-service is self-only, admins may also target service accounts, never another human; (c) expiry is required, at most 90 days for people and 365 for service accounts (configurable); (d) optional two-person approval for service-account tokens (off by default); (e) an Authentik notification rule fires if the qbit's service account ever touches a protected user's token; (f) everything audited with the client channel. |
| 28 | **Optional per-token scopes** (read only; specific apps and tables), narrower than the user, stored in `authentik_token_scope` and enforced by core scoped credentials (QRun-IO/qqq#1153, `QCredentialScope`) through a signed `qqq_token` claim (§13.5). |
| 29 | **First class:** usage instructions generated from the instance's real URLs; expiry reminders 7 days and 1 day before (bell and email); last used; an unused-token cleanup suggestion; admin views by user and by age with bulk revoke; the doc page [`ACCESS-TOKENS.md`](ACCESS-TOKENS.md). |
| 30 | **Owner-approved revision of the mechanism (2026-10-13, coordinator-approved under the owner's direction).** Authentik 2026.8.3 always creates a token for the *caller* unless the caller is a superuser (`TokenViewSet.perform_create`), so `add_token` cannot mint for anyone else (§13.2). The qbit therefore creates a person's token **as that person**, through RFC 8693 token exchange with a 2-minute API token from the `qqq-<appKey>-token-broker` provider (with a deny policy for protected users). Service accounts use `POST /core/users/service_account/` (create), `set_key` (rotate) and `PATCH` (extend). The qbit role gains `change_token`, `set_token_key` and `add_token`, keeps `delete_token` and `add_user`, and **never** holds `view_token_key`. `add_token` was found necessary by the contract tests (`service_account` needs `add_user` + `add_token`; `set_key` needs the POST object permission `add_token` + `set_token_key`) and is safe because a non-superuser's create is always the caller's (§13.9). Limitation: one app password per service account. |
**Owner decision (2026-10-14): `add_token` (option A).** The contract tests showed that Authentik 2026.8.3 requires `add_token` for `POST /core/users/service_account/` (with `add_user`) and for `POST /core/tokens/{id}/set_key/` (the POST object permission, with `set_token_key`).
| # | Decision |
|---|---|
| 31 | **Grant `add_token` to the qbit role, so service-account creation and rotation stay in the app.** It is safe: for a non-superuser, `POST /core/tokens/` always creates the token for the caller, so it can never mint a token for anyone else. `view_token_key` stays **not** granted. `TokenContractIT` pins 403 without `add_token` and 200/204 with it. The detective alarm (§13.4 e) also notifies `identity-admins` whenever the qbit's account creates a service account. The blueprint explains this next to the permission (cluster-gitops#103). |
### 0.2 Phase 0 findings (2026-10-11, verified on Authentik 2026.8.3)
The four open points of revision 2 were resolved against a disposable Authentik 2026.8.3 (Testcontainers contract tests in `QRun-IO/qbit-authentik`). Six further facts changed the design; each is pinned by a contract test, so an Authentik upgrade that changes it fails CI.
@@ -94,7 +114,7 @@ Both call the same Authentik code, which applies the same validation. QQQ does n
**How the two front doors stay in sync.**
- QQQ reads live, so a change made in the Authentik UI shows up in QQQ on the next screen load (allowing for a 30-second cache).
- For things QQQ keeps a small copy of (the Web Users join and home-page counts), Authentik sends a **webhook** to QQQ whenever a user or group changes. QQQ refreshes just that record through the ESB.
- A 15-minute background check catches anything a webhook missed.
- A five-minute background check catches anything a webhook missed, with a fifteen-minute permission freshness ceiling.
**Where Git still fits.** Git (cluster-gitops blueprints) keeps the **plumbing** that should be reviewed and versioned:
- the login, recovery and enrollment flows
@@ -320,7 +340,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which
│ ├─ processes (table/record actions; preview→review→execute) │
│ ├─ AdminActionAuditor (local audit row + Authentik custom event) │
│ ├─ Mirror tables (RDBMS) + MirrorSync (webhook→ESB + reconcile) │
│ ├─ AuthentikWebhookRouteProvider (/qqq/v1/authentik/webhook) │
│ ├─ AuthentikWebhookRouteProvider (/qqq/hooks/authentik/webhook) │
│ ├─ IdentitySnapshotExport (optional nightly read-only YAML → git) │
│ └─ AuthentikGroupPermissionMapper (auth customizer; uses the resolver) │
└──────────────┬───────────────────────────────────────────────────────────┘
@@ -357,7 +377,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which
| `confirmations` | see §6.3 | Per-action confirmation level: `CONFIRM`, `TYPED`, `TWO_PERSON` |
| `throttle` | 10 req/s, burst 20; bulk pacing 100 ms | Self-imposed; authenticated calls are not throttled server-side |
| `cacheTtl` | 30 s lists, 60 s group attributes, 5 min computed app access | Memoization TTLs |
| `mirror` | backend name, 15-minute reconcile, 30-day inbox retention | Used if `MIRROR` is on |
| `mirror` | backend name, five-minute reconcile, fifteen-minute authorization freshness, 30-day inbox retention | Used if `MIRROR` is on |
| `webhookSecret` | `${env.AUTHENTIK_WEBHOOK_SECRET}` | Shared secret in a header set by the transport's header mapping |
| `esbDestinationName` | optional | If set, webhook events go through the ESB; otherwise they are processed inline |
| `snapshotExport` | off | Git remote, branch, path and schedule for the optional nightly export (§7.3) |
@@ -593,6 +613,7 @@ The role `qqq-<appKey>-idadmin` is assigned directly to the service account (boo
|---|---|---|
| `authentik_core.view_user`, `view_group`, `view_application`, `view_authenticatedsession`, `authentik_rbac.view_role`, `authentik_policies.view_policybinding`, `authentik_events.view_event`, `authentik_blueprints.view_blueprintinstance` | Read screens, access computation, drift report (the phase 0/1 set in §4.3) | 1 |
| `authentik_core.view_token`, `authentik_stages_invitation.view_invitation`, device `view_*` | Token, invitation and MFA lists | 3 |
| `authentik_flows.view_flow`, `authentik_flows.view_flowstagebinding`, `authentik_policies_expression.view_expressionpolicy` (plus existing `view_policybinding`) | Uncached verification of invitation enrollment correlation protection before accepting new-user evidence; no configuration writes | 3 |
| (none) for the access matrix: `check_access?for_user=` cannot be used by a non-superuser (§0.2 iii); the qbit evaluates `qqq.io/app-access` and the app's bindings itself | Access matrix | 1 |
| `authentik_events.add_event` | `custom_qqq_admin_action` audit events | 1 |
| `authentik_core.add_user`, `change_user` | Create, edit, deactivate, reactivate | 3 |
@@ -602,13 +623,15 @@ The role `qqq-<appKey>-idadmin` is assigned directly to the service account (boo
| `authentik_core.add_user_to_group`, `remove_user_from_group` | Membership | 2 |
| `authentik_core.delete_authenticatedsession` | Revoke sessions | 3 |
| `authentik_core.delete_token` | Revoke app passwords | 3 |
| `authentik_core.change_token` | Extend a token's expiry (§13); `PATCH` always sends `intent`, `user` and `expires` | 6 |
| `authentik_core.set_token_key` | Rotate a **service account's** app password with a key the qbit generates (§13). Guarded by §13.4 (a) and the detective rule (e) | 6 |
| Device `delete_*` for TOTP, WebAuthn, static, email, SMS | MFA reset | 3 |
| `authentik_stages_invitation.add_invitation`, `change_invitation`, `delete_invitation` | Invites | 3 |
| `authentik_rbac.change_role`, `assign` (object, on non-qbit roles) | Role management | 4 |
**Never granted:**
- `enable_group_superuser` / `disable_group_superuser`. Authentik then refuses every route into superuser ancestry: membership (direct, bulk, by user or by group), `parents` under a superuser group or any descendant, creating a group under one, and flipping `is_superuser` (verified, §0.2 ii).
- `view_token_key`, `set_token_key`, `add_token` (MHP APPS-1462)
- `view_token_key` (MHP APPS-1462). Since revision 6, `set_token_key` and `add_token` are granted for service-account creation and rotation only, behind §13.4; `add_token` cannot mint for others (§13.2, §13.9)
- `impersonate`; `delete_user` and `delete_group` unless the `DELETE` feature is on (§12.5)
- any `change_*` on flows, stages, providers, applications, policies, bindings, brands or blueprints
- any change on its own role. Role permissions are object permissions excluding `qqq-*` roles, and the guard also refuses.
@@ -652,7 +675,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost
- `AuthentikActionUtil.doQuery/doGet/doInsert/doUpdate/doDelete` call `AuthentikGuard.check(table, op, session)` first. The guard checks the QQQ permission, the feature flag, `readOnly` and scope.
- A session with no user is refused, except `MirrorSync`'s internal read-only session.
- Every qbit process's first backend step re-checks `checkProcessPermissionThrowing` and the per-record rules (§5.4). It does not trust that the middleware ran.
- Field-level protection does not use `FieldSecurityLock` (#1062):
- Field-level protection retains explicit stripping for direct backend calls; core also enforces `FieldSecurityLock` in the RC.5 baseline (#1062):
- Sensitive columns are removed in `jsonObjectToRecord` without `authentik.audit.read`. These are `attributes` (except the `qqq.io/*` keys, shown under `users.read`), `last_ip`, `user_agent` and `geo_ip`.
- Filters on those fields are rejected for that session.
- Tables also declare `QPermissionRules`, so the UI hides what the user can't use.
@@ -682,10 +705,11 @@ This is weaker than revision 1's per-group object permissions. That is the cost
| Action | Allowed | Mechanism |
|---|---|---|
| Create user | Yes (`USERS`) | `POST /core/users/` with **no password** (`is_active=true`, `path=newUserPath`, `attributes.qqq.io/created-by`), then memberships, then `recovery_email` so the user sets their own password |
| Invite user | Yes (`USERS`) | Invitation (`fixed_data` email and name, `single_use`, expires in 7 days) + `send_email`, through the `kof22-enrollment` flow. Groups are applied by QQQ when the `invitation_used` webhook arrives. |
| Invite user | Yes (`USERS`) | Invitation (`fixed_data` email and name, `single_use`, expires in 7 days) + `send_email`, through the `kof22-enrollment` flow. QQQ applies selected groups after verifying invitation consumption and successful user creation; see [invitation assignment implementation](INVITATION-ASSIGNMENTS.md). |
| Send password-reset email | Yes (`CREDENTIALS`) | `recovery_email` with `recoveryEmailStage`, `token_duration` 30 minutes |
| Show or copy recovery link, set password, set hash | **No** | Never called. A link given to the operator amounts to account takeover. |
| View or set token keys; create tokens | **No** | Not permitted for the service account; not exposed |
| Create, rotate, extend, revoke access tokens | Yes (`TOKENS`, §13) | A person's own tokens: as that person, through the token broker. Service accounts: `service_account`, `set_key`, `PATCH`. The key is shown once and never stored |
| Read token keys (`view_key`) as the service account | **No** | Never granted; a key is read only by its owner, in the owner's own exchange |
| Revoke sessions | Yes (`SESSIONS`) | `bulk_delete?user_pks=` / per-session `DELETE` |
| Reset MFA | Yes (`CREDENTIALS`) | Device list, then per-type `DELETE`, with typed confirmation and a reason |
| Revoke app passwords | Yes (`CREDENTIALS`) | `DELETE /core/tokens/{identifier}/`, intent `app_password` only |
@@ -703,6 +727,10 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace
**Fallback.** If Authentik is unreachable at sign-in, the mapper uses the mirror. If the mirror is empty, it grants only what the static config provides. It never grants everything.
#### Automated sessions
Server-created sessions (scheduled reports and SSH-key users of the SFTP gateway) have no token. `customizeAutomatedSessionForUser` treats `userId` as the email idReference used by `user_email` mode and requires exactly one Authentik user with that email (trimmed, case-insensitive), who must be active. It resolves the API user's direct groups and their ancestors through the same permission resolver, static fallbacks and cut-over guard as sign-in, including the host permission source while the guard blocks. Service accounts follow the same rule. Bootstrap grants match only the resolved user's immutable pk or uuid; email verification is never assumed. Unknown, duplicate, inactive, unreadable or truncated lookups grant only empty-group static fallbacks. Existing session permissions are always preserved; an API failure never grants all permissions.
---
## 6. Data model
@@ -722,7 +750,7 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace
| `authentik_policy_binding` | `/policies/bindings/?target=` | uuid | Query | Read only. Shows the policy, group or user, plus negate, order and expiry. |
| `authentik_session` | `/core/authenticated_sessions/` | uuid | Query, delete (revoke) | Sensitive |
| `authentik_mfa_device` | `/authenticators/admin/all/?user=` | `type:id` | Query, delete (reset) | Metadata only. Query requires a `user` criterion. |
| `authentik_token` | `/core/tokens/?user__username=` | identifier | Query, delete (revoke) | **No key field.** Only `intent`, `expires`, `description`. |
| `authentik_token` | `/core/tokens/?user__username=` | identifier | Query; changed only by the token actions (§13) | **No key field.** Owner, intent, expires, description, managed, plus computed `lastUsed`, `scopeSummary`, `ownerType`, `isProtectedOwner`. |
| `authentik_event` | `/events/events/` | uuid | Query, get | Sensitive. Ordered `-created`. `context` is rendered as JSON. |
| `authentik_invitation` | `/stages/invitation/invitations/` | uuid | Query; insert via *Invite*; delete (cancel) | |
| `authentik_blueprint` | `/managed/blueprints/` | uuid | Query | Read only. Used by the drift report. |
@@ -767,7 +795,7 @@ Each process:
| Process | Table / slot | Records | Permission | Default confirmation | Authentik calls |
|---|---|---|---|---|---|
| Create user | `authentik_user` query action | 0 | `authentik.users.manage` (+ `memberships.manage` if groups given) | CONFIRM | `POST /core/users/` (no password) → `add_user` per group → `recovery_email` |
| Invite user | same | 0 | `authentik.users.manage` | CONFIRM | `POST /stages/invitation/invitations/` + `send_email`; groups applied on `invitation_used` |
| Invite user | same | 0 | `authentik.users.manage` | CONFIRM | `POST /stages/invitation/invitations/` + `send_email`; groups applied after verified `invitation_used` and new-account `user_write` correlation |
| Edit user | `authentik_user` view action | 1 | `authentik.users.manage` | CONFIRM | `PATCH /core/users/{id}/` (name, email, path, type internal/external) |
| Deactivate | `authentik_user` | 1–25 | `authentik.users.manage` | TYPED | `PATCH is_active=false` (deletes sessions) + optional app-password revoke |
| Reactivate | same | 1–25 | `authentik.users.manage` | CONFIRM | `PATCH is_active=true` |
@@ -803,7 +831,7 @@ Each process:
These objects are infrastructure in Git (`present`):
1. **Event matcher policies** `qqq-<appKey>-sync-*`: `model_created`, `model_updated`, `model_deleted` for `authentik_core.user` and `authentik_core.group`, plus `login`, `login_failed`, `password_set`, `invitation_used`, `user_write`. They are bound to one rule (ANY).
2. **Webhook transport** `qqq-<appKey>`:
- `webhook_url` `https://admin.kof22.com/qqq/v1/authentik/webhook`
- `webhook_url` `https://admin.kof22.com/qqq/hooks/authentik/webhook`
- a **body mapping** returning `{eventPk, action, created, username, model: context.model, app}`. The default body has no event id or model.
- a **header mapping** adding `X-QQQ-Webhook-Secret` from the worker env
- `send_once: true`
@@ -826,9 +854,31 @@ Changes made by QQQ itself come back as webhooks too. They are harmless, because
### 7.2 Reconcile
Webhooks are hints: delivery is at least once, unordered, and lost if no destination resolves. So:
- **Every 15 minutes**: `GET /core/users/?last_updated__gt=<watermark>`, a full group sweep (groups have no `last_updated` in 2026.8, and are few), plus `login_failed` events since the watermark.
- **Every five minutes**: `GET /core/users/?last_updated__gt=<watermark>`, a full group sweep (groups have no `last_updated` in 2026.8, and are few), plus `login_failed` events since the watermark.
- **Nightly**: a full user, group and membership sweep (about 20 requests per 1,000 users), which removes deleted rows.
**Cadence adjustment (2026-11-05).** The original fifteen-minute schedule collided
with the request-time adapter's fifteen-minute freshness ceiling. Freshness is
measured from scan start, so starting the next scan only at expiry denies access
until that scan commits. Five-minute scheduling provides headroom without relaxing
the authorization limit or advancing a watermark before publication. The directory
lease still serializes overlapping work; a busy tick skips publication. A stalled
or overloaded directory can still expire and must fail closed. SQL regressions
cover four-minute scans, one skipped tick, and stale rejection; actual API-scale
throughput and scheduler jitter remain release acceptance gates. More frequent
scans increase API usage, including the per-user metrics described below.
**Pinned 2026.8.3 metrics implementation.** The event-list endpoint has no supported
created-time filter. Reconciliation therefore obtains `failedLogins24h` from
`GET /events/events/stats/?action=login_failed&username=<encoded>&count_steps=hours%3D24`,
using the server's `count_step.hours24` aggregate. This preserves the rolling
24-hour meaning without sending an ignored date parameter or storing raw events.
`mfaDeviceCount` counts confirmed devices from `/authenticators/admin/all/?user=<pk>`;
unconfirmed enrollment does not count as configured MFA. Both reads are uncached,
renew worker ownership, and persist only counts. Unavailable or malformed metrics
are null (unknown), not zero. These per-user metric requests are additional to the
directory paging estimate above and remain part of the API-scale acceptance gate.
### 7.3 Optional nightly snapshot export (read-only, not authoritative)
`IdentitySnapshotExport` (feature `SNAPSHOT_EXPORT`, scheduled nightly) writes `identity/<appKey>/users.yaml`, `groups.yaml` and `memberships.yaml` into a configured Git repo and commits only if something changed. The proposal is a private `KofTwentyTwo/identity-snapshots` repo, kept separate from cluster-gitops so ArgoCD can never apply it.
@@ -927,13 +977,14 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh
| Threat | Control |
|---|---|
| A compromised QQQ backend escalates in Authentik | The service account can't create superusers, touch superuser groups, read or mint tokens, impersonate, delete users, or change flows and policies (§5.1) |
| A compromised QQQ backend escalates in Authentik | The service account can't create superusers, touch superuser groups, read token keys, create tokens for others, impersonate, delete users, or change flows and policies (§5.1). Since revision 6 it can set a token's key (`set_token_key`, for service-account rotation): guard §13.4 (a) plus the detective rule (e) |
| A person's token is minted by someone else | Tokens for people are created only as that person, through token exchange (§13.3.1); Authentik enforces it |
| A QQQ operator escalates themselves or a friend | Subset rule, no self-edits, two-person approval for `authentik.*` grants, protected accounts |
| Lockout (last admin removed or deactivated) | Last-admin guard; break-glass superuser outside QQQ; `bootstrapOperatorSubjects` |
| A blueprint re-apply reverts QQQ edits | No `present` identity entries, no member lists (CI lint); bootstrap as `created` (verified, §4.2); reversion detector |
| Lost update between QQQ and the Authentik UI | Stale check at execute; attribute merge-patch after a fresh read |
| Account takeover through a recovery link | Links are never shown; email only |
| A non-HTTP caller bypasses permissions (#1061) | Guard in the backend util, plus ArchUnit |
| A direct backend caller bypasses core actions | Guard in the backend util, plus ArchUnit |
| A field leaks because `FieldSecurityLock` isn't enforced (#1062) | Sensitive fields stripped in the util |
| A forged webhook | Shared secret, constant-time compare; it only triggers re-reads |
| Stale session permissions after a demotion | Re-evaluation on token refresh plus webhook-triggered marking (§5.6); live groups used for guard checks |
@@ -1010,8 +1061,8 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a
| Session permissions go stale after a demotion | Token-refresh re-evaluation, webhook marking, live checks in the guard |
| The service account can lock administrators out (remove members from `authentik Admins`, deactivate break-glass, strip `qqq.io/protected`, §0.2 ii-b). Re-parenting under a superuser group **is** refused by Authentik (§0.2 ii) | Guard refusal of every write that targets a protected account or a superuser group; drift report; recovery through cluster access |
| Load on the shared Postgres | Throttle, caps, mirror |
| Webhook loss | Inbox, 15-minute reconcile, nightly sweep |
| QQQ core gaps (#1061, #1062) | Guard in the backend util, ArchUnit, field stripping |
| Webhook loss | Inbox, five-minute reconcile, nightly sweep |
| Direct backend calls outside core enforcement | Guard in the backend util, ArchUnit, field stripping |
| Token leakage | Redaction tests; no body logging on sensitive paths |
| An Authentik upgrade changes fields | Pinned schema, fixtures per version, version check |
@@ -1057,14 +1108,18 @@ A **policy** is a JSON document on an Authentik group that grants QQQ permission
}
```
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` = all five actions). Those are QQQ permission names, so a QQQ session that holds them passes QQQ's own permission checks. Policies cover the instance's tables, processes, apps, reports and widgets. Named permissions that are not `{resource}.{action}` (for example `authentik.users.manage`) stay in `qqq.io/permissions` (§5.6); a policy cannot grant them, because a resource cannot contain a dot.
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` on a named resource = only the five standard actions: `hasAccess`, `read`, `insert`, `edit`, `delete`). Those are QQQ permission names, so a QQQ session that holds them passes QQQ's own permission checks. Policies cover the instance's tables, processes, apps, reports and widgets. Named permissions that are not `{resource}.{action}` (for example `authentik.users.manage`) stay in `qqq.io/permissions` (§5.6); individual policy resources cannot contain a dot. Full access includes these named permissions in session form.
Table feature actions are generic strings registered in the table's permission catalog: for example `analyze` (qbit-data-analysis), or `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration). `{"Effect":"Allow","Action":["analyze"],"Resource":["person"]}` grants `person.analyze`, checked with `session.hasPermission("person.analyze")`. A feature must exist for every resource in its statement. Unknown features reject the document. `Action: ["*"]` on a named resource never includes features, so those statements do not acquire new grants when a feature is registered.
**Full access.** `{"Effect":"Allow","Action":["*"],"Resource":["*"]}` grants every name from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, the qbit permission catalog (including hidden record actions), and all `IdentityPermission` session names. This includes table features, READ_WRITE `write`, custom permission names enumerated by core, and future resources. Expansion happens at permission computation, never by replacing the stored statement with today's list. `Resource "*"` requires exactly `Action ["*"]`; other combinations reject the whole document. Other statements are still validated. Canonical full access is exactly one wildcard statement; enumerating all current grants never invents it. See [Full access](POLICY-FORMAT.md#full-access) for exact inclusion rules.
**Fail closed.** Any of these grants **zero** permissions from that document and logs a warning:
- an unknown `PolicySpec` or an unsupported `PolicyVersion`
- a document that is not a JSON object, or a `Statement` that is not an array
- a statement whose `Effect` is not `Allow`
- an unknown action
- a resource that does not match `[A-Za-z0-9_]+`
- a resource that does not match `[A-Za-z0-9_]+`, except the full-access `*`
- any other parse error
This is stricter than the reference implementation in two places. The reference ignores unknown actions, while this qbit rejects the whole document. `Deny` is not supported yet, so a `Deny` statement also rejects the document; ignoring it would grant more than its author intended.
@@ -1108,20 +1163,27 @@ The feature `POLICIES` turns on the editor, the `authentik_policy` table and the
*Edit Policy* is a record action on `authentik_group` and on `authentik_policy`, never in the nav. It is a standard streamed-ETL process with three steps.
**1. Edit** (frontend step). The step shows the widget `authentikPolicyEditor`, type `AUTHENTIK_POLICY_EDITOR`, a lazy-loaded Next UI widget. The server sends it two things.
**1. Edit** (frontend step). The step shows the widget `authentikPolicyEditor`, type `AUTHENTIK_POLICY_EDITOR`, a lazy-loaded Next UI widget. The server sends its catalog, current document and member preview data.
The **catalog** lists every app, table, process, report and protected widget in the QInstance. Each entry has its permission base name, label, kind, app, and the actions QQQ actually checks for it:
- `hasAccess` for apps, processes, reports and widgets
- `read`, `insert`, `edit` and `delete` for tables, limited by the table's capabilities and permission level
- Table features from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, matched by the table's effective permission base name plus `.`. Their labels come from `AvailablePermission.permissionType`; registered `TableFeaturePermissionsMetaData` features appear through this core API. The five standard suffixes are excluded.
The widget JSON keeps `actions` unchanged and adds a `features` array to each table resource, for example `"actions":["read","insert","edit","delete"],"features":[{"name":"analyze","label":"Analyze"},{"name":"comment","label":"Comment"}]`. Tables without features have `"features":[]`. Features also appear for `HAS_ACCESS` and `READ_WRITE` tables. Shared permission base names merge their features. Feature checkboxes must emit explicit action names.
The catalog also follows these rules:
- Hidden processes are included. Record actions are hidden from the nav but are still permission-checked.
- Objects that are in no app are listed under "Other".
- Tables that use `READ_WRITE` permissions are shown with `read` only, and a note, because `write` is not in the action set yet.
- Tables that use `READ_WRITE` permissions are shown with the standard action `read` and any registered features, and a note about their single write permission. The generic matching rule also exposes their available `write` permission as an explicit named action; `*` does not grant it.
- Objects with a custom permission checker, or `NOT_PROTECTED`, are left out because a policy cannot grant them.
**Full-access UI contract (Next implementation in a separate PR).** The widget payload adds `fullAccess: boolean`, true only when the current valid document contains the full-access statement, false for an absent or invalid document. Next must show a prominent **Full access (all current and future resources)** checkbox initialized from this field. While checked, disable the permissions matrix and emit a document with the payload's `spec` and `version` and exactly `Statement: [{"Effect":"Allow","Action":["*"],"Resource":["*"]}]` through the existing `outputFieldName`. Do not enumerate the matrix into explicit permissions. Unchecking removes that statement and re-enables the matrix; the emitted document then contains only explicit selected grants. `fullAccess` is display state, not a separately trusted save input: the server derives authority from the submitted document. Member `basePermissions` already expands full-access policies from other groups; the UI must label full access as including future resources, not infer completeness from the matrix alone.
The **current document** is read live from the group in Authentik, the source of truth, together with the group's fingerprint for the stale check. If it differs from the last QQQ save, a notice says it was changed in Authentik.
**Preview for a member.** On editor load only, under `authentik.policies.manage`, the payload adds `members` (up to 200 active, in-scope members of this group or its descendants, sorted by username), `membersTruncated` (true when more eligible people exist), and `mapperEnabled` from config so the UI can say "not enforced at sign-in". Each member has `username`, `label` (`Full Name (username)`), `viaGroups` (sorted direct group names that are this group or its descendants), and `basePermissions` (sorted session permissions with only this group's policy document excluded). Named `qqq.io/permissions`, other groups' and ancestors' policies, wildcard expansion, static direct-group fallbacks and bootstrap-subject grants still count. The Next UI combines that base with the current and edited documents to preview effective permissions and additions/removals locally.
The screen works like this:
- Resources are grouped by app, with a search box.
- Each row has a checkbox per action, plus *all* for the row. Each action column and each app has *select all*.
@@ -1134,7 +1196,7 @@ The screen works like this:
- regenerates it in canonical form and checks that parse(generate(x)) = x (the round-trip invariant)
- flags unknown resources
- shows the permissions added and removed, and how many members are affected
- applies the subset rule (§5.4 rule 4): the operator must hold every **added** permission
- applies the subset rule (§5.4 rule 4): the operator must hold every **added** permission; for full access they must hold its **entire current expansion** (their own effective full-access policy satisfies this). Review and audit summaries say **Full access (all current and future resources)**. Removing full access also preserves the last qualifying identity administrator.
Refusals name the reason.
@@ -1144,11 +1206,13 @@ Refusals name the reason.
- writes the `authentik_admin_action` row and the Authentik event
- clears the caches
Saving an empty matrix removes the attribute. The canonical form is the reference generator's:
- one statement per action, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`
Saving an empty matrix removes the attribute. Full access canonicalizes to one wildcard statement. Other documents retain the reference generator's canonical form:
- one statement per action, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`, followed by feature names sorted alphabetically
- resources sorted
- a resource with every action is still written as five statements, not `*`, so diffs stay readable
**Maintenance API.** Reviewed Java code in the host repository, typically a run-once job such as `KOF22_RUN_ONCE`, may call `AuthentikMaintenance.applyPolicy(apiBackendName, groupName, policyJson, operatorName, reason)`. This is a plain Java API with no registered process or route; hosts must not expose it over HTTP. It resolves exactly one group by name and uses the same audited Save Policy plan and execution, including stale, scope, superuser ancestry, read-only and feature checks. A temporary non-system session grants `authentik.policies.manage` plus the document's permissions (the complete current expansion for full access); its `maintenance:` or `run-once:` operator name and the required reason are recorded verbatim. The caller's context is restored. Equivalent policies return `NO_CHANGE` without a write or audit row; `policyTwoPersonApproval` queues approval instead of applying. `AuthentikMaintenance.fullAccessDocument(config)` generates the canonical compact full-access document for the host's spec/version. System sessions remain forbidden from writing directly.
### 12.5 Full user and group CRUD (revision 5 additions)
| Action | Table / slot | Permission | Feature | Confirmation | Authentik |
@@ -1178,6 +1242,54 @@ Deactivate stays the default for people, and *Delete User* says so in its help t
The token needs these only when writes are on. A read-only deployment (`readOnly`, or no write features) keeps the phase 1 read set.
### 12.8 Cut-over: making policies take effect safely
Saved policies do nothing until the host signs people in through the qbit mapper. The qbit makes that state visible, helps seed policies from today's access, and refuses to switch over in a way that would lock administrators out.
**Host SPI.** `AuthentikHostPermissionSource` returns what a user (or a group's members) gets *today* from the host, typically a hard-coded grant such as "admins get `adminPermissions()`". The host sets it with `withHostPermissionSource(...)`.
**Config.**
| Setting | Default | Meaning |
|---|---|---|
| `mapperEnabled` | `false` | The host's own switch (kof22: `AUTHENTIK_QBIT_MAPPER`). Off means "policies are saved but not enforced". A host that calls the mapper without setting it is treated as on. |
| `cutoverGuardGroups` | none | Groups whose members must lose no permission at the cut-over (kof22: `kof22-admins`) |
| `mapperOverride` | `false` | The owner's explicit override of a failing guard |
| `staticGroupPermissions` | none | Permissions always granted to a group's members (the floor), e.g. today's admin grant |
**Status** (`AccessComparison.state`, cached 60 s):
- `OFF`: the mapper is not in use.
- `ACTIVE`: the guard passed, or nothing is guarded.
- `OVERRIDDEN`: the owner overrode a failing guard.
- `BLOCKED`: the guard failed or could not reach Authentik.
The status is shown in three places:
- the *Policy enforcement* banner, first on group and policy records and on the Edit Policy step. When off it reads "Policies are saved but NOT enforced at sign-in on this instance (mapper off)".
- the home's Connection card ("Policies at sign-in")
- **Compare Access**
**Compare Access** is a read-only `TABLE` widget on the app home (`authentik.admin`). For every active person it compares two sets:
- **today**: the host source
- **with the mapper**: the person's groups' policies and `qqq.io/permissions` including ancestors, the static fallback of their direct groups, and all `authentik.*` for a bootstrap subject
It shows what is added and removed, and the unchanged count. Guarded people are marked, and the footer says whether the guard passes.
**Guard.** While the status is `BLOCKED`, the mapper does not apply policies. A sign-in gets:
- today's access from the host source
- the static fallbacks
- the bootstrap subjects
So switching the mapper on can never take access away from a guarded admin until Compare Access shows no removal for them, or the owner sets `mapperOverride`. The static fallback and the bootstrap subject (the owner's immutable Authentik pk) stay in force in every state, so a lockout is impossible.
**Generate Policy From Current Access** is a record action on groups that saves a policy. It takes what the group's members get today (`groupPermissions`):
- Permissions a policy can express are joined with the group's current policy, so nothing is removed, and become the document.
- Named permissions the app defines but a policy cannot express (dotted names, `write`) are merged into `qqq.io/permissions`.
- Anything else is listed as skipped.
Review shows the generated document, the diff and the subset rule before anything is saved. For the subset rule, the operator's own access today (host source plus static fallback) counts.
**Rollback** is one switch: set the host flag off (kof22: `AUTHENTIK_QBIT_MAPPER=false`). Sign-in returns to the host's own grant immediately. Saved policies stay in Authentik and in `authentik_policy`, unused.
### 12.6 Tests
- **Parser:** every fail-closed case (null, blank, not JSON, unknown spec, unsupported version, `Deny`, unknown action, bad resource, non-array fields), `*` expansion, and merging several statements.
@@ -1199,6 +1311,239 @@ The token needs these only when writes are on. A read-only deployment (`readOnly
---
## 13. Access tokens: QQQ's personal access tokens (revision 6)
Decisions 23–30 (§0.1). Authentik stays the source of truth: every token is an Authentik `Token` row. QQQ keeps only non-secret metadata about the tokens it created: the scope, who created it and when.
### 13.1 What a QQQ access token is
A QQQ access token is an Authentik **app password** (`intent: app_password`) owned by a person or a service account. A client uses it in two steps:
1. **Exchange it for a short-lived JWT** at the app's Authentik token endpoint: `POST {authentikPublicUrl}/application/o/token/` with:
- `grant_type=client_credentials`
- `client_id={app client id}`
- `username={owner username}`
- `password={token}`
- `scope=openid profile email`
No client secret: Authentik 2026.8.3 authenticates this grant by the username and app password, even for a confidential client (client authentication is required only for the code, refresh, device and token-exchange grants; pinned by `TokenContractIT`). Authentik checks that the owner is active, that the token exists with intent `app_password`, and that the app's policies let the owner in. It records a `login` event with `method: token` and the token's identifier.
2. **Call the app** (`/qqq/v1/...` and the published APIs) with `Authorization: Bearer {jwt}`. When the JWT expires (the provider's access-token validity), exchange the token again.
The qbit generates these instructions from the instance's real URLs (§13.7).
API-intent tokens (`intent: api`) call Authentik's own API, not the app. QQQ lists them and can revoke them, but does not create them, because an API token acts with all of its owner's Authentik permissions.
### 13.2 Why the qbit can't just call `POST /core/tokens/` (verified in the 2026.8.3 source)
- **Create belongs to the caller.** `TokenViewSet.perform_create` saves every new token with `user = request.user` unless the caller is a superuser, and `add_token` changes nothing. The qbit's service account is not a superuser and never will be (§5.1), so it **can't** create a token for anyone else.
- **PATCH has dangerous defaults.** `TokenSerializer.validate` runs `attrs.setdefault("intent", "api")` and `attrs.setdefault("user", request.user)`, and forces `expires` to the default for API tokens.
- A `PATCH` without `intent` turns an app password into an API token with the default expiry.
- A `PATCH` without `user` moves the token to the caller.
- So **every `PATCH` the qbit sends carries `intent`, `user` (the current owner's pk) and `expires`**, and the qbit re-reads the token afterwards (the race guard in §13.3).
- **Expiry is capped.** An app password's expiry is capped by `goauthentik.io/user/token-maximum-lifetime` (for example `days=90`) from `User.group_attributes`, which merges the **brand's** attributes, then the user's groups, then the user. Without it the cap is the tenant's default token duration, which is short. The blueprint sets the attribute on the brands (§13.9); the qbit sets `days=365` on each service account it creates.
- **Expired tokens linger.** They stay in the database until Authentik's cleanup task removes them, and the token-endpoint lookup (`Token.objects.filter(key=...)`) doesn't check `expires`. So the qbit never relies on an expired token being unusable. Revoke deletes the token.
### 13.3 How each action works
| Action | Where | Who | Authentik calls |
|---|---|---|---|
| *Create Token* (own) | Preferences "Access tokens"; `authentik_token` table action | Any signed-in user, for themselves | As the user, through token exchange (§13.3.1): `POST /core/tokens/` (`intent: app_password`, identifier `qqq-pat-{username}-{yyyymmdd}-{6 random}`, `description`, `expires`, `expiring: true`), then `GET /core/tokens/{id}/view_key/` (the owner may read their own new key) |
| *Rotate Token* (own) | same | The owner | As the user: create a new token with the same description, expiry and scope, read its key, then `DELETE` the old one. The new identifier replaces the old one in `authentik_token_scope`. If the delete fails, the result says so and the old token stays listed. |
| *Revoke Token* | Preferences (own); table record and bulk action (`authentik.tokens.manage`) | The owner, or an admin for any owner who isn't protected | `DELETE /core/tokens/{identifier}/`. Own tokens: as the user. Admin: as the service account, with `delete_token`. |
| *Extend Expiry* | Preferences (own); table record action | The owner; an admin for service accounts | Own tokens: as the user. Service accounts: as the qbit's service account, with `change_token`. Always `PATCH {intent, user, expires}`, then a re-read. |
| *Create Service Account* | `authentik_user` table action | `authentik.tokens.manage` + `authentik.users.manage` | `POST /core/users/service_account/` (`name`, `create_group: false`, `expiring: true`, `expires`). The response's `token` is the one-time key. Then set `goauthentik.io/user/token-maximum-lifetime: days=365` on the new account (its expiry cap, §13.9) and add it to the groups chosen in the policy editor, subject to the subset rule (§5.4). |
| *Rotate Token* (service account) | `authentik_token` record action | `authentik.tokens.manage` | The qbit generates a key (48 random bytes, URL-safe base64) and calls `POST /core/tokens/{identifier}/set_key/`. The identifier, expiry and scope don't change. |
**Limitation (decision 30).** A service account has exactly one app password: the one `service_account` creates. Only a superuser could add a second. To run two credentials side by side, create a second service account.
**Contract finding (2026-10-13, verified on 2026.8.3).** `UserViewSet.service_account` requires `add_user` **and** `add_token`, and `POST …/set_key/` requires `add_token` (Authentik's `ObjectPermissions` maps a detail POST to `add_*`) as well as `set_token_key`. Without `add_token` both return 403. The role therefore holds `add_token` (§13.9); `TokenContractIT` pins both behaviours.
**Race guard.** Before an extend or a rotate, the qbit reads the token (owner pk and intent), and it reads the token again after the `PATCH` or `set_key`. If the owner or the intent changed:
- the run fails;
- the audit row is `FAILED` with "token moved";
- an alert is raised (§13.8).
Authentik's `validate_user` refuses a different user, and sending `user` explicitly closes the `setdefault` path.
#### 13.3.1 Acting as the user: token exchange
**The broker.** The blueprint adds a confidential OAuth2 provider and application, `qqq-{appKey}-token-broker`:
- access-token validity `minutes=2`;
- property mappings: only the default `goauthentik.io/api` scope;
- `jwt_federation_providers`: the app's own sign-in provider;
- a **deny policy** bound to the application for superusers, members of `authentik Admins` and `identity-admins`, users with `qqq.io/protected`, and the qbit's own service account (§13.4 a).
**The exchange.** The qbit takes the signed-in user's current access JWT for the app. In kof22 this is the token the OAuth2 module stores in `qqq_user_session`; a host can supply another source with `withSubjectTokenSource(...)`. The qbit posts:
- `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`
- `client_id` and `client_secret` of the broker
- `subject_token={jwt}`
- `subject_token_type=urn:ietf:params:oauth:token-type:jwt`
- `scope=goauthentik.io/api`
**What Authentik does.** It verifies the JWT against the federated provider and applies the broker app's policies, so the deny policy runs inside Authentik. It then returns a 2-minute access token **for that user**. The qbit uses it for the token calls of that one action, then drops it; it is never stored or logged.
**Why this is safe.** The calls are made as the user, so Authentik itself enforces "own tokens only": a user can create, and read the key of, only their own tokens.
**Config.**
- `tokenBrokerClientId`: `${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}`
- `tokenBrokerClientSecret`: `${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}`
- `tokenEndpointUrl`: default `{baseUrl}/application/o/token/`
Without a broker, the self-service actions are hidden, and Review says why.
### 13.4 Guardrails (checked at Review and again at execute)
| | Rule | Where enforced |
|---|---|---|
| (a) | **No minting or rotating for privileged accounts.** Refused for: superusers, including by inheritance; members of `authentik Admins`, `identity-admins` and the configurable `tokenProtectedGroups`; the break-glass account and anything with `qqq.io/protected`; `protectedUsernames`; and the qbit's own service account. Hard-coded for the built-in classes, plus the configurable list. | The `AuthentikTokens` guard; the broker app's deny policy (self-service); for protected owners, `set_key` is refused before the call |
| (b) | **Self-service is self-only.** A person without `authentik.tokens.manage` may target only themselves. An admin may also target service accounts, but **never another human**; this covers create, rotate and extend. Revoke is the exception: for incident response, an admin may revoke any token whose owner isn't protected. | The qbit guard; Authentik itself for the as-user calls |
| (c) | **Expiry is required.** There's no non-expiring option. The maximum is `tokenMaxDaysUser` (default 90) for people and `tokenMaxDaysServiceAccount` (default 365) for service accounts. Extend counts from now. Authentik's group attribute is a second cap. | The qbit (Review refuses); Authentik (`token-maximum-lifetime`) |
| (d) | **Two-person approval** for service-account tokens (create service account, rotate): `tokenTwoPersonApproval`, default **off** while the team is one person. When on, the change is queued in `authentik_approval_request`, and the key is shown to the approver who applies it. | The qbit |
| (e) | **Detective control.** An Authentik notification rule and an expression policy fire whenever the qbit's service account creates or updates a token whose owner is protected, reads its key, or sets its key (`model_created`, `model_updated`, `secret_view` or `secret_rotate` on `authentik_core.token`). They notify `identity-admins` through Authentik notifications. The rule should never fire; if it does, guard (a) was bypassed. It also notifies `identity-admins` whenever the qbit's account creates a service account (decision 31). | The Authentik blueprint (§13.9). The qbit also raises an `authentik_token_guard_bypass` alert through the Alerts producer when its own re-read finds a protected owner. |
| (f) | **Everything is audited.** Before each call, every action writes an `authentik_admin_action` row with the client channel (`audit.clientChannel`, for example `web`, `api` or `token:{identifier}`), plus an Authentik `custom_qqq_admin_action` event. The assistant can't reach these tables or actions, because the assistant wall denies `authentik_*`. | The qbit; core audit |
### 13.5 Scopes
**What a scope is.** A token may be narrower than its owner: **read only**, and/or limited to a list of **apps** and **tables**. Advanced scopes can also list processes, reports, widgets and operationIds. No scope means the token can do everything its owner can.
**Where scopes are stored.** In the local table `authentik_token_scope`, one row per token the qbit created:
- `id`
- `tokenIdentifier` (unique)
- `ownerUsername`, `ownerPk`
- `readOnly`
- `apps`, `tables`, `processes`, `reports`, `widgets`, `operationIds` (TEXT, one per line)
- `createdBy`, `createdAt`, `rotatedAt`
- `lastReminder7d`, `lastReminder1d`
There is no key, and no hash of a key. Generic insert, edit and delete are off; only the token actions change the table. Record-level audit is on.
**Binding the JWT to its token.** A scope mapping on the app's provider (scope name `profile`) adds `qqq_token: {identifier}` to JWTs minted by `client_credentials` with an app password. It finds the app password the request presented, owned by the same user. Authentik signs the claim. It is bound to `profile` because the app needs the `groups` claim (also `profile`) to accept a token at all, so a client can't drop the claim and keep access.
**Enforcement.** The mapper reads `qqq_token`, loads the scope row (cached 60 s) and sets core's `QCredentialScope` (QRun-IO/qqq#1153) on the session. Core then:
- intersects the scope with the owner's live permissions in `PermissionsHelper`;
- checks `operationIds` in the UI API;
- records the channel `token:{identifier}`.
STAND-IN(core=QRun-IO/qqq#1153, removeBy=the RC that ships #1153, RC.7 at the earliest). Until then the mapper narrows the session's permission set itself:
- read only keeps only `.read` and `.hasAccess`;
- apps and tables keep only permissions whose base name belongs to a listed app or table.
**Fail closed.**
- Scoped tokens are offered only when the host declares the claim mapping installed (`tokenScopesEnabled`; kof22 sets it to `true` once the blueprint is applied).
- A JWT whose `qqq_token` names a token with no scope row is unscoped (the token was created outside the qbit).
- A `qqq_token` naming a token that no longer exists gets **no** permissions.
### 13.6 The key is shown once
**The type.** Core's `com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret` (`label`, `value`, `masked`, `usageText`) from QRun-IO/qqq#1162. Its `toString()` never includes the value.
**Preferences.**
- The provider's `runAction` returns the secret in `UserPreferenceActionResult.secrets`.
- The endpoint responds with `Cache-Control: no-store`.
- Next shows it in a show-once dialog with *Copy* and the usage text.
**Record actions.** These use core's one-time process output values: `RunBackendStepOutput.addOneTimeSecret(...)`, in the QRun-IO/qqq PR `feature/process-one-time-values`.
- The value travels only in the step's HTTP response.
- `ProcessState`, traces and lifecycle listeners (ESB) see only the masked form: `••••` plus the last 4 characters.
- If the step outlives the middleware's step timeout (3 s) and finishes as a job, core parks the secrets in memory (`OneTimeSecretHandoff`, 5 minutes, never in state) and returns them once, to the first status poll that sees the job complete. A poll that reaches another server instance or comes too late finds nothing: the key is lost by design, and the result says "Rotate the token to get a new key". (Found in the demo: a create through the broker can take longer than 3 s.)
**Where the key never goes.** The qbit never passes it to the audit row, the Authentik event context, `AdminResult.message`, notifications, logs or exceptions. `AuthentikRedactor` also masks any `key` or `token` JSON value in error text.
**The test.** A test runs every action, then searches for the key in every QQQ table, the captured log output, the stored process state and the published events (§13.11).
### 13.7 How to use it (generated)
The result screen, the Preferences dialog and `docs/ACCESS-TOKENS.md` show:
- the token endpoint (`{authentikPublicUrl}/application/o/token/`), the app's client id and the owner's username;
- a `curl` that gets a JWT;
- a `curl` to `{appBaseUrl}/qqq/v1/metaData`, and one to a published API (`{appBaseUrl}/api/{version}/...`) when the instance publishes one;
- the token's expiry and scope.
The URLs come from config (`appPublicUrl`, `authentikPublicUrl`, `appClientId`), defaulting to the OAuth2 module's metadata. The key appears in the usage text only inside the one-time response.
### 13.8 Reminders, last used and cleanup
**Last used.** Read from Authentik `login` events with `method: token` (`context.method_args.identifier`), through `/events/events/?action=login`, over a 90-day window, cached for 5 minutes. API tokens show "not tracked".
**Expiry reminders.** A daily scheduled job finds tokens expiring within 7 days and within 1 day, and notifies each owner (bell and email):
- through the Alerts producer SPI when qbit-alerts is installed;
- otherwise through core notifications, if present;
- otherwise not at all.
Reminders are deduplicated through `lastReminder7d` and `lastReminder1d`. Tokens created outside the qbit have no scope row, so they are reminded by owner and identifier.
**Cleanup suggestion.** The app home lists tokens not used for 60 days (`tokenUnusedDays`, configurable) and tokens of deactivated users. It links to the filtered list and the bulk *Revoke Token* action.
### 13.9 Authentik changes (cluster-gitops, kof22)
**The qbit role** `qqq-kof22-admin-idadmin`:
- add `authentik_core.change_token`, `authentik_core.set_token_key` and `authentik_core.add_token`;
- it already has `delete_token`, `view_token` and `add_user`;
- **not** `view_token_key`.
`add_token` is safe here: for a non-superuser, `POST /core/tokens/` always creates the token for the **caller**, so it can never mint a token for someone else. It is required by `service_account` and by `set_key` (the POST object permission).
**New Authentik objects:**
- the broker provider and application, with the deny policy (§13.3.1);
- the `qqq_token` scope mapping on the kof22 admin provider (§13.5);
- `goauthentik.io/user/token-maximum-lifetime: days=90` on the brands (people). Groups are `state: created` and can't take new attributes from a blueprint; the qbit sets `days=365` on service accounts it creates;
- the broker in its own blueprint file (`kof22-qqq-tokens.yaml`), so a problem there can't affect sign-in;
- the notification rule and policy (§13.4 e).
**Rollout (kof22).** Seed the Infisical key `/kof22-website-authentik-api/token-broker-client-secret` first, then merge the role-permissions gitops PR, then deploy the backend. `TOKENS` is on by default, so there is no flag PR. The ESO secrets are `AUTHENTIK_TOKEN_BROKER_CLIENT_ID` and `AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET` (Infisical `/kof22-website-authentik-api/`).
### 13.10 Permissions, tables and config
| QQQ permission | Grants |
|---|---|
| (none beyond signing in) | The "Access tokens" Preferences section, for one's own tokens |
| `authentik.tokens.manage` | The `authentik_token` table (all tokens); revoking any token whose owner isn't protected (single and bulk); service-account create, rotate and extend; the admin widgets |
| `authentik.audit.read` | Still reads `authentik_token` (unchanged) |
| Setting | Default | Meaning |
|---|---|---|
| `tokensEnabled` | `true` when any write feature is on | Opt-out (`AUTHENTIK_QBIT_TOKENS=false`) |
| `tokenMaxDaysUser` / `tokenMaxDaysServiceAccount` | 90 / 365 | Guardrail (c) |
| `serviceAccountTokenLifetime` | `days=365` | Authentik lifetime cap merged into each newly created service account's USER attributes |
| `tokenProtectedGroups` | `authentik Admins`, `identity-admins` | Added to the hard-coded classes of guardrail (a) |
| `tokenTwoPersonApproval` | `false` | Guardrail (d) |
| `tokenScopesEnabled` | `false` | Offer scopes once the claim mapping is installed |
| `tokenBrokerClientId` / `tokenBrokerClientSecret` / `tokenEndpointUrl` | env / env / `{baseUrl}/application/o/token/` | §13.3.1 |
| `appPublicUrl`, `authentikPublicUrl`, `appClientId` | from the OAuth2 module | §13.7 |
| `serviceAccountTokenLifetime` | `days=365` | Set on service accounts the qbit creates |
| `tokenUnusedDays` | 60 | The cleanup suggestion |
### 13.11 Tests
**In-memory Authentik.** `FakeAuthentik` is extended to model the 2026.8.3 behaviour of §13.2: caller-owned create, `setdefault` on `PATCH`, the lifetime cap, `service_account`, `set_key` and token exchange. Tests cover:
- create, rotate, revoke and extend, both for self and for service accounts;
- every guardrail refusal: the protected classes, another human, a missing or too-long expiry, no broker;
- the race guard;
- approval when (d) is on;
- scope rows, reminders and last used.
**The key is never persisted or logged.** After each action, the key is absent from every local table, the audit rows, the captured log, the stored `ProcessState`, the published ESB events and the Authentik event body the qbit posted.
**Contract tests on Authentik 2026.8.3** (Testcontainers):
- the §13.2 facts: a non-superuser create belongs to the caller, a `PATCH` without `intent` changes the token, and the lifetime cap applies;
- token exchange as a user through the broker;
- the deny policy refuses a protected user;
- `service_account`, `set_key` and `PATCH` for a service account;
- the `qqq_token` claim on a `client_credentials` JWT;
- the detective notification rule fires for a protected owner.
### 13.12 Delivery
| Phase | Scope |
|---|---|
| 6a | This spec and `docs/ACCESS-TOKENS.md` |
| 6b | The `TOKENS` feature, the `authentik_token` columns, `authentik_token_scope`, the token actions with guardrails, service accounts, the one-time key (core PR), tests and contract tests |
| 6c | Scopes and the `qqq_token` claim, last used, reminders, admin widgets and bulk revoke |
| 6d | The "Access tokens" Preferences provider (core #1162) and the user-menu entry (Next, by the Preferences agent) |
| 6e | The cluster-gitops role permissions and blueprint objects; kof22 adoption (draft) |
---
## Appendix A: Example API mapping (`authentik_user`)
| QQQ | Authentik |
+202
View File
@@ -0,0 +1,202 @@
# Invitation assignment implementation
Automatic group assignment after enrollment remains a 1.0 requirement. The
invitation form and admin action now save group/QQQ-role selections when MIRROR,
WEBHOOK and MEMBERSHIP are configured. The deferred event-to-membership worker
is connected through AuthentikAdmin with live original-operator authorization,
audit-before-write and durable attempts. The native email/signup/membership
workflow now passes on a SQL host. Rendered UI acceptance and the remaining
failure/recovery scenarios are still required before release.
## Pinned Authentik behavior
Inspection of the owned Authentik 2026.8.3 image establishes that the invitation
stage emits `invitation_used` and deletes a single-use invitation before the user
write stage creates the account. The event records `context.invitation_uuid`;
its user is not necessarily the future enrollee. The later native `user_write`
event records the created user's pk and a server-set `context.created` flag.
Fixed invitation data is merged into the pending enrollment data before this
write. The contract `InvitationEnrollmentContractIT` exercises this through the
real flow executor, including its same-endpoint stage redirects.
Verified 2026-11-05: `target/release-invitation-enrollment-live-03.log` passes
419 unit tests, this real-provider contract and coverage. The contract observes
the consumed invitation returning 404 while no user exists, then verifies the
new account and matching `user_write` event. Earlier setup failures corrected
the stage endpoint (`/stages/invitation/stages/`) and client redirect handling;
no provider repair was needed.
Neither the invitation-used event's user nor an email lookup is sufficient to
identify the account that should receive a deferred grant. A correlation value
is only a lookup key for pending intent; it is not authorization by itself.
## Implemented event verification
`AuthentikInvitationEvidence` now fetches native events by UUID through the
uncached, audit-read-guarded `AuthentikActionUtil.syncEvent` method. It checks
event ID, native action, exact flow executor path, the saved invitation/reference
and the supplied intent time window. User creation requires a strict boolean
`created=true` and a positive integral user pk; impersonation/agent markers fail
closed. It returns only minimal immutable facts, never grants or raw context.
API errors, missing events and malformed data produce sanitized failures.
The verifier is connected to the SQL inbox and deferred admin writer. Expected
values and time bounds come from persisted intent. Immutable paired evidence,
protected flow validation and one-time group attempts provide the additional
binding and replay protections described below.
Verified: `target/release-invitation-evidence-live.log` passes 423 unit tests,
the real enrollment contract using the QBit's non-superuser service account for
event verification, and coverage. Focused rejection and schema checks also pass
in `target/release-invitation-evidence-unit.log`.
## Remaining implementation and verification
### Server-owned correlation boundary
Native prompt stages merge validated form fields into `prompt_data`. A configured
editable field named `qqq_invitation_reference` can therefore overwrite the value
copied from invitation fixed data. Matching that value in `user_write` alone is
not proof that the account consumed the intended invitation.
The packaged `authentik/invitation-reference-policy.py` expression restores the
reference from the invitation object retained in the server's flow plan. Bind it
to the user-write stage binding with `evaluate_on_plan=false`,
`re_evaluate_policies=true` and `policy_engine_mode=all`, after all prompts and
immediately before user-write. Set the mode explicitly: Authentik defaults to `any`.
Authentik's re-evaluation marker disables policy caching. The expression first
removes submitted correlation and only restores a canonical UUID from that
server-held invitation; it does not assign any memberships. It contains no
credentials and must not log flow data.
Verified by `target/release-invitation-policy-live-04.log`: 426 unit tests, two
real Authentik enrollment cases and coverage pass with no failures or skips.
The cases submit an editable forged reference after consuming an invitation:
the saved reference is restored when present, and a fabricated reference is
removed when no reference was saved. Both use native prompt submission and
service-account event verification. Earlier runs stopped in test-client setup
because they incorrectly required a CSRF cookie for anonymous enrollment; the
corrected client sends the anonymous POST through the native executor.
`AuthentikInvitationFlow` now validates live configuration before the enrollment
evidence helper returns a created-user fact. It requires an enrollment flow with
one invitation stage before one user-write stage, unambiguous stage ordering,
stage-time re-evaluation and exactly one policy on the write binding. The policy
must be enabled, non-negated, non-expiring, fail closed on execution errors, have
execution logging disabled and match the packaged source (allowing platform
newlines and surrounding whitespace only). Additional write-binding policies are
rejected because policy evaluation can be concurrent. Place other enrollment
validation before this protected write stage.
Configuration reads are audit-read guarded, uncached, bounded and server-filtered;
incomplete pagination and mismatched target/stage IDs fail closed. Required
service-account reads add `authentik_flows.view_flow`,
`authentik_flows.view_flowstagebinding` and
`authentik_policies_expression.view_expressionpolicy` to the existing
`authentik_policies.view_policybinding`. No provider configuration writes are
granted. The test bootstrap contains these read permissions; host deployment must
apply the corresponding role update. `target/release-invitation-flow-live-04.log`
passes 429 unit tests, both native enrollment cases and coverage with no failures
or skips. It verifies configuration through the non-superuser service account
and rejects the same enrollment event after the policy binding is disabled.
The provider uses different IDs for policy lookup and serialization: filter
policy bindings by `policybindingmodel_ptr_id`, but compare their returned
`target` with the concrete flow-stage binding `pk`. The regression fixtures keep
these IDs distinct. Also, Authentik 2026.8.3's policy-binding serializer requires
`target` and `policy` in an `enabled` PATCH; the native test supplies those fields.
The invitation action uses this validation before saving selected assignment
intent; the deferred worker also uses it before applying grants.
An event field or host declaration alone is not sufficient. Unprotected flows
remain unsupported for automatic assignments.
The SQL intent store now saves immutable operator/group intent, binds a single
enrollment identity and serializes per-group attempts across workers. A started
attempt is never automatically reset after a crash or lost response. Expiry
prevents new work; a matching existing attempt can still record its result after
expiry. Daily retention removes only COMPLETE/CANCELLED intent more than thirty
days after expiry in the current scope. Unresolved intent remains for review.
The invitation action starts its normal audit before saving intent, creates the
provider invitation with the generated reference and matching expiry, attaches
the provider ID durably, and only then sends email. Storage failure stops further
provider actions. Failure to attach after creation reports that an invitation
exists but email was not sent, for operator review before retrying. SQL access
temporarily uses the internal session and restores the original operator before
every provider write. Submitted operator identity fields are ignored.
The existing paged group/QQQ-role picker is registered when MIRROR, WEBHOOK and
MEMBERSHIP are enabled. Planning and execution require current user/membership
management authority, the existing subset/protected-group rules, an active scoped
operator with a canonical immutable UUID, at most 100 selected groups/roles and
a reason of at most 2048 characters. Configured flow UUIDs resolve server-side to
the actual slug and must match exactly.
Cancellation stops pending intent even if the provider invitation was already
consumed or expired. An in-flight attempt cannot be promised cancelled and is
refused for review; completed membership changes are not undone. The connected
event worker persists authoritative facts under inbox ownership, then reserves
each automatic grant before calling the normal audited admin path. Focused
creation/cancellation tests use actual H2 SQL and fixture provider HTTP.
Verified by `target/release-invitation-create-integration.log`: 438 unit tests,
44 integration tests (42 PostgreSQL and two native Authentik enrollment cases)
and coverage pass with no failures or skips. The public admin action tests cover
intent-before-creation, attachment-before-email, cancellation after consumption
and expiry, changed flow after preview, audit/storage/attachment failures,
session restoration and revoked live membership authority. Native contracts also
verify configured UUID-to-slug resolution. The later native gate below extends
this evidence to actual email delivery and deferred membership.
Native workflow verified by `target/release-invitation-native-grants-03.log`:
460 unit tests, two Authentik enrollment contracts and coverage pass without
failures or skips. The saved-reference case invokes the public invitation
action with the non-superuser service bearer, verifies Mailpit delivery, submits
a username plus forged correlation through the native flow, then delivers the
real enrollment event before consumption to the production host inbox processor.
It asserts the actual provider membership and a SUCCEEDED administrative audit
stored in SQL. The second case still proves that a fabricated reference is
removed when none was saved. Temporary provider permissions are restored.
This is HTTP flow-executor acceptance, not rendered browser/UI acceptance or a
test of provider notification delivery to the webhook endpoint.
1. Complete rendered UI acceptance and native invitation/email acceptance for the
new group/QQQ-role selection and persisted intent path.
2. Verify native event details through the Authentik API using the event UUID.
Associate consumption and successful new-user creation with the stored
invitation and configured enrollment flow. Handle either delivery order,
duplicate hints, missing events, expired/cancelled invitations and account
identity changes. Never take grant values or user identity from a webhook
payload alone. Correlation replay/copying between enrollments needs explicit
adversarial coverage before this can authorize writes.
3. Route each assignment through `AuthentikAdmin`, retaining audit-before-write,
current operator authority, protected-account checks, subset rules and any
required approval. Persist progress so retries do not re-add a membership
after a later intentional removal. Ambiguous remote write outcomes must be
visible for review rather than silently replayed.
4. Verify the full operator invitation → native signup → durable event handling
→ audited membership flow on the SQL host and real Authentik. Include lost,
reordered and duplicate delivery, partial completion, cancellation, revoked
operator permissions, changed groups and spoofed correlation/event data.
The native enrollment contract establishes provider behavior only. It does not
prove the authorization, persistence, UI or assignment requirements above.
## Invitation event pairing verified
Consumption and enrollment can now be persisted independently in either delivery order. First verified facts are immutable; the combined bind API uses the same transitions and cannot bypass a saved identity. Rejected pairs roll back both mutations, invalid chronology becomes REVIEW, and duplicate completed events cannot reset APPLIED progress. H2/PostgreSQL regressions cover both orders, restart, conflicting identity, rollback and replay. Missing configured invitation flow now refuses creation, including execute-time drift.
Gate target/release-invitation-pairing-postgres.log passed 441 units +37 AuthentikMirrorPostgresIT tests, zero failures/errors/skips and coverage; session78862 terminal0. Initial restricted run had nine localhost fixture errors (no assertion failures); rerun used approved localhost/Docker access. No source changes during final gate. No review restart, commit or release.
NEXT: add connection-aware event fact persistence inside inbox.withClaim (same SQL transaction, no nested independent commit); connect authoritative event lookup/verification to saved intent; implement audited membership execution under original immutable operator identity and CURRENT authority. Keep system-session write prohibition. Add simultaneous event delivery/lease-loss/cancellation/expiry coverage and native full signup-to-membership acceptance. Overall 1.0 goal remains active.
## Invitation inbox evidence worker connected
AuthentikInvitationEvents now handles invitation_used/user_write from the production AuthentikApplyChangeEvents.forHost path. It re-fetches authoritative event details, checks event ID/action, looks up only saved intent, uses the existing flow/time/reference verifier, and binds enrollment only to a live active scoped immutable user UUID. Envelope username/timestamp are not assignment authority. Consumption skips mirror refresh because it precedes account creation; enrollment retains existing mirror processing.
Store connection-aware transitions join inbox.withClaim's SQL transaction; no independent nested commit, no network work under lock. Tests prove rollback invisibility, reclaimed lease rejection, lease loss during HTTP reads, mismatched event identity/action/flow rejection, concurrent independent claims preserving both facts, and public batch pairing in either order without provider writes.
Final target/release-invitation-worker-postgres.log passes445units +41AuthentikMirrorPostgresIT tests, zero failures/errors/skips and coverage; session70988 terminal0. Focused02 passes2 tests; first focused run exposed a fixture returning a user for a different username, fixed to return an empty server-filtered result. Production scope validation retained. No Java edits during finalgate, no review restart/commit/release.
NEXT: implement AuthentikAdmin-controlled deferred grant execution from saved READY intent. Each group needs original operator pk+UUID and CURRENT live permission validation, active scoped target UUID check, flow protection, normal guard/protected/self/subset/audit-before-write, durable begin/finish and no replay after ambiguous outcomes. Do not weaken system-session write prohibition or expose generic impersonation. operator() currently resolves ordinary sessions by email; deferred path must be pinned to stored immutable identity, not reuse a later email owner. Add current-authority revocation, target/operator replacement, cancellation/expiry/lease loss and provider-write failure tests; then full native signup/email/membership and rendered UI acceptance. Automatic memberships are still not executed by this worker. Full1.0 remains active.
## Deferred invitation membership execution connected
AuthentikAdmin.applyInvitationAssignments accepts only the internal worker plus saved reference/claim. READY intent reserves each group attempt in the inbox ownership transaction. A private delegated admin pins original operator pk+UUID, reads active scoped identities uncached, derives current group/static/host permissions, requires both USERS_MANAGE and MEMBERSHIPS_MANAGE, verifies protected enrollment flow, and creates a short-lived human QSession only for normal ADD_MEMBER execution. No caller-supplied identity/action/grants enter this path. Original worker context is always restored; guard's system-session write prohibition remains unchanged. Revalidation immediately before provider write retains protected/self/subset checks and original current authority. No-change counts as settled. Errors mark REVIEW; failed outcome storage leaves IN_FLIGHT. Tickets cannot be replayed after completion/ambiguity/manual removal. Read-only or disabled infrastructure suspends new attempts without writing provider.
AuthentikInvitationEvents now calls that writer after successful fenced evidence commit. FakeAuthentik now respects server-side UUID filters (needed for production syncUser reads).
New SQL/admin tests cover audit RUNNING and IN_FLIGHT before HTTP, human attribution/context restoration, original/target UUID replacement or inactivity, revoked rights before and after preparing delegation, disabled flow, protected target/superuser/escalated group, internal-caller requirement, missing audit, read-only/cancel/lost ownership, ambiguous remote side effect and no replay after manual removal. A public invitation -> SQL inbox -> authoritative event/flow verification -> admin grant test passes with only provider HTTP simulated. Existing native enrollment tests remain separate.
Final target/release-invitation-grants-postgres.log:460units +41AuthentikMirrorPostgresIT tests, zero failures/errors/skips and coverage; session85250 terminal0. Focused56 tests passed earlier. No Java changes during finalgate. No review restart/commit/release.
NEXT: full native SQL-host invitation/email/signup/inbox/audited membership acceptance and rendered UI. Reuse or extend InvitationEnrollmentContractIT protected flow; public INVITE_USER fixed_data has email/name/reference but no username, so native flow must prompt for username. SessionSqlHost provides H2 sync tables and QQQ RECORD audit; admin action tables need configured backend as in QbitWriteContractIT. Grant only needed temporary provider permissions through stack.grantTemporaryPermissions and restore them. Add partial multi-group failure, expiry/cancellation races and native changed/revoked original authority cases. Current live review still predates changes. Broader mirror views, browser/deployed-node auth, scale, staging/rollback and release supply-chain gates remain open. Full1.0 goal active.
- Pause recovery: READY assignments in read-only mode or with MEMBERSHIP disabled retain their inbox trigger through deferral; re-enabling resumes with current authorization. Verified by both pause-mode SQL tests and release-invitation-pause-postgres.log (462 units +41 PG/coverage). MIRROR/WEBHOOK must remain enabled for the worker to be registered.
+142
View File
@@ -0,0 +1,142 @@
# Local review
This host runs the local backend with QQQ 4.1.0-RC.21 and either its frontend
dependency or an explicitly supplied frontend JAR. It is a local manual-review
environment, not a release or a production deployment. The running packaged
review is at **http://127.0.0.1:18300/app/authentikAdmin**; its artifact path and
SHA-256 are recorded in `target/review-process.json`.
## Start and stop (Windows)
Requirements: Java 21, PowerShell 7, Docker Desktop with Linux containers.
```powershell
./scripts/Start-Review.ps1
# Optional alternate UI port:
./scripts/Start-Review.ps1 -Port 18301
# Serve an unpublished production build directly through QQQ:
./scripts/Start-Review.ps1 -FrontendJar C:/Users/james/.cache/qqq-authentik-access-ui/target/qqq-frontend-next-1.0.0-production-review.jar
./scripts/Stop-Review.ps1
```
The launcher builds with the Maven wrapper and starts a hidden JVM. First boot
can take several minutes. `target/review-server.log` prints `REVIEW UI`,
`REVIEW AUTHENTIK` and `REVIEW MAIL` once ready. Build failures are in
`target/review-build.log`; JVM stderr is in `target/review-server-error.log`.
Open <http://localhost:18300/app/authentikAdmin>. No password is required for
the QQQ host: it automatically signs in as the synthetic review operator.
The application and Authentik stack ports are bound to 127.0.0.1. Never expose or deploy
this test host. Existing local Authentik stacks are not reused or changed.
A new Authentik 2026.8.3 server/worker, PostgreSQL and Mailpit stack is created
per launch. Four synthetic people and two review groups are added to the
contract-test blueprints. Writes use the dedicated non-superuser service
account and retain the qbit's live authorization, audit and safety checks.
QQQ audit/approval/policy and synchronization records use a private H2 SQL
database initialized from the published migrations. Stopping the host discards
review data; restart to reset. Use Stop-Review to remove the owned containers.
Native Authentik notifications reach the loopback app through Docker Desktop's
`host.docker.internal` gateway. The generated webhook secret stays in runtime
configuration and is not written to the ownership receipts. This launcher
requires Docker Desktop on Windows; other Docker host networking is unverified.
## Review checklist
- Open Identity, People and Groups; inspect parent/child record screens.
- Edit `review.sam` or `review.alex`; verify the updated record.
- Create a user; inspect the captured recovery email in the printed Mailpit URL.
- Create a group, add/remove a review user, edit its permissions/policy.
- Deactivate/reactivate a review user and inspect the audit trail.
- Try editing yourself or a protected account and verify the guard refuses it.
- Requests granting identity administration should queue for second approval.
## Limits
Automatic local login does not test OAuth, real claims mapping or personal
token exchange. The default session is one operator, so completing a
second-person approval uses a fixed second synthetic review identity in the
browser acceptance test described below. This does not verify real login. Personal token
creation/rotation requires a real OAuth host with the broker configured.
Enrollment now includes an invitation stage, username prompt and protected
user-write correlation policy. Password setup uses recovery. The host enables
the SQL mirror, webhook ingress and Quartz synchronization jobs. Full rendered
enrollment with selected application-role assignment is verified through the
rendered browser, local email, native notifications, SQL intent completion and
actual Authentik membership/audit. Audit and policy persistence across restarts, production rollout and all
contract tests remain separate release gates.
## Application access editor (2026-11-04)
Live browser smoke: `node scripts/Smoke-Access-UI.cjs` uses the running frontend
and backend, checks catalog/search/draft selections and inherited grant sources,
then cancels without executing changes. It requires the frontend worktree's
installed Playwright and Chromium. `QBIT_REVIEW_FRONTEND` and `QBIT_REVIEW_UI_URL`
override the defaults. Screenshots and the run log belong in `target/`.
The 2026-11-04 refreshed review stack passes this smoke check. The frontend now
accepts QQQ's omission of empty application IDs in catalog JSON; without that
fix the matrix displayed a catalog-loading error despite a successful HTTP response.
The same smoke also passes against the packaged JAR served by QQQ: set
`QBIT_REVIEW_UI_URL=http://127.0.0.1:18300` before running it. Real OAuth,
two-operator execution and full workflow acceptance remain separate requirements.
The new controls are in the isolated frontend worktree `C:/Users/james/.cache/qqq-authentik-access-ui`, branch `feature/authentik-access-ui`. The published frontend JAR does not yet include them. Start the backend using `scripts/Start-Review.ps1`, then run `scripts/Start-Access-UI.ps1`. Open **http://127.0.0.1:18301/app/authentikAdmin**. Both servers bind to loopback.
- **Users → select a user → Actions → Edit User Permissions:** select direct grants, enter a reason, Review, then execute. Expand inherited access to see its source. Direct user grants use an isolated Authentik group which cannot be shared through these screens.
- **Users → Add To Group Or Role / Remove From Group Or Role:** choose the group or reusable role.
- **Groups and Application Roles → Create Application Role:** name it and select permissions. Review Viewer and Review Editor are seeded examples for Review Documents.
- **Group or role → Edit Permissions And App Access:** checkbox selections for named grants and application sign-in access. **Edit Policy** retains the existing resource/action matrix.
- **Group or role → Edit Group:** select the parent groups or roles whose permissions it inherits. Clearing the selection removes all parent assignments.
- **Group or role → Add Members / Remove Members:** search and select users. Selections survive changes to the search.
Unchecking a direct grant does not revoke access supplied by another group, role or policy. Changes take effect on session refresh; this automatically logged-in review host is not an OAuth/fresh-sign-in acceptance test. Protected accounts, self-edits, subset checks, last-administrator protection and approval requirements remain enforced.
Use `scripts/Stop-Access-UI.ps1` and `scripts/Stop-Review.ps1` to stop these processes. Review data is disposable. Source changes have not been committed or published.
Manual review containers are named `qbit-authentik-review-<unique-id>-<component>` and carry `io.qrun.qbit.project` / `io.qrun.qbit.review` labels. Only this manual launcher disables Ryuk; `Stop-Review.ps1` validates the saved JVM identity and each recorded container’s ownership before cleanup. Use that script when finished.
## Permission catalog at scale
In Edit Permissions or Edit User Permissions, use Application and Resource type to narrow the matrix, then search by resource name or label. Each page shows at most 25 resources. Select/Clear on this page changes only its named permissions; other pages remain selected. Use Show → Selected direct grants to inspect/remove existing or legacy grants, or Show → Why access is granted to inspect inherited sources. These views also paginate. Review still validates the final change before saving. This does not automatically grant access to future resources.
## Saved workflow acceptance (2026-11-05)
Run `node scripts/Verify-Access-Writes.cjs` against the default review host on
18300. It changes only `review.sam` through rendered UI controls, checks the
provider independently, restores the initial direct read grant, adds/removes
Review Viewer, and verifies SQL administrative audit success. It refuses to
start if that user is already a Review Viewer member. An interrupted run can
leave a synthetic change; inspect the user before rerunning.
Run `node scripts/Verify-Invitation-UI.cjs` to create one unique synthetic
invitation with Review Viewer selected, receive it in local Mailpit and finish
username signup in a browser. It verifies native callback processing, COMPLETE
SQL intent, actual provider membership and one successful deferred ADD_MEMBER
audit. The resulting synthetic enrollee is retained for inspection. One-time
invitation links and credentials are never printed. Consuming an invitation
then closing the browser before signup abandons that enrollment session; use a
fresh invitation for another attempt.
Both workflows passed on the SQL-backed review host. Evidence is in
`target/review-access-writes.log` and `target/review-invitation-ui.log`.
These checks do not prove real OAuth sign-in, production scale, or the complete
release contract suite.
`node scripts/Verify-Group-Inheritance.cjs` verifies role permission addition and
removal, parent-role assignment and removal, inherited user access/source labels,
actual Authentik parent/attribute values and SQL audits. It restores the synthetic
Review Editor/Review Readers groups and refuses a nonbaseline starting state.
`node scripts/Verify-Approval-UI.cjs` verifies pending changes leave Authentik
unchanged, self-approval is refused, a distinct synthetic operator approves,
and the provider grant and SQL approver audit agree. It removes the granted
permission afterward. The test-only ReviewAuthentication recognizes the fixed
`qbit-authentik-review-second` session selector for review.second; every other
session uses review.operator. The anonymous frontend creates a session during
boot, so the test selects the second identity after boot, before the action.
This selector exists only in test sources and is not an authentication mechanism.
Both group inheritance and two-person approval checks passed. Evidence is in
`target/review-group-inheritance.log` and `target/review-approval-ui.log`.
+119 -19
View File
@@ -1,6 +1,6 @@
# QQQ policy format
Version 1.0.0 of the policy documents that `qbit-authentik` reads and writes. The design is in [DESIGN.md §12](DESIGN.md#12-policies-iam-style-permission-statements-revision-5).
Version 1.1.0 of the policy documents that `qbit-authentik` reads and writes. The design is in [DESIGN.md §12](DESIGN.md#12-policies-iam-style-permission-statements-revision-5).
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.
@@ -9,7 +9,7 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is
```json
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.0.0",
"PolicyVersion": "1.1.0",
"Statement": [
{
"Effect": "Allow",
@@ -28,15 +28,16 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is
| 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.0.0`) |
| `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` (the only effect in 1.0.0) |
| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*` |
| `Statement[].Resource` | array of strings | Each one a QQQ permission base name matching `[A-Za-z0-9_]+`: a table, process, app, report or widget name (or its custom `permissionBaseName`) |
| `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. `*` stands for all five actions. The result is the union over all statements, and over all of the user's groups.
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 |
|---|---|---|
@@ -45,14 +46,101 @@ Each statement grants `{resource}.{action}` for every resource and action it lis
| `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"]}` grants `cms_page.read`.
- `{"Action": ["*"], "Resource": ["cms_page"]}` grants `cms_page.hasAccess`, `cms_page.read`, `cms_page.insert`, `cms_page.edit` and `cms_page.delete`.
- `{"Action": ["analyze", "comment"], "Resource": ["person"]}` grants `person.analyze` and `person.comment` when both features are registered for `person`.
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.0.0:** `Deny`, wildcard or prefix resources, conditions, and field-level rules. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots. They stay in the group attribute `qqq.io/permissions`.
**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 `Deny` removes what the user's groups grant through policy documents and `qqq.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 valid `Deny`. A `Deny` only removes access, so the guard never switches it off. The ONLY exemption is the bootstrap-operator identity permissions (break-glass). A `Deny` that 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 `Deny` can 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:
```json
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
```
It grants **Full access (all current and future resources)**. A complete document is:
```json
{
"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.name` returned by `PermissionsHelper.getAllAvailablePermissions(qInstance)`**, without limiting names to the editor's resource syntax. Core uses effective permission base names and permission levels: table `hasAccess`, `read`, `insert`, `edit`, `delete`, READ_WRITE `write`, registered table feature names, and process/app/report/protected-widget `hasAccess`. 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 as `cdn.purge` or `email.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 `IdentityPermission` session name**, such as `authentik.users.manage.hasAccess`, `authentik.permissions.manage.hasAccess` and `authentik.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_WRITE `write`, named identity permissions and future resources.
- `{"Effect":"Allow","Action":["*"],"Resource":["cms_page"]}` still grants only the five standard actions for `cms_page`; it does not grant features or `write`.
- `{"Effect":"Allow","Action":["read"],"Resource":["*"]}` is invalid. So is `Action: ["*", "read"]` with `Resource: ["*"]`. The error says that `Resource "*" 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
@@ -61,17 +149,25 @@ A document grants **nothing**, and the parser logs a warning, when any of these
- `PolicySpec` is missing or is not the configured name
- `PolicyVersion` is missing or is not the supported version
- `Statement` is present but not an array, or a statement is not an object
- a statement's `Effect` is not `Allow` (including `Deny`, which is not supported yet)
- `Action` or `Resource` is missing or not an array of strings
- an action is not one of the six above
- a resource does not match `[A-Za-z0-9_]+`
- a statement's `Effect` is neither `Allow` nor `Deny`
- a statement has a key other than `Effect`, `Action`, `Resource` and `Permission`, or mixes `Permission` with `Action`/`Resource`
- a wildcard resource is not a valid prefix
- `Action` or `Resource` is missing or not an array of strings (a statement with `Permission` has neither)
- the document has a top-level key other than `PolicySpec`, `PolicyVersion` and `Statement` (a misspelled `Statement` would otherwise read as an empty policy), or `Statement` is missing (an empty array is fine)
- a `Permission` name is not dot-separated segments of `[A-Za-z0-9_-]`
- a statement has an empty `Resource`, `Action` or `Permission` array (it would do nothing, which for a `Deny` is 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 exactly `Action: ["*"]`
There is no partial credit: one bad statement voids the whole document. When the qbit reads policies from the group graph, each group's document is parsed on its own, so one bad group voids only that group. In the merged token claim, one bad statement voids the claim.
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
The qbit always writes the canonical form:
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`
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**
@@ -85,13 +181,13 @@ Before every save, the qbit checks the round trip: parsing the generated documen
| 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.0.0` | `policyVersion` |
| 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. Steps:
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:
1. Apply the example blueprint, [`src/test/resources/blueprints/qqq-policy-claim.yaml`](../src/test/resources/blueprints/qqq-policy-claim.yaml). Edit its spec, version, attribute and claim name to match your config.
2. Add the mapping to the OAuth2 provider's `property_mappings`. It uses the scope `profile`, which every QQQ sign-in requests.
@@ -99,11 +195,15 @@ Authentik puts the merged document into the token through a scope mapping. Steps
4. Register the customizer in your app:
- `AuthentikPolicyClaimMapper` on its own, or
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat())`.
- 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 saves policies on a parent and a child group through the qbit, 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 both groups' permissions.
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
+128
View File
@@ -0,0 +1,128 @@
# 1.0 production release acceptance
## RC checkpoint approved 2026-11-05
The user approved committing, pushing and merging the current backend and companion
UI as an RC candidate. This does not close the full 1.0 acceptance gates below.
The local UI passes permission editing, membership/role assignment, inheritance,
two-person approval, native invitation enrollment and the token-widget regression.
The latest full gate passed 462 unit tests but had three errors among 108 integration
tests: SchemaDriftIT's literal-placeholder regex, a PostgreSQL mirror connection
closure and a PostgreSQL webhook-inbox I/O error. The subsequent token-widget fix
passed nine focused tests and real-browser acceptance. Blueprint startup also
exhibited a PostgreSQL deadlock before recovering. These remain documented RC
limitations; required remote checks and branch protections still apply.
Objective authorized 2026-11-04: take the QBit and its coordinated UI to a full 1.0 production release. Earlier local-review-only scope is superseded. The goal remains active until the delivered release is verified; a version bump or local test run is not completion.
## Requirements and evidence
| Requirement | Evidence required | Current state |
|---|---|---|
| Full lifecycle, assignments, QQQ roles, direct grants and policies | Guard matrix, real Authentik contract tests, UI workflows | Implemented; final unchanged-source gate and UI acceptance pending |
| Read-only defaults, scoped reads, protected/self/last-admin safeguards, subset rules, audit-before-write, two-person approval | Unit/architecture tests plus two-operator live acceptance | Unit coverage exists; live acceptance pending |
| Fresh OAuth sign-in and revoke behavior | Real token/claims flow through production mapper, fresh sign-in after edits | Actual Authentik-issued same-token revocation passes through native verifier, reconciliation and SQL bindings; interactive browser acceptance pending |
| Webhook authentication, durable inbox, ESB handling, session invalidation | DESIGN 7.1 and 9.5 contract/security tests, retry/idempotency and latency evidence | Producer registration, HTTP ingress, inline processing, native ESB runtime and timer recovery verified on SQL hosts; scale and deployed-host acceptance pending |
| Mirror tables and reconciliation | DESIGN 7.2 metadata, persistence, reconciliation and stale-data tests | Full/incremental workers, metrics, scheduling and producer registration verified; fallback and host rollout pending |
| Group tree, user access matrix and health | DESIGN 8 workflows, guarded bounded widget data and UI acceptance | Partial; remaining views unimplemented |
| Large systems | 1,000-resource functional tests, bounded policy editor, realistic hierarchy/membership and concurrent-edit checks | Both editors paged; 1,000-level concurrent-reader check passes; API/concurrent-edit gates pending |
| Production frontend artifact | Full tests, lint/typecheck, static export/JAR, served artifact smoke and UI acceptance | Corrected static export/JAR built and served directly by QQQ; real-browser catalog/search/draft selection/source/cancel smoke passes. Full workflow and user acceptance remain pending |
| Clean Authentik contracts and schema compatibility | CI=true wrapper verify, no manual bootstrap repair | Full baseline gate passes: 376 units, 76 integration tests, zero skips and coverage; repeat after subsequent session/mirror implementation |
| Host integration and migration | DESIGN 4.4 / PHASE0-OWNER-CHECKLIST staging rehearsal and actual host configuration | Must verify current external state |
| Release supply chain | Coordinated reviewed changes, green remote CI, versioned artifacts and immutable release tag on primary Gitea | Pending |
| Operations | Upgrade/rollback instructions, defaults/flags, secret handling, retention and monitoring checks | Existing docs require final audit |
Authentik administrative RBAC remains explicitly post-v1 per the approved design and user clarification. Snapshot export and Alerts integration are optional in the approved spec. Required functionality will not be reclassified as optional to obtain a green release.
## Execution order
1. Run full suites and diagnose bootstrap/test failures without weakening assertions.
2. Close lifecycle/authentication/security and large-catalog gaps.
3. Implement outstanding approved v1 integrations and views with targeted tests.
4. Build production artifacts and exercise end-to-end workflows and host rollout in staging.
5. Audit requirements against evidence, then coordinate primary-forge release/CI and verify published artifacts and rollout.
All Docker/process operations must stay scoped to unique ownership identifiers; preserve other agents' work. Keep the independent local review instance available whenever possible.
## Evidence collected during release work
- Invitation event verifier implemented and proven through non-superuser service-account API reads of real native enrollment events.423units +live provider contract/coverage pass (target/release-invitation-evidence-live.log). Guarded uncached detail reads, identity/action/flow/time/reference checks, strict new-user validation and sanitized minimal facts are covered. Persisted intent, one-time binding/replay protection, UI and audited writes remain; no automatic membership assignment is enabled.
- Native invitation ordering verified against real2026.8.3:419units +InvitationEnrollmentContractIT and coverage pass (target/release-invitation-enrollment-live-03.log). Invitation consumption/deletion precedes account creation; user_write supplies the created account pk and fixed-data correlation. DESIGN corrected to require both stages, not immediate grant on invitation_used. Automatic assignments remain unimplemented; INVITATION-ASSIGNMENTS.md captures required persistence, identity/authority/replay checks and UI/end-to-end gates.
- Directory API outage acceptance passes419units +39PostgreSQL contracts/coverage (target/release-mirror-outage-postgres.log). Existing refreshing adapter uses fresh committed mirror grants without additional identity API reads; failed reconciliation cannot advance freshness, and revoked/inactive/stale identities cannot regain access. Native OAuth verifier remains active against the loopback issuer; this is not an actual provider shutdown or interactive outage test. See SESSION-IDENTITY.md for the required adapter migration and cut-over guard limits. Widget fallback remains outstanding.
- Public producer integration passes418units +38PostgreSQL contracts and coverage (target/release-sync-registration-postgres.log). MIRROR/WEBHOOK now explicitly opt-in, with six tables/jobs/native bound route registration and preflight host/name validation. Real HTTP tests verify namespace/secret separation; H2/PostgreSQL producer test verifies baseline, audit, accepted inbox event and resulting grants. Shared status dropdown registration corrected for multiple QBits. Native class-and-properties loading replaces unsupported lambda route references. See SYNCHRONIZATION.md; review runtime unchanged. Optional-classpath matrix, full host startup and staging/scale/fallback/invitation/UI acceptance remain.
- Cadence collision reproduced and fixed: five-minute incremental scheduling retains the fifteen-minute authorization ceiling. SQL regression covers scan duration of four minutes, one skipped tick, and fail-closed expiry; 414 units +37 PostgreSQL contracts and coverage pass (target/release-sync-freshness-postgres.log). Earlier fifteen-minute design cadence superseded with rationale in DESIGN 7.2. More frequent scans increase API demand; production-scale throughput/jitter remain unproven and feature registration remains disabled.
- 2026-11-05 issuer/source alignment gate passes 413 units, 37 selected integration tests (PostgreSQL and actual Authentik), zero failures/skips and coverage: target/release-issuer-live.log. Production registration and final complete contracts remain pending. Source inspection additionally identified a possible freshness-denial gap between the 900-second reconcile cadence and 15-minute scan-start-based freshness; deterministic reproduction and resolution are required before enablement. This remains a no-go for 1.0.
- First full frontend run: 12 failures under default unbounded worker concurrency. A four-worker run passed 339 files / 3,656 tests, with one existing skip (target/release-frontend-tests-02.log). Worker cap now configured; timeout assertions were not weakened.
- Static frontend build and isolated resource JAR packaging succeeded (target/release-frontend-build-01.log, release-frontend-jar-01.log). Review JAR is unpublished and must be rebuilt after subsequent source edits.
- Policy editor now uses server-paged catalog data (25 resources), with explicit page bulk scope and a passing 1,000-resource selection regression. Both editor suites pass 32 tests; typecheck, changed-file lint and the second static build pass.
- Fixed caller-supplied approval and stale preview execution: all public request execution removes approved; external plans are revalidated; only approve(stored-id) reaches trusted approval execution. 59 related security/admin tests passed (target/release-approval-tests.log).
- Contract run 01 bootstrapped cleanly, then lost its disposable containers. Run 02 passed 55 contracts with unique names and explicit JVM cleanup (local Ryuk disabled); bootstrap succeeded unattended after 424 seconds. This run is diagnostic because source changes occurred while it was active; a final immutable full run remains required.
- Fresh credential-OAuth grant/revoke regression passed with 332 units and coverage (target/release-oauth-grants-02.log). Interactive browser sign-in remains a separate acceptance gate.
- In-process scale check passes for 1,000 users, 1,000 hierarchy levels, four concurrent readers, cycles, and a refreshed revoked-grant snapshot (roughly 0.9–1.0 seconds locally). This does not establish API latency or concurrent-write safety.
- Sync storage foundation added: read-only mirror/inbox/checkpoint metadata and additive docs/schema-sync.sql. H2 PostgreSQL-mode tests execute the DDL, match every metadata column and verify event/membership/identity uniqueness. Authenticated bounded webhook envelope strips arbitrary context before persistence. Five targeted scale/schema/envelope tests pass. No route, reconciler or fallback is enabled yet; actual PostgreSQL acceptance and end-to-end sync remain required.
- Durable inbox implementation now commits before acknowledgment, deduplicates by canonical event UUID, leases batches of at most 50, retries at most five times with persisted backoff, fences stale workers and expires terminal rows after 30 days. Pending work is retained. Local mirror/session-marker writes can run inside the ownership-checked transaction; network reads must happen outside it. Seven identical tests pass on H2 and actual PostgreSQL, including concurrent delivery/claims, restarts, expired leases, rollback and retention (target/release-inbox-postgres-02.log). Full unit/coverage gate passes 344 tests (target/release-inbox-unit-verify.log). No inbox test containers remain. Ingress, host database adapter, actual live-state handler, mirror audit/reconciliation, ESB/scheduling and session refresh remain unconnected and the features remain disabled.
- Strict uncached synchronization reads now reject missing/contradictory pagination, size ceilings, duplicate IDs, changing counts and scope violations. Mirror projection checks both membership representations, normalizes email identities, excludes unrelated attributes, resolves inheritance, and clears inactive-user grants. The QQQ-action writer retains RECORD audit and stable IDs (memory action semantics tested). Live Authentik directory/projection/watermark contract plus all 353 units and coverage pass (target/release-mirror-read-contract.log). Host-RDBMS writer/audit atomicity, rename swaps, and live deletion rechecks remain gates; API pagination is not an atomic directory snapshot.
- Repackaged the latest static frontend into the unpublished review JAR (target/release-frontend-jar-02.log). Both review HTTP endpoints return 200 when checked with required localhost access. Full frontend rerun 03 encountered local-server failures under sandbox restrictions; the same four wrapper tests pass with localhost access (target/release-frontend-network-diagnostic.log). Do not treat the frontend full rerun as passed until its final results and remaining failures are resolved.
- Frontend run 03 was explicitly stopped after confirmed localhost-fixture restrictions (not an observation timeout). Run 04 with required localhost access passed all 339 files / 3,658 tests, one existing skip (target/release-frontend-tests-04.log). No frontend assertions were weakened.
- Mirror writer transaction tests now pass through QQQ's H2 and PostgreSQL backend modules: outside connections cannot see uncommitted mirrors/audits; rollback removes both; committed rename swaps preserve IDs and hide temporary unique-name reservations. PostgreSQL run 01 exposed an incorrect test JDBC schema separator; run 02 corrected it and passed both scenarios (target/release-mirror-postgres-02.log). Worker/connection integration and live deletion rechecks are still required before enabling MIRROR.
- Guarded deletion/scope-exit preflight now passes eight focused sync tests, three schema tests, and a live Authentik contract together with all 359 units and coverage (target/release-deletion-live.log). Fresh detail 404s confirm deletions; existing users require an empty complete server-scoped immutable-UUID query. Failures abort rather than remove rows. Worker integration remains pending; this does not establish end-to-end reconciliation.
- Full-refresh integration passes all 364 units plus 14 PostgreSQL contracts and coverage (target/release-worker-postgres.log). One directory lease serializes publishers; the worker uses the host connection provider, live deletion preflight, inbox fencing and one QQQ transaction for mirror/audit/checkpoint plus the required session-publication hook. Callback rollback/commit behavior is tested. The actual session marker adapter, incremental sync, metrics, scheduling/ingress and fallback are still pending; feature flags remain unavailable.
- Incremental reconciliation now preserves the complete local baseline, applies changed profiles and complete group graphs, discovers previously unseen scoped members, and recomputes inherited access. Scope/configuration changes force full baselines. Previously true API superuser status is refreshed so demotion does not remain sticky. Latest gate: 370 units, ten PostgreSQL worker contracts and coverage (target/release-incremental-final.log); preceding live Authentik scoped-identity contract passes (target/release-incremental-contract.log). Metrics/scheduling/session integration/fallback and full end-to-end release acceptance remain pending.
- Long-scan renewal passes 373 units, 18 PostgreSQL worker/inbox contracts and coverage (target/release-sync-renewal-final.log). Explicit per-request checkpoints renew both leases and stop scans after ownership loss; no shared callback state or background thread. This is functional safety evidence, not the outstanding production-scale latency benchmark.
- Fresh replacement mapper gate passes all 389 units and coverage (release-refresh-unit-verify.log), including five new refresh/security regressions. This is not end-to-end OAuth revocation acceptance; native OAuth adapter and durable binding integration remain pending.
- OAuth adapter now connects native authentication to durable bindings and fresh grants; seven isolated protocol tests plus all396units/coverage pass (release-oauth-adapter-unit-verify.log). No feature enablement. Actual provider/database cross-node and interactive acceptance remain required.
- Native verifier + real host SQL acceptance passes on H2/PostgreSQL: same signed token across separate module instances follows committed revocations, unchanged deadline, forged signature rejection, service username check and durable logout. All397units +19PostgreSQL contracts/coverage pass (release-signed-session-postgres.log). OIDC issuer is a local protocol fixture, not actual Authentik; provider/callback/deployed-node gates remain.
- Actual Authentik-issued same-token revocation now passes through native verifier, production full reconciliation and H2 mirror/binding storage; second module and original-session refresh lose removed real group grant; logout rejects reuse. All397units + focused TokenContract/coverage pass (release-authentik-session-live.log). Bootstrap recovered without repair. Interactive login/refresh and deployed-node acceptance remain.
- Native cookie/code/refresh protocol passes on H2/PostgreSQL with encrypted refresh storage, unchanged binding deadline, current permissions, missing-binding denial and credential cleanup;398units +20PG contracts/coverage pass (release-cookie-refresh-postgres.log). Loopback OAuth fixture does not replace actual Authentik browser acceptance.
- HTTP webhook ingress now tested on H2/PostgreSQL for auth-before-parse, committed dedupe, redaction, bounded chunked bodies and failure responses;399units +28PG/coverage pass on rerun (release-webhook-http-postgres-02.log). Initial run had an unexplained incremental-sync renewal SQLException; isolated/full reruns pass, SQL-state diagnostic added, cause not proven fixed. Route registration stays disabled pending processing/scheduling.
- Targeted event processing passes 402 units +30 PostgreSQL contracts/coverage (release-targeted-events-postgres-02.log). Events re-read current identities/group graph, retain untouched metrics and preserve baseline freshness; stale deletion/replay checks pass. First run had a connection reset during unrelated identity-swap test setup; stability remains an open gate. Scheduler/ESB/inline integration and actual event-latency acceptance remain pending.
- Native sync-process metadata/execution and contention deferral pass404units +32PostgreSQL contracts/coverage (release-sync-process-postgres-02.log). Automatic registration, actual scheduler timing, ESB/inline delivery and production enablement remain pending. Initial run encountered Windows socket allocation exhaustion in an existing mirror-access test; host stability acceptance remains open.
- Post-commit inline/ESB delivery now passes406units +34PostgreSQL contracts/coverage (release-event-delivery-postgres.log). Real embedded Artemis/native asynchronous consumption and real Quartz polling recover pending events; strengthened grant/revoke assertions pass on H2/PostgreSQL (release-event-permissions-postgres.log). These are bounded small-fixture checks, not scale, broker-outage or deployed-host acceptance. Automatic production registration remains disabled.
- Bearer lifetime/retention gap closed: min(exp, signed iat + configured lifetime), max30days, invalid/missing/future/old issue times rejected. Purged logout cannot revive an old token even when core's optional lifetime cap is disabled. release-bearer-age-live.log passes409units +35PostgreSQL +1actual Authentik token contract/coverage, zero skips. Issuer-source alignment and production enablement remain pending.
- Invitation intent persistence gate passed: 425 units +41 selected PostgreSQL integrations, zero failures/errors/skips, coverage met (target/release-invitation-intent-postgres.log). This does not complete invitation assignment: UI, event integration, guarded audited grants and end-to-end acceptance remain pending. Release readiness remains NO-GO.
- Invitation lifecycle follow-up passes426units +42PostgreSQL integrations and coverage (release-invitation-retention-postgres.log). Matching existing attempts can record outcomes after expiry without permitting new attempts; daily settled-intent retention is wired and preserves unresolved rows. Automatic assignments remain unconnected; this is storage/lifecycle evidence, not end-to-end release acceptance.
- Server-owned invitation correlation artifact verified:426units +2native Authentik prompt/enrollment contracts and coverage pass (release-invitation-policy-live-04.log). Saved reference overrides forged form input; absent saved reference cannot be fabricated. Host flow validation and audited assignment integration remain pending; this does not establish end-to-end automatic memberships.
- Live invitation-flow validation now gates enrollment evidence.429units +2real Authentik contracts/coverage pass (release-invitation-flow-live-04.log): non-superuser configuration reads and same-event rejection after protection disablement. Bounded uncached pagination, stage/target identities and exact protected policy checks covered. Host role/policy rollout plus invitation UI/admin/event/assignment integration remain pending.
- Invitation creation/cancellation integration verified:438units +44selected integrations (42PostgreSQL+2native Authentik) and coverage pass (release-invitation-create-integration.log). Existing picker feeds persisted audited intent before remote creation/email; pending intent cancellation survives consumption/expiry. Actual deferred event-to-membership worker and native public-action/email/renderedUI acceptance remain incomplete. No production rollout or review restart.
- Invitation pairing gate: 441 units +37 PostgreSQL integrations and coverage pass (target/release-invitation-pairing-postgres.log). Either-order durable evidence, immutable identity, rollback and completed replay covered; missing-flow creation refused. Inbox worker/automatic audited grants and full native workflow remain incomplete.
- Invitation event evidence worker connected to production inbox processing; atomic claim/evidence persistence verified on H2/PostgreSQL, including concurrent claims, rollback and expired ownership. 445 units +41 PostgreSQL integrations and coverage pass (release-invitation-worker-postgres.log). Automatic audited membership execution and full native/UI acceptance remain incomplete.
- Deferred membership execution connected through AuthentikAdmin with immutable original actor/current rights, audit-before-write, fenced durable attempts and no replay after ambiguous/completed outcomes. Public invitation-to-event-to-grant SQL integration uses simulated provider HTTP. 460 units +41 PostgreSQL integrations and coverage pass (release-invitation-grants-postgres.log). Full native email/signup/membership and rendered UI acceptance remain required.
- Native invitation/email/signup/deferred-membership flow and SQL administrative audit passed:460 units +2 native contracts/coverage (release-invitation-native-grants-03.log). Subsequent pause/resume correction passed462 units +41 PostgreSQL contracts/coverage (release-invitation-pause-postgres.log). Native webhook notification transport and rendered UI acceptance remain open; final whole-suite gate must cover the eventual release source.
## Rendered saved-workflow acceptance (2026-11-05)
The SQL-backed local review passed direct-permission checkbox save/removal and
QQQ role membership add/removal through Review/Submit, with independent actual
Authentik reads and successful SQL administrative audits. It also passed UI
Invite User with Review Viewer selected, local Mailpit delivery, real browser
username enrollment, native notification-to-inbox handling, COMPLETE assignment
intent, actual provider membership and exactly one successful deferred ADD_MEMBER
audit. Scripts: Verify-Access-Writes.cjs and Verify-Invitation-UI.cjs. Evidence:
target/review-access-writes.log and target/review-invitation-ui.log.
These are synthetic local acceptance checks, not production OAuth or scale
proof. Group/role permission editing and inheritance, two-operator approval,
clean final launcher startup, full unchanged-source contracts, deployed-node
and staging rollout/rollback acceptance and release supply-chain remain open.
- RC checkpoint validation: 463 unit tests and JaCoCo passed via wrapper verify -DskipITs; companion UI 33 focused tests and typecheck passed. Full integration errors above remain open. UI PR205 is submitted on primary Gitea; authenticated merge and RC publication are pending.
+147
View File
@@ -0,0 +1,147 @@
# Immutable identity for session reevaluation
The request-time authentication adapter remains opt-in and is not yet ready for
production enablement. `AuthentikRefreshingOAuth2Module` connects core OAuth
authentication to durable bindings and the refreshing mapper. H2/PostgreSQL
tests exercise native signature verification, cookie/refresh exchange, logout
and old-token rejection after retention cleanup. A real Authentik-issued-token
contract verifies current-grant revocation through the adapter. Separate module
instances share SQL state; independently deployed nodes and interactive browser
acceptance remain release gates.
Mirror authorization expires fifteen minutes after the last published scan's
start time. Incremental reconciliation is scheduled every five minutes so the
next scan can finish before that expiry. Busy ticks do not extend freshness;
targeted webhook updates also do not make an old directory baseline fresh.
If synchronization stalls beyond the limit, authentication fails closed. Size
the host and API budget so scans finish within this window; realistic load
verification remains required before production enablement.
## Directory API outages
The refreshing adapter reads committed mirror permissions on every request;
it does not retry the directory API in the request path. This provides the
mapper's bounded outage fallback without merging old token groups, policies or
session permissions. H2 and PostgreSQL acceptance tests use native signed OAuth
verification, force reconciliation API reads to fail, and verify that the failed
scan cannot advance the watermark. Fresh mirrored access remains usable; committed
revocations, inactive identities and snapshots older than fifteen minutes do not.
This applies to `AuthentikRefreshingOAuth2Module` with
`AuthentikRefreshingPermissionMapper`, after immutable identity mapping and durable
binding setup. The legacy `AuthentikGroupPermissionMapper` is the compatibility
sign-in customizer and does not provide request-time session reevaluation. Hosts
must migrate to the refreshing pair to obtain these guarantees. OAuth signature,
issuer, audience, lifetime and binding checks still run: directory API fallback
does not bypass an unavailable OAuth login or refresh endpoint. Configured cut-over
guards retain their existing fail-safe host-permission behavior if their live API
comparison cannot run; the outage acceptance test covers an already enforced
mapper without cut-over guard groups. Widget outage behavior and deployed-host
acceptance are separate release gates.
The live `TokenContractIT.existingOAuthTokenReflectsReconciledPermissionRevocation`
now also passes with an Authentik-issued token and real provider discovery/keys.
It removes a real group permission, publishes the directory through the production
worker into H2, and proves that the same token loses access through a second
module instance. Durable logout prevents reuse. Interactive login/token refresh
and independently deployed application nodes remain separate acceptance gates.
The native authorization-code exchange, stored-cookie resume and refresh-token
HTTP exchange now also pass against the loopback OAuth fixture on H2 and
PostgreSQL. The tests use core session/refresh-token metadata and encrypted
refresh-token storage. Refresh replaces the stored access token without extending
the identity binding deadline; current grants apply, missing bindings reject,
and logout removes both core credential rows. This is protocol acceptance,
not a browser login against the actual Authentik authorization flow.
The kof22 provider uses `sub_mode: user_email`. Email and username can change
or be reused, so neither is sufficient to bind an existing QQQ session to an
Authentik user. Keep the existing subject mode for host compatibility and add
the managed **QQQ - immutable user identity** profile scope mapping from
[the tested infrastructure blueprint](../src/test/resources/blueprints/kof22-oauth2-apps.yaml).
Attach it to the application's OAuth provider alongside its existing property
mappings; do not replace the provider's other mappings.
The mapping returns:
```python
if not request.user.is_authenticated:
return {}
return {"qqq_identity": {"pk": request.user.pk, "uuid": str(request.user.uuid)}}
```
The app must request `profile`. A token without that scope has no identity
claim and cannot be used by the forthcoming reevaluation adapter. This is an
additional signed claim, not a change to the OAuth subject or an authorization
grant. Existing authentication behavior remains unchanged until the adapter is
explicitly configured.
`AuthentikSessionIdentity.fromVerifiedClaims` accepts only a positive integral
pk within the API's integer range and a canonical UUID. It does not verify JWT
signatures, issuer, audience or lifetime. It must receive claims from the
authenticated OAuth path, never from request parameters or a decoded but
unverified token. Missing or malformed claims have no email/username fallback.
The adapter must bind a newly verified login to that pk and UUID using
`AuthentikSessionBindings`. Cookie-session resumption must load the existing
binding; it must never recreate a missing, expired or revoked binding from old
claims. The original login deadline is fixed. An existing core user-reference
change must not reassign the binding to a different identity.
On reevaluation, `AuthentikMirrorAccess` supplies only fresh, committed directory
grants for the bound identity. Its result must replace stale directory/token
grants while preserving separately established host contributions and credential
restrictions. A denied or unavailable read cannot justify restoring old claims.
This replacement and the cross-node request path remain release acceptance gates.
Evidence: `TokenContractIT.clientCredentialsClaimsRequireProfile` mints real
Authentik tokens, compares the claim with the API user record, checks its absence
without `profile`, then changes both username and email and verifies that the
claim is unchanged while `sub` changes. Interactive authorization-code/session
refresh acceptance remains separate. The production provider mapping has not
been deployed; coordinate it with the host adapter before feature enablement.
## Adapter configuration and behavior
The opt-in host module code reference is `AuthentikRefreshingOAuth2Module`,
with `AuthentikRefreshingPermissionMapper` (or a host subclass) as the OAuth
customizer. Configure `sessionStoreEnabled=false` and a positive
`maxSessionLifetimeSeconds` no greater than 2,592,000 (thirty days). The adapter rejects missing prerequisites rather
than changing shared metadata during requests. Core bearer cache hits bypass
the customizer, so leaving that cache enabled would defeat reevaluation.
The host must install the local sync/binding schema and publish a fresh mirror
before enabling this module. Verified authorization-code logins bind immutable
identity and original core user reference; cookie resumption only looks up an
existing binding. Missing, expired, revoked or reassigned bindings reject.
Set `authentikPublicUrl` to the public root of the same Authentik installation
used by `baseUrl` for API reads. It defaults to `baseUrl`; set it explicitly when
the API uses an internal service address. On login and every permission refresh,
the mapper checks the verified `iss` origin (scheme, host and effective port)
and deployment-path boundary against that public root before reading the mirror.
Native OAuth still verifies the signature and exact provider/audience allowlist;
this additional check prevents an independently trusted issuer from borrowing
matching local user IDs. Both global and per-provider issuer paths within the
installation are supported. Userinfo, query strings, fragments, escaped paths
and dot-segment/duplicate-slash ambiguity fail closed. Use canonical HTTP(S) URLs.
Verified bearer and service credentials use the same durable binding. Their
absolute deadline is the earlier of token `exp` and signed `iat` plus the
configured session lifetime. The adapter requires an integral, positive `iat`
that is not in the future; keep the provider and host clocks synchronized.
It enforces this deadline on every bind and resume, even if core's optional
bearer-token lifetime limit is disabled. Cookie login deadlines remain fixed
at the verified callback, and cookie resumption never recreates missing bindings.
Repeated binding cannot extend the original deadline. Retention keeps logout
records for thirty days after binding expiry; the lifetime cap and signed-age
check prevent a still-signed old bearer from creating a new binding after those
records are purged. Logout records durable revocation before core
credential cleanup. Every successful resume and validity check refreshes
permissions, applying credential restrictions after current grants.
Host permissions must be supplied by current static-group configuration or the
host permission source during blocked cut-over. Do not restore an old session's
permission set in a host customizer. Interactive OAuth, token refresh, real
same-token revocation across nodes and provider rollout remain acceptance gates.
+306
View File
@@ -0,0 +1,306 @@
# Session state — 2026-11-04
## Latest readiness assessment — 2026-11-05
Invitation evidence implementation: added AuthentikInvitationEvidence and audit-read-guarded uncached client.syncEvent. Re-fetches native event by canonical UUID; checks action, exact flow path, intent time bounds, invitation UUID/correlation, strict created=true and positive integral user pk; rejects impersonation/agent markers and strips all extra event data from returned facts. This is NOT write authorization or replay protection. Four production-client rejection/guard/cache tests plus pinned schema checks pass. Extended real enrollment contract verifies the production helper via non-superuser QBit service account. Final423units +1provider contract/coverage pass (release-invitation-evidence-live.log, handle65122 terminal0). Next persisted operator/group intent, one-time enrollment binding/replay defenses, UI group selector and guarded/audited membership application. No automatic assignments enabled; goal active.
Invitation protocol follow-up: native 2026.8.3 invitation stage emits invitation_used and deletes the invitation BEFORE user creation. New InvitationEnrollmentContractIT runs a uniquely named real enrollment flow via same-origin executor redirects; observes invitation404 with zero users, then actual user_write event with created=true, resulting user.pk and fixed_data qqq_invitation_reference. Final419units +1real-provider contract/coverage pass (release-invitation-enrollment-live-03.log, handle28688 terminal0). First attempts corrected test endpoint (invitation/stages) and normal302 handling; no provider repair. DESIGN invitation wording corrected to require consumption plus user-write correlation. INVITATION-ASSIGNMENTS.md records the remaining implementation: persisted operator/group intent, authoritative event verification and replay/copy protection, audited guarded assignment with durable progress, UI and end-to-end acceptance. Correlation alone is NOT authorization; automatic groups remain unimplemented. Review unchanged; goal active.
Outage acceptance follow-up: existing refreshing mapper already uses the committed mirror for every request. Added native signed-OAuth +H2/PostgreSQL regression forcing identity API reads to fail: failed reconciliation leaves watermark unchanged; fresh sessions/resumes use mirror without API calls; committed revocations/inactive users/16-minute snapshots reject old access. No production code change was necessary. Final419units +39PG/coverage pass (release-mirror-outage-postgres.log, handle55353 terminal0). Documentation distinguishes refreshing adapter from legacy sign-in customizer and retains normal OAuth/cut-over checks. Widget fallback and actual deployed-provider outage remain separate gates. Next invitation-used assignment implementation: planInvite currently explicitly instructs manual Add to group and stores no selected groups; envelope discards invitation model identity. Need authoritative event/invitation-to-user correlation, persisted approved assignment intent and audited guarded writes, not email matching or trusting webhook group claims. Review unchanged; goal active.
Producer integration follow-up: MIRROR/WEBHOOK are now available as explicit opt-ins. Producer registers six sync tables, native jobs and bound QJavalin route references; validates RDBMS/scheduler/ESB dependencies and sync name collisions before mutation. Distinct prefixes get distinct paths/secrets. Fixed shared status-PVS registration for multiple named QBits. Use QCodeReferenceWithProperties/InitializableViaCodeReference: native QCodeLoader does not support lambda references. Public-producer SQL/HTTP test confirms migration-backed baseline, audit, durable inbox and resulting grants; namespace HTTP test verifies cross-secret rejection and live disablement. Final418units +38PostgreSQL contracts/coverage pass (release-sync-registration-postgres.log, handle91678 terminal0). Test containers cleaned; review7561a868 remains healthy and unchanged. Host integration documented in SYNCHRONIZATION.md. Feature availability is not production sign-off: fallback, invitation-used assignments, remaining views, optional-dependency classpath combinations, full host startup/browser, scale, staging and final release remain gates.
Cadence follow-up: reproduced READY/UNAVAILABLE failure with the production schedule and a coherent aged SQL snapshot (release-sync-freshness-red.log). Incremental cadence changed from fifteen to five minutes; fifteen-minute freshness remains unchanged. Regression covers four-minute scan time, one busy tick and sixteen-minute rejection on H2 and PostgreSQL. Full 414 units +37 PostgreSQL contracts and coverage pass (release-sync-freshness-postgres.log, handle64910 terminal0). DESIGN 7.2 records the cadence adjustment and increased API load. This resolves the deterministic schedule collision, not production throughput or indefinite contention. Next: production registration/namespace-safe routing and fallback, then remaining release gates. Review runtime unchanged; goal active.
Not ready for 1.0. Issuer alignment gate completed successfully: target/release-issuer-live.log reports 413 unit tests, 37 selected integration tests, zero failures/skips, and passing coverage (04:02, completed 00:10:06 local). This is not a final full-contract run. Public Authentik issuer/source alignment now guards login and permission refresh, including foreign trusted-issuer SQL regression. Production feature registration, fallback, invitation-used assignments, remaining views, browser/deployed-host acceptance, realistic scale and release packaging remain outstanding; review runtime was not updated by this gate.
Original concern (now addressed by the cadence follow-up above): incremental scheduling was 900 seconds, mirror freshness is 15 minutes, and publication stamps use scan start. A nonzero reconciliation duration left a freshness-denial interval before the next publication.
User scope: QQQ application roles only, with checkbox permission editing and searchable user/group/role assignments. User goal now authorizes pursuing a full 1.0 production release; requirements and release evidence are tracked in RELEASE-1.0.md.
Backend: review/local-latest, refreshed from Gitea develop. Frontend: C:/Users/james/.cache/qqq-authentik-access-ui, feature/authentik-access-ui.
Implemented: tagged group-backed application roles; named-grant and sign-in-access checkboxes; server-searched multi-select assignments with stable IDs; isolated direct-user grant groups; inherited source display; guarded/audited writes; sample Viewer/Editor roles and Review Documents; scoped launch/stop scripts.
Verified:
- 330 backend tests and JaCoCo gate passed: target/access-verify.log.
- 46 frontend access/process tests passed; typecheck and changed-file lint passed (target/frontend-access-* logs).
- Real Authentik smoke passed: role creation, group inheritance, user search/selection, membership add/remove, direct grant save/revoke/reopen/restore, inherited grants retained. target/access-smoke.log.
- Frontend proxy, group permission/sign-in checkbox payload, and UI HTTP 200 verified.
Running review:
- UI http://127.0.0.1:18301/app/authentikAdmin
- Backend http://127.0.0.1:18300
- Authentik http://127.0.0.1:42845; Mailpit http://127.0.0.1:43089 (container ports change on restart).
- Container owner prefix qbit-authentik-review-cbfa0d16, all published ports loopback-only.
- review.sam has a direct Review Documents insert grant plus inherited read/edit through the smoke-test role and group. Use this synthetic account for review.
Manual review has explicit ownership/cleanup. Earlier stacks were force-removed while their JVMs remained alive and the reaper then exited. Start-Review disables Ryuk only for its child JVM; Stop-Review checks the saved process identity and recorded container ownership before removal. Automated contract tests keep their normal lifecycle. Start/Stop-Access-UI manage only the frontend process tree.
Remaining release acceptance: complete contract suite, fresh OAuth sign-in grant/revoke checks, user UX review and coordinated frontend/backend packaging. This is not a 1.0 release sign-off. See LOCAL-REVIEW.md and TODO.md.
## Verified scaling pass
Implemented resource matrix with guarded server-side catalog search/filter/paging (25 resources), page-scoped bulk changes, paged selected grants and grant-source explanation. Selection is independent of page requests and preserves unknown grants. Generated 1,000-table paging test, 331 backend tests and coverage gate, and 47 frontend tests pass; TypeScript and changed-file lint pass. Live frontend-proxy catalog search finds Review Documents with four actions and bounded pages. Refreshed review JVM PID 177936; exact ownership receipts remain under target/. Final grant/revoke smoke results: target/scale-live-smoke.log.
## Active production release goal
Full CI=true verify running (exec session 33448, log target/release-contract-01.log); full frontend suite running (session 29771, target/release-frontend-tests-01.log). First clean contract stack session label 106130fc-93be-4b04-8626-00cc0570794a has all three blueprints successful without manual repair. Review stack remains separate. New harness code gives subsequent contract runs unique qbit names and cleans failed startup once rather than repeatedly leaking stacks. Requirements audit found mandatory webhook/session invalidation, mirror/reconcile and views unfinished; they remain in scope.
Release progress: full frontend suite passes at four workers (configured in vitest.config.ts); static export and unpublished review JAR built. Policy widget has bounded 25-resource rendering and a 1,000-resource regression. Approval input/stale-plan bypass fixed; 59 relevant backend tests pass. Contract run 02 is active, session 38689, target/release-contract-02.log, owner qbit-authentik-contract-df3be1d9. All new contract stack names are unique; failed initialization cleans its own stack and is remembered. Do not treat run02 as final immutable evidence: backend was recompiled for security tests during it.
Latest readiness check: contract run 02 completed successfully (55 contracts); a final unchanged-source full run remains required. Fresh credential-OAuth grant/revoke regression passed with 332 unit tests and coverage (target/release-oauth-grants-02.log); interactive browser sign-in acceptance remains pending. Policy editor now uses server-paged catalog data, and 32 editor tests/typecheck/changed-file lint pass. Second static frontend build passed; review JAR needs repackaging. PermissionScaleTest has been written but not run. Missing webhook/session invalidation, mirror/reconciliation, remaining views, staging acceptance and coordinated release publication still prevent 1.0 sign-off. Goal remains active.
## Sync foundation continuation
PermissionScaleTest now passes: 1,000 users/ancestry levels, four readers, cycles and refreshed revocation, about 0.9–1.0 seconds locally. Added sync/AuthentikSyncTables and docs/schema-sync.sql (mirrors, durable inbox, checkpoint/lease columns), plus AuthentikWebhookEnvelope with constant-time authentication, bounded body and allowlisted persistence. H2 PostgreSQL-mode DDL/metadata/uniqueness tests pass; this is not real PostgreSQL acceptance. Full unit/coverage verify passed 337 tests (target/release-sync-unit-verify.log). A subsequent invitation-event context fix passed all three envelope tests (target/release-webhook-envelope.log). No running build handles remain from this continuation.
Next: wire durable inbox and transactional processing, projections/reconcile, mapper fallback and session refresh before enabling MIRROR/WEBHOOK. QJavalin is currently test-scoped; an inbound route needs an optional middleware integration, distinct from UI routes. Existing EasyPost route provider provides a local reference, but its core checkout may differ from the pinned BOM. Preserve the separate running review instance and uniquely name any new resources. All release blockers remain tracked in RELEASE-1.0.md; do not publish or mark complete from these component tests alone.
## Durable inbox continuation
Added AuthentikWebhookInbox (host JDBC DataSource): transactional dedupe, canonical UUID, database-clock leases, conditional claims, maximum 50/batch and five attempts, persisted backoff, crashed-final-attempt recovery, 30-day terminal retention. withClaim locks and verifies the current owner before atomic local work, preventing old workers from publishing stale snapshots. AuthentikApplyChangeEvents runs bounded batches; Handler receives the envelope and claim and must use withClaim for mirror/session-marker writes after external reads. No exception text is persisted. Added test-only PostgreSQL JDBC driver 42.7.14, and inbox lease fields to metadata/DDL.
Verification: seven identical concurrency/recovery/rollback/retention tests pass on H2 and uniquely owned loopback-only PostgreSQL (target/release-inbox-postgres-02.log). Docker check confirmed no qbit-authentik-inbox-* containers remain. Full verify -DskipITs passes 344 tests and coverage (target/release-inbox-unit-verify.log); all build handles terminal. Only a Javadoc clarification changed afterward.
Next integration remains substantial: host connection adapter (QQQ RDBMS ConnectionManager.getConnection metadata API, currently test-scoped dependency), mirror projection and RECORD audit in the same transaction, live Authentik re-read handler, scheduler/ESB/ingress, and session refresh/fallback. RDBMSTransaction.getConnection and AbstractTableActionInput transaction support exist in local core but confirm against pinned RC.21 before using. Do not bypass QQQ mirror audit with unaudited SQL. MIRROR/WEBHOOK still deliberately unavailable; no release publication or production deployment occurred.
## Mirror read/projection/write continuation
Added guarded syncUsers(watermark,limit) and syncGroups(limit) to AuthentikActionUtil: full pagination validation, duplicate/count/scope/ceiling checks, explicitly uncached requests, server-side scope/type/watermark parameters. Added last_updated__gt to the schema-tested API surface. Added AuthentikMirrorSnapshot: known-attribute projection, case-normalized unique email identities, membership agreement between users/groups, inheritance, no inactive-user grants, nullable unknown metrics. Added AuthentikMirrorWriter: internal-session-only QQQ actions, explicit caller transaction, RECORD audit, stable local IDs and idempotent replay; caller must rollback all failures. Two action/audit tests currently use memory, so actual host-RDBMS writer/audit transaction acceptance remains required. Also handle username/email swaps and recheck deletion candidates live before enabling full sweeps.
Verified pinned RC.21 RDBMSTransaction and table-action transaction APIs with javap. Full 353 units plus live QbitReadContractIT#synchronizationReadsCompleteDirectoryAndProjectsMemberships and coverage passed (target/release-mirror-read-contract.log); exec 89690 terminal. The live test covers real user/group membership shape, projection and future watermark returning empty. Test stack owner was qbit-authentik-contract-5c29e597. No changes were made during that backend run.
Latest frontend static export repackaged successfully into unpublished qqq-frontend-next-1.0.0-production-review.jar (target/release-frontend-jar-02.log). Existing review Java/node PIDs still match receipts and both HTTP endpoints return 200 with escalated localhost access; sandbox HTTP falsely reported unavailable. Review backend has NOT been restarted and still predates release fixes.
ACTIVE VERIFICATION: full frontend run 03 is still confirmed live via exec session 78997, target/release-frontend-tests-03.log, started about 20:09 local. Do not restart on observation timeout. It reported four standalone local-server failures and one record-page failure under sandbox restrictions. Targeted reruns with localhost access passed: 4/4 standalone tests (target/release-frontend-network-diagnostic.log, exec 58251 terminal) and 15/15 record-page tests (target/release-frontend-record-diagnostic.log, exec 21792 terminal). Poll 78997 to completion, inspect final failures, then run the full frontend suite with required localhost access. No frontend source/assertions were changed to make those tests pass.
## Current verification and SQL integration state
Frontend run 03 was explicitly stopped with Ctrl-C through its exact exec session after confirmed localhost restrictions and teardown failures; exit 1 confirmed terminal. It was not restarted due to an observation timeout. Escalated full run 04 (exec 36259, now terminal) passed 339 files / 3,658 tests, one existing skip; target/release-frontend-tests-04.log. No assertion changes.
Added AuthentikMirrorSqlTest and AuthentikMirrorPostgresIT. They use standard QQQ audit metadata and actual RDBMSTransaction. Verified mirror/audit invisibility until commit, rollback of both, committed writes and stable IDs across unique username/email swaps. MirrorWriter now reserves changed names/emails inside the caller's transaction through audited QQQ updates before final writes; temporary values never appear to outside connections. Added test-only qqq-backend-module-postgres dependency from pinned BOM. PostgreSQL fixture initially had a double question mark in its JDBC schema parameter; corrected separator, and run 02 passed both contracts (target/release-mirror-postgres-02.log). All qbit-authentik-mirror-* containers were confirmed cleaned.
Full backend verify -DskipITs now passes 355 unit tests and coverage (target/release-mirror-sql-unit-verify.log, exec 84371 terminal). All build handles from this continuation are terminal. Next: host connection adapter, lease-protected mirror worker integration, live rechecks of deletion candidates, scheduled incremental/full reconciliation and session invalidation/fallback. MIRROR/WEBHOOK remain unavailable until end-to-end integration is complete. Existing review app was left running on the older review JVM; no production rollout/publication occurred. Release goal remains active.
## Deletion recheck continuation
Added guarded confirmSyncDeletions: validate all candidate keys before requests; deduplicate keys; bypass caches; a group must return detail 404. Existing users require an empty complete server-scoped query by the immutable UUID from their live detail record. This permits removal after leaving the configured scope without trusting mutable usernames or locally filtering API results. Authorization errors, malformed identities, incomplete paging and still-visible objects abort. UUID list filtering is pinned by SchemaDriftTest.
Targeted eight synchronization tests plus three schema tests pass (target/release-deletion-rechecks-02.log). Full verify and focused real Authentik contract are ACTIVE in exec session 69124, target/release-deletion-live.log, uniquely named owner qbit-authentik-contract-d04f4949. All 359 unit tests already passed; live stack is booting. Poll that exact handle; do not recompile/edit backend source while it runs. No review restart or publication.
The deletion preflight still needs worker wiring. Next integration: a directory-wide durable lease spanning remote reads and fenced publication, shared by scheduled and webhook workers. The per-inbox-event lease alone does not serialize different events. Use the host ConnectionManager and standard RDBMSTransaction lifecycle, preserving after-commit callbacks (confirmed in pinned RC.21 via javap). Do not wrap the inbox-owned JDBC commit in a transaction whose callbacks are silently dropped. Worker must compare preflight candidate keys with rows under its publication lease and rollback on unexpected changes.
Deletion verification completed: exec 69124 exited 0. All 359 units, the focused live Authentik directory/deletion/scope-exit contract, and JaCoCo passed (target/release-deletion-live.log). This supersedes ACTIVE above. Live evidence covers existing in-scope user refusal, actual user/group 404s, and existing user removal only after a fresh immutable-UUID server-scoped query returns empty. No backend source changed during the run. All test handles from this continuation are terminal. The guard remains a preflight component awaiting connection to the lease-protected worker; MIRROR/WEBHOOK remain disabled and the release goal remains active.
## Full-refresh worker integration verified
Added AuthentikSyncLease: one host-local directory owner across webhook and scheduled publishers, unique-row initialization, short transactional acquire/renew/release, stale-owner fencing and atomic watermark/full-sync checkpoint completion. PostgreSQL clock_timestamp() is used after locks (CURRENT_TIMESTAMP is transaction-start time); inbox time checks now share this helper.
Added AuthentikMirrorWorker: requires QSystemUserSession; uses host RDBMS ConnectionManager without another pool; invalidates API cache, reads complete live users/groups, projects snapshot, performs deletion/scope preflight, rechecks local identity sets under the directory publication lock, fences optional inbox claim, and writes mirrors/audit/checkpoint in one RDBMSTransaction. A mandatory Publication callback is the integration point for transactional session markers; it is NOT yet implemented by a production session adapter. Commit/rollback preserve QQQ after-commit callbacks. RDBMS dependency is now optional compile scope. Inbox lockClaim supports a caller-owned QQQ transaction.
Verification: target/release-worker-postgres.log passes all 364 units, seven mirror/worker PostgreSQL contracts, seven inbox PostgreSQL contracts and coverage. Tests cover concurrent ownership, expired/replaced workers, checkpoint rollback, host connection adapter, failed publication hook rollback, commit callbacks, webhook claim fencing, and wired live-deletion checks. Remote API responses in SQL-worker tests are controlled fixtures; the separate prior live Authentik read/deletion contract remains evidence for API semantics. Exec 9281 exited 0; all handles terminal. No backend source changed during full verify. Review JVM left unchanged; no publication/deployment.
Next: incremental users/full-group reconciliation and metrics; heartbeat or bounded per-page renewal for remote scans exceeding the 15-minute lease (current code refuses publication safely after expiry); actual persistent cross-host session markers and live reevaluation; fallback; metadata, scheduler and webhook/ESB route wiring. MIRROR/WEBHOOK remain disabled. OAuth customizer in pinned RC.21 has customizeSession/finalCustomizeSession/automated hooks but no per-request validation hook; core OAuth isSessionValid checks stored JWT expiry. Simply evicting QSession cache can reconstruct old policy/group claims. Resolve this before claiming session invalidation complete.
## Incremental reconciliation in verification
Added incrementalRefresh: changed users since checkpoint minus a 60-second overlap, complete groups each time, retained local profiles with membership rebuilt from the current graph, and live immutable-UUID scoped lookup of previously unknown group members. Empty incremental pages retain known users; missing users are left for the nightly full sweep. Scope exits and removed groups still pass live deletion preflight. Added source path/protection/superuser fields to the unreleased mirror schema, plus scopeFingerprint in state; configuration changes force a complete baseline. lastFullSync advances only on full runs.
ACTIVE: exec 59507, target/release-incremental-contract.log; all 369 units passed, focused live Authentik contract is booting unique stack qbit-authentik-contract-b2649b83, then nine PostgreSQL mirror/worker tests run. Do not modify/recompile backend until this exact handle finishes. Next correction already identified: Authentik's source is_superuser itself may include inherited status. Retained rows with sourceIsSuperuser=true need a fresh scoped lookup during incremental refresh so the source value cannot remain sticky after demotion; add regression on H2/PostgreSQL. Current pure projection tests cover locally inherited flags but not this API-derived flag. Feature flags remain disabled and review remains untouched.
## Incremental reconciliation verified
Both runs completed successfully; supersedes ACTIVE above. target/release-incremental-contract.log: 369 units, live Authentik scoped-identity/deletion contract, nine PostgreSQL worker tests and coverage. Then fixed the source-superuser edge: incremental reconciliation re-reads profiles previously reported by Authentik as superusers unless already present in the changed-user batch. This clears API-derived inherited status after demotion; disappearance/scope exit still requires live deletion preflight. target/release-incremental-final.log: all 370 units, ten PostgreSQL worker contracts and coverage pass; exec 34347 terminal. No source edits during either full verify.
New schema fields: user path, sourceIsSuperuser, sourceIsProtected; state scopeFingerprint. These are allowlisted facts needed to recompute retained profiles, not raw attributes. Missing source facts or changed API root/scope force a full baseline. Scope fingerprints persist only SHA-256; incremental runs preserve lastFullSync. Changed profiles use a 60-second watermark overlap. Rebuilt memberships/permissions update retained users; unknown group members are checked with guarded uncached syncUser(pk) (detail identity plus complete server-scoped UUID query, verifies returned UUID/pk/scope). Existing ordinary users do not need detail reads. Snapshot-only removed profiles remain until the full sweep, except separately confirmed scope/deletion checks. Unknown metrics still null.
All build handles terminal. Review JVM unchanged; no publication/deployment. Next: metrics and scheduler/ingress/ESB plus persistent session reevaluation and fallback. Add periodic renewal inside long paginated reads/deletion batches before large-directory latency acceptance; current code fails safely on expiry but may not finish huge scans. Consider audit churn from refreshedAt-only changes and hide source-only mirror fields when registering UI metadata. End-to-end scheduled/webhook sync remains disabled until all integrations pass. Production clock synchronization is required for the overlap assumption; nightly full sync repairs missed incremental hints.
## Long-scan ownership renewal verified
Added explicit AuthentikActionUtil.SyncProgress overloads for full user/group pages, scoped user discovery and deletion preflight. Each synchronization HTTP read checks/renews before and after; callbacks run after the read guard and before any partial result can escape. Existing non-worker overloads retain their behavior. No shared mutable callback or background thread.
Worker renews both the directory lease and an optional webhook claim through this callback. Lost ownership is an expected false result with no publication; DB/API errors still propagate. Projection/publication fencing remains unchanged. A single HTTP call that exceeds its lease still fails safely; ordinary multi-page scans can continue by renewing between requests.
Verified target/release-sync-renewal-final.log: all 373 unit tests, eleven PostgreSQL mirror/worker contracts, seven PostgreSQL inbox contracts and coverage pass. New regressions verify per-page boundaries/cancellation, deletion and detail boundaries, no callback before permission checks, webhook expiration during a read stopping before the group scan, replacement-owner retry and persisted renewal. Exec 43111 exited 0; all handles terminal. No source edits during full verify. Review JVM untouched; feature flags remain disabled; no publication/deployment.
Next remains metrics, scheduling/ingress/ESB, actual persistent session reevaluation and resolver fallback. Metrics API reference located: /authenticators/admin/all/?user=<pk> returns an unpaginated Device array (pinned schema line 517); AuthentikAdmin.mfaDevices already uses getApiArray. New synchronization metrics reads should be guarded, uncached and use SyncProgress. Counts of failed logins require strict bounded event paging and a rolling 24-hour window, not stale retained totals. Still review refreshedAt-only audit churn and hide source-only fields when exposing mirror metadata.
## Metrics integration in verification
Added guarded internal-only syncMetrics for scoped directory records, confirmed MFA device counts and server-side rolling failed-login aggregates. Pinned Authentik stats source/schema verified read-only in review image: count_steps=hours=24 maps to count_step.hours24; username filter handles the user/context identity. No unsupported event date filters or client-side event filtering. Counts are uncached; malformed/unavailable metrics become null, and ownership/interrupt failures propagate. Worker now populates these counts on full/incremental snapshots. Added schema surface entries, fixture routes and tests for zero vs unknown, rolling updates and permission/lease boundaries. DESIGN 7.2 documents supported stats implementation and extra per-user request cost.
First metrics gate target/release-sync-metrics-final.log: all 376 units and twelve PostgreSQL mirror/worker contracts passed, but the live Authentik contract failed during bootstrap after its uniquely owned containers disappeared. Before disappearance, OAuth/identity blueprints were successful and token blueprint reported error; stored task messages lacked a useful cause. Startup diagnostics masked the original exception with a connection failure; fixed diagnostic handling to preserve it. The independent review containers remained healthy.
ACTIVE rerun: exec 48865, target/release-sync-metrics-live-02.log, owner qbit-authentik-contract-3a29c4a2. Ryuk disabled only in this test command's environment; harness still explicitly stops its owned stack. Unit tests passed, Authentik booting. Expanded live test includes fixture-only confirmed/unconfirmed TOTP devices, recent and 25-hour-old login_failed events, login action and other-user events; verifies counts 1 and 2, then cleans its uniquely marked fixtures. Do not edit/recompile backend while this handle runs. Final metrics live/coverage gate is NOT yet proven. No production publication or review restart.
## Release readiness assessment
Latest metrics rerun completed successfully: target/release-sync-metrics-live-02.log ends BUILD SUCCESS at 2026-11-04 21:30:54, with the focused live Authentik contract and coverage passing. This supersedes ACTIVE above. Bootstrap eventually recovered without manual repair; observed dependency/deadlock warnings do not establish a permanent startup defect. Full unchanged-source contract suite remains pending. Not ready for 1.0: production ingress/scheduling/ESB, persistent session reevaluation, fallback, remaining views, browser/two-operator acceptance, real API scale/concurrent edits, staging and release supply chain remain open. No publication or deployment performed.
## Full gate and live review refresh
Full CI=true wrapper verify completed successfully without backend source edits or manual bootstrap repair: target/release-full-contracts-03.log, 376 unit tests, 76 integration tests, zero skips, coverage met; exec 19535 terminal exit 0. Ryuk was disabled only for this run and harness cleanup owns its unique containers. This is current-source evidence, not completion of remaining 1.0 features.
Pinned RC.21 javap confirms QAuthenticationMetaData.withModuleCodeReference and overridable OAuth2AuthenticationModule.createSession/isSessionValid. A qbit authentication module is a possible request-time integration seam. The sign-in mapper unions token policy and prior session permissions; cache eviction alone can therefore restore stale grants. Required acceptance must use the SAME old token/session after group removal and compare two application nodes, rollback, inactive/deleted users and restricted credential scopes. Do not treat fresh-sign-in tests as that evidence.
Live browser smoke (Playwright, no mocked API) found that the old review JVM renders user details but the paginated permission catalog shows Could not load permissions. HTTP health alone was insufficient. Restarted only the owned review stack through Stop-Review/Start-Review; synthetic review data reset. New review JVM PID 153772 started, readiness still pending. Frontend 18301 unchanged. Browser scratch harness target/release-review-browser.cjs; user detail wait currently hardcodes old review.sam pk19 and needs to derive route after reseed. Full UI workflow remains unproven until retry on fresh backend.
## Live catalog integration defect fixed
Review replacement is healthy: JVM153772, owner qbit-authentik-review-76d7d4d0, UI18301/backend18300, Authentik43637/Mailpit42131. Synthetic data reset. Catalog still failed after restart; actual HTTP200 payload omits empty row.app and apps[].id because QQQ strips empty strings. PermissionMatrix zod schema incorrectly required those values. Frontend now defaults only those application identities to empty string; permission IDs remain required. Regression reproduced red then both editor suites passed33tests (frontend target/release-catalog-wire-red.log and -green.log). New scripts/Smoke-Access-UI.cjs uses real backend and passes catalog load, resource search, draft checkbox toggle, selection preservation/view, actual inherited authentik.users.read grant from Review Readers, and cancel. No permission execution. Evidence target/release-access-ui-smoke.log and screenshots release-access-ui-catalog.png/release-access-ui-smoke.png. The prior catalog error was a frontend wire-format defect, not just stale review JVM.
Backend source unchanged since full376unit/76integration gate. Frontend static artifact predates this parser fix and must be rebuilt and served before release. Type/lint initially failed only because pnpm launcher attempted inaccessible registry lookup; reissued with installed node_modules binaries, sessions47319(typecheck) and20901(lint) pending. No publication/deployment. Goal remains active; session module integration, ingress/scheduling/fallback/views and remaining acceptance still open.
Typecheck and changed-file ESLint both completed exit0 using installed binaries; sessions47319 and20901 terminal. All build/browser handles from this continuation are terminal. Review JVM remains running intentionally. No backend changes after the full gate; frontend artifact rebuild remains required.
## Session mirror authorization boundary verified
Added AuthentikMirrorAccess: internal QSystem-only, uncached host ConnectionManager reads, pk+uuid binding required, one SELECT joins committed state/user row. READY returns immutable direct-group/effective-permission sets; DENIED means inactive/missing/identity mismatch; UNAVAILABLE means no complete baseline, stale/future/inconsistent timestamps, changed scope/policy format or malformed JSON. SQL errors propagate. Maximum age explicitly positive and <=15minutes. It does not grant host/static/bootstrap rights or merge token claims. Authentication adapter and durable session binding are still NOT implemented; no flags enabled.
AuthentikSyncLease scopeFingerprint is package-visible static and now includes policy spec/version/attribute, forcing a complete baseline after policy interpretation changes. The new regression exposed a real timezone bug: QQQ binds UTC LocalDateTime into mirror columns, but TIMESTAMP WITH TIME ZONE reinterpreted it under the local DB zone (six-hour shift). Unreleased schema-sync.sql now uses UTC timezone-free TIMESTAMP for QQQ-written mirror dates; JDBC-owned inbox/state remain TIMESTAMP WITH TIME ZONE. Reader explicitly decodes mirror LocalDateTime as UTC and compares exactly with checkpoint. Do not loosen equality to hide this. PostgreSQL fixture connections now explicitly use America/Chicago for both QQQ and direct JDBC, so correctness no longer depends on UTC defaults.
Verification: target/release-session-mirror-postgres.log BUILD SUCCESS, all379units,15PostgreSQL mirror/worker contracts plus7inbox contracts, coverage met. Tests cover separate-reader committed revoke/rollback/immutable results, missing/deactivated identities, stale/future/mismatched stamps, policy scope changes, malformed grants and non-system rejection. Exec24236 terminal0; no backend edits during verify. Earlier full376unit/76integration baseline still needs rerun on final source. Review stack left running unchanged; frontend artifact rebuild and other release gates remain open. Next: persistent identity binding at authenticated session creation and request-time adapter; never infer reauthorization from old email/claims or reuse stale permission union. No publication/deployment.
## Persistent session identity storage verified
Added AuthentikSessionBindings and additive authentik_session_binding metadata/DDL. It uses the configured host ConnectionManager, QSystem-only entry points, a SHA-256 hash of the canonical QQQ cookie-session UUID, source-root fingerprint, immutable Authentik pk+uuid, original core user reference and a fixed login deadline. No access/refresh tokens or reusable session credential are stored. bind is first-writer-wins; duplicate same-identity calls return the original deadline, conflicting identity/source/expired/revoked rows reject. Uncached find uses database-clock expiry. revoke leaves a durable tombstone even before first bind, preventing delayed creation from undoing logout. Expired rows/tombstones are retained for 30 days after their expiry before purge. The authentication resume path MUST use find only and deny missing bindings, never rebind from old claims/email. Store does not establish token authenticity; caller must bind only a newly verified login.
Verification: target/release-session-bindings-postgres.log passes all 382 units, 18 PostgreSQL mirror/session contracts and 7 inbox contracts, zero skips, coverage met. Concurrent creators with different identities and separate connections/contexts produce exactly one winner. Tests also verify upper/lower UUID canonicalization, source-root changes (including an existing store), unchanged deadlines, expiry/retention, revocation before/after login, malformed identifiers and non-system denial. Exec 72903 terminal exit 0; no source changes during verify. All build handles from this continuation terminal; review stack left running unchanged. No feature enablement or publication.
Next integration path: host fixture currently uses sub_mode=user_email; mutable subject/email cannot safely produce a persistent immutable identity binding. Read-only inspection of pinned Authentik source in review-76d7d4d0 confirms User.uuid is a unique, non-editable UUIDField (core/models.py line372), distinct from its uid property. Add a signed custom identity claim containing request.user.pk and str(request.user.uuid) through a managed scope mapping; prove it with real token contracts, and fail closed when absent/malformed in the opt-in session adapter. Existing deployments need coordinated provider mapping before enablement. Then connect moduleCodeReference OAuth2 adapter, durable new-login binding, per-request current mirror grant replacement (never union old policy claims/old session grants), preserve legitimate host/static/bootstrap contributions and credential narrowing, and cover same-token revocation across app nodes. Current store/reader are not yet that end-to-end adapter. Other 1.0 acceptance remains in RELEASE-1.0.md.
## Signed identity claim and packaged UI verified
Added managed profile scope mapping QQQ - immutable user identity to the tested infrastructure blueprint and provider property_mappings. It emits qqq_identity={pk:request.user.pk,uuid:str(request.user.uuid)} for authenticated users. Added strict AuthentikSessionIdentity parser: positive integral API-range pk, canonical UUID, no coercion or mutable-email/subject fallback. Parser does not authenticate JWTs and must receive claims from the verified OAuth path. docs/SESSION-IDENTITY.md records provider rollout and adapter requirements.
Verification: release-session-identity-live.log BUILD SUCCESS, all 384 units, focused real TokenContractIT.clientCredentialsClaimsRequireProfile and coverage. Server-issued claim matches live API identity; missing without profile; both username and email changed, sub changed, immutable claim unchanged. Blueprint lint passes with zero violations via uv run --with pyyaml (system Python alias unavailable; dependency installed in uv tool cache). Exec 18663 terminal exit 0; no backend source edits during verify.
Rebuilt static frontend including catalog omission fix (frontend target/release-catalog-static-build.log). Packaged fresh resource classes with Maven wrapper into qqq-frontend-next-1.0.0-production-review.jar (backend target/release-catalog-jar.log). Prior frontend target/classes preserved under unique classes-before-catalog-* backup. JAR contains 353 dashboard files and excludes mockServiceWorker.js. Start-Review.ps1 now accepts FrontendJar, validates static entry point, removes old frontend dependency from classpath and records path/SHA-256 in receipt. Syntax check passed.
Stopped only the owned previous review stack and restarted with this exact JAR. Current JVM 110296, owner qbit-authentik-review-7561a868, backend AND packaged UI18300, Authentik43021, Mailpit43129. Frontend JAR SHA-256 661929DAC06418E4D8B99302D553940CB8072091B8BBC2F61B619BEF0F82A90B. Development frontend18301 still exists but prefer18300 for review. Synthetic data reset. Live Playwright Smoke-Access-UI passes on18300 with no development server or mocks: catalog/search/draft checkbox/selected view/actual inherited source/cancel, no execution. Evidence release-packaged-ui-smoke.log and release-access-ui-catalog.png/release-access-ui-smoke.png (visually inspected packaged screenshot). All build/browser handles terminal; review JVM remains intentionally running.
Next remains request-time OAuth module integration with immutable claim + persistent bindings + fresh mirror grants, preserving host/static/bootstrap permissions and credential narrowing without unioning stale grants. Provider mapping has only been applied to disposable test/review stacks, not production. Other release gates unchanged; no production publication/deployment.
## Fresh permission replacement verified
AuthentikRefreshingPermissionMapper now compiles and has five regressions: same-session grant/group removal without retaining token groups/policies; denied/unavailable projection clears access; legacy claims without immutable identity reject; blocked cut-over uses current mirror email/groups for host fallback; malformed credential scope narrows even immutable bootstrap grants on every refresh. Mapper temporarily uses QSystem context and restores the caller. Mirror Access now carries current email for host lookup. Retained internal claims are allowlisted identity/sub/exp/configured credential identifier, never raw tokens. Fixed duplicate reserved scope-claim names causing Set.of failure by iterating a list.
Verification: wrapper verify -DskipITs passed 389 units, zero failures/errors/skips, coverage (target/release-refresh-unit-verify.log). Exec4622 terminal0. Integration tests intentionally excluded; PostgreSQL and full final contracts must rerun after adapter implementation. Earlier focused command named nonexistent AuthentikMirrorWorkerTest alongside mapper and only ran three mapper tests; do not count it as SQL coverage. Full unit gate includes existing H2 mirror tests.
No OAuth module exists yet: mapper is opt-in code, not enabled in review or production. Next implement AuthentikRefreshingOAuth2Module with native OAuth verification, required disabled session-store cache (core bearer cache bypasses customizer), finite login deadline, new-login bind versus resume find-only, identity/reference checks, durable logout revocation and per-request refresh. Verify actual same-token/session cross-node revocation; do not claim mapper tests establish this. Review stack left unchanged. Goal active; remaining integrations/views/acceptance in RELEASE-1.0.md still required.
## OAuth request adapter implemented; real-provider gate next
Added AuthentikRefreshingOAuth2Module using native core OAuth createSession/createServiceSession/isSessionValid/logout. Requires OAuth metadata, refreshing mapper, sessionStoreEnabled !=true and positive maxSessionLifetimeSeconds; native bearer cache bypasses customizers, so cached configuration rejects. Core verifies credentials before adapter binding. Verified callback and bearer/service sessions can bind; cookie resume finds only. Checks immutable pk+uuid, configured backend and original core user reference; refreshes current permissions after checking binding. Bearer deadline capped by core verified expiry and host lifetime; store preserves original deadline. Logout revokes durable binding then core cleanup in finally. All failures clear returned/session permissions and restore context. QSystem validity retains core behavior.
Seven isolated protocol tests pass: missing-cookie binding never created; same-object later revoke denies; callback identity/deadline; core reference reassignment denies; actual metadata rejects cache/unbounded/wrong mapper; bearer expiry bound; logout tombstone. Fixtures deliberately stub core verification and SQL; these tests are NOT end-to-end acceptance. Initial fixture compile/validation failures corrected without weakening production assertions. Auth metadata references existing synthetic qbit tables only because core OAuth is stubbed in these protocol tests.
Full wrapper verify -DskipITs passes396units, zero failures/errors/skips, coverage (target/release-oauth-adapter-unit-verify.log), exec90801 terminal0. No source changes during gate. All handles terminal. docs/SESSION-IDENTITY.md describes opt-in prerequisites and remaining gates. Adapter is not configured/enabled in review or production. No review process restart or container changes.
Next: real OAuth verifier + actual binding/mirror database integration, same bearer reused after grant revoke across separate module instances, expired/revoked/missing binding paths, service-account path, interactive callback/refresh acceptance. Existing TokenContractIT can mint actual server JWTs; AuthentikMirrorSqlTest/PostgresIT provides host schema fixtures. Consider failed-login core-row cleanup, double mirror reads (customizer then adapter), scope-cache revocation timing and long-lived bearer tombstone retention in acceptance. Required ingress/scheduling/fallback/views, performance, final contracts and release rollout remain open; goal active.
## Native signed bearer plus host SQL verified
Added SignedOAuthTestIssuer: uniquely bound loopback HttpServer, ephemeral RSA key, public JWKS/OIDC discovery, signed access tokens with immutable identity and deliberately stale group claims. No verification seams mocked. Added inherited AuthentikMirrorSqlTest.signedBearerSessionsObserveCommittedRevocationAcrossModules, run identically on H2 and non-UTC PostgreSQL. Uses actual native OAuth verifier, production refreshing mapper/module, host ConnectionManager and durable binding/mirror SQL. Separate module instances reuse exactly the same signed token: grants removed from committed mirror disappear, original session validity refresh drops stale grants, binding deadline unchanged, service expected-username checked, logout revocation prevents token rebind. Forged signature fails before an additional binding. Test publishes synthetic directory fixture data and updates SQL directly to isolate authentication/DB integration; it does NOT prove webhook latency or actual Authentik issuance.
Verification: release-signed-session-sql.log focused H2 success; release-signed-session-postgres.log all397units +19PostgreSQL contracts, zero skips, coverage passed. Exec39962 terminal0, no source edits during final gate. Initial PowerShell unquoted -Dit.test invocation failed before tests; quoted property fixed. Disposable PostgreSQL container cleaned up (docker ps name filter empty). Review stack unchanged. No flags enabled, deployments or releases.
Next integrate actual Authentik-issued token/provider discovery using TokenContractIT and real host storage; interactive authorization-code/refresh still unproven. Module-instance separation proves uncached store behavior within one JVM, not independent deployed nodes. Validate trusted issuer alignment with source API, bearer maximum lifetime/tombstone retention (core permits null lifetime limit and missing iat), credential-scope cache timing, failed-login core-row cleanup and redundant mirror reads before enablement. Remaining 1.0 gates unchanged.
## Actual Authentik token plus SQL/reconciliation acceptance passed
Added SessionSqlHost fixture: unique H2 backend, production sync DDL, actual QQQ audit tables, production worker and host ConnectionManager. Existing unrelated bookkeeping (including token scope metadata) keeps TokenContractIT's memory backend; mirrors/checkpoints/session bindings/audits are SQL. Added TokenContractIT.existingOAuthTokenReflectsReconciledPermissionRevocation: real app-password-issued access JWT, actual discovery/JWKS/signature verification, real group grant, production full reconciliation, native refreshing module, durable binding. Remove actual group attribute, reconcile again, reuse EXACT token with second module, verify revoked grant absent and original QSession validity refresh clears it; logout rejects reuse. No mocks in provider/worker/authentication/session-storage path. Still one JVM and H2, not separate deployed nodes or interactive callback/refresh.
Verification: target/release-authentik-session-live.log all397units plus focused real TokenContract, zero skips and coverage, BUILD SUCCESS. Exec61332 terminal0; no source changes during gate. Unique stack qbit-authentik-contract-252d528e; cleanup verified empty docker ps exact-name filter. Bootstrap took ~6minutes; transient token-blueprint error recovered through normal Authentik processing. Only read-only diagnostic status/log calls; no manual apply/DB repairs. Review stack unchanged, no flags enabled or publication.
Next gates: interactive authorization-code/stored-cookie refresh lifecycle; native-provider/source trust alignment; bounded bearer lifetime versus retained revocation tombstones; separate deployed-node behavior; scheduling/webhook ingress/ESB/fallback and remaining group/access/health views. Production provider rollout and final release gates remain in RELEASE-1.0.md. Goal active.
## Native cookie and refresh protocol with SQL verified
Extended SignedOAuthTestIssuer with complete discovery metadata, one-use authorization-code endpoint and refresh-token response with distinct signed access JWT. Added inherited authorizationCodeCookieResumeRequiresOriginalBinding test: real native PKCE code exchange persists core UserSession and immutable binding; core UserSessionRefreshToken metadata/table stores AES-GCM-encrypted refresh token using an ephemeral in-memory test key. Refresh skew forces native refresh HTTP exchange without sleep; asserts endpoint call, changed stored access token, current revoked grants, original cookie UUID and unchanged durable deadline. Removing binding denies resume without recreating it; logout removes both core session and refresh rows. Generic fixture native SQL date columns store UTC LocalDateTime. This is loopback protocol acceptance, NOT browser interaction or actual Authentik authorization-code issuance; actual Authentik bearer issuance gate from previous turn remains valid separately.
Verification: focused H2 code then refresh tests pass; final release-cookie-refresh-postgres.log all398units +20PostgreSQL mirror/session contracts, zero skips, coverage. Exec10364 terminal0; no source edits during final gate. PostgreSQL container cleanup verified. All test handles terminal. Only test/docs files changed this continuation; production adapter unchanged. Review stack untouched, no flags enabled/publication.
Next: actual interactive Authentik callback/refresh and independently deployed-node acceptance; close issuer/source alignment and bearer lifetime/tombstone retention concerns before enablement; then ingress/ESB/scheduler/fallback, group/access/health UI, scale acceptance and final rollout gates. Goal active.
## Authenticated HTTP inbox ingress implemented
Added optional webhook/AuthentikWebhookRouteProvider at /qqq/v1/authentik/webhook, X-QQQ-Webhook-Secret. Constant-time secret check before stream reading; declared and actual/chunked body cap65536; allowlisted envelope;204 only after inbox commit (duplicates204),401 auth,400 malformed,413 oversized,503 missing secret/storage. Feature-off creates no route and later disable returns404. No payload/header logging or new threads/pools. Javalin dependency changed from test-only to optional; middleware classes isolated in webhook package. Producer registration remains absent; WEBHOOK/MIRROR validation still refuses enablement until full integration is ready. Host constructor for AuthentikWebhookInbox uses ConnectionManager and requires QSystem/RDBMS; existing DataSource constructor retained for independent contract seams. Envelope exposes authenticate for pre-read validation.
Real HTTP inherited test passes H2/PostgreSQL: wrong secret rejected before invalid JSON; malformed400; duplicate committedonce; arbitrary context discarded; chunked oversized413; missing secret503; dropped inbox503; feature disable404. Focused release-webhook-http-sql.log passes. First full gate release-webhook-http-postgres.log passed new ingress and inbox but failed existing incrementalWorkerDiscoversMembersAndRecomputesUnchangedProfiles with a SQL exception during directory lease renew (not ownership expiry). Error boundary discarded diagnostics, so cause unknown. Exact failing test passes in isolation (release-lease-renew-diagnostic.log). Added only allowlisted five-character SQL state to safe renewal error; no retries/lease semantics relaxed, no original SQL/connection message exposed. Do NOT claim the intermittent failure was fixed; carry it into stability acceptance.
Final release-webhook-http-postgres-02.log all399units +28PostgreSQL contracts, zero skips, coverage passed. Exec12544 terminal0; no source changes during final gate. Test containers cleaned up; review stack unchanged. All handles terminal. No feature enablement, release or deployment.
Next connect actual event handler/affected-user refresh, cache invalidation and request-time session effects, ESB publication/trigger plus2second durable polling fallback,15minute incremental/nightly full/purge schedules. ESB APIs available locally in qqq/qqq-esb (EsbPublishAction/Input, EsbTrigger); native scheduled BackendStep pattern in qbit-easypost-tracking/processes/InboxProcesses.java. Do not implement a full directory/metrics scan per event as final behavior: required affected-member path and <10sec latency at scale remain gates. Add scheduler/destination config and register sync metadata/route only when complete. Native callback actual-browser, trust/retention concerns, fallback/views/performance/release acceptance still open.
## Targeted event processing checkpoint
Webhook processing now re-reads touched user identities (or an exact server-filtered username), refreshes the live group graph and recomputes inherited grants. Existing unaffected metrics are retained; only touched/new users request metrics. Old delete hints cannot remove a still-live scoped identity; deletion preflight is retained. Events preserve the directory watermark/refreshedAt rather than certifying untouched profiles as newly reconciled. Missing/incompatible baselines still require full refresh. forHost connects the production client, host inbox and fenced worker; contention is retried, never acknowledged as applied.
The failing stale-delete regression used an invalid synthetic UUID. Replaced that shared SQL fixture identity with a canonical UUID without relaxing production validation. Focused 16-test gate passes. Final release-targeted-events-postgres-02.log passes all402 units +30 PostgreSQL contracts and coverage, zero skips; handle16379 terminal0. First run passed new targeted tests but encountered a connection reset during existing uniqueIdentitySwapsAreAtomicAndKeepIds setup (createSource), before assertions. Root cause remains unknown, alongside earlier renewal SQLException; do not claim stability fixed by passing rerun. No source edits during final gate.
Next: native scheduler and optional ESB integration, inline/polling fallback, invitation-used group application, real event latency/scale and browser/cross-node acceptance. schedulerName already exists in config; esbDestinationName does not. Feature registration remains disabled. Review stack unchanged; no release/deployment. Goal active.
## Native sync process checkpoint (2026-11-04)
Added AuthentikSyncProcesses native BackendStep/metadata factory: inbox polling2s, incremental900s with1s initial delay, full sweep cron02:00UTC, retention86400s. Reuses existing schedulerName; missing/ambiguous/non-cron scheduler rejected. Names use config prefix; metadata hidden/admin-gated; runtime requires QSystem and enabled MIRROR/WEBHOOK without elevating callers. Process inputs contain backend/operation only; outputs aggregate counts; exceptions sanitized. Refreshing OAuth adapter reads committed grants every request, so publication itself is the durable signal and no separate node-local marker is added. Producer registration remains disabled pending integration; no claim of real timer-driven deployment acceptance.
Added contention deferral: forHost turns worker false into a distinct private signal; inbox.defer fences ownership, returns PENDING after2s and undoes the attempt increment. Actual failures retain existing bounded retry/backoff; stale/expired claims cannot defer. Result adds deferred count. Seven repeated busy-directory attempts now preserve attempts0, then success after release consumes attempt1. Native QQQ process regression executes all four operations on H2/PostgreSQL, tests scheduling metadata, retention cleanup, ordinary-session denial, live flag changes and sanitized unknown-operation errors.
Verification: focused9tests pass; release-sync-process-postgres-02.log all404units +32PostgreSQL contracts/coverage, zero skips. Handle66069 terminal0; no source edits during final gate. First gate release-sync-process-postgres.log had403units pass/new native process PG pass, but an existing mirror-access test failed opening a connection with Windows SocketException No buffer space available (maximum connections reached?): connect. Aggregate read-only diagnostic:1840TIME_WAIT,705CLOSE_WAIT,210ESTABLISHED; TCPdynamic49152+16384. This identifies that failure as socket-allocation exhaustion, not a failed business assertion; it does NOT prove the earlier reset/renewal failures share a cause. No global network setting/process changes or assertions/retries weakened.
Next optionalESB destination/trigger(BATCH50,2s,concurrency1,maxAttempts5), post-commit route publication or inline execution with polling recovery, actual timer/runtime acceptance and configuration/producer registration. ESB local source defaults trigger execution to QSystem; APIs in qqq/qqq-esb. Invitation-used groups, session trust/lifetime concerns, mirror fallback/views and full scale/browser/deployed-node/release gates remain. Review untouched; no commit/publication/deployment; full1.0 goal active.
## Inline/ESB delivery and native timer acceptance (2026-11-04)
Added esbDestinationName config; optional BOM-pinned qqq-esb dependency (4.1.0-RC.21) and isolated esb/AuthentikEsbDelivery. Host destination required, never auto-created. Inbox process receives native BATCH50/2000ms/concurrency1/maxAttempts5 trigger while keeping2s polling recovery. Notification is persistent JMS with empty data and fixed qqq.authentik.inbox.ready type, no identity/grants/payload. AuthentikEventDelivery guards QSystem/MIRROR/WEBHOOK and chooses inline batch or ESB; direct ESB publication guarded too. Route commits receipt before dispatch; notification exceptions cannot undo committed receipt/204, and poll recovers pending work. No exception bodies logged/persisted. No producer registration or feature enablement yet.
Tests use real loopback HTTP, uniquely named in-VM embedded Artemis2.57.0 (test-only dependency, matches pinned QQQ parent), native EsbTriggerHandler, actual asynchronous QEsbRuntime consumer and real Quartz2s inbox timer. First timer test failed because test host lacked QScheduleManager's standard PROCESS schedulable type; added actual SchedulableProcessRunner metadata. No mock scheduler/handler. Recovery test creates notification configuration failure after durable receipt then lets real timer finish it. This is not a live broker-outage/restart acceptance test. Broker test verifies empty persistent message, native trigger settings, replay/audit idempotence and automatic second consumption. Each runtime/server/broker stops in finally; Quartz unInit resets only test JVM state.
Full release-event-delivery-postgres.log passes406units +34PG contracts/coverage, zero skips, handle48588 terminal0. Then strengthened only the two delivery tests to assert actual grant addition and revocation in effective_permissions; focused H2+PG rerun release-event-permissions-postgres.log passes2+2tests/coverage, handle66431 terminal0. Production source unchanged between full gate and stronger assertions. All handles terminal. Ten-second bounds cover one-user fixtures, not realistic scale. No transient socket issue in these gates; do not erase prior stability evidence.
Next: production config/metadata/route registration (namespace route binding also needs review), final session issuer-source and lifetime/tombstone fixes before enablement, invitation-used group assignment, mirror fallback and remaining group/access/health UI, actual Authentik browser/deployed-node/scale and rollout acceptance. Document readiness without claiming core fixtures prove production deployment. Review stack remains untouched; full1.0 goal active, no release/commit/deployment.
## Signed bearer lifetime and retention safety (2026-11-04)
Closed a rebind gap under permissive native bearer-lifetime configuration: adapter previously used now+maxSessionLifetimeSeconds on every bearer bind, so long-lived credentials could receive a new deadline after expired/revoked rows were purged. Focused red tests reproduce both reset deadline and missing-age acceptance (release-bearer-age-red.log). Mapper now retains verified iat. Adapter requires positive integral non-future signed iat for bearer/service sessions; deadline=min(token expiry,iat+configured lifetime), checked on both bind and resume before storage/permission refresh. Configuration maximum is30days, sharing AuthentikSessionBindings.REVOCATION_RETENTION; cookie callback fixed deadlines/find-only resume unchanged. Missing/string/fractional/negative/future/too-old iat fails closed. Docs SESSION-IDENTITY updated with formula and clock requirement.
Added SignedOAuthTestIssuer lifetime overload and SQL test purgedLogoutCannotReviveAnOldSignedBearer: native verifier accepts a31day-old token with future exp when its optional bearerTokenMaxLifetimeSeconds is explicitly null; actual logout tombstone is aged/purged; two adapter instances reject reuse without recreating rows. First SQL attempt hit core's default token-lifetime cap, so the test explicitly models the supported permissive setting (not weakened signature/expiry verification). Focused14protocol tests and2SQL tests pass.
Final release-bearer-age-live.log passes409units +36integration tests (35PostgreSQL +1real Authentik-issued same-token revocation), zero skips and coverage. Handle8092 terminal0 after7m56; stack67dace92 bootstrapped without manual repair, no source changes during gate. This also reverified existing cookie/refresh and actual provider compatibility with retained iat. All handles terminal. No feature enablement/review restart/release/commit/deployment.
Next close issuer/source alignment before production registration. Existing config.getAuthentikPublicUrl() defaults to baseUrl and supports distinct public OAuth/internal API addresses; reuse rather than add a redundant setting. Retain verified iss and check the accepted issuer belongs to that configured Authentik public instance (native verification remains authoritative for signature/audience). Consider global/per-provider issuer paths and explicit other-issuer rejection; bind/fingerprint implications need review. Namespace route/config/metadata enablement, invitation-used groups, mirror fallback/UI and browser/deployed-node/scale/rollout gates remain open. Goal active.
## Release readiness check
Verified target/release-invitation-intent-postgres.log ends BUILD SUCCESS: 425 unit tests, 41 selected PostgreSQL integration tests, zero failures/errors/skips and coverage passed. Invitation intent persistence is tested but not connected to automatic audited membership assignment or the UI. 1.0 remains no-go pending feature completion, final full contracts, interactive acceptance, scale and host rollout/release gates.
## Invitation outcome expiry and retention verified
Gate release-invitation-retention-postgres.log passed 426 units +42 selected PostgreSQL integrations, zero failures/errors/skips and coverage; session43357 terminal0. Existing attempts can record success/failure after expiry; new attempts remain refused. Daily retention now purges only COMPLETE/CANCELLED intent in the current scope thirty days after expiry, preserving unresolved state. Both success/failure after expiry, forged/duplicate outcomes, scope separation, recent rows and non-system rejection are tested. Seven sync tables documented. No review restart or release. Next: connect intent to invitation UI/admin and trusted event worker, current original-operator authorization and audit-before-write; copied correlation and full native workflow remain acceptance gates.
## Server-owned enrollment correlation verified
Native prompt stages can overwrite fixed-data correlation through configured fields. Added packaged authentik/invitation-reference-policy.py: run on the user-write binding after prompts (evaluate_on_plan=false,re_evaluate_policies=true); remove submitted reference, restore only canonical UUID from invitation.fixed_data retained in server flow context. Native re-evaluation disables policy caching. Real prompt POST contract now covers saved reference restoration and missing-reference fabrication removal, verified through production event helper/non-superuser service-account reads. Final release-invitation-policy-live-04.log passes426units +2real Authentik contracts and coverage; session2912 terminal0. Earlier01/02 failed only test's unsupported CSRF-cookie requirement; anonymous POST works without that assumption (03 also passed). All handles terminal; no source changes during finalgate. Next implement authoritative host flow/policy validation before enabling deferred grants, then UI/intent creation, event pairing and original-operator current-authority audited assignments. Policy artifact alone is not production integration. Review stack untouched; goal remains active.
## Live invitation-flow validation verified
Added AuthentikInvitationFlow and guarded uncached AuthentikActionUtil.invitationConfiguration. Evidence.enrolled now requires live verified enrollment flow: one invite before one write, unique stage order, stage-time evaluation/all mode, one enabled non-negated non-expiring fail-closed policy, no execution logging, exact packaged expression. Complete bounded server-filtered lists and target/stage identities enforced. API surface/schema checks cover four new endpoints. Bootstrap adds only view_flow/view_flowstagebinding/view_expressionpolicy alongside existing view_policybinding; production host role migration still needed.
Final release-invitation-flow-live-04.log passes429units +2real Authentik contracts and coverage, zero failures/errors/skips; session61662 terminal0. Native test validates configuration using non-superuser token, then disables policy and proves prior enrollment evidence is refused. Earlier failures fixed native default mode(any; fixture now explicitall), base-vs-concrete policy-binding UUID comparison, and Authentik PATCH serializer requiring target/policy even for enabled-only changes. Unit fixtures now distinguish IDs. Safe literal failure categories added without provider bodies/causes. All handles terminal; no source edits during finalgate, no review restart/release.
Next: invitation form/group-role selection and admin intent creation before email; pair durable event evidence in either order; execute memberships through AuthentikAdmin under original immutable operator identity and CURRENT authority with audit-before-write and per-group persisted attempts. Flow validation/evidence/store alone still do not implement automatic grants. Full release goal active.
## Invitation creation and cancellation connected
Public INVITE_USER now accepts groupIds through existing paged group/QQQ-role editor when MIRROR+WEBHOOK+MEMBERSHIP configured. Plans/execution validate original operator live user/membership rights, active scoped immutable UUID, subset/protected-group rules, up to100groups and reason<=2048, protected flow. Configured flowUUID resolves server-side to exactslug with bounded complete pagination. Caller-supplied operator fields ignored. Normal admin audit begins before SQLintent; local-only system session is restored before provider writes. Intent is created before remoteinvitation, reference/expiry put into fixeddata/body, providerID attached beforeemail. Attach failure explicitly reports invitationcreated/emailnot sent, without exposing inviteID. CANCEL_INVITATION cancels localpending intent even after providerconsumption/expiry, refusesinflight; no completedgrants removed.
New InvitationCreationSqlTest uses H2 host SQL plus established fakeprovider/memory audit; covers ordering, forgedoperatorparams, previewdrift, audit/storage/attachmentfailure, contextrestoration, revokedliveauthority, cancelafterconsumption/expiry andpickerflags. Existing H2/PG storetests now cover findByInvitation scope and expiredcancel. Native enrollment contract validates flowUUID resolution. Final release-invitation-create-integration.log passes438units +44integrations(42PG+2native Authentik), zero failures/errors/skips, coverage; session13981 terminal0 after9m14. Focused59tests also passed release-invitation-create-unit.log. No sourcechanges duringfinalgate; allhandles terminal. No reviewrestart/deployment/release.
NEXT: eventworker is still NOT connected to stored intent or actual grants. Implement pairing both deliveryorders via authoritativeeventdetails, one-time identity binding, and AuthentikAdmin audited per-group grants under original operator immutableidentity/currentrights. Pending/ambiguous attempts must not replay intentional removals; cancellation races must respectstate. Native public invitation/email/fullmembership +renderedUI acceptance remain. Also identified legacy no-group flowUuid-null path omits flow: add regression/refuse missingconfiguredflow before release. Current review runtime remains older; do not claim newinvitationworkflow running there. Full1.0 goal active.
## Invitation event pairing verified
Consumption and enrollment can now be persisted independently in either delivery order. First verified facts are immutable; the combined bind API uses the same transitions and cannot bypass a saved identity. Rejected pairs roll back both mutations, invalid chronology becomes REVIEW, and duplicate completed events cannot reset APPLIED progress. H2/PostgreSQL regressions cover both orders, restart, conflicting identity, rollback and replay. Missing configured invitation flow now refuses creation, including execute-time drift.
Gate target/release-invitation-pairing-postgres.log passed 441 units +37 AuthentikMirrorPostgresIT tests, zero failures/errors/skips and coverage; session78862 terminal0. Initial restricted run had nine localhost fixture errors (no assertion failures); rerun used approved localhost/Docker access. No source changes during final gate. No review restart, commit or release.
NEXT: add connection-aware event fact persistence inside inbox.withClaim (same SQL transaction, no nested independent commit); connect authoritative event lookup/verification to saved intent; implement audited membership execution under original immutable operator identity and CURRENT authority. Keep system-session write prohibition. Add simultaneous event delivery/lease-loss/cancellation/expiry coverage and native full signup-to-membership acceptance. Overall 1.0 goal remains active.
## Invitation inbox evidence worker connected
AuthentikInvitationEvents now handles invitation_used/user_write from the production AuthentikApplyChangeEvents.forHost path. It re-fetches authoritative event details, checks event ID/action, looks up only saved intent, uses the existing flow/time/reference verifier, and binds enrollment only to a live active scoped immutable user UUID. Envelope username/timestamp are not assignment authority. Consumption skips mirror refresh because it precedes account creation; enrollment retains existing mirror processing.
Store connection-aware transitions join inbox.withClaim's SQL transaction; no independent nested commit, no network work under lock. Tests prove rollback invisibility, reclaimed lease rejection, lease loss during HTTP reads, mismatched event identity/action/flow rejection, concurrent independent claims preserving both facts, and public batch pairing in either order without provider writes.
Final target/release-invitation-worker-postgres.log passes445units +41AuthentikMirrorPostgresIT tests, zero failures/errors/skips and coverage; session70988 terminal0. Focused02 passes2 tests; first focused run exposed a fixture returning a user for a different username, fixed to return an empty server-filtered result. Production scope validation retained. No Java edits during finalgate, no review restart/commit/release.
NEXT: implement AuthentikAdmin-controlled deferred grant execution from saved READY intent. Each group needs original operator pk+UUID and CURRENT live permission validation, active scoped target UUID check, flow protection, normal guard/protected/self/subset/audit-before-write, durable begin/finish and no replay after ambiguous outcomes. Do not weaken system-session write prohibition or expose generic impersonation. operator() currently resolves ordinary sessions by email; deferred path must be pinned to stored immutable identity, not reuse a later email owner. Add current-authority revocation, target/operator replacement, cancellation/expiry/lease loss and provider-write failure tests; then full native signup/email/membership and rendered UI acceptance. Automatic memberships are still not executed by this worker. Full1.0 remains active.
## Deferred invitation membership execution connected
AuthentikAdmin.applyInvitationAssignments accepts only the internal worker plus saved reference/claim. READY intent reserves each group attempt in the inbox ownership transaction. A private delegated admin pins original operator pk+UUID, reads active scoped identities uncached, derives current group/static/host permissions, requires both USERS_MANAGE and MEMBERSHIPS_MANAGE, verifies protected enrollment flow, and creates a short-lived human QSession only for normal ADD_MEMBER execution. No caller-supplied identity/action/grants enter this path. Original worker context is always restored; guard's system-session write prohibition remains unchanged. Revalidation immediately before provider write retains protected/self/subset checks and original current authority. No-change counts as settled. Errors mark REVIEW; failed outcome storage leaves IN_FLIGHT. Tickets cannot be replayed after completion/ambiguity/manual removal. Read-only or disabled infrastructure suspends new attempts without writing provider.
AuthentikInvitationEvents now calls that writer after successful fenced evidence commit. FakeAuthentik now respects server-side UUID filters (needed for production syncUser reads).
New SQL/admin tests cover audit RUNNING and IN_FLIGHT before HTTP, human attribution/context restoration, original/target UUID replacement or inactivity, revoked rights before and after preparing delegation, disabled flow, protected target/superuser/escalated group, internal-caller requirement, missing audit, read-only/cancel/lost ownership, ambiguous remote side effect and no replay after manual removal. A public invitation -> SQL inbox -> authoritative event/flow verification -> admin grant test passes with only provider HTTP simulated. Existing native enrollment tests remain separate.
Final target/release-invitation-grants-postgres.log:460units +41AuthentikMirrorPostgresIT tests, zero failures/errors/skips and coverage; session85250 terminal0. Focused56 tests passed earlier. No Java changes during finalgate. No review restart/commit/release.
NEXT: full native SQL-host invitation/email/signup/inbox/audited membership acceptance and rendered UI. Reuse or extend InvitationEnrollmentContractIT protected flow; public INVITE_USER fixed_data has email/name/reference but no username, so native flow must prompt for username. SessionSqlHost provides H2 sync tables and QQQ RECORD audit; admin action tables need configured backend as in QbitWriteContractIT. Grant only needed temporary provider permissions through stack.grantTemporaryPermissions and restore them. Add partial multi-group failure, expiry/cancellation races and native changed/revoked original authority cases. Current live review still predates changes. Broader mirror views, browser/deployed-node auth, scale, staging/rollback and release supply-chain gates remain open. Full1.0 goal active.
## Native invitation/email/signup/membership verified; pause recovery fixed
InvitationEnrollmentContractIT saved-reference case now creates its invitation through public AuthentikAdmin using non-superuser service bearer; temporary grants limited to invitation read/create and group change/add-member, restored in finally. SessionSqlHost optional includeAdminTables migrates administrative audit/approval tables to real H2 SQL as well as sync storage. Native flow prompts username (public fixed_data does not provide one) and forged correlation; protected policy restores server reference. Mailpit recipient delivery verified. Native user_write then invitation_used delivered to production forHost inbox processor; actual group membership and SQL ADD_MEMBER SUCCEEDED audit asserted. No mocked provider HTTP in that contract. Existing no-reference fabrication rejection and disabled-flow rejection retained.
Gate release-invitation-native-grants-03.log passed460units +2native contracts and coverage; session15316 terminal0 after8m19. Initial01 compile failure fixed effectively-final reference;02 bootstrapped cleanly but native host config lacked required recoveryEmailStage, fixed using standard kof22-recovery-email. Final03 bootstrap unattended; no manual repair. This validates HTTP flow executor and manual delivery of native event hints, not rendered browser acceptance or native notification transport to ingress.
Then corrected read-only/membership-feature pause: once intent READY, worker returns deferred instead of acknowledging its event, retaining trigger without spending retries. Both pause modes resume same saved work after enablement. Full unit/PostgreSQL gate release-invitation-pause-postgres.log passed462units +41PG contracts and coverage; session16453 terminal0. Native gate precedes this small pause-branch change; final full unchanged-source contracts remain a release gate. All processes terminal; no review restart, commit or release.
NEXT USER-FACING STEP: update local review host and validate UI. Existing AuthentikReviewServer still memory-backed, with placeholder enrollment and no MIRROR/WEBHOOK. Need real host SQL/producer registration, protected username/enrollment flow, service read permissions, and event delivery before advertising automatic invitation assignment in the review instance. Preserve unique process/container ownership; never rebuild Java during live tests. Review UI18300 still older backend. Keep native notification ingress/real browser acceptance, partial multi-group failures/cancellation/expiry races, missing-event recovery, mirror tree/access/health views, scale/deployed auth/host rollback/release supply-chain gates open. Full1.0 goal active.
## SQL review host and strict-route startup verified (2026-11-05)
Previous status-only turn made no implementation progress; resumed from current files. Added ReviewSqlDatabase using actual local/sync migrations and standard QQQ audits; review now registers MIRROR/WEBHOOK before producer, protected invitation enrollment, native Quartz jobs, initial baseline and native notification mappings/rule. Runtime-only generated webhook secret passed to owned Authentik containers. SQL initialization succeeded at real application startup.
Full host startup exposed QQQ strict UI API rejection of raw /qqq/v1/authentik/webhook. Followed core UI-API-EXTENSIONS contract for machine-to-machine routes: moved to /qqq/hooks/{prefix}/webhook (empty prefix /qqq/hooks/webhook), updated bootstrap/design/operator docs and native contract expectation. Regression checks actual strict route auditor, namespace and secret isolation. 3 focused tests pass; full review-hooks-unit-gate.log passes462 units/coverage, integration tests explicitly skipped. No source changes during test JVM runs.
First review attempt177368 uses unique owner qbit-authentik-review-4b2aa234 (earlier failed14444/8d0ec6af cleaned exactly). Current UI http://127.0.0.1:18300/app/authentikAdmin, Authentik43617, Mailpit43599. Same frontend JAR/hash as previous receipt. Review UI smoke passes in review-sql-ui-smoke.log. Testcontainers SSH forwarding disconnected during startup (no remote listener); native Docker Desktop host.docker.internal reached loopback with200. Updated only review transport live, confirmed actual Authentik user update -> native notification -> durable SQL inbox SUCCEEDED -> mirrored name change and restoration. review-native-webhook-check.log records10successful receipts (includes retries), not a latency/scale claim. Removed only exact obsolete SSH helper after session/image ownership checks. Launcher source now uses native gateway without helper; final test-compile passes review-native-gateway-compile.log. Running JVM started with former helper setup but transport is live-corrected and helper removed; next clean launch must confirm final startup code end-to-end.
All build/test handles terminal. Current review JVM177368 remains running, receipt start2026-11-05T10:01:03.8119935Z. No commit/push/release. Next: rendered saved permission/role/membership and invitation enrollment acceptance, clean final launcher start, changed blueprint SignInPolicyIT and full unchanged-source contract gate. Remaining1.0 views, realistic scale, interactive OAuth/deployed nodes, failure scenarios, staging migration/rollback and release supply-chain gates remain open. Do not mistake review readiness for production readiness.
## Rendered permission, role and invitation writes verified (2026-11-05)
Previous turn was progress (SQL review/native webhook acceptance). Current running instance177368/owner4b2aa234 stayed available throughout; no Java or frontend production changes, restart, commit or release in this turn.
Added scripts/Verify-Access-Writes.cjs: actual UI checkbox edit -> Review -> Submit; independently reads Authentik isolated direct-access group attributes and singleton membership; adds/removes Review Viewer through searchable Group picker; checks SQL EDIT_USER_PERMISSIONS/ADD_MEMBER/REMOVE_MEMBER success audits. Final script preserves initial direct read grant, refuses preexisting Viewer membership, restores syntheticuser. Final target/review-access-writes.log passes, session20129 terminal0. Earlier one-off direct-grant addition was cleaned through UI; role menu needed accessible menuitem selector, not exact text. No app defect in those selector timeouts.
Added scripts/Verify-Invitation-UI.cjs: unique synthetic email, Review Viewer checkbox, review summary, Submit, localMailpit, actual emailed link with localprovider-origin check, same fresh browser session through username Continue, native webhook and SQLCOMPLETE, independent provideruser/group, exactlyone deferred ADD_MEMBER audit. Final target/review-invitation-ui.log passes, session57348 terminal0. Synthetic enrollee review.invite.1793873997368 retained for userreview. Earlier successful manualenrollee and one consumed-but-abandoned invitation intent also remain in disposable stack. The inspection browser consumed an earlier invitation then closed before username submission; test now completes signup in one browser session. Documented need to assess operational handling of abandoned consumed intents, not silently delete unresolved evidence.
All script handles terminal. Review UI18300, Authentik43617,Mailpit43599 unchanged. Next: rendered group/role permission and parent-inheritance flows, two-person approval; clean final launcher startup (currentJVM had previous helperremoved/live transportpatched), changed SignInPolicyIT and fullunchanged-source gate, remaining mirror tree/access/health views, realOAuth/deployednodes, realistic scale, stagingmigration/rollback and releasereview/publication. 1.0 goal remainsactive and incomplete.
## Group inheritance and two-person UI acceptance (2026-11-05)
Previous turn made concrete UI-workflow progress. Verify-Group-Inheritance.cjs now passes role permission add/remove, parent-role add/remove, inherited permission/source display, provider attributes/parents and two successful audits for each action; synthetic groups restored. Log review-group-inheritance.log, session4574 terminal0.
Added fixed second synthetic identity selector to test-only AuthentikReviewServer.ReviewAuthentication; every other selector remains primary. Clean compiled launcher started without SSH helper or live transport patch, JVM118516/ownerqbit-authentik-review-bc6aa5a1/start2026-11-05T10:26:17.8669892Z; UI18300/Auth43115/Mail42823, same frontendJARhash. Old stack removed only viaownershipreceipt. All3blueprints successful and SQLbaseline/strictstartup passed.
Verify-Approval-UI.cjs passes request->PENDING/providerunchanged, selfapprovalrefusal, differentliveactor review.second approval, providergrant, SQLapprover audit, role restoration (review-approval-ui.log/session35630 terminal0). First harnessfailure expectedNext but approval startsatReview; second selectedcookie beforeanonymousUIbootstrap which replaced it. Final selects bothsessionId/sessionUUID afterrecordpageboot, beforewriteprocess. No productionauthentication/approvalguard changed. Two priorpendingtestrequests rejected via secondoperatorUI and statusesverified (review-approval-rejection.log).
Clean-final-launch native invitation rerun passes UI/email/browsersignup/nativewebhooks/COMPLETEintent/providerrole/SQLaudit: review-clean-launch-invitation-03.log, session28315 terminal0, retaineduserreview.invite.1793875277381. Earlier01/02 failedatresult assertion becauseasynchronousprocess took>5s; providercreated/emailedsuccessfully. Added explicitResultscreenwait usingexisting20sUIbound, businessassertionsunchanged; scriptsnowlogphaseonlywithoutone-timeURLs. Two unconsumed syntheticinvitations fromthoseattempts remain disposablefixtures.
FULL RELEASE GATE IS LIVE: exec session1568, target/release-full-after-ui.log, command CI=true TESTCONTAINERS_RYUK_DISABLED=true ./mvnw.cmd verify (noITskips). 462units passed, realAuthentikcontractstack61e1632d started; currentlyBootstrapBlueprintIT. Do NOT edit/recompileJava whilethis testJVMruns. Pollsamehandle; timeoutnottermination. No gatepassclaimyet. Reviewstackseparateandrunning. No commit/push/release. Next collectfullgateanddiagnoseonlyobservedfailures, thenremainingtree/accessmatrix/health/mirrorfallbackviews, realisticAPIscale, actualOAuth/deployednodes, stagingrollout/rollback/releasegates.
### Full-gate diagnostic update (same live run1568)
Contract bootstrap initially showedinfra/tokenblueprintERROR; read-only native taskAPI captured PostgreSQLdeadlock in authentik_flows_stage. No blueprintapply/retry/manualrepair invoked. Bootstrap later recovered onitsown and all5BootstrapBlueprintIT tests PASSED after426.5s. Do not change workerconcurrency based solelyonthis observation; exactnativeautorecoverymechanism stillunverified. Nativeworkerdefaults processes2/threads1; metaapplyblueprint directlyreimportsdependencies ratherthanlocking/serializingotherworker tasks. Preserve bootstrapstabilityrisk.
Confirmed distinctfailure: SchemaDriftIT.containerSchemaMatchesPinnedForUsedOperations throws PatternSyntaxException Illegal repetition for /flows/instances/{slug}/ at paramsline92. Its duplicatepathmatcher runs candidate.matches(surfacePath.replace("{key}",...)) evenforliteralnamedplaceholder; SchemaDriftTest.findPath avoids thiswhenexactpathpresent but sharesunquotedregexfallback. AFTER exec1568 isterminal: reuseonepublic findPath helper inIT, quote literalpathsegments and wildcardonly{key}, addregressions exactnamed/absentnamed/placeholderalias/literalregexcharacters/missingoperation. Do notweakenlive/pinnedparametercomparisons orskipoperation. Javaunchanged whilegatealive. RemainingcontractscurrentlyBlueprintReapplyIT; pollsamesession1568 tocollectallfailures.
## 2026-11-05 — token widget column mapping
- User-reported Tokens and app passwords failure traced to ReviewSqlDatabase overwriting explicit last_reminder_7d / last_reminder_1d backend names with generic snake-case names. Preserve explicit field mappings; convention applies only when unset.
- ReviewSqlDatabaseTest reproduces the actual TokenScopeStore.find query against published migrations: red with missing LAST_REMINDER7D before fix; green after fix. ReviewSqlDatabaseTest + TokenOperationsTest: 9 passed (target/review-token-regression-green.log).
- Full prior release gate finished: 462 units passed, 108 integrations with 3 errors: SchemaDriftIT literal placeholder regex, mirror PostgreSQL closed connection, webhook PostgreSQL I/O error. Remain release blockers; no 1.0 readiness claim.
- Review restart owner qbit-authentik-review-b28e1ef7, PID 81864; disposable prior review data reset. Browser widget acceptance pending below.
- Browser acceptance PASS: scripts/Verify-Token-Widgets.cjs sent a reset email to synthetic review.sam, then verified the real verification-token row and Owner permissions scope in Tokens and app passwords with no widget/query error (target/review-token-ui.log). Review is ready at http://127.0.0.1:18300/app/authentikAdmin. Authentik 42755, Mailpit 41919. Startup hit a blueprint PostgreSQL deadlock and recovered via the existing launcher retry; bootstrap stability remains a separate release risk.
## RC checkpoint — 2026-11-05
User explicitly approved committing, pushing and merging this work as an RC candidate. Latest backend verification: wrapper verify -DskipITs passed all 463 unit tests and JaCoCo (target/rc-unit-verify-unsandboxed.log). The initial restricted-sandbox run stalled and was stopped by exact owned fork ID before rerunning; no test code or gate was weakened. Prior full gate retains three integration errors documented above.
Companion UI signed feature commit119f1211 plus upstream mergeafb9d75c are pushed to primary Gitea, PR https://git.kof22.com/QRun-IO/qqq-frontend-next/pulls/205. Its 33 focused tests, typecheck, license hook and staged secret scan pass. Backend staged secret scan passes. Both default branches confirmed through SSH (backend develop, frontend main). API access requires authentication; tea has no login, no managed Gitea credential is available, and browser access host is unavailable. PR submission works with existing SSH AGit. Merges/RC publication still require normal authenticated forge access and required checks; no bypass or RC publication claimed.
+38 -3
View File
@@ -2,6 +2,25 @@
The Authentik qbit's progress through DESIGN.md section 11, with links. Updated with each PR.
## Local review update — 2026-11-04
- Refreshed from Gitea `develop` (`fe6f716`) on `review/local-latest`.
- Updated the QQQ BOM to 4.1.0-RC.21 and added a local Next UI 1.0.0-RC.37 host.
- `mvnw.cmd verify -DskipITs`: 321 tests passed, zero failures/errors/skips;
JaCoCo coverage gate passed.
- Real browser check: Identity dashboard, users, user details, Edit User Review
and Result all worked against disposable Authentik 2026.8.3; a synthetic user's
display name was successfully changed through the qbit.
- A clean full contract gate is still required: the separate test stack hit
first-boot blueprint errors. Manual reapplication is diagnostic assistance,
not evidence of an unattended green bootstrap.
- This is review-ready, not a 1.0 release sign-off. OAuth, personal token
exchange, two-operator approval and production rollout still need acceptance
in a fully configured host. Earlier owner checklist items below have not been
revalidated against production during this session.
Start/stop instructions and review limitations: [LOCAL-REVIEW.md](LOCAL-REVIEW.md).
## v1 = phases 0-3 (the full user lifecycle and group/permission management)
| Phase | State |
@@ -11,14 +30,14 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated
| 2. Memberships and permission editing | done, except the webhook route, ESB trigger, inbox and session re-evaluation marking |
| 3. Full user lifecycle | done, except the optional snapshot export |
| 4. RBAC role management | post-v1, not started |
| 5. Policies and full CRUD (DESIGN.md section 12) | spec approved 2026-10-13; 5a policy model, 5b editor, 5c CRUD additions in progress |
| 5. Policies and full CRUD (DESIGN.md section 12) | 5a policy model (#14), 5b policy editor (#15), 5c user and group CRUD additions; the Next UI widget is a separate qqq-frontend-next PR |
### Built in this line (0.1)
| Area | What |
|---|---|
| People | Create, invite, edit, deactivate, reactivate, password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
| Groups | Create, edit (parents), add and remove members (bulk), edit permissions and app access |
| People | Create, invite, edit (username, name, email, path, other attributes), deactivate, reactivate, delete (feature `DELETE`), password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
| Groups | Create, edit (parents, other attributes), delete (feature `DELETE`), add and remove members (bulk), edit permissions and app access, edit the group policy (feature `POLICIES`) |
| Safety | Subset rule on live groups, protected accounts and superuser groups, no self-edits, last-admin protection, reasons, typed confirmations, bulk caps, stale checks, two-person approval for `authentik.*` |
| Audit | `authentik_admin_action` (before the call), Authentik `custom_qqq_admin_action` events, approval requests |
| Screens | Home dashboard, main tables in the nav, child lists on parents, every action as a table or record action |
@@ -57,3 +76,19 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated
## Contract and unit tests (Authentik 2026.8.3)
`CI=true ./mvnw verify`. The numbers are filled in at the end of each PR; see the PR description.
The diagnostic full run was stopped during BlueprintReapplyIT after the assisted bootstrap (5 tests) and schema drift (1 test) passed. It is incomplete and is not a passing contract gate. The review JVM remains running independently.
## Application access editor — 2026-11-04
Implemented QQQ application roles (tagged Authentik groups), checkbox named-permission/sign-in access editing, server-searched assignment selectors, direct user grants isolated in single-member groups, and inherited grant provenance. Generic group operations cannot repurpose direct-user access groups. All writes retain the live guard, audit, stale-state and approval paths.
Validation: 330 offline backend tests passed with the JaCoCo gate; 46 frontend access/process widget tests passed; typecheck and changed-file lint passed. Real-Authentik smoke testing caught and fixed the default picker OR-filter incompatibility and reopening after the last direct grant is removed. This is review-ready development work, not a 1.0 release acceptance claim: the complete contract suite and fresh OAuth sign-in acceptance remain outstanding.
Review instructions and scoped start/stop commands: `docs/LOCAL-REVIEW.md`. The new UI source is in `C:/Users/james/.cache/qqq-authentik-access-ui`; it is not in the published frontend JAR yet. No changes have been committed or published.
Final live result: `target/access-smoke.log` passes role creation, group inheritance, searchable selections, membership add/remove, direct grant save/revoke/reopen/restore and inherited access retention. The frontend proxy and group permission/sign-in checkbox payload were verified, and the review UI returns HTTP 200. `review.sam` is seeded with both direct and inherited application grants for manual review.
### Catalog scaling verification (2026-11-04)
Direct grants now use guarded 25-resource catalog pages with application/type/search filters and page-scoped bulk editing. Complete direct selections remain independent of page requests; unknown and off-page grants are retained. Generated 1,000-table functional coverage passes. Backend verify: 331 tests and coverage gate; frontend: 47 tests, typecheck and lint. Live frontend proxy serves the new catalog. This is not a production concurrency benchmark or 1.0 release acceptance.
+67
View File
@@ -0,0 +1,67 @@
# Host synchronization integration
`MIRROR` and `WEBHOOK` are opt-in features. The QBit producer now registers their
metadata, jobs and routes. The updated local review host enables both with SQL
storage; see [local review](LOCAL-REVIEW.md) for its acceptance limitations.
Production rollout still requires the staging, scale, browser-session and
rollback checks in [the release checklist](RELEASE-1.0.md).
Before producing the QBit, the host must register its local RDBMS backend and a
cron-capable scheduler. Set `localBackendName` and, when more than one scheduler
exists, `schedulerName`. Enable `READ` and `MIRROR`; add `WEBHOOK` and resolve
`webhookSecret` from the host secret environment to accept events. Missing
dependencies or conflicting mirror table/process names fail production of the
metadata. These features do not require enabling upstream administrative writes.
The host owns migrations: apply [schema-sync.sql](schema-sync.sql), adapting table
names for the configured prefix, and the existing [local schema](schema.sql).
Register standard QQQ audit tables and possible-value sources. Enrich local
tables with the host backend's physical table details before validating the
instance. Mirror field metadata already supplies explicit column names. The
producer makes no database or Authentik calls and does not create SQL tables.
H2 and PostgreSQL have executable integration coverage; other database vendors
have not been verified for synchronization.
The producer adds all seven synchronization tables: users, groups, memberships,
inbox, checkpoint/lease state, durable session bindings and invitation assignment
intent. Generic writes remain
disabled. It registers incremental reconciliation every five minutes, full
reconciliation at 02:00 UTC, daily retention and, with `WEBHOOK`, inbox polling
every two seconds. The host starts its normal QQQ scheduler. Jobs require the
internal system session and use database ownership to serialize publishers.
`WEBHOOK` also adds a native QJavalin route-provider reference. The host's normal
QJavalin server loads it. Configure Authentik to POST with the
`X-QQQ-Webhook-Secret` header to:
| Table/component prefix | Webhook path |
|---|---|
| `authentik` (default) | `/qqq/hooks/authentik/webhook` |
| `workforce` | `/qqq/hooks/workforce/webhook` |
| Empty | `/qqq/hooks/webhook` |
These are machine-to-machine routes, outside the versioned UI API as required
by QQQ's UI API extension contract. Earlier development configuration used
`/qqq/v1/.../webhook`; update notification transports to the paths above. No
legacy alias is registered: it would fail QQQ's strict UI route audit.
Each provider is bound to its QBit API backend; credentials and feature state are
resolved for every request. Multiple QBits also need distinct producer namespaces
and component prefixes. Existing host route definitions are preserved. Do not
register another handler on a QBit webhook path. Requests authenticate before
body parsing and acknowledge only committed inbox receipts. Polling recovers
failed delivery notifications.
Without `esbDestinationName`, committed events are processed inline. With it,
register the destination and provider in the host's ESB metadata before producing
the QBit, include the optional ESB dependency, and start the normal host ESB
runtime. Notifications contain no event payload; SQL inbox processing remains
authoritative and polling stays enabled.
Registration does not change the host's authentication module. Configure the
[refreshing OAuth adapter and immutable identity mapping](SESSION-IDENTITY.md)
separately after a successful baseline. Its fifteen-minute freshness ceiling is
unchanged: prolonged synchronization failure denies access rather than restoring
old token grants. The refreshing mapper's bounded directory-outage fallback is
verified on H2 and PostgreSQL. Widget outage behavior, invitation-used assignments,
operational views and production-scale acceptance remain release work.
+91
View File
@@ -0,0 +1,91 @@
# Remaining release acceptance
- [x] Add server-owned invitation correlation policy and prove native prompt forgery cannot replace or fabricate the reference (426 units +2 real Authentik contracts/coverage, release-invitation-policy-live-04.log).
- [x] Implement uncached guarded invitation-flow validation in the enrollment-evidence path; verify native service-account reads and rejection after policy disablement (429 units +2 real Authentik contracts/coverage, release-invitation-flow-live-04.log).
- [x] Connect the existing group/QQQ-role picker and public invitation action to validated flow and durable intent-before-email; cancel pending intent after consumption/expiry.438units +44selected integrations/coverage pass (release-invitation-create-integration.log).
- [ ] Connect durable event pairing and deferred audited assignments under the original immutable operator's current authority; validate again before grants. Complete actual public invitation/email/signup/membership and rendered UI acceptance, then deploy and verify host role/policy configuration.
- [ ] Verify and close the legacy no-group invitation path's behavior when a configured flow slug cannot resolve: current flowUuid can return null and the body omits flow. Do not permit an unintentionally unbound invitation.
- [x] Implement guarded authoritative invitation event verification and test malformed/mismatched/native-vs-custom/edited/impersonated cases.423units +real native enrollment/service-account verification and coverage pass (release-invitation-evidence-live.log).
- [x] Persist invitation operator/group intent, one-time enrollment binding and serialized grant attempts; verify expiry completion and scope-bound settled retention on H2/PostgreSQL (426 units +42 PostgreSQL integrations and coverage, release-invitation-retention-postgres.log).
- [ ] Connect persisted invitation intent and event verification to UI and audited assignments; prevent copied references and replay from granting access through full native enrollment acceptance.
- [x] Verify native invitation/enrollment ordering and event correlation: real2026.8.3 consumes/deletes invite before creating user; later user_write carries created-user pk and fixed data.419units +provider contract/coverage pass (release-invitation-enrollment-live-03.log).
- [ ] Implement and verify automatic invitation group/QQQ-role assignments per INVITATION-ASSIGNMENTS.md; correlation proof alone must not authorize writes.
- [x] Verify refreshing OAuth mapper's bounded directory API outage fallback on H2/PostgreSQL: failed scan preserves watermark; no API calls for fresh mirror resumes; revocations/inactive users/stale snapshots deny.419units +39PG/coverage pass (release-mirror-outage-postgres.log). Legacy mapper migration, widget fallback and deployed-provider acceptance remain separate.
- [x] Reproduce and resolve sync cadence/freshness collision: five-minute schedule retains fifteen-minute permission freshness. SQL regression covers a four-minute scan, one skipped tick, and stale rejection; 414 units +37 PostgreSQL contracts/coverage pass (release-sync-freshness-postgres.log). Actual API throughput, jitter and prolonged contention remain scale gates.
- [x] Run complete Authentik contracts on unchanged current source: release-full-contracts-03.log passes 376 units, 76 integration tests, zero skips and coverage. Repeat for final release source after remaining implementations.
- [ ] Verify grant and revoke behavior through a fresh OAuth sign-in with the production host mapper configuration (the local review host logs in its operator automatically).
- [ ] User review of the new permissions/assignment UI.
- [ ] Package the frontend change and complete the coordinated backend/frontend release when authorized.
Implementation is recorded in SESSION-STATE.md. The user authorized the full production release goal; do not call it complete without the acceptance evidence in RELEASE-1.0.md. Deployment destination remains to be confirmed.
- [x] Add direct-grant resource matrix, server-side catalog paging/search, scoped bulk controls and 1,000-table functional coverage.
- [ ] Benchmark production-like group depth, membership counts, latency and concurrent permission edits; the 1,000-table test does not cover these.
- [x] Verify 1,000-level hierarchy with 1,000 users and concurrent snapshot readers, cycle safety and refreshed revocations.
- [x] Add sync storage schema and authenticated, bounded webhook envelope; verify SQL constraints and exclusion of arbitrary context.
- [x] Implement durable inbox deduplication, leased claims, retries, retention and atomic fenced local writes; verify the same seven race/recovery scenarios on H2 and PostgreSQL.
- [ ] Connect durable inbox, retries, reconcile scheduling, mirror projection/fallback and session invalidation; then enable MIRROR/WEBHOOK with real PostgreSQL and Authentik acceptance.
- [x] Add uncached complete sync reads, reject incomplete/changing pagination, and verify watermark filtering and membership projection against live Authentik.
- [x] Add allowlisted mirror projection and QQQ-action writer with RECORD audit, stable IDs and idempotent replay tests.
- [x] Verify mirror/audit commit and rollback through QQQ's H2 and PostgreSQL backends; handle unique username/email swaps without changing IDs or exposing intermediate values.
- [x] Recheck candidate deletions against live Authentik before committing a full sweep, then connect the mirror writer to the lease-protected full-refresh worker; H2 and PostgreSQL acceptance pass.
- [x] Add and unit-test uncached deletion/scope-exit preflight; existing users are checked with a server-scoped immutable UUID query.
- [x] Add incremental reconciliation with retained profiles, group-member discovery, scope-change full baselines and fresh source-superuser checks; verify on H2/PostgreSQL.
- [ ] Add metrics, scheduling/ingress and persistent session reevaluation before feature enablement; complete mirror fallback and end-to-end acceptance.
- [x] Verify nonzero MFA and rolling failed-login metrics against live Authentik, including unconfirmed devices, old events and other users.
- [x] Add uncached, immutable-ID mirror authorization reads with freshness/scope checks and transaction isolation; verify on H2 and non-UTC PostgreSQL.
- [ ] Bind authenticated sessions persistently to immutable Authentik identities and connect request-time reevaluation without restoring old token or session grants. The mirror reader alone does not implement this.
- [x] Implement durable write-once session binding storage, cross-node lookup, revocation tombstones, expiry and retention; verify concurrent creators on H2 and PostgreSQL. Authentication wiring remains pending.
- [x] Add and verify signed immutable-user identity claim against real Authentik tokens, including username/email changes and omission without profile scope. Production mapping deployment and adapter remain pending.
- [x] Rebuild the corrected frontend artifact and smoke-test it through QQQ's actual static JAR serving path.
- [x] Renew directory and webhook leases around each sync HTTP request; verify cancellation discards partial results and old workers cannot publish (373 units and 18 PostgreSQL contracts plus coverage).
- [x] Verify current-grant replacement mapper with same-session revocation, host fallback, immutable-identity rejection and credential narrowing (389-unit/coverage gate). OAuth request-path wiring remains open.
- [x] Implement opt-in OAuth binding/refresh adapter and seven protocol tests (396-unit/coverage gate). Real OAuth plus SQL cross-node revocation and callback/refresh acceptance remain open.
- [x] Exercise actual native JWT verification, same-token revocation across separate module instances, service identity and logout through H2/PostgreSQL (397 units +19 PostgreSQL contracts). Real Authentik issuance and separate deployed-node/interactive acceptance remain open.
- [x] Verify actual Authentik-issued token, provider discovery/signature, live group revocation and production reconciliation with SQL session binding (397units + focused live contract/coverage). Interactive cookie/refresh and independently deployed nodes remain open.
- [x] Verify native authorization-code exchange, cookie resume, encrypted refresh-token HTTP exchange and logout through H2/PostgreSQL (398units +20PG contracts). Actual Authentik browser flow remains open.
- [x] Implement bounded authenticated HTTP inbox adapter using host connection provider; real H2/PostgreSQL HTTP contracts pass (399units +28PG/coverage). Registration remains disabled pending handler/ESB/scheduler integration.
- [ ] Resolve/stability-verify intermittent PostgreSQL renewal SQLException from release-webhook-http-postgres.log; isolated and full reruns pass but original root cause unknown. Safe SQL-state diagnostics added.
- [x] Connect targeted live-state event handler to host inbox/worker; verify stale-delete safety, group grant/revoke, idempotent replay, bounded metric reads and preserved baseline watermark (402 units +30 PostgreSQL contracts/coverage).
- [ ] Diagnose intermittent PostgreSQL connection reset during identity-swap test setup in release-targeted-events-postgres.log; unchanged-source rerun passes, cause unknown.
- [x] Define native guarded sync processes for2s inbox/15min reconcile/nightly full/retention and verify execution through QQQ on H2/PostgreSQL; registration and actual scheduler runtime acceptance pending.
- [x] Defer directory-lease contention without exhausting event failure attempts; repeated contention then recovery passes on H2/PostgreSQL (404units +32PG/coverage).
- [ ] Investigate shared-host socket allocation exhaustion observed in release-sync-process-postgres.log; no global settings changed. Do not conflate with earlier reset/renewal exceptions without evidence.
- [x] Connect post-commit HTTP delivery to inline processing or optional ESB; verify real embedded broker/native asynchronous trigger, real Quartz recovery, idempotence and resulting grant/revoke on H2/PostgreSQL (406units +34PG full gate; strengthened2+2focused checks).
- [x] Register opt-in sync metadata/processes/routes with host database, scheduler/destination and namespace validation. Native route loader, isolated HTTP secrets and producer-to-SQL/inbox/grant publication verified;418units +38PG/coverage pass (release-sync-registration-postgres.log).
- [ ] Verify minimal host classpath combinations without optional RDBMS/Javalin/ESB dependencies, full application server startup and staging migration/rollback before production enablement.
- [x] Anchor bearer deadlines to signed iat and cap session lifetime at30days; verify invalid age, tombstone-purge replay rejection, cookie refresh and actual Authentik tokens (409units +35PG +1live contract/coverage).
- [ ] Align verified OAuth issuer with the configured Authentik directory/public URL before production enablement; retain public/internal URL support.
- [x] Verify durable invitation event pairing in either order, restart, conflicting identity/rollback and completed replay on H2/PostgreSQL (441 units +37 PG/coverage).
- [ ] Connect invitation evidence transitions to inbox ownership transaction and execute original-operator-authorized audited grants; verify lease loss, concurrent delivery and native full workflow.
- [x] Connect authoritative invitation evidence to inbox transaction; verify concurrent events, rollback, lost lease during API reads and both delivery orders on H2/PostgreSQL (445 units +41 PG/coverage).
- [ ] Execute READY invitation grants through AuthentikAdmin with immutable original-operator identity/current rights and durable attempts, then verify full native/signup/UI workflow.
- [x] Connect READY invitation grants to AuthentikAdmin with current immutable actor/target checks, normal guardrails, audit-before-write and durable no-replay attempts (460 units +41 PG/coverage).
- [ ] Verify public invitation/email/native signup/inbox/audited membership on SQL host; add partial multi-group failures and native authority changes, then rendered UI acceptance.
- [x] Verify native public invitation, Mailpit delivery, protected username signup, reversed native-event delivery, actual membership and SQL administrative audit (460 units +2 native/coverage).
- [x] Preserve/resume READY work across read-only and membership-feature pauses without spending failure retries (462 units +41 PG/coverage).
- [ ] Update local review to SQL/protected enrollment/event delivery, then render and exercise assignment UI; native webhook transport and remaining failure cases still open.
- [x] SQL-backed review host, protected enrollment configuration, strict QQQ startup, rendered permission draft smoke and actual native notification-to-inbox-to-mirror delivery verified (2026-11-05). Final launcher clean-start and saved assignment/invitation browser acceptance remain pending.
- [x] Move machine-to-machine webhook to /qqq/hooks/{prefix}/webhook per QQQ contract; namespace/authentication/strict-auditor regression and462-unit coverage gate pass. Changed blueprint contract/full IT rerun still required.
- [x] Rendered direct-permission and role membership add/remove, actual Authentik state and SQL audits verified; repeatable Verify-Access-Writes.cjs added.
- [x] Rendered Invite User with selected Review Viewer, local email, browser username signup, native webhook processing, COMPLETE SQL intent, provider membership and exactly one successful deferred audit verified; Verify-Invitation-UI.cjs added.
- [ ] Complete rendered group/role permission editing, parent-role inheritance and two-person approval acceptance; review handling of consumed but abandoned invitation intents.
- [x] Rendered role permission edit/removal, group parent-role inheritance/removal and inherited user source labels, actual provider state and SQL audits verified (Verify-Group-Inheritance.cjs).
- [x] Two distinct synthetic review operators: pending leaves provider unchanged, self-approval refused, second approval applies, audit identifies approver, role restored; rejection also verified (Verify-Approval-UI.cjs).
- [x] Final native-gateway launcher clean startup and fresh full invitation-browser/native-webhook assignment passed (review-clean-launch-invitation-03.log). Real OAuth login remains separate.
- [ ] Collect full unchanged-Java-source verify gate target/release-full-after-ui.log, exec1568;462units passed, contracts running. Never rebuild Java until terminal.
- [x] Fix local review token widget SQL mappings; preserve explicitly named reminder columns. Regression reproduces missing-column error; 9 focused tests pass.
- [ ] Resolve final full-suite failures: SchemaDriftIT placeholder regex and PostgreSQL connection errors in mirror/inbox tests (release-full-after-ui.log, 108 integrations / 3 errors).
- [x] Verify repaired Tokens and app passwords in real browser with an actual synthetic password-reset token and rendered scope. Review instance restored on port 18300.
+117
View File
@@ -0,0 +1,117 @@
-- Additive PostgreSQL migration for DESIGN sections 6.2 and 7.
-- Apply to the host's local database, NEVER Authentik's database.
-- Replace authentik_ with the configured table prefix when applicable.
-- Storage foundation only: MIRROR and WEBHOOK remain disabled until the
-- reconciler, durable processing, and session invalidation gates are complete.
CREATE TABLE authentik_user_mirror
(
id SERIAL PRIMARY KEY,
pk INTEGER NOT NULL UNIQUE,
uuid VARCHAR(64),
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) UNIQUE,
name VARCHAR(255),
type VARCHAR(40),
path VARCHAR(1024),
source_is_superuser BOOLEAN,
source_is_protected BOOLEAN,
is_active BOOLEAN,
last_login TIMESTAMP,
is_superuser BOOLEAN,
is_protected BOOLEAN,
group_names TEXT,
effective_permissions TEXT,
mfa_device_count INTEGER,
failed_logins24h INTEGER,
refreshed_at TIMESTAMP NOT NULL
);
-- Blank email must be normalized to NULL. Ambiguous nonblank emails must
-- stop synchronization and cannot be used for permission fallback.
-- QQQ DATE_TIME binds UTC LocalDateTime values. Mirror DATE_TIME columns use
-- timezone-free TIMESTAMP containing UTC, so a non-UTC database session cannot
-- reinterpret them. JDBC-owned inbox/state clocks below use zoned instants.
CREATE TABLE authentik_group_mirror
(
id SERIAL PRIMARY KEY,
pk VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255),
parent_pks TEXT,
attributes TEXT,
member_count INTEGER,
is_superuser BOOLEAN,
refreshed_at TIMESTAMP NOT NULL
);
CREATE TABLE authentik_membership_mirror
(
id SERIAL PRIMARY KEY,
user_pk INTEGER NOT NULL,
group_pk VARCHAR(64) NOT NULL,
group_name VARCHAR(255),
refreshed_at TIMESTAMP NOT NULL,
UNIQUE (user_pk, group_pk)
);
CREATE INDEX idx_authentik_membership_mirror_group ON authentik_membership_mirror (group_pk);
CREATE TABLE authentik_webhook_inbox
(
id SERIAL PRIMARY KEY,
event_pk VARCHAR(64) NOT NULL UNIQUE,
payload_json TEXT NOT NULL,
status VARCHAR(30) NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
received_at TIMESTAMP WITH TIME ZONE NOT NULL,
next_attempt_at TIMESTAMP WITH TIME ZONE,
processed_at TIMESTAMP WITH TIME ZONE,
lease_owner VARCHAR(64),
lease_until TIMESTAMP WITH TIME ZONE,
error_code VARCHAR(100)
);
CREATE INDEX idx_authentik_webhook_inbox_pending ON authentik_webhook_inbox (status, next_attempt_at);
CREATE INDEX idx_authentik_webhook_inbox_retention ON authentik_webhook_inbox (received_at);
CREATE TABLE authentik_sync_state
(
id SERIAL PRIMARY KEY,
sync_key VARCHAR(64) NOT NULL UNIQUE,
watermark TIMESTAMP WITH TIME ZONE,
last_full_sync TIMESTAMP WITH TIME ZONE,
scope_fingerprint VARCHAR(64),
lease_owner VARCHAR(64),
lease_until TIMESTAMP WITH TIME ZONE
);
-- Authentication adapter-owned immutable identity binding. Store only the hash
-- of the QQQ session UUID, never its bearer credential or access/refresh tokens.
CREATE TABLE authentik_session_binding
(
id SERIAL PRIMARY KEY,
session_hash VARCHAR(64) NOT NULL UNIQUE,
issuer_fingerprint VARCHAR(64) NOT NULL,
user_pk INTEGER,
user_uuid VARCHAR(64),
user_reference VARCHAR(1024),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
CHECK (revoked OR (user_pk IS NOT NULL AND user_pk > 0 AND user_uuid IS NOT NULL AND user_reference IS NOT NULL))
);
CREATE INDEX idx_authentik_session_binding_expiry ON authentik_session_binding (expires_at);
-- Internal, immutable invitation intent plus one-time binding/assignment progress.
-- Payload contains operator identity, reason and group IDs, never invitation URLs,
-- passwords, API tokens or email delivery credentials.
CREATE TABLE authentik_invitation_assignment
(
id SERIAL PRIMARY KEY,
reference VARCHAR(36) NOT NULL UNIQUE,
scope_fingerprint VARCHAR(64) NOT NULL,
invitation_id VARCHAR(36) UNIQUE,
status VARCHAR(20) NOT NULL,
payload_json TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
CHECK (status IN ('WAITING', 'READY', 'COMPLETE', 'REVIEW', 'CANCELLED'))
);
CREATE INDEX idx_authentik_invitation_assignment_expiry ON authentik_invitation_assignment (expires_at);
+42
View File
@@ -49,3 +49,45 @@ CREATE TABLE authentik_approval_request
);
CREATE INDEX idx_authentik_approval_request_status ON authentik_approval_request (status);
-- authentik_policy (DESIGN.md section 12): the QQQ record of each group's last policy save.
-- Authentik holds the live document (group attribute qqq.io/policy); field-level QQQ audit on
-- this table is the policy's change history.
CREATE TABLE authentik_policy
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
group_pk VARCHAR(64) NOT NULL,
group_name VARCHAR(255),
policy_json TEXT,
statement_count INTEGER,
permission_count INTEGER,
saved_by VARCHAR(255),
saved_date TIMESTAMP WITH TIME ZONE,
authentik_fingerprint VARCHAR(64),
CONSTRAINT uk_authentik_policy_group_pk UNIQUE (group_pk)
);
-- Token metadata only (DESIGN section 13.5). No key or key hash is stored.
CREATE TABLE authentik_token_scope
(
id SERIAL PRIMARY KEY,
token_identifier VARCHAR(255) NOT NULL,
owner_username VARCHAR(255),
owner_pk INTEGER,
read_only BOOLEAN,
apps TEXT,
tables TEXT,
processes TEXT,
reports TEXT,
widgets TEXT,
operation_ids TEXT,
created_by VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE,
rotated_at TIMESTAMP WITH TIME ZONE,
last_reminder_7d TIMESTAMP WITH TIME ZONE,
last_reminder_1d TIMESTAMP WITH TIME ZONE,
CONSTRAINT uk_authentik_token_scope_identifier UNIQUE (token_identifier)
);
+43 -16
View File
@@ -18,6 +18,9 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -36,7 +39,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -76,6 +79,17 @@
</scm>
<dependencies>
<dependency>
<groupId>org.apache.artemis</groupId>
<artifactId>artemis-jakarta-server</artifactId>
<version>2.57.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-esb</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
@@ -88,7 +102,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
@@ -96,6 +110,11 @@
<version>2.2.220</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-postgres</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -131,6 +150,24 @@
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.14</version>
<scope>test</scope>
</dependency>
<!-- Optional webhook adapter; hosts that expose it supply the middleware. -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.38</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -139,6 +176,7 @@
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-authentik.properties</include>
</includes>
</resource>
@@ -146,6 +184,7 @@
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-authentik.properties</exclude>
</excludes>
</resource>
@@ -237,7 +276,7 @@
<profiles>
<!-- Needs qqq 4.1 (QBit producer API). Until qbit-build-parent imports the -->
<!-- released 4.1 BOM, this profile is active by default and imports -->
<!-- qqq-bom-pom 4.1.0-RC.2 ahead of the parent 4.0.0 BOM, as in -->
<!-- qqq-bom-pom 4.1.0-RC.7 ahead of the parent 4.0.0 BOM, as in -->
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
@@ -245,7 +284,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -258,18 +297,6 @@
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
<repository>
<id>sonatype-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</profile>
</profiles>
</project>
+60
View File
@@ -0,0 +1,60 @@
/* Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0. */
/**
* Live local-review smoke test. Uses the actual QQQ backend, without API mocks.
* Requires the access UI's installed @playwright/test and Chromium browser.
* Opens the synthetic review user, filters the permission matrix, toggles a
* draft grant and cancels. Never executes a permission change.
* Run: node scripts/Smoke-Access-UI.cjs
*/
const path = require('node:path');
const { createRequire } = require('node:module');
const frontend = process.env.QBIT_REVIEW_FRONTEND || path.join(process.env.USERPROFILE, '.cache', 'qqq-authentik-access-ui');
const { chromium, expect } = createRequire(path.join(frontend, 'package.json'))('@playwright/test');
const baseURL = process.env.QBIT_REVIEW_UI_URL || 'http://127.0.0.1:18301';
/** Check rendered controls and draft selection without persisting changes. */
async function main()
{
const browser = await chromium.launch({ headless: true });
try
{
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(20000);
page.setDefaultNavigationTimeout(20000);
await page.goto(baseURL + '/app/authentik_user');
await page.getByText('review.sam', { exact: true }).first().click();
await page.waitForURL(/\/authentik_user\/\d+\/?$/);
await page.getByRole('button', { name: 'Record actions menu', exact: true }).click();
await page.getByText('Edit User Permissions', { exact: true }).click();
const matrix = page.locator('[data-qqq-id="access-permission-matrix"]');
await expect(matrix).toBeVisible();
await expect(matrix.getByRole('checkbox').first()).toBeVisible();
await expect(matrix.getByRole('alert')).toHaveCount(0);
await matrix.getByLabel('Search permissions or resources', { exact: true }).fill('review_document');
const read = matrix.getByRole('checkbox', { name: 'review_document.read', exact: true });
await expect(read).toBeEnabled();
const wasChecked = await read.isChecked();
await read.setChecked(!wasChecked);
await expect(read).toBeChecked({ checked: !wasChecked });
await page.screenshot({ path: 'target/release-access-ui-catalog.png', fullPage: true });
await matrix.getByLabel('Show', { exact: true }).selectOption('selected');
if(!wasChecked)
{
await expect(matrix.getByRole('checkbox', { name: 'review_document.read', exact: true })).toBeChecked();
}
await matrix.getByLabel('Show', { exact: true }).selectOption('inherited');
await matrix.getByLabel('Search permissions or resources', { exact: true }).fill('');
await expect(matrix.getByText(/authentik\.users\.read\.hasAccess.*Review Readers/)).toBeVisible();
await expect(matrix.getByText('Inherited grants must be changed at their source. Policy grants use Edit Policy.')).toBeVisible();
await page.screenshot({ path: 'target/release-access-ui-smoke.png', fullPage: true });
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
console.log('PASS: live permission catalog, resource search, draft checkbox, selection view, inherited-source view and cancel. No changes executed.');
}
finally
{
await browser.close();
}
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });
+27
View File
@@ -0,0 +1,27 @@
param(
[string]$FrontendPath = (Join-Path $env:USERPROFILE '.cache/qqq-authentik-access-ui'),
[int]$Port = 18301,
[int]$BackendPort = 18300
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$frontend = (Resolve-Path -LiteralPath $FrontendPath).Path
$cli = Join-Path $frontend 'node_modules/next/dist/bin/next'
if (-not (Test-Path -LiteralPath $cli)) { throw "Install the frontend's pinned dependencies first: pnpm install --frozen-lockfile" }
$stateFile = Join-Path $repo 'target/access-ui-process.json'
if (Test-Path $stateFile)
{
$previous = Get-Content $stateFile -Raw | ConvertFrom-Json
$running = Get-Process -Id $previous.pid -ErrorAction SilentlyContinue
if ($running -and $running.StartTime.ToUniversalTime() -eq ([datetime]$previous.started).ToUniversalTime())
{
throw "Access UI is already running at http://127.0.0.1:$($previous.port)."
}
}
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
try { $listener.Start() } finally { $listener.Stop() }
$env:QQQ_BACKEND_URL = "http://127.0.0.1:$BackendPort"
$env:NEXT_PUBLIC_MOCK_API = 'false'
$process = Start-Process -FilePath (Get-Command node).Source -ArgumentList @(('"' + $cli + '"'), 'dev', '--hostname', '127.0.0.1', '--port', $Port) -WorkingDirectory $frontend -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $repo 'target/access-ui.log') -RedirectStandardError (Join-Path $repo 'target/access-ui-error.log')
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().ToString('o');port=$Port} | ConvertTo-Json | Set-Content $stateFile
Write-Output "Access UI: http://127.0.0.1:$Port/app/authentikAdmin"
+48
View File
@@ -0,0 +1,48 @@
param([int]$Port = 18300, [string]$FrontendJar)
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
Push-Location $repo
try
{
New-Item -ItemType Directory -Force target | Out-Null
if (Test-Path target/review-process.json)
{
$previous = Get-Content target/review-process.json -Raw | ConvertFrom-Json
$running = Get-Process -Id $previous.pid -ErrorAction SilentlyContinue
if ($running -and $running.StartTime.ToUniversalTime() -eq ([datetime]$previous.started).ToUniversalTime())
{
throw "Review server is already running (PID $($previous.pid))."
}
}
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
try { $listener.Start() } finally { $listener.Stop() }
& ./mvnw.cmd test-compile dependency:build-classpath '-Dmdep.outputFile=target/review-classpath.txt' *> target/review-build.log
if ($LASTEXITCODE -ne 0) { throw 'Review build failed; see target/review-build.log.' }
$dependencies = (Get-Content target/review-classpath.txt -Raw).Trim().Split(';')
$frontendHash = $null
if ($FrontendJar)
{
$FrontendJar = (Resolve-Path -LiteralPath $FrontendJar).Path
$archive = [System.IO.Compression.ZipFile]::OpenRead($FrontendJar)
try
{
if (-not $archive.GetEntry('next-dashboard/index.html')) { throw 'Frontend JAR has no static dashboard entry point.' }
}
finally { $archive.Dispose() }
# Serve this exact artifact; old dependency assets must not fill missing files.
$dependencies = @($FrontendJar) + @($dependencies | Where-Object { [System.IO.Path]::GetFileName($_) -notlike 'qqq-frontend-next-*.jar' })
$frontendHash = (Get-FileHash -LiteralPath $FrontendJar -Algorithm SHA256).Hash
}
$classpath = (Join-Path $repo 'target/test-classes') + ';' + (Join-Path $repo 'target/classes') + ';' + ($dependencies -join ';')
$arguments = @('-XX:ActiveProcessorCount=4', '-cp', ('"' + $classpath.Replace('\', '/') + '"'), 'com.kingsrook.qbits.authentik.contract.AuthentikReviewServer', "$Port")
Set-Content target/review-java.args $arguments -Encoding utf8NoBOM
# A manual review must outlive an idle Ryuk connection. Stop-Review owns cleanup.
$previousRyuk = $env:TESTCONTAINERS_RYUK_DISABLED
$env:TESTCONTAINERS_RYUK_DISABLED = 'true'
$process = Start-Process -FilePath (Get-Command java).Source -ArgumentList '@target/review-java.args' -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput target/review-server.log -RedirectStandardError target/review-server-error.log
$env:TESTCONTAINERS_RYUK_DISABLED = $previousRyuk
@{ pid = $process.Id; started = $process.StartTime.ToUniversalTime().ToString('o'); port = $Port; frontendJar = $FrontendJar; frontendSha256 = $frontendHash } | ConvertTo-Json | Set-Content target/review-process.json
Write-Output "Review starting at http://localhost:$Port/app/authentikAdmin (PID $($process.Id)). First boot can take several minutes."
Write-Output 'Read target/review-server.log for readiness and the Authentik/Mailpit URLs. Run scripts/Stop-Review.ps1 when done.'
}
finally { Pop-Location }
+12
View File
@@ -0,0 +1,12 @@
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$state = Get-Content (Join-Path $repo 'target/access-ui-process.json') -Raw | ConvertFrom-Json
$process = Get-Process -Id $state.pid -ErrorAction SilentlyContinue
if (-not $process) { Write-Output 'Access UI is already stopped.'; return }
if ($process.ProcessName -ne 'node' -or $process.StartTime.ToUniversalTime() -ne ([datetime]$state.started).ToUniversalTime())
{
throw 'The saved PID is no longer this access UI; refusing to stop it.'
}
# Next's development server has a child process; stop only this verified process tree.
& taskkill /PID $process.Id /T /F
if ($LASTEXITCODE -ne 0) { throw 'Could not stop the access UI process tree.' }
+25
View File
@@ -0,0 +1,25 @@
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$state = Get-Content (Join-Path $repo 'target/review-process.json') -Raw | ConvertFrom-Json
$process = Get-Process -Id $state.pid -ErrorAction SilentlyContinue
if ($process -and ($process.StartTime.ToUniversalTime() -ne ([datetime]$state.started).ToUniversalTime() -or $process.ProcessName -ne 'java'))
{
throw 'The PID no longer belongs to this review server; refusing to stop it.'
}
if ($process) { Stop-Process -Id $process.Id }
$manifestPath = Join-Path $repo 'target/review-containers.json'
if (Test-Path $manifestPath)
{
$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
foreach ($id in $manifest.containers)
{
if ($id -notmatch '^[a-f0-9]{64}$') { throw 'Invalid container identity in review manifest.' }
$owner = & docker inspect --format '{{index .Config.Labels "io.qrun.qbit.project"}}' $id 2>$null
if ($LASTEXITCODE -ne 0) { continue }
if ($owner -ne 'qbit-authentik') { throw 'Container ownership does not match this review.' }
& docker rm -f $id | Out-Null
}
# Docker refuses removal if another container is still attached.
if ($manifest.network -match '^[a-f0-9]{64}$') { & docker network rm $manifest.network 2>$null | Out-Null }
}
Write-Output 'Review server and its owned containers stopped; review data is not retained.'
+104
View File
@@ -0,0 +1,104 @@
/* Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0. */
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const { chromium, expect } = createRequire(path.join(process.env.USERPROFILE, '.cache', 'qqq-authentik-access-ui', 'package.json'))('@playwright/test');
const baseURL = 'http://127.0.0.1:18300';
const reason = 'UI acceptance ' + Date.now();
/** Read synthetic provider state independently of the QQQ UI. Never print credentials. */
async function provider(resource)
{
const log = fs.readFileSync('target/review-server.log', 'utf8');
const root = log.match(/REVIEW AUTHENTIK (http:\/\/127\.0\.0\.1:\d+)/)?.[1];
if(!root) throw new Error('No owned review Authentik URL');
const source = fs.readFileSync('src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java', 'utf8');
const token = source.match(/BOOTSTRAP_TOKEN\s*=\s*"([^"]+)"/)?.[1];
const response = await fetch(root + '/api/v3/' + resource, { headers: { Authorization: 'Bearer ' + token } });
if(!response.ok) throw new Error('Provider read failed: ' + response.status);
return response.json();
}
/** Open the real record action; all mutations go through rendered UI controls. */
async function action(page, name)
{
await page.goto(baseURL + '/app/authentik_user');
await page.getByText('review.sam', { exact: true }).first().click();
await page.waitForURL(/\/authentik_user\/\d+\/?$/);
await page.getByRole('button', { name: 'Record actions menu', exact: true }).click();
await page.getByRole('menuitem', { name, exact: true }).click();
}
/** Require valid review and successful execution before checking provider state. */
async function submit(page)
{
await page.getByRole('textbox', { name: 'Reason', exact: true }).fill(reason);
await page.getByRole('button', { name: 'Next', exact: true }).last().click();
await expect(page.getByRole('form', { name: 'Review', exact: true })).toContainText('OK: 1');
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await expect(page.getByRole('form', { name: 'Result', exact: true })).toContainText('OK: 1');
}
/** Exercise direct grant addition/removal, role addition/removal and durable audit. */
async function main()
{
const users = await provider('core/users/?username=review.sam');
expect(users.results).toHaveLength(1);
const pk = users.results[0].pk;
const roles = await provider('core/groups/?name=Review%20Viewer&include_users=true');
expect(roles.results).toHaveLength(1);
const rolePk = roles.results[0].pk;
expect(roles.results[0].users).not.toContain(pk);
const browser = await chromium.launch({ headless: true });
try
{
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(20000);
page.setDefaultNavigationTimeout(20000);
await action(page, 'Edit User Permissions');
const initialBox = page.getByRole('checkbox', { name: 'review_document.read', exact: true });
await expect(initialBox).toBeVisible();
const initialGrant = await initialBox.isChecked();
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
for(const selected of [!initialGrant, initialGrant])
{
await action(page, 'Edit User Permissions');
const box = page.getByRole('checkbox', { name: 'review_document.read', exact: true });
await expect(box).toBeVisible();
if(await box.isChecked() !== selected)
{
await box.setChecked(selected);
await submit(page);
}
else await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const groups = await provider('core/groups/?page_size=100&include_users=true');
expect(groups.pagination.next).toBeFalsy();
const direct = groups.results.filter(group => group.attributes?.['qqq.io/user-access-owner'] === 'kof22-admin:' + pk);
expect(direct.length).toBeLessThanOrEqual(1);
expect(direct.some(group => group.attributes['qqq.io/permissions']?.includes('review_document.read'))).toBe(selected);
if(direct.length) expect(direct[0].users).toEqual([pk]);
}
for(const add of [true, false])
{
await action(page, add ? 'Add To Group Or Role' : 'Remove From Group Or Role');
await page.getByRole('combobox', { name: 'Group', exact: true }).click();
await page.getByRole('textbox', { name: 'Search Group options', exact: true }).fill('Review Viewer');
await page.getByRole('option', { name: 'Review Viewer', exact: true }).click();
await submit(page);
const group = await provider('core/groups/' + rolePk + '/?include_users=true');
expect(group.users.includes(pk)).toBe(add);
}
const response = await page.request.post(baseURL + '/qqq/v1/table/authentik_admin_action/query?limit=100', { data: {} });
expect(response.ok()).toBeTruthy();
const audit = (await response.json()).records.map(record => record.values).filter(record => record.reason === reason);
for(const name of ['EDIT_USER_PERMISSIONS', 'ADD_MEMBER', 'REMOVE_MEMBER'])
{
expect(audit.some(record => record.actionName === name && record.status === 'SUCCEEDED')).toBeTruthy();
}
await page.screenshot({ path: 'target/review-access-writes.png', fullPage: true });
console.log('PASS: rendered direct grant add/remove, role add/remove, independent Authentik state and successful SQL audits. Synthetic user restored.');
}
finally { await browser.close(); }
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });
+108
View File
@@ -0,0 +1,108 @@
/* Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0. */
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const { chromium, expect } = createRequire(path.join(process.env.USERPROFILE, '.cache', 'qqq-authentik-access-ui', 'package.json'))('@playwright/test');
const baseURL = 'http://127.0.0.1:18300';
const reason = 'Two-person UI acceptance ' + Date.now();
/** Independently read the synthetic role's actual provider permissions. */
async function role()
{
const root = fs.readFileSync('target/review-server.log', 'utf8').match(/REVIEW AUTHENTIK (http:\/\/127\.0\.0\.1:\d+)/)?.[1];
if(!root) throw new Error('Review host not ready');
const source = fs.readFileSync('src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java', 'utf8');
const token = source.match(/BOOTSTRAP_TOKEN\s*=\s*"([^"]+)"/)[1];
const response = await fetch(root + '/api/v3/core/groups/?name=Review%20Editor', { headers: { Authorization: 'Bearer ' + token } });
if(!response.ok) throw new Error('Provider read failed');
const groups = (await response.json()).results;
expect(groups).toHaveLength(1);
return groups[0];
}
/** Read local records without trusting the process success message alone. */
async function records(page, table)
{
const response = await page.request.post(baseURL + '/qqq/v1/table/' + table + '/query?limit=100', { data: {} });
expect(response.ok()).toBeTruthy();
return (await response.json()).records.map(row => row.values);
}
/** Open a record action using its rendered menu. */
async function action(page, url, name, secondOperator = false)
{
await page.goto(url);
await page.getByRole('button', { name: 'Record actions menu', exact: true }).waitFor();
if(secondOperator)
{
// Anonymous UI boot creates a fresh session. Select the review-only
// second identity after boot and before invoking any write process.
await page.context().addCookies(['sessionId', 'sessionUUID'].map(cookie =>
({ name: cookie, value: 'qbit-authentik-review-second', url: baseURL })));
}
await page.getByRole('button', { name: 'Record actions menu', exact: true }).click();
await page.getByRole('menuitem', { name, exact: true }).click();
}
/** Execute the three-step process, keeping review and result visible. */
async function submit(page, hasDetails = true)
{
if(hasDetails) await page.getByRole('button', { name: 'Next', exact: true }).last().click();
await page.getByRole('form', { name: 'Review', exact: true }).waitFor();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page.getByRole('form', { name: 'Result', exact: true }).waitFor();
}
/** Request as one operator, refuse self-approval, apply as the other, restore. */
async function main()
{
const initial = await role();
expect(initial.attributes['qqq.io/permissions'].some(value => value.startsWith('authentik.'))).toBe(false);
const browser = await chromium.launch({ headless: true });
try
{
const first = await browser.newContext();
const second = await browser.newContext();
const requester = await first.newPage();
const approver = await second.newPage();
for(const page of [requester, approver])
{
page.setDefaultTimeout(20000);
page.setDefaultNavigationTimeout(20000);
}
const groupURL = baseURL + '/app/authentik_group/' + initial.pk;
await action(requester, groupURL, 'Edit Permissions And App Access');
await requester.getByRole('checkbox', { name: 'authentik.users.read.hasAccess', exact: true }).check();
await requester.getByRole('textbox', { name: 'Reason', exact: true }).fill(reason);
await submit(requester);
await expect(requester.getByRole('form', { name: 'Result', exact: true })).toContainText('sent for approval');
const requests = (await records(requester, 'authentik_approval_request')).filter(row => row.reason === reason);
expect(requests).toHaveLength(1);
expect(requests[0].requester).toBe('review.operator@example.test');
expect(requests[0].status).toBe('PENDING');
expect((await role()).attributes).toEqual(initial.attributes);
const approvalURL = baseURL + '/app/authentik_approval_request/' + requests[0].id;
await action(requester, approvalURL, 'Approve And Apply');
await submit(requester, false);
await expect(requester.getByRole('form', { name: 'Result', exact: true })).toContainText('cannot approve your own request');
expect((await records(requester, 'authentik_approval_request')).find(row => row.id === requests[0].id).status).toBe('PENDING');
expect((await role()).attributes).toEqual(initial.attributes);
await action(approver, approvalURL, 'Approve And Apply', true);
await submit(approver, false);
const decided = (await records(approver, 'authentik_approval_request')).find(row => row.id === requests[0].id);
expect(decided.status).toBe('APPROVED');
expect(decided.approver).toBe('review.second@example.test');
expect((await role()).attributes['qqq.io/permissions']).toContain('authentik.users.read.hasAccess');
await action(requester, groupURL, 'Edit Permissions And App Access');
await requester.getByRole('checkbox', { name: 'authentik.users.read.hasAccess', exact: true }).uncheck();
await requester.getByRole('textbox', { name: 'Reason', exact: true }).fill(reason + ' restore');
await submit(requester);
expect((await role()).attributes).toEqual(initial.attributes);
const audit = await records(requester, 'authentik_admin_action');
expect(audit.some(row => row.reason === reason && row.status === 'SUCCEEDED' && row.approver === 'review.second@example.test')).toBe(true);
console.log('PASS: UI approval request, provider unchanged while pending, self-approval refused, distinct live second operator approval, provider grant and SQL audit, role restored.');
}
finally { await browser.close(); }
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });
+94
View File
@@ -0,0 +1,94 @@
/* Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0. */
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const { chromium, expect } = createRequire(path.join(process.env.USERPROFILE, '.cache', 'qqq-authentik-access-ui', 'package.json'))('@playwright/test');
const baseURL = 'http://127.0.0.1:18300';
const reason = 'Group inheritance UI acceptance ' + Date.now();
/** Read real provider state independently; all mutations use rendered controls. */
async function provider(resource)
{
const root = fs.readFileSync('target/review-server.log', 'utf8').match(/REVIEW AUTHENTIK (http:\/\/127\.0\.0\.1:\d+)/)?.[1];
if(!root) throw new Error('Review provider not ready');
const source = fs.readFileSync('src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java', 'utf8');
const token = source.match(/BOOTSTRAP_TOKEN\s*=\s*"([^"]+)"/)[1];
const response = await fetch(root + '/api/v3/' + resource, { headers: { Authorization: 'Bearer ' + token } });
if(!response.ok) throw new Error('Provider read failed: ' + response.status);
return response.json();
}
/** Open the selected record's named action. */
async function action(page, table, record, name)
{
await page.goto(baseURL + '/app/' + table);
await page.getByText(record, { exact: true }).first().click();
await page.getByRole('button', { name: 'Record actions menu', exact: true }).click();
await page.getByRole('menuitem', { name, exact: true }).click();
}
/** Validate the dry-run and successful result. */
async function submit(page)
{
await page.getByRole('textbox', { name: 'Reason', exact: true }).fill(reason);
await page.getByRole('button', { name: 'Next', exact: true }).last().click();
await expect(page.getByRole('form', { name: 'Review', exact: true })).toContainText('OK: 1');
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await expect(page.getByRole('form', { name: 'Result', exact: true })).toContainText('OK: 1');
}
/** Verify role grants, group parents, inherited user access and removals. */
async function main()
{
const readers = (await provider('core/groups/?name=Review%20Readers&include_parents=true')).results;
const editors = (await provider('core/groups/?name=Review%20Editor')).results;
expect(readers).toHaveLength(1);
expect(editors).toHaveLength(1);
const reader = readers[0];
const editor = editors[0];
expect(reader.parents_obj || []).toHaveLength(0);
expect(editor.attributes['qqq.io/permissions']).not.toContain('review_document.delete');
const browser = await chromium.launch({ headless: true });
try
{
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(20000);
page.setDefaultNavigationTimeout(20000);
for(const selected of [true, false])
{
await action(page, 'authentik_group', 'Review Editor', 'Edit Permissions And App Access');
await page.getByRole('checkbox', { name: 'review_document.delete', exact: true }).setChecked(selected);
await submit(page);
const actual = await provider('core/groups/' + editor.pk + '/');
expect(actual.attributes['qqq.io/permissions'].includes('review_document.delete')).toBe(selected);
expect(actual.attributes['qqq.io/application-role']).toBe(true);
}
for(const inherited of [true, false])
{
await action(page, 'authentik_group', 'Review Readers', 'Edit Group');
await page.getByRole('checkbox', { name: 'Review Editor', exact: true }).setChecked(inherited);
await submit(page);
const actual = await provider('core/groups/' + reader.pk + '/?include_parents=true');
expect((actual.parents_obj || []).map(parent => parent.pk).includes(editor.pk)).toBe(inherited);
expect(actual.attributes).toEqual(reader.attributes);
await action(page, 'authentik_user', 'review.sam', 'Edit User Permissions');
const matrix = page.locator('[data-qqq-id="access-permission-matrix"]');
await matrix.getByLabel('Show', { exact: true }).selectOption('inherited');
await matrix.getByLabel('Search permissions or resources', { exact: true }).fill('review_document.edit');
if(inherited) await expect(matrix).toContainText('Review Editor');
else await expect(matrix).not.toContainText('Review Editor');
await page.screenshot({ path: 'target/review-group-inheritance-' + inherited + '.png', fullPage: true });
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
}
const auditResponse = await page.request.post(baseURL + '/qqq/v1/table/authentik_admin_action/query?limit=100', { data: {} });
const audit = (await auditResponse.json()).records.map(row => row.values).filter(row => row.reason === reason);
for(const actionName of ['EDIT_PERMISSIONS', 'EDIT_GROUP'])
{
expect(audit.filter(row => row.actionName === actionName && row.status === 'SUCCEEDED')).toHaveLength(2);
}
console.log('PASS: role permission add/remove, group parent add/remove, inherited user access/source, actual Authentik state and SQL audits. Synthetic groups restored.');
}
finally { await browser.close(); }
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });
+87
View File
@@ -0,0 +1,87 @@
/* Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0. */
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const { chromium, expect } = createRequire(path.join(process.env.USERPROFILE, '.cache', 'qqq-authentik-access-ui', 'package.json'))('@playwright/test');
let phase = 'startup';
/** Create one synthetic enrollee through UI/email/signup; retain it for review. */
async function main()
{
const baseURL = 'http://127.0.0.1:18300';
const log = fs.readFileSync('target/review-server.log', 'utf8');
const providerURL = log.match(/REVIEW AUTHENTIK (http:\/\/127\.0\.0\.1:\d+)/)?.[1];
const mailURL = log.match(/REVIEW MAIL (http:\/\/localhost:\d+)/)?.[1];
if(!providerURL || !mailURL) throw new Error('Review host is not ready');
const username = 'review.invite.' + Date.now();
const email = username + '@example.test';
const reason = 'Rendered invitation acceptance ' + username;
const browser = await chromium.launch({ headless: true });
try
{
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(20000);
page.setDefaultNavigationTimeout(20000);
await page.goto(baseURL + '/app/authentik_user');
await page.getByRole('button', { name: 'Actions', exact: true }).click();
await page.getByRole('menuitem', { name: 'Invite User', exact: true }).click();
await page.getByRole('checkbox', { name: 'Review Viewer', exact: true }).check();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill(email);
await page.getByRole('textbox', { name: 'Full Name', exact: true }).fill('Review invitation acceptance');
await page.getByRole('textbox', { name: 'Reason', exact: true }).fill(reason);
phase = 'review selected role';
await page.getByRole('button', { name: 'Next', exact: true }).last().click();
await page.getByRole('form', { name: 'Review', exact: true }).waitFor();
await expect(page.getByRole('form', { name: 'Review', exact: true })).toContainText('after enrollment: Review Viewer');
phase = 'submit invitation';
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page.getByRole('form', { name: 'Result', exact: true }).waitFor();
await expect(page.getByRole('form', { name: 'Result', exact: true })).toContainText('1 invitation was sent');
let message;
phase = 'local email delivery';
await expect.poll(async () =>
{
const list = await (await fetch(mailURL + '/api/v1/messages')).json();
message = list.messages.find(item => item.To.some(recipient => recipient.Address === email));
return Boolean(message);
}, { timeout: 60000 }).toBeTruthy();
const body = await (await fetch(mailURL + '/api/v1/message/' + message.ID)).json();
const links = [...(body.HTML || '').matchAll(/href=["']([^"']+)["']/g)].map(match => match[1].replaceAll('&amp;', '&'));
const link = links.find(value => value.includes('itoken='));
if(!link || new URL(link).origin !== providerURL) throw new Error('Unexpected invitation link');
const invitationId = new URL(link).searchParams.get('itoken');
const signup = await browser.newPage();
phase = 'browser enrollment';
signup.setDefaultTimeout(20000);
await signup.goto(link);
await signup.getByRole('textbox', { name: 'Username', exact: true }).fill(username);
await signup.getByRole('button', { name: 'Continue', exact: true }).click();
phase = 'automatic assignment';
await expect.poll(async () =>
{
const response = await page.request.post(baseURL + '/qqq/v1/table/authentik_invitation_assignment/query?limit=100', { data: {} });
const rows = (await response.json()).records;
return rows.find(row => row.values.invitationId === invitationId)?.values.status;
}, { timeout: 60000, intervals: [1000, 2000] }).toBe('COMPLETE');
const source = fs.readFileSync('src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java', 'utf8');
phase = 'provider and audit verification';
const token = source.match(/BOOTSTRAP_TOKEN\s*=\s*"([^"]+)"/)[1];
const headers = { Authorization: 'Bearer ' + token };
const users = await (await fetch(providerURL + '/api/v3/core/users/?username=' + username, { headers })).json();
expect(users.results).toHaveLength(1);
const roles = await (await fetch(providerURL + '/api/v3/core/groups/?name=Review%20Viewer&include_users=true', { headers })).json();
expect(roles.results).toHaveLength(1);
expect(roles.results[0].users).toContain(users.results[0].pk);
const auditResponse = await page.request.post(baseURL + '/qqq/v1/table/authentik_admin_action/query?limit=100', { data: {} });
const audits = (await auditResponse.json()).records.map(row => row.values);
expect(audits.filter(row => row.reason === reason && row.actionName === 'ADD_MEMBER' && row.status === 'SUCCEEDED')).toHaveLength(1);
console.log('PASS: UI invitation, local email, browser signup, native webhooks, completed SQL intent, actual role membership and one successful membership audit.');
console.log('Synthetic enrollee retained for review: ' + username);
}
finally { await browser.close(); }
}
// Browser errors can contain the one-time invitation URL. Do not log their bodies.
main().catch(error => { console.error('FAIL: invitation UI acceptance at ' + phase + ' (' + error.name + '); inspect synthetic records and sanitized host logs.'); process.exitCode = 1; });
+42
View File
@@ -0,0 +1,42 @@
/* Copyright 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0 */
const path = require('node:path');
const { createRequire } = require('node:module');
const frontend = process.env.QBIT_REVIEW_FRONTEND || path.join(process.env.USERPROFILE, '.cache', 'qqq-authentik-access-ui');
const { chromium, expect } = createRequire(path.join(frontend, 'package.json'))('@playwright/test');
/** Send a reset email to synthetic Sam, then render the resulting token. */
async function main()
{
const browser = await chromium.launch({ headless: true });
try
{
const page = await browser.newPage();
page.setDefaultTimeout(30000);
await page.goto('http://127.0.0.1:18300/app/authentik_user');
await page.getByText('review.sam', { exact: true }).first().click();
await page.waitForURL(/\/authentik_user\/\d+\/?$/);
const userURL = page.url();
await page.getByRole('button', { name: 'Record actions menu', exact: true }).click();
await page.getByRole('menuitem', { name: 'Send Password Reset Email', exact: true }).click();
await page.getByRole('textbox', { name: 'Reason', exact: true }).fill('Local token widget regression verification');
await page.getByRole('button', { name: 'Next', exact: true }).last().click();
const review = page.getByRole('form', { name: 'Review', exact: true });
await review.waitFor();
await expect(review).toContainText('OK: 1');
await page.getByRole('button', { name: 'Submit', exact: true }).click();
const result = page.getByRole('form', { name: 'Result', exact: true });
await result.waitFor();
await expect(result).toContainText('OK: 1');
await page.goto(userURL);
await page.getByRole('tab', { name: 'Tokens and app passwords', exact: true }).click();
await expect(page.getByRole('cell', { name: 'verification', exact: true }).first()).toBeVisible({ timeout: 30000 });
await expect(page.getByRole('cell', { name: 'Owner permissions', exact: true }).first()).toBeVisible();
await expect(page.getByText(/An error occurred loading widget content|Error executing query/)).toHaveCount(0);
console.log('PASS: Tokens and app passwords renders a real password-reset token without a query error.');
}
finally
{
await browser.close();
}
}
main().catch(error => { console.error(error.message.replace(/[a-f0-9]{32,}/g, '<identifier>')); process.exitCode = 1; });
@@ -26,12 +26,21 @@ public enum AuthentikFeature
/** Read-only tables: users, groups, memberships, roles, applications, bindings, blueprints, sessions, events. */
READ,
/** Access tokens (DESIGN section 13). */
TOKENS,
/** Create, invite, edit, deactivate users (phase 3). */
USERS,
/** Create and edit groups, permissions and app-access attributes (phase 2/3). */
GROUPS,
/** Edit group policies (IAM-style statements) with the policy editor (DESIGN.md section 12). */
POLICIES,
/** Delete users and groups as an explicit action (DESIGN.md section 12.5). */
DELETE,
/** Add and remove members (phase 2). */
MEMBERSHIP,
File diff suppressed because it is too large. Load diff
@@ -24,12 +24,15 @@ import java.util.Properties;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.app.AuthentikRelations;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.health.AuthentikHealthChecks;
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
import com.kingsrook.qbits.authentik.processes.AuthentikProcesses;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
@@ -174,6 +177,10 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
List<String> errors = new ArrayList<>();
qBitConfig.validate(qInstance, errors);
if(errors.isEmpty() && qBitConfig.hasFeature(AuthentikFeature.MIRROR))
{
com.kingsrook.qbits.authentik.sync.AuthentikSyncRegistration.validateNames(qInstance, qBitConfig, errors);
}
if(!errors.isEmpty())
{
throw (new QException("AuthentikQBit configuration validation failed: " + String.join("; ", errors)));
@@ -184,15 +191,31 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
output.addSelfToInstance(qInstance);
if(qBitConfig.hasFeature(AuthentikFeature.MIRROR))
{
com.kingsrook.qbits.authentik.sync.AuthentikSyncRegistration.register(qInstance, qBitConfig);
}
AuthentikHealthChecks.register(qInstance, qBitConfig);
addPossibleValueSources(qInstance);
AuthentikRelations.apply(qInstance, qBitConfig);
for(QProcessMetaData process : AuthentikProcesses.processes(qBitConfig))
{
qInstance.addProcess(process);
}
for(AuthentikProcesses.Def def : AuthentikProcesses.definitions(qBitConfig))
{
if(com.kingsrook.qbits.authentik.processes.AccessEditorWidget.supports(def))
{
qInstance.addWidget(com.kingsrook.qbits.authentik.processes.AccessEditorWidget.metadata(qBitConfig, def));
}
}
// Registration is metadata-only; core skips startup sync without a helpContent table.
HelpBundles.register(qInstance, "help/qbit-authentik.yaml");
AuthentikHelp.apply(qInstance, qBitConfig);
LOG.info("Authentik QBit produced", logPair("version", VERSION), logPair("appKey", qBitConfig.getAppKey()),
logPair("features", qBitConfig.getFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
logPair("features", qBitConfig.getEffectiveFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
return (new MetaDataProducerMultiOutput());
}
@@ -209,14 +232,19 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
String name = qBitConfig.applyTablePrefix(spec);
if(qInstance.getPossibleValueSource(name) == null)
{
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(name));
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(name)
.withSearchFields(List.of(AuthentikTables.USER.equals(spec) ? "username" : "name")));
}
}
if(qBitConfig.getLocalBackendName() != null)
{
for(QPossibleValueSource source : AuthentikLocalTables.possibleValueSources())
{
qInstance.addPossibleValueSource(source);
// These fixed status enums are shared by all named QBit instances.
if(qInstance.getPossibleValueSource(source.getName()) == null)
{
qInstance.addPossibleValueSource(source);
}
}
}
}
@@ -23,7 +23,9 @@ import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
@@ -44,7 +46,13 @@ public class AuthentikRuntime
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
private final Memoization<String, String> responseCache;
private final Memoization<String, GroupGraph> groupGraphCache;
private final Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> tokenScopeCache = new Memoization<>(Duration.ofSeconds(60));
private final Memoization<String, Map<String, java.time.Instant>> tokenActivityCache = new Memoization<>(Duration.ofMinutes(5));
private volatile java.util.Set<String> permissionCatalog;
private volatile PolicyCatalog policyCatalog;
private volatile AccessComparison.State mapperState;
private volatile boolean mapperInvoked;
private com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink tokenAlerts = new com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts()::send;
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
@@ -74,6 +82,16 @@ public class AuthentikRuntime
/*******************************************************************************
** Every registered runtime (a snapshot).
*******************************************************************************/
public static java.util.List<AuthentikRuntime> all()
{
return (java.util.List.copyOf(BY_BACKEND.values()));
}
/*******************************************************************************
** Find the runtime for an API backend name.
*******************************************************************************/
@@ -125,6 +143,8 @@ public class AuthentikRuntime
public void invalidateCache()
{
responseCache.clear();
tokenScopeCache.clear();
tokenActivityCache.clear();
groupGraphCache.clear();
}
@@ -142,21 +162,54 @@ public class AuthentikRuntime
/*******************************************************************************
** QQQ permission names that qqq.io/permissions may grant: every permission
** the instance's metadata defines (PermissionsHelper), computed once.
** the instance's metadata defines (PermissionsHelper), computed per instance.
*******************************************************************************/
public java.util.Set<String> getPermissionCatalog()
{
PolicyCatalog currentPolicyCatalog = getPolicyCatalog();
if(permissionCatalog == null)
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
permissionCatalog = qInstance == null ? java.util.Set.of()
: new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
if(qInstance == null)
{
return (java.util.Set.of());
}
java.util.Set<String> catalog = new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
catalog.addAll(currentPolicyCatalog.permissionNames());
permissionCatalog = catalog;
}
return (permissionCatalog);
}
/*******************************************************************************
** What a policy can grant in the current instance (built lazily per instance: the
** instance is complete by the first request).
*******************************************************************************/
public PolicyCatalog getPolicyCatalog()
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(qInstance == null)
{
return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance()));
}
///////////////////////////////////////////////////////////////////////
// always the shared per-instance catalog (PolicyCatalog.of), so the //
// graph path and the claim path see the same one, rebuilt when the //
// metadata it is built from changes //
///////////////////////////////////////////////////////////////////////
PolicyCatalog current = PolicyCatalog.of(qInstance);
if(current != policyCatalog)
{
policyCatalog = current;
permissionCatalog = null;
}
return (policyCatalog);
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
@@ -206,4 +259,81 @@ public class AuthentikRuntime
return (responseCache);
}
/*******************************************************************************
** The cached mapper state (DESIGN.md section 12.8), or null.
*******************************************************************************/
public AccessComparison.State getMapperState()
{
return (mapperState);
}
/*******************************************************************************
** Cache a mapper state.
*******************************************************************************/
public void setMapperState(AccessComparison.State mapperState)
{
this.mapperState = mapperState;
}
/*******************************************************************************
** Whether the host has run the qbit mapper at sign-in (counts as "enabled"
** for hosts that call the mapper without setting mapperEnabled).
*******************************************************************************/
public boolean isMapperInvoked()
{
return (mapperInvoked);
}
/*******************************************************************************
** Record that the mapper ran.
*******************************************************************************/
public void markMapperInvoked()
{
if(!mapperInvoked)
{
mapperInvoked = true;
mapperState = null;
}
}
/*******************************************************************************
** Token claims are cached independently of the general list TTL.
*******************************************************************************/
public Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> getTokenScopeCache()
{
return (tokenScopeCache);
}
/*******************************************************************************
** Login-event aggregation is shared by every token record in this runtime.
*******************************************************************************/
public Memoization<String, Map<String, java.time.Instant>> getTokenActivityCache()
{
return (tokenActivityCache);
}
/*******************************************************************************
** Optional Alerts delivery, replaceable by a host or a capturing test sink.
*******************************************************************************/
public com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink getTokenAlerts()
{
return (tokenAlerts);
}
/*******************************************************************************
** Install a sink for this runtime only.
*******************************************************************************/
public AuthentikRuntime withTokenAlerts(com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink sink)
{
tokenAlerts = java.util.Objects.requireNonNull(sink);
return (this);
}
}
@@ -27,6 +27,13 @@ import com.kingsrook.qbits.authentik.security.IdentityPermission;
*******************************************************************************/
public enum AdminAction
{
// Null permission means self-service: AuthentikTokens verifies the live owner.
// Processes add no permission beyond their table; cross-owner actions require TOKENS_MANAGE.
CREATE_TOKEN("Create token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
ROTATE_TOKEN("Rotate token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
REVOKE_TOKEN("Revoke token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
EXTEND_TOKEN("Extend expiry", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
CREATE_SERVICE_ACCOUNT("Create service account", "user", IdentityPermission.TOKENS_MANAGE, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
CREATE_USER("Create user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
INVITE_USER("Invite user", "invitation", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
EDIT_USER("Edit user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
@@ -38,6 +45,10 @@ public enum AdminAction
CREATE_GROUP("Create group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_GROUP("Edit group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_PERMISSIONS("Edit permissions and app access", "group", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_USER_PERMISSIONS("Edit user permissions", "user", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
SAVE_POLICY("Save policy", "group", IdentityPermission.POLICIES_MANAGE, AuthentikFeature.POLICIES, Confirmation.CONFIRM),
DELETE_USER("Delete user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
DELETE_GROUP("Delete group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
SEND_RECOVERY_EMAIL("Send password reset email", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.CONFIRM),
RESET_MFA("Reset MFA", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
REVOKE_APP_PASSWORDS("Revoke app passwords", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,150 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.json.JSONObject;
/*******************************************************************************
** Read native enrollment facts from the guarded Authentik API, never from a
** webhook body. These facts are NOT write authorization: the caller must still
** match persisted intent, bind one user once and recheck current grant rights.
*******************************************************************************/
public final class AuthentikInvitationEvidence
{
public static final String REFERENCE = "qqq_invitation_reference";
private final AuthentikActionUtil client;
/***************************************************************************
** Use the configured directory's client and its normal read guard.
***************************************************************************/
public AuthentikInvitationEvidence(AuthentikActionUtil client)
{
this.client = Objects.requireNonNull(client);
}
/***************************************************************************
** Minimal consumption fact; the event's actor is not the future enrollee.
***************************************************************************/
public record Consumption(String eventId, String invitationId, Instant createdAt)
{
}
/***************************************************************************
** Minimal creation fact. No mutable email/name, grants or raw context escape.
***************************************************************************/
public record Enrollment(String eventId, String reference, Integer userPk, Instant createdAt)
{
}
/***************************************************************************
** Confirm consumption of the exact saved invitation in its configured flow.
***************************************************************************/
public Consumption consumed(String eventId, String invitationId, String flowSlug, Instant earliest, Instant latest) throws QException
{
try
{
String expected = uuid(invitationId, false);
JSONObject event = event(eventId, "invitation_used", flowSlug, earliest, latest);
String actual = uuid(event.getJSONObject("context").get("invitation_uuid"), true);
if(!expected.equals(actual))
{
throw (new IllegalArgumentException());
}
return (new Consumption(uuid(eventId, false), actual, Instant.parse(event.getString("created"))));
}
catch(Exception invalid)
{
throw (new QException("Invitation consumption evidence could not be verified."));
}
}
/***************************************************************************
** Confirm an actual new account, not a profile edit or impersonated actor.
** Expected correlation comes from saved intent, never from a webhook field.
***************************************************************************/
public Enrollment enrolled(String eventId, String reference, String flowSlug, Instant earliest, Instant latest) throws QException
{
try
{
String expected = uuid(reference, false);
JSONObject event = event(eventId, "user_write", flowSlug, earliest, latest);
new AuthentikInvitationFlow(client).verify(flowSlug);
JSONObject context = event.getJSONObject("context");
JSONObject user = event.getJSONObject("user");
Object pk = user.get("pk");
if(!Boolean.TRUE.equals(context.get("created")) || !expected.equals(uuid(context.get(REFERENCE), false))
|| user.has("on_behalf_of") || user.has("authenticated_as") || user.has("is_agent")
|| !(pk instanceof Number))
{
throw (new IllegalArgumentException());
}
int userPk = new java.math.BigDecimal(pk.toString()).intValueExact();
if(userPk < 1)
{
throw (new IllegalArgumentException());
}
return (new Enrollment(uuid(eventId, false), expected, userPk, Instant.parse(event.getString("created"))));
}
catch(Exception invalid)
{
throw (new QException("Invitation enrollment evidence could not be verified."));
}
}
/***************************************************************************
** Match the event's immutable ID, native action, flow endpoint and bounded
** creation window. API failures and malformed records fail closed. The
** existing event table reader enforces audit-read permissions before I/O.
***************************************************************************/
private JSONObject event(String eventId, String action, String flowSlug, Instant earliest, Instant latest) throws QException
{
String id = uuid(eventId, false);
if(flowSlug == null || !flowSlug.matches("[A-Za-z0-9_-]{1,100}") || earliest == null || latest == null || latest.isBefore(earliest))
{
throw (new IllegalArgumentException());
}
JSONObject event = client.syncEvent(id);
if(event == null || !id.equals(uuid(event.get("pk"), false)) || !action.equals(event.get("action"))
|| !(event.get("created") instanceof String timestamp))
{
throw (new IllegalArgumentException());
}
Instant created = Instant.parse(timestamp);
Object path = event.getJSONObject("context").getJSONObject("http_request").get("path");
if(created.isBefore(earliest) || created.isAfter(latest)
|| !("/api/v3/flows/executor/" + flowSlug + "/").equals(path))
{
throw (new IllegalArgumentException());
}
return (event);
}
/***************************************************************************
** Native invitation context uses UUID.hex; event IDs use canonical UUIDs.
** Reject Java UUID parsing's abbreviated forms and arbitrary coercions.
***************************************************************************/
private static String uuid(Object value, boolean allowHex)
{
if(!(value instanceof String text))
{
throw (new IllegalArgumentException());
}
if(allowHex && text.matches("[0-9a-fA-F]{32}"))
{
text = text.substring(0, 8) + "-" + text.substring(8, 12) + "-" + text.substring(12, 16) + "-" + text.substring(16, 20) + "-" + text.substring(20);
}
UUID parsed = UUID.fromString(text);
if(!parsed.toString().equalsIgnoreCase(text))
{
throw (new IllegalArgumentException());
}
return (parsed.toString());
}
}
@@ -0,0 +1,126 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.qbits.authentik.admin;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.json.JSONObject;
/*******************************************************************************
** Validate the live provider configuration before trusting enrollment reference
** data. Configuration is read without caches; this is not grant authorization.
*******************************************************************************/
public final class AuthentikInvitationFlow
{
private final AuthentikActionUtil client;
/***************************************************************************
** Use the same guarded provider client as enrollment evidence.
***************************************************************************/
public AuthentikInvitationFlow(AuthentikActionUtil client)
{
this.client = java.util.Objects.requireNonNull(client);
}
/***************************************************************************
** Require one invitation before one write, with exactly the packaged policy
** on that write binding. Multiple policies can run concurrently, so numeric
** policy order alone does not establish which mutation wins.
***************************************************************************/
public String verify(String slug) throws QException
{
String check = "configuration read";
try
{
JSONObject configuration = client.invitationConfiguration(slug);
JSONObject flow = configuration.getJSONObject("flow");
check = "flow identity";
require(slug.equals(flow.getString("slug")) && "enrollment".equals(flow.getString("designation")));
var orders = new HashSet<Integer>();
JSONObject invitation = null;
JSONObject write = null;
check = "stage identity and order";
for(Object item : configuration.getJSONArray("bindings"))
{
JSONObject binding = (JSONObject) item;
JSONObject stage = binding.getJSONObject("stage_obj");
require(flow.getString("pk").equals(binding.getString("target"))
&& binding.getString("stage").equals(stage.getString("pk")) && orders.add(binding.getInt("order")));
switch(stage.getString("meta_model_name"))
{
case "authentik_stages_invitation.invitationstage" ->
{
require(invitation == null);
invitation = binding;
}
case "authentik_stages_user_write.userwritestage" ->
{
require(write == null);
write = binding;
}
default -> { }
}
}
require(invitation != null && write != null && invitation.getInt("order") < write.getInt("order"));
check = "write-stage evaluation";
require(Boolean.FALSE.equals(write.get("evaluate_on_plan")) && Boolean.TRUE.equals(write.get("re_evaluate_policies"))
&& "all".equals(write.getString("policy_engine_mode")));
var policies = write.getJSONArray("qqqPolicies");
check = "policy binding";
require(policies.length() == 1);
JSONObject binding = policies.getJSONObject(0);
require(write.getString("pk").equals(binding.getString("target"))
&& Boolean.TRUE.equals(binding.get("enabled")) && Boolean.FALSE.equals(binding.get("negate"))
&& Boolean.FALSE.equals(binding.get("failure_result")) && Boolean.FALSE.equals(binding.get("expiring"))
&& binding.isNull("expires") && binding.isNull("user") && binding.isNull("group"));
JSONObject definition = binding.getJSONObject("definition");
check = "policy definition";
require(binding.getString("policy").equals(definition.getString("pk"))
&& Boolean.FALSE.equals(definition.get("execution_logging"))
&& normalize(expression()).equals(normalize(definition.getString("expression"))));
return (flow.getString("pk"));
}
catch(Exception invalid)
{
throw (new QException("Invitation flow protection could not be verified (" + check + "). Check the enrollment stage and policy configuration."));
}
}
/***************************************************************************
** One packaged source is used for provider deployment and verification.
***************************************************************************/
public static String expression() throws java.io.IOException
{
try(var input = AuthentikInvitationFlow.class.getResourceAsStream("/authentik/invitation-reference-policy.py"))
{
if(input == null)
{
throw (new java.io.IOException("Invitation policy resource is unavailable."));
}
return (new String(input.readAllBytes(), StandardCharsets.UTF_8));
}
}
/***************************************************************************
** Permit platform newlines and surrounding whitespace, not code changes.
***************************************************************************/
private static String normalize(String expression)
{
return (expression.replace("\r\n", "\n").strip());
}
/***************************************************************************
** Keep provider data out of diagnostic failures.
***************************************************************************/
private static void require(boolean valid)
{
if(!valid)
{
throw (new IllegalArgumentException());
}
}
}
@@ -20,6 +20,8 @@ package com.kingsrook.qbits.authentik.admin;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
@@ -27,6 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValu
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
@@ -39,8 +42,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
*******************************************************************************/
public final class AuthentikLocalTables
{
public static final String TOKEN_SCOPE = "token_scope";
public static final String ADMIN_ACTION = "admin_action";
public static final String APPROVAL_REQUEST = "approval_request";
public static final String POLICY = "policy";
public static final String PVS_ACTION_STATUS = "authentik_action_status";
public static final String PVS_APPROVAL_STATUS = "authentik_approval_status";
@@ -212,6 +217,30 @@ public final class AuthentikLocalTables
/*******************************************************************************
** authentik_policy: one row per group with a policy (DESIGN.md section 12).
** Authentik holds the live document; this row is the QQQ record of the last
** save, and field-level audit is its change history. Written only by the
** Edit Policy action.
*******************************************************************************/
public static QTableMetaData policy(AuthentikQBitConfig config)
{
return (base(config, POLICY, "Policies", "policy", IdentityPermission.USERS_READ)
.withRecordLabelFormatAndFields("%s", "groupName")
.withUniqueKey(new UniqueKey("groupPk"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withField(ro("groupPk", QFieldType.STRING).withLabel("Group Id").withMaxLength(64))
.withField(ro("groupName", QFieldType.STRING).withLabel("Group").withMaxLength(255))
.withField(ro("policyJson", QFieldType.TEXT).withLabel("Policy Document"))
.withField(ro("statementCount", QFieldType.INTEGER).withLabel("Statements"))
.withField(ro("permissionCount", QFieldType.INTEGER).withLabel("Permissions"))
.withField(ro("savedBy", QFieldType.STRING).withLabel("Saved By").withMaxLength(255))
.withField(ro("savedDate", QFieldType.DATE_TIME).withLabel("Saved"))
.withField(ro("authentikFingerprint", QFieldType.STRING).withLabel("Authentik Fingerprint").withMaxLength(64)));
}
/*******************************************************************************
** Common shape: id, create and modify dates, no generic writes.
*******************************************************************************/
@@ -241,4 +270,40 @@ public final class AuthentikLocalTables
return (new QFieldMetaData(name, type).withIsEditable(false));
}
/*******************************************************************************
** Non-secret scope metadata only (DESIGN section 13.5).
*******************************************************************************/
public static QTableMetaData tokenScope(AuthentikQBitConfig config)
{
QTableMetaData table = base(config, TOKEN_SCOPE, "Token Scopes", "key", IdentityPermission.TOKENS_MANAGE)
.withPermissionRules(com.kingsrook.qbits.authentik.security.TokenReadPermission.rules())
.withRecordLabelFormatAndFields("%s", "tokenIdentifier")
.withUniqueKey(new UniqueKey("tokenIdentifier"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
.withField(ro("tokenIdentifier", QFieldType.STRING).withMaxLength(255))
.withField(ro("ownerUsername", QFieldType.STRING).withMaxLength(255))
.withField(ro("ownerPk", QFieldType.INTEGER))
.withField(ro("readOnly", QFieldType.BOOLEAN))
.withField(ro("createdBy", QFieldType.STRING).withMaxLength(255));
table.getFields().remove("createDate");
table.getFields().remove("modifyDate");
for(String name : List.of("apps", "tables", "processes", "reports", "widgets", "operationIds"))
{
table.withField(ro(name, QFieldType.TEXT));
}
for(String name : List.of("createdAt", "rotatedAt"))
{
table.withField(ro(name, QFieldType.DATE_TIME));
}
////////////////////////////////////////////////////////////////////////
// explicit column names: a camel-to-snake mapping would give //
// last_reminder7d, while docs/schema.sql (and hosts) use _7d / _1d //
////////////////////////////////////////////////////////////////////////
table.withField(ro("lastReminder7d", QFieldType.DATE_TIME).withBackendName("last_reminder_7d"));
table.withField(ro("lastReminder1d", QFieldType.DATE_TIME).withBackendName("last_reminder_1d"));
return (table);
}
}
@@ -0,0 +1,173 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.admin;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONObject;
/*******************************************************************************
** Audited policy maintenance for reviewed host Java code, typically a run-once
** job. This API deliberately creates its own authority; never expose it through
** an HTTP route or a user-invokable process. No route or process is registered.
*******************************************************************************/
public final class AuthentikMaintenance
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikMaintenance()
{
}
/*******************************************************************************
** Apply a policy to exactly one group by name through the normal Save Policy
** plan and execute path. Requires an initialized QContext with the host's
** QInstance. The caller's context is restored on success, refusal or failure.
**
** Runs as the host's maintenance service principal (config
** maintenancePrincipal, default "maintenance"; docs/SERVICE-IDENTITY.md in
** qqq): under OAuth2 a real token for its Authentik service account, so its
** authority is exactly what the owner granted that account (policies.manage
** and every grant the document applies - the subset rule still holds). No
** authority is created here. The audit operator is service:{principal};
** the job name (maintenance: or run-once:, nonblank suffix) prefixes the
** required reason. Existing scope, protection, readOnly, feature and stale
** checks still apply. Equivalent policies return NO_CHANGE without a write
** or an audit row. With policyTwoPersonApproval enabled, a change queues an
** approval instead of writing to Authentik; this API never bypasses
** second-person approval.
*******************************************************************************/
public static AuthentikAdmin.AdminResult applyPolicy(String apiBackendName, String groupName, String policyJson, String operatorName, String reason) throws QException
{
if(operatorName == null || !(operatorName.startsWith("maintenance:") || operatorName.startsWith("run-once:"))
|| operatorName.substring(operatorName.indexOf(':') + 1).isBlank())
{
throw (new QUserFacingException("The operator name must start with maintenance: or run-once: and name the job."));
}
if(!StringUtils.hasContent(reason))
{
throw (new QUserFacingException("A reason is required for every change."));
}
if(!StringUtils.hasContent(groupName))
{
throw (new QUserFacingException("A group name is required."));
}
CapturedContext caller = QContext.capture();
try
{
String principal = AuthentikRuntime.forBackend(apiBackendName).getConfig().getMaintenancePrincipal();
QContext.init(caller.qInstance(), ServiceIdentities.session(caller.qInstance(), principal));
AuthentikAdmin admin = AuthentikAdmin.forBackend(apiBackendName);
AuthentikRuntime runtime = admin.getUtil().getRuntime();
runtime.getGuard().authorize(AdminAction.SAVE_POLICY);
if(StringUtils.hasContent(policyJson))
{
PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
}
runtime.invalidateCache();
List<JSONObject> groups = admin.getUtil().getAllPages("/core/groups/?name=" + URLEncoder.encode(groupName, StandardCharsets.UTF_8)
+ "&include_users=true&include_parents=true&include_children=true", 2);
if(groups.size() != 1 || !groupName.equals(groups.get(0).optString("name")))
{
throw (new QUserFacingException("Expected exactly one Authentik group named " + groupName + "."));
}
JSONObject group = groups.get(0);
if(isServicePrincipalGroup(group))
{
////////////////////////////////////////////////////////////////////
// a principal never changes a service principal's grants (its own //
// or another's): only the owner does, in Identity //
////////////////////////////////////////////////////////////////////
throw (new QUserFacingException("Service principal groups are changed only by the owner in Identity."));
}
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), operatorName + ": " + reason)
.with(AuthentikAdmin.POLICY_DOCUMENT, policyJson)
.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group, runtime.getConfig().getPolicyFormat().attribute()));
return (admin.execute(request));
}
catch(PolicyParseException e)
{
throw (new QUserFacingException("The policy is not valid: " + e.getMessage()));
}
finally
{
QContext.init(caller);
}
}
/*******************************************************************************
** A service principal role group: named service:..., or a child of
** qqq-service-accounts.
*******************************************************************************/
static boolean isServicePrincipalGroup(JSONObject group)
{
if(group.optString("name", "").startsWith("service:"))
{
return (true);
}
org.json.JSONArray parents = group.optJSONArray("parents_obj");
for(int i = 0; parents != null && i < parents.length(); i++)
{
if("qqq-service-accounts".equals(parents.optJSONObject(i) == null ? null : parents.getJSONObject(i).optString("name")))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** Canonical compact full-access document using the host's spec and version.
*******************************************************************************/
public static String fullAccessDocument(AuthentikQBitConfig config) throws QException
{
try
{
return (PolicyGenerator.toCompact(PolicyGenerator.generate(Set.of(PolicyPermissions.FULL_ACCESS), config.getPolicyFormat())));
}
catch(PolicyParseException e)
{
throw (new QException("Could not generate the full-access policy.", e));
}
}
}
@@ -141,7 +141,7 @@ public final class AuthentikSafety
{
List<Integer> members = new ArrayList<>(node.memberPks());
members.remove(Integer.valueOf(userPk));
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed()));
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed(), node.policy()));
}
@@ -151,7 +151,7 @@ public final class AuthentikSafety
*******************************************************************************/
public static GroupGraph.Node withPermissions(GroupGraph.Node node, List<String> permissions, List<String> appAccess)
{
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed()));
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed(), node.policy()));
}
@@ -161,7 +161,17 @@ public final class AuthentikSafety
*******************************************************************************/
public static GroupGraph.Node withParents(GroupGraph.Node node, List<String> parentPks)
{
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed()));
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed(), node.policy()));
}
/*******************************************************************************
** A copy of a node with other policy rules (to simulate saving a policy).
*******************************************************************************/
public static GroupGraph.Node withPolicy(GroupGraph.Node node, com.kingsrook.qbits.authentik.policy.PolicyRules rules)
{
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), node.memberPks(), node.malformed(), rules));
}
@@ -214,6 +224,17 @@ public final class AuthentikSafety
** the Authentik UI after a preview is noticed.
*******************************************************************************/
public static String groupFingerprint(JSONObject group)
{
return (groupFingerprint(group, null));
}
/*******************************************************************************
** The fingerprint including the configured policy attribute even when it is
** outside qqq.io/ (a custom attribute): a change there is noticed too.
*******************************************************************************/
public static String groupFingerprint(JSONObject group, String policyAttribute)
{
JSONObject attributes = group.optJSONObject("attributes");
Map<String, Object> qqq = new java.util.TreeMap<>();
@@ -221,7 +242,7 @@ public final class AuthentikSafety
{
for(String key : attributes.keySet())
{
if(key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX))
if(key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX) || key.equals(policyAttribute))
{
qqq.put(key, String.valueOf(attributes.get(key)));
}
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.admin;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.logging.QLogger;
/*******************************************************************************
** Optional Alerts bridge. The qbit compiles on RC.2 without either Alerts API.
** No credentials enter this interface. Hosts and tests can supply a sink.
*******************************************************************************/
public class AuthentikTokenAlerts
{
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenAlerts.class);
/*******************************************************************************
** Safe notification envelope. A null recipient denotes the admin audience.
*******************************************************************************/
public record Notice(String key, String severity, String recipient, String message)
{
}
/*******************************************************************************
** Return true only when delivery was accepted, so absence never dedupes.
*******************************************************************************/
@FunctionalInterface
public interface Sink
{
/*******************************************************************************
** Accept a safe notification, returning false when delivery is unavailable.
*******************************************************************************/
boolean send(Notice notice);
}
/*******************************************************************************
** Constructor for the reflection adapter.
*******************************************************************************/
public AuthentikTokenAlerts()
{
}
/*******************************************************************************
** Guard incidents are raised through core QAlerts; owner reminders through
** core QAlerts.notify (qqq#1125, QQQ 4.1.0-RC.6+). Never another qbit's API.
*******************************************************************************/
public boolean send(Notice notice)
{
try
{
if("high".equals(notice.severity()))
{
QAlerts.raise(new AlertSignal("authentik.token.guard-bypass")
.withDedupKey(notice.key())
.withTitle("Access token guard bypass")
.withSummary(notice.message())
.withSeverity(AlertSeverity.CRITICAL));
return (true);
}
///////////////////////////////////////////////////////////////////
// owner reminders: core's notification SPI (bell and email via //
// whichever alerts producer the host installed; qqq#1125) //
///////////////////////////////////////////////////////////////////
QAlerts.notify(new NotificationSignal("Access token expiry reminder")
.withBody(notice.message())
.withSourceName("authentik")
.withSourceKey(notice.key())
.withAudience(NotificationAudience.user(notice.recipient())));
return (true);
}
catch(RuntimeException e)
{
///////////////////////////////////////////////////////////
// never fail the caller; never log the notice payload //
///////////////////////////////////////////////////////////
LOG.info("Access-token notification delivery failed; notification skipped.");
return (false);
}
}
}
@@ -0,0 +1,703 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.admin;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker;
import com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Access-token actions (DESIGN section 13). All target checks run at Review and
** again at execution; credentials exist only in the action and one-time result.
*******************************************************************************/
public class AuthentikTokens
{
public static final String DELIVERY_UNAVAILABLE = "This QQQ version cannot show a key once; upgrade QQQ (4.1.0-RC.7+)";
public static final String TOKEN_MOVED = "Token moved: owner or intent changed after the update; investigate in Authentik.";
private static final QLogger LOG = QLogger.getLogger(AuthentikTokens.class);
private static final SecureRandom RANDOM = new SecureRandom();
private final AuthentikAdmin admin;
private final AuthentikActionUtil util;
private final AuthentikQBitConfig config;
private final TokenScopeStore scopes;
/*******************************************************************************
** Delegate with the existing admin's live operator and subset checks.
*******************************************************************************/
public AuthentikTokens(AuthentikAdmin admin)
{
this.admin = admin;
util = admin.getUtil();
config = util.getRuntime().getConfig();
scopes = new TokenScopeStore(config);
}
/*******************************************************************************
** Review all constraints, without exchanging or creating a credential.
*******************************************************************************/
public AdminPlan plan(AdminPlan plan) throws QException
{
AdminRequest request = plan.getRequest();
AdminAction action = request.action();
util.getRuntime().invalidateCache();
try
{
if(action == AdminAction.CREATE_SERVICE_ACCOUNT)
{
if(!request.str("name").matches("[A-Za-z0-9][A-Za-z0-9._-]{0,99}"))
{
return (plan.refuse("A service-account name (letters, numbers, dot, dash or underscore) is required."));
}
if(admin.userPkByUsername(request.str("name")) != null)
{
return (plan.refuse("That username already exists."));
}
JSONObject candidate = new JSONObject().put("username", request.str("name")).put("type", "service_account");
checkProtected(candidate);
expiry(request, true);
if(!TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
for(GroupGraph.Node group : serviceGroups(request))
{
String problem = admin.checkGroupGrantable(group);
if(problem != null)
{
return (plan.refuse(problem));
}
checkProtectedGroup(group);
}
plan.withTargetLabel(request.str("name"));
return (approval(plan));
}
JSONObject token = action == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
Integer operatorPk = admin.operator().pk();
int ownerPk = token == null ? (operatorPk == null ? -1 : operatorPk) : ownerPk(token);
if(action == AdminAction.CREATE_TOKEN && (!request.targetKey().isBlank() && !request.targetKey().equals(String.valueOf(operatorPk))))
{
return (plan.refuse("Create Token only creates a token for you."));
}
if(ownerPk < 0)
{
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
}
JSONObject owner = owner(ownerPk);
boolean own = Objects.equals(operatorPk, ownerPk);
boolean service = "service_account".equals(owner.optString("type"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if(service && action == AdminAction.CREATE_TOKEN)
{
return (plan.refuse("Service accounts have one app password. Use Rotate Token or Create Service Account."));
}
if(!own && !manager || service && !manager)
{
return (plan.refuse("You may only manage your own tokens; service accounts need authentik.tokens.manage."));
}
if(!own && !service && action != AdminAction.REVOKE_TOKEN)
{
return (plan.refuse("You cannot create, rotate or extend another person's token."));
}
checkProtected(owner);
plan.withTargetLabel(token == null ? owner.getString("username") : request.targetKey());
if(action != AdminAction.REVOKE_TOKEN && token != null && !"app_password".equals(token.optString("intent")))
{
return (plan.refuse("Only app-password tokens can be rotated or extended here. API tokens can be revoked."));
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.EXTEND_TOKEN)
{
expiry(request, service);
}
if(action == AdminAction.ROTATE_TOKEN)
{
checkExpiry(token, service);
}
if(action == AdminAction.CREATE_TOKEN)
{
if(request.str("description").isBlank())
{
return (plan.refuse("A description is required."));
}
if(!Boolean.TRUE.equals(config.getTokenScopesEnabled()) && TokenScopeStore.SCOPE_FIELDS.stream().anyMatch(request.params()::containsKey))
{
return (plan.refuse("Scopes are not enabled on this app yet."));
}
}
if(action == AdminAction.CREATE_TOKEN && Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.validate(request);
QRecord scope = new QRecord();
request.params().forEach(scope::setValue);
plan.withChange(null, TokenScopeStore.summary(scope));
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
{
if(!TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
}
if(own && !service)
{
if(blank(config.getTokenBrokerClientId()) || blank(config.getTokenBrokerClientSecret()))
{
return (plan.refuse("Self-service tokens need a configured Authentik token broker."));
}
if(config.getSubjectTokenSource() == null || blank(config.getSubjectTokenSource().subjectToken(QContext.getQSession())))
{
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
}
}
return (service && action == AdminAction.ROTATE_TOKEN ? approval(plan) : plan);
}
catch(QUserFacingException e)
{
return (plan.refuse(e.getMessage()));
}
}
/*******************************************************************************
** Optional approval for new service accounts and service-account rotation.
*******************************************************************************/
private AdminPlan approval(AdminPlan plan)
{
if(Boolean.TRUE.equals(config.getTokenTwoPersonApproval()) && !plan.getRequest().bool("approved"))
{
plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("Service-account tokens require a second person's approval.");
}
return (plan);
}
/*******************************************************************************
** Resolve requested groups with the same subset rules used for membership.
*******************************************************************************/
private List<GroupGraph.Node> serviceGroups(AdminRequest request) throws QException
{
Set<String> names = new LinkedHashSet<>(admin.requestedGroupNames(request, "groups"));
GroupGraph graph = util.groupGraph();
List<GroupGraph.Node> groups = new ArrayList<>();
for(String name : names)
{
GroupGraph.Node node = graph.byName(name);
if(node == null)
{
throw (new QUserFacingException("Group not found: " + name));
}
groups.add(node);
}
return (groups);
}
/*******************************************************************************
** Required expiry measured from now, with separate human and service caps.
*******************************************************************************/
private Instant expiry(AdminRequest request, boolean service) throws QUserFacingException
{
int maximum = service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser();
try
{
int days = Integer.parseInt(request.str("expiresInDays"));
if(days >= 1 && days <= maximum)
{
return (Instant.now().plus(days, ChronoUnit.DAYS));
}
}
catch(NumberFormatException ignored)
{
// Missing, fractional and malformed values are all refused.
}
throw (new QUserFacingException("Expiry is required: expiresInDays must be between 1 and " + maximum + "."));
}
/*******************************************************************************
** Rotation preserves a valid existing expiry, never a non-expiring token.
*******************************************************************************/
private Instant checkExpiry(JSONObject token, boolean service) throws QUserFacingException
{
try
{
Instant expires = Instant.parse(token.getString("expires"));
if(token.optBoolean("expiring") && expires.isAfter(Instant.now())
&& !expires.isAfter(Instant.now().plus(service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser(), ChronoUnit.DAYS)))
{
return (expires);
}
}
catch(RuntimeException ignored)
{
// No expiry is never acceptable for a new credential.
}
throw (new QUserFacingException("Token expiry is missing, expired or exceeds the maximum lifetime."));
}
/*******************************************************************************
** Built-in protected groups cannot be removed by configuration.
*******************************************************************************/
private void checkProtectedGroup(GroupGraph.Node group) throws QException
{
Set<String> protectedNames = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
protectedNames.addAll(List.of("authentik Admins", "identity-admins"));
if(config.getTokenProtectedGroups() != null)
{
protectedNames.addAll(config.getTokenProtectedGroups());
}
for(GroupGraph.Node ancestor : util.groupGraph().selfAndAncestors(group.pk()))
{
if(ancestor.ownSuperuser() || protectedNames.contains(ancestor.name()))
{
throw (protectedAccount("member of " + ancestor.name()));
}
}
}
/*******************************************************************************
** Reuse account protection, adding inherited protected-group membership.
*******************************************************************************/
private void checkProtected(JSONObject owner) throws QException
{
AuthentikGuard.ProtectionResult protection = util.getRuntime().getGuard().protection(owner);
if(protection.isProtected())
{
throw (protectedAccount(protection.reason()));
}
JSONArray groups = owner.optJSONArray("groups_obj");
for(int i = 0; groups != null && i < groups.length(); i++)
{
GroupGraph.Node node = util.groupGraph().byPk(groups.getJSONObject(i).getString("pk"));
if(node == null)
{
throw (protectedAccount("group protection could not be verified"));
}
checkProtectedGroup(node);
}
}
/*******************************************************************************
** Consistent protected-owner refusal, including service-account set_key.
*******************************************************************************/
private static QUserFacingException protectedAccount(String reason)
{
return (new QUserFacingException("Protected account (" + reason + "): tokens for it are managed in the Authentik admin UI."));
}
/*******************************************************************************
** Fetch token metadata without retaining a bearer or key.
*******************************************************************************/
private JSONObject token(String identifier) throws QException
{
util.getRuntime().invalidateCache();
JSONObject token = util.getApiJsonOrNull(path(identifier));
if(token == null)
{
throw (new QUserFacingException("That token no longer exists."));
}
return (token);
}
/*******************************************************************************
** Token owner pk from the pinned schema.
*******************************************************************************/
private static int ownerPk(JSONObject token)
{
return (token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk"));
}
/*******************************************************************************
** Fetch live owner facts, refusing missing owners.
*******************************************************************************/
private JSONObject owner(int pk) throws QException
{
util.getRuntime().invalidateCache();
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
if(owner == null)
{
throw (new QUserFacingException("The token's owner no longer exists."));
}
return (owner);
}
/*******************************************************************************
** Validated token URL component.
*******************************************************************************/
private static String path(String identifier) throws QException
{
return ("/core/tokens/" + AuthentikAdmin.safeKey(identifier) + "/");
}
/*******************************************************************************
** Whether a resolved config string is absent.
*******************************************************************************/
private static boolean blank(String value)
{
return (value == null || value.isBlank());
}
/*******************************************************************************
** Execute only after auditing has begun; return the key outside audit data.
*******************************************************************************/
public AuthentikAdmin.AdminResult perform(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
if((request.action() == AdminAction.CREATE_TOKEN || request.action() == AdminAction.ROTATE_TOKEN || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
&& !TokenKeyDelivery.get().available())
{
throw (new QUserFacingException(DELIVERY_UNAVAILABLE));
}
if(request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
{
return (createServiceAccount(plan, auth));
}
JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before));
checkProtectedAfterRead(owner);
boolean service = "service_account".equals(owner.optString("type"));
boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if((!own || service) && !manager || !own && !service && request.action() != AdminAction.REVOKE_TOKEN)
{
throw (new QUserFacingException("Token ownership changed; review again."));
}
String bearer = own && !service ? new AuthentikTokenBroker().exchange(util, auth) : config.getResolvedApiToken();
switch(request.action())
{
case CREATE_TOKEN:
return (createPersonal(request, owner, expiry(request, false), null, bearer, auth));
case REVOKE_TOKEN:
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
scopes.delete(request.targetKey());
return (success("Token revoked."));
case EXTEND_TOKEN:
util.writeAs(bearer, auth, "PATCH", path(request.targetKey()), new JSONObject().put("intent", before.getString("intent"))
.put("user", owner.getInt("pk")).put("expires", expiry(request, service).toString()).put("expiring", true));
raceGuard(before, token(request.targetKey()));
scopes.resetReminders(request.targetKey());
return (success("Token expiry updated."));
case ROTATE_TOKEN:
if(!service)
{
return (createPersonal(request, owner, checkExpiry(before, false), before, bearer, auth));
}
String scope = scopeSummary(request.targetKey());
Instant expires = checkExpiry(before, true);
byte[] random = new byte[48];
RANDOM.nextBytes(random);
String key = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
util.writeAs(bearer, auth, "POST", path(request.targetKey()) + "set_key/", new JSONObject().put("key", key));
JSONObject after = token(request.targetKey());
raceGuard(before, after);
return (success("Service-account token rotated. The old key no longer works.")
.withSecret(oneTime(request.targetKey(), key, owner, expires, scope), () ->
{
throw (new QUserFacingException("The old key no longer works and the new key is lost. Rotate it in the Authentik admin UI."));
}));
default:
throw (new QException("Unsupported token action"));
}
}
/*******************************************************************************
** Guard owner and intent after every PATCH or set_key, with a safe warning.
*******************************************************************************/
private void raceGuard(JSONObject before, JSONObject after) throws QException
{
if(ownerPk(before) != ownerPk(after) || !before.getString("intent").equals(after.getString("intent")))
{
alertGuardBypass();
LOG.warn(TOKEN_MOVED);
throw (new QUserFacingException(TOKEN_MOVED));
}
checkProtectedAfterRead(owner(ownerPk(after)));
}
/*******************************************************************************
** Create as the operator, fetch their key, and optionally replace the old id.
** On partial failure discard the new credential; the old one stays usable.
*******************************************************************************/
private AuthentikAdmin.AdminResult createPersonal(AdminRequest request, JSONObject owner, Instant expires, JSONObject old,
String bearer, WriteAuthorization auth) throws QException
{
String identifier = identifier(owner.getString("username"));
String description = old == null ? request.str("description") : old.optString("description");
util.writeAs(bearer, auth, "POST", "/core/tokens/", new JSONObject().put("identifier", identifier).put("intent", "app_password")
.put("description", description).put("expiring", true).put("expires", expires.toString()));
try
{
JSONObject created = util.getAs(bearer, auth, path(identifier));
checkProtectedAfterRead(owner(ownerPk(created)));
if(ownerPk(created) != owner.getInt("pk") || !"app_password".equals(created.optString("intent")))
{
throw (new QUserFacingException(TOKEN_MOVED));
}
String key = util.getAs(bearer, auth, path(identifier) + "view_key/").getString("key");
Instant actualExpiry = checkExpiry(created, false);
if(old == null)
{
scopes.create(identifier, owner, request, admin.operator().email());
}
else
{
QRecord previous = scopes.find(request.targetKey());
if(previous == null)
{
scopes.create(identifier, owner, AdminRequest.of(request.action(), identifier, request.reason()), admin.operator().email());
}
else
{
scopes.move(request.targetKey(), identifier);
}
}
OneTimeKey secret = oneTime(identifier, key, owner, actualExpiry, scopeSummary(identifier));
if(old != null)
{
try
{
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
}
catch(QException e)
{
scopes.move(identifier, request.targetKey());
throw (new QUserFacingException("Rotation failed: the old token could not be deleted and remains listed."));
}
}
return (success(old == null ? "Token created. The key is shown once." : "Token rotated. The old token was deleted.")
.withSecret(secret, () -> revokeIssued(identifier, bearer, auth)));
}
catch(Exception e)
{
revokeIssued(identifier, bearer, auth);
if(e instanceof QUserFacingException facing)
{
throw (facing);
}
throw (new QUserFacingException("The new token could not be completed and was revoked."));
}
}
/*******************************************************************************
** Create the single service-account app password and grant checked groups.
*******************************************************************************/
private AuthentikAdmin.AdminResult createServiceAccount(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
String identifier = "service-account-" + request.str("name") + "-password";
JSONObject created = util.write(auth, "POST", "/core/users/service_account/", new JSONObject().put("name", request.str("name"))
.put("create_group", false).put("expiring", true).put("expires", expiry(request, true).toString()));
try
{
Integer pk = created.has("user_pk") ? created.getInt("user_pk") : admin.userPkByUsername(created.optString("username", request.str("name")));
if(pk == null)
{
throw (new QException("The service account could not be found."));
}
JSONObject current = owner(pk);
JSONObject attributes = new JSONObject(current.optJSONObject("attributes") == null ? "{}" : current.getJSONObject("attributes").toString());
attributes.put("goauthentik.io/user/token-maximum-lifetime", config.getServiceAccountTokenLifetime());
util.write(auth, "PATCH", "/core/users/" + pk + "/", new JSONObject().put("attributes", attributes));
for(GroupGraph.Node group : serviceGroups(request))
{
checkProtectedGroup(group);
String problem = admin.checkGroupGrantable(group);
if(problem != null)
{
throw (new QUserFacingException(problem));
}
util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk));
}
JSONObject owner = owner(pk);
checkProtectedAfterRead(owner);
JSONObject after = token(identifier);
checkProtectedAfterRead(owner(ownerPk(after)));
scopes.create(identifier, owner, request, admin.operator().email());
return (success("Service account created. " + String.join(" ", plan.getNotes()))
.withSecret(oneTime(identifier, created.getString("token"), owner, checkExpiry(after, true), "Owner permissions"),
() -> revokeIssued(identifier, config.getResolvedApiToken(), auth)));
}
catch(Exception e)
{
revokeIssued(identifier, config.getResolvedApiToken(), auth);
throw (new QUserFacingException("The service account was created, but setup failed. Its token was revoked; finish setup in Authentik."));
}
}
/*******************************************************************************
** Emergency revocation for a newly created token whose key cannot be shown.
*******************************************************************************/
private void revokeIssued(String identifier, String bearer, WriteAuthorization auth) throws QException
{
try
{
util.writeAs(bearer, auth, "DELETE", path(identifier), null);
scopes.delete(identifier);
}
catch(QException e)
{
throw (new QUserFacingException("The new key could not be delivered and token revocation failed. Revoke it in the Authentik admin UI."));
}
}
/*******************************************************************************
** Scope summary contains metadata only.
*******************************************************************************/
private String scopeSummary(String identifier) throws QException
{
QRecord row = scopes.find(identifier);
List<String> parts = new ArrayList<>();
for(String field : TokenScopeStore.SCOPE_FIELDS)
{
if(row != null && row.getValue(field) != null)
{
parts.add(field + ": " + row.getValueString(field));
}
}
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
}
/*******************************************************************************
** Build the isolated one-time object, including its response-only usage text.
*******************************************************************************/
private OneTimeKey oneTime(String identifier, String key, JSONObject owner, Instant expires, String scope)
{
return (new OneTimeKey(identifier, key, TokenUsageText.build(config, owner.getString("username"), key, expires, scope)));
}
/*******************************************************************************
** Non-secret result text.
*******************************************************************************/
private static AuthentikAdmin.AdminResult success(String message)
{
return (new AuthentikAdmin.AdminResult(true, false, false, message, null));
}
/*******************************************************************************
** Collision-resistant identifier, independent of the secret value.
*******************************************************************************/
private static String identifier(String username)
{
String alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder suffix = new StringBuilder();
for(int i = 0; i < 6; i++)
{
suffix.append(alphabet.charAt(RANDOM.nextInt(alphabet.length())));
}
String slug = username.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-]+", "-");
return ("qqq-pat-" + slug + "-" + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC).format(Instant.now()) + "-" + suffix);
}
/*******************************************************************************
** A protected owner observed after Review or a write is a guard incident.
*******************************************************************************/
private void checkProtectedAfterRead(JSONObject owner) throws QException
{
try
{
checkProtected(owner);
}
catch(QUserFacingException e)
{
alertGuardBypass();
throw (e);
}
}
/*******************************************************************************
** Safe incident payload: no response bodies, credentials or owner details.
*******************************************************************************/
private void alertGuardBypass()
{
try
{
util.getRuntime().getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null,
"An access token changed owner or intent, or a protected owner was observed after Review. Investigate in Authentik."));
}
catch(RuntimeException e)
{
LOG.warn("Access-token guard alert delivery failed.");
}
}
}
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.admin;
/*******************************************************************************
** A response-only value. Deliberately not Serializable; never a process value.
*******************************************************************************/
public final class OneTimeKey
{
private final String label;
private final String value;
private final String usageText;
/*******************************************************************************
** Construct the one-time response.
*******************************************************************************/
public OneTimeKey(String label, String value, String usageText)
{
this.label = label;
this.value = value;
this.usageText = usageText;
}
/*******************************************************************************
** Getter for label.
*******************************************************************************/
public String label()
{
return (label);
}
/*******************************************************************************
** Getter for value.
*******************************************************************************/
public String value()
{
return (value);
}
/*******************************************************************************
** Getter for masked.
*******************************************************************************/
public String masked()
{
return ("\u2022\u2022\u2022\u2022" + value.substring(Math.max(0, value.length() - 4)));
}
/*******************************************************************************
** Getter for usageText.
*******************************************************************************/
public String usageText()
{
return (usageText);
}
/*******************************************************************************
** Never render the key or its usage text.
*******************************************************************************/
@Override
public String toString()
{
return ("OneTimeKey{label=" + label + ", value=[one-time]}" );
}
}
@@ -0,0 +1,192 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Non-secret bookkeeping. No method accepts a key or a bearer.
*******************************************************************************/
public class TokenScopeStore
{
public static final List<String> SCOPE_FIELDS = List.of("readOnly", "apps", "tables", "processes", "reports", "widgets", "operationIds");
private final AuthentikQBitConfig config;
/*******************************************************************************
** Bind to one qbit's local backend.
*******************************************************************************/
public TokenScopeStore(AuthentikQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** Local table name.
*******************************************************************************/
private String table()
{
return (config.applyTablePrefix(AuthentikLocalTables.TOKEN_SCOPE));
}
/*******************************************************************************
** Fetch the unique scope row as a trusted SYSTEM read (QueryInput default).
*******************************************************************************/
public QRecord find(String identifier) throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput().withTableName(table())
.withFilter(new QQueryFilter(new QFilterCriteria("tokenIdentifier", QCriteriaOperator.EQUALS, identifier)))).getRecords();
return (rows.isEmpty() ? null : rows.get(0));
}
/*******************************************************************************
** Record a new identifier, even when scopes are disabled.
*******************************************************************************/
public void create(String identifier, JSONObject owner, AdminRequest request, String operator) throws QException
{
QRecord row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
.withValue("ownerUsername", owner.getString("username")).withValue("createdBy", operator).withValue("createdAt", Instant.now());
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
for(String field : SCOPE_FIELDS)
{
if(request.params().containsKey(field))
{
row.setValue(field, request.params().get(field));
}
}
}
List<QRecord> rows = new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords();
check(rows);
}
/*******************************************************************************
** Keep all scope and reminder fields when rotating a personal credential.
*******************************************************************************/
public void move(String oldIdentifier, String newIdentifier) throws QException
{
QRecord row = find(oldIdentifier);
if(row != null)
{
row.setValue("tokenIdentifier", newIdentifier);
row.setValue("rotatedAt", Instant.now());
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
/*******************************************************************************
** Revoke removes only the corresponding metadata row.
*******************************************************************************/
public void delete(String identifier) throws QException
{
QRecord row = find(identifier);
if(row != null)
{
new DeleteAction().execute(new DeleteInput().withTableName(table()).withPrimaryKeys(List.of(row.getValue("id"))));
}
}
/*******************************************************************************
** Fail closed if local storage rejects the write.
*******************************************************************************/
private static void check(List<QRecord> rows) throws QException
{
if(rows.isEmpty() || !rows.get(0).getErrors().isEmpty())
{
throw (new QException("Token scope metadata could not be saved."));
}
}
/*******************************************************************************
** Human-readable, non-secret scope for lists and Review.
*******************************************************************************/
public static String summary(QRecord row)
{
java.util.ArrayList<String> parts = new java.util.ArrayList<>();
for(String field : SCOPE_FIELDS)
{
if(row != null && row.getValue(field) != null && !row.getValueString(field).isBlank())
{
parts.add(field + ": " + row.getValueString(field));
}
}
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
}
/*******************************************************************************
** Persist dedupe metadata. External tokens get no invented creation date or
** restrictions; their token row remains unscoped.
*******************************************************************************/
public void reminded(String identifier, JSONObject owner, String field, Instant at) throws QException
{
QRecord row = find(identifier);
if(row == null)
{
row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
.withValue("ownerUsername", owner.getString("username")).withValue(field, at);
check(new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
else
{
row.setValue(field, at);
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
/*******************************************************************************
** A new expiry starts a new set of reminder windows.
*******************************************************************************/
public void resetReminders(String identifier) throws QException
{
QRecord row = find(identifier);
if(row != null)
{
row.setValue("lastReminder7d", null);
row.setValue("lastReminder1d", null);
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
}
@@ -0,0 +1,69 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
/*******************************************************************************
** Usage instructions held exclusively inside the one-time response.
*******************************************************************************/
public final class TokenUsageText
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TokenUsageText()
{
}
/*******************************************************************************
** Generate shell-quoted exchange and application requests from public URLs.
*******************************************************************************/
public static String build(AuthentikQBitConfig config, String username, String key, Instant expires, String scope)
{
String clientId = config.getAppClientId();
if(clientId == null && QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth)
{
clientId = oauth.getClientId();
}
String endpoint = config.getAuthentikPublicUrl().replaceAll("/+$", "") + "/application/o/token/";
String app = config.getAppPublicUrl() == null ? "{appPublicUrl}" : config.getAppPublicUrl().replaceAll("/+$", "");
return ("Expires: " + expires + "\nScope: " + scope + "\n\n"
+ "curl -X POST " + quote(endpoint) + " --data-urlencode 'grant_type=client_credentials'"
+ " --data-urlencode " + quote("client_id=" + (clientId == null ? "{appClientId}" : clientId))
+ " --data-urlencode " + quote("username=" + username) + " --data-urlencode " + quote("password=" + key)
+ " --data-urlencode 'scope=openid profile email'\n\n"
+ "curl -H 'Authorization: Bearer {jwt}' " + quote(app + "/qqq/v1/metaData"));
}
/*******************************************************************************
** Quote literal shell arguments, including usernames containing apostrophes.
*******************************************************************************/
private static String quote(String value)
{
return ("'" + value.replace("'", "'\"'\"'") + "'");
}
}
@@ -17,20 +17,18 @@
package com.kingsrook.qbits.authentik.app;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -56,8 +54,6 @@ public class AuthentikHomeData
{
private static final QLogger LOG = QLogger.getLogger(AuthentikHomeData.class);
public static final int TOKEN_WARNING_DAYS = 14;
private boolean peopleAvailable;
private int activePeople;
private int inactivePeople;
@@ -84,8 +80,10 @@ public class AuthentikHomeData
private List<QRecord> recentActions = new ArrayList<>();
private List<String> attention = new ArrayList<>();
private String healthStatus = "UNKNOWN";
private Integer healthComponentId;
private AuthentikQBitConfig config;
private String version;
@@ -104,7 +102,7 @@ public class AuthentikHomeData
data.loadApprovals();
data.loadActions();
data.loadAttention(util);
data.version = util.fetchServerVersion().orElse(null);
data.loadHealth();
return (data);
}
@@ -240,8 +238,7 @@ public class AuthentikHomeData
/*******************************************************************************
** Things that need a person: drift findings, blueprint state, tokens about
** to expire, bootstrap operators still set.
** Configuration review and bootstrap operators still set. Health is central.
*******************************************************************************/
private void loadAttention(AuthentikActionUtil util)
{
@@ -275,26 +272,6 @@ public class AuthentikHomeData
LOG.info("Identity home: drift report unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
if(mayAudit)
{
try
{
Instant soon = Instant.now().plus(TOKEN_WARNING_DAYS, ChronoUnit.DAYS);
for(JSONObject token : util.getAllPages("/core/tokens/?user__username=" + java.net.URLEncoder.encode(config.getEffectiveServiceAccountUsername(), java.nio.charset.StandardCharsets.UTF_8), 50))
{
Instant expires = AuthentikJson.parseInstant(token.optString("expires", null));
if(token.optBoolean("expiring", true) && expires != null && expires.isBefore(soon))
{
attention.add("The qbit's API token (" + token.optString("identifier") + ") expires on "
+ java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(java.time.ZoneOffset.UTC).format(expires) + ": rotate it (owner checklist).");
}
}
}
catch(Exception e)
{
LOG.debug("Identity home: token expiry not checked", e);
}
}
}
@@ -320,11 +297,51 @@ public class AuthentikHomeData
/*******************************************************************************
** Getter for version
** Read the central rollup using the current session's table permissions.
*******************************************************************************/
public String getVersion()
private void loadHealth()
{
return (version);
if(QContext.getQInstance().getTable("health_component") == null)
{
return;
}
try
{
QueryInput query = new QueryInput();
query.setTableName("health_component");
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
query.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "authentik")).withLimit(1));
List<QRecord> records = new QueryAction().execute(query).getRecords();
if(!records.isEmpty())
{
healthStatus = com.kingsrook.qqq.backend.core.health.HealthStatus.valueOf(records.getFirst().getValueString("status")).name();
healthComponentId = records.getFirst().getValueInteger("id");
}
}
catch(Exception e)
{
LOG.debug("Identity home: central health unavailable");
}
}
/*******************************************************************************
** Getter for the central component status.
*******************************************************************************/
public String getHealthStatus()
{
return (healthStatus);
}
/*******************************************************************************
** Getter for the central component record link.
*******************************************************************************/
public Integer getHealthComponentId()
{
return (healthComponentId);
}
@@ -66,7 +66,7 @@ public final class AuthentikHomeView
people(html, data, config);
groups(html, data, config);
activity(html, data, config);
connection(html, data);
health(html, data);
return (html.append("</div>").toString());
}
@@ -204,15 +204,12 @@ public final class AuthentikHomeView
/*******************************************************************************
**
*******************************************************************************/
private static void connection(StringBuilder html, AuthentikHomeData data)
private static void health(StringBuilder html, AuthentikHomeData data)
{
open(html, "Connection");
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Authentik", data.getConfig().getBaseUrl());
row(html, "Version", data.getVersion() == null ? "Not reachable" : data.getVersion());
row(html, "Service account", data.getConfig().getEffectiveServiceAccountUsername());
row(html, "Changes", Boolean.TRUE.equals(data.getConfig().getReadOnly()) ? "Off (read-only)" : "On, every change is audited here and in Authentik");
html.append("</dl>");
open(html, "Health");
String target = prefix(data.getConfig()) + "/health_component" + (data.getHealthComponentId() == null ? "" : "/" + data.getHealthComponentId());
html.append("<p>Authentik: ").append(esc(data.getHealthStatus())).append(" - <a href=\"")
.append(esc(target)).append("\">System Health</a></p>");
close(html);
}
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.authentik.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -81,13 +82,26 @@ public final class AuthentikRelations
List<QFieldSection> groupSections = new ArrayList<>();
groupSections.add(new QFieldSection("details", "Details", new QIcon("groups"), Tier.T1,
List.of("name", "isSuperuser", "isSuperuserInherited", "parentNames", "childNames", "memberCount", "roleNames", "pk")));
List.of("name", "accessType", "isSuperuser", "isSuperuserInherited", "parentNames", "childNames", "memberCount", "roleNames", "pk")));
groupSections.add(new QFieldSection("permissions", "Permissions and app access", new QIcon("admin_panel_settings"), Tier.T2,
List.of("permissions", "effectivePermissions", "appAccess")));
relate(qInstance, groupSections, "members", "Members", "group", group, membership, "pk", "groupPk");
enforcement(qInstance, config, groupSections);
relate(qInstance, groupSections, "policy", "Policy", "policy", group, config.applyTablePrefix(AuthentikLocalTables.POLICY), "pk", "groupPk");
groupSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("attributes")));
applySections(qInstance, group, groupSections);
String policyTable = config.applyTablePrefix(AuthentikLocalTables.POLICY);
if(qInstance.getTable(policyTable) != null)
{
List<QFieldSection> policySections = new ArrayList<>();
enforcement(qInstance, config, policySections);
policySections.add(new QFieldSection("policy", "Policy", new QIcon("policy"), Tier.T1,
List.of("groupName", "statementCount", "permissionCount", "savedBy", "savedDate", "policyJson")));
policySections.add(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3, List.of("id", "groupPk", "authentikFingerprint", "createDate", "modifyDate")));
applySections(qInstance, policyTable, policySections);
}
List<QFieldSection> appSections = new ArrayList<>();
appSections.add(new QFieldSection("details", "Details", new QIcon("apps"), Tier.T1,
List.of("name", "slug", "group", "providerName", "policyEngineMode", "launchUrl", "publisher", "hiddenFromLibrary")));
@@ -97,6 +111,20 @@ public final class AuthentikRelations
/*******************************************************************************
** The policy enforcement banner as the first section (when produced).
*******************************************************************************/
private static void enforcement(QInstance qInstance, AuthentikQBitConfig config, List<QFieldSection> sections)
{
String widget = config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.ENFORCEMENT);
if(qInstance.getWidget(widget) != null)
{
sections.add(0, new QFieldSection("enforcement", new QIcon("verified_user"), Tier.T2).withLabel("Policy enforcement").withWidgetName(widget));
}
}
/*******************************************************************************
** One join plus its child-list widget and the section that shows it.
*******************************************************************************/
@@ -0,0 +1,180 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.app;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Standard TABLE widget: inventory, age and actionable cleanup suggestions.
** Computed fields never become API filters; links use owner or token keys.
*******************************************************************************/
public class AuthentikTokenWidget extends AbstractWidgetRenderer
{
/*******************************************************************************
** Check the permission here as well as in metadata (non-HTTP callers).
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backend = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get("backendName"));
return (new RenderWidgetOutput(data(AuthentikActionUtil.forBackend(backend), Instant.now())));
}
/*******************************************************************************
** Report complete inventory and explicit unknown ages. An API token's
** activity is untracked and never qualifies as unused.
*******************************************************************************/
public static TableData data(AuthentikActionUtil util, Instant now) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
{
throw (new QPermissionDeniedException("Access token widgets require authentik.tokens.manage."));
}
AuthentikQBitConfig config = util.getRuntime().getConfig();
List<JSONObject> tokens = util.getAllPages("/core/tokens/", Integer.MAX_VALUE);
Map<String, Instant> activity = AuthentikTokenActivity.lastUsed(util);
Map<String, List<String>> owners = new LinkedHashMap<>();
Map<String, List<String>> ages = new LinkedHashMap<>();
for(String bucket : List.of("<30d", "30-90d", "90-365d", ">365d", "unknown"))
{
ages.put(bucket, new ArrayList<>());
}
Map<Integer, JSONObject> users = new LinkedHashMap<>();
List<Map<String, Object>> cleanup = new ArrayList<>();
TokenScopeStore scopes = new TokenScopeStore(config);
for(JSONObject token : tokens)
{
String id = token.getString("identifier");
JSONObject owner = token.getJSONObject("user_obj");
String username = owner.getString("username");
owners.computeIfAbsent(username, k -> new ArrayList<>()).add(id);
QRecord scope = scopes.find(id);
Instant created = scope == null ? null : scope.getValueInstant("createdAt");
long days = created == null ? -1 : ChronoUnit.DAYS.between(created, now);
ages.get(days < 0 ? "unknown" : days < 30 ? "<30d" : days < 90 ? "30-90d" : days <= 365 ? "90-365d" : ">365d").add(id);
int pk = owner.getInt("pk");
if(!users.containsKey(pk))
{
users.put(pk, util.getApiJsonOrNull("/core/users/" + pk + "/"));
}
JSONObject live = users.get(pk);
String links = link(list(config, "username", List.of(username)), username) + " / " + link(list(config, "identifier", List.of(id)), id);
if(live != null && !live.optBoolean("is_active", true))
{
cleanup.add(row("Owner deactivated", 1, links));
}
Instant last = activity.get(id);
Instant cutoff = now.minus(config.getTokenUnusedDays(), ChronoUnit.DAYS);
// Without a creation time or an event we cannot prove age. A 90-day
// event window cannot prove inactivity beyond 90 days either.
boolean unused = last != null ? last.isBefore(cutoff)
: config.getTokenUnusedDays() <= 90 && created != null && created.isBefore(cutoff);
if("app_password".equals(token.optString("intent")) && unused)
{
cleanup.add(row("Unused for " + config.getTokenUnusedDays() + " days", 1, links));
}
}
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(row("Total", tokens.size(), link(list(config, null, List.of()), "All access tokens")));
owners.entrySet().stream().sorted(Comparator.<Map.Entry<String, List<String>>>comparingInt(e -> e.getValue().size()).reversed().thenComparing(Map.Entry::getKey))
.limit(10).forEach(e -> rows.add(row("Owner: " + e.getKey(), e.getValue().size(), link(list(config, "username", List.of(e.getKey())), "Tokens"))));
ages.forEach((bucket, ids) -> rows.add(row("Age: " + bucket, ids.size(), ids.isEmpty() ? "" : link(list(config, "identifier", ids), "Tokens"))));
rows.addAll(cleanup);
return (new TableData("Access tokens", List.of(
new TableData.Column("default", "Category", "category", "35%", "left"),
new TableData.Column("default", "Count", "count", "10%", "right"),
new TableData.Column("html", "Access tokens", "tokens", "55%", "left")), rows)
.withRowsPerPage(25).withLinkText("All access tokens / bulk revoke").withLinkURL(list(config, null, List.of())));
}
/*******************************************************************************
** A standard table row; only the link cell contains escaped anchor markup.
*******************************************************************************/
private static Map<String, Object> row(String category, int count, String tokens)
{
return (Map.of("category", category, "count", count, "tokens", tokens));
}
/*******************************************************************************
** Native list filter format shared by Material and Next. Primary-key IN is
** supported by the API adapter as individual GETs; no computed filtering.
*******************************************************************************/
private static String list(AuthentikQBitConfig config, String field, List<String> values)
{
String url = (config.getLinkPrefix() == null ? "" : config.getLinkPrefix()) + "/" + config.applyTablePrefix("token");
if(field != null)
{
JSONObject criteria = new JSONObject().put("fieldName", field).put("operator", values.size() == 1 ? "EQUALS" : "IN").put("values", values);
String filter = new JSONObject().put("booleanOperator", "AND").put("criteria", new JSONArray().put(criteria)).toString();
url += "?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8);
}
return (url);
}
/*******************************************************************************
** Escape both the link target and text; descriptions are never HTML.
*******************************************************************************/
private static String link(String url, String text)
{
return ("<a href=\"" + escape(url) + "\">" + escape(text) + "</a>");
}
/*******************************************************************************
** Minimal HTML escaping for a standard table link cell.
*******************************************************************************/
private static String escape(String value)
{
return (value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
}
File diff suppressed because it is too large. Load diff
@@ -58,17 +58,30 @@ public final class AuthentikApiSurface
{
List<Operation> rs = new ArrayList<>();
rs.add(new Operation("get", "/admin/version/", Set.of()));
rs.add(new Operation("get", "/flows/instances/{slug}/", Set.of()));
rs.add(new Operation("get", "/flows/instances/", Set.of("flow_uuid", "page", "page_size")));
rs.add(new Operation("get", "/flows/bindings/", Set.of("target", "page", "page_size")));
rs.add(new Operation("get", "/policies/bindings/", Set.of("target", "page", "page_size")));
rs.add(new Operation("get", "/policies/expression/{policy_uuid}/", Set.of()));
rs.add(new Operation("post", "/core/tokens/", Set.of()));
rs.add(new Operation("patch", "/core/tokens/{key}/", Set.of()));
rs.add(new Operation("delete", "/core/tokens/{key}/", Set.of()));
rs.add(new Operation("get", "/core/tokens/{key}/view_key/", Set.of()));
rs.add(new Operation("post", "/core/tokens/{key}/set_key/", Set.of()));
rs.add(new Operation("post", "/core/users/service_account/", Set.of()));
///////////////////////////////////////////////////////////
// virtual membership table, group graph, drift report //
///////////////////////////////////////////////////////////
rs.add(new Operation("get", "/core/groups/", Set.of("include_users", "include_parents", "include_children", "name", "ordering", "page", "page_size")));
rs.add(new Operation("get", "/core/groups/{key}/", Set.of("include_users")));
rs.add(new Operation("get", "/core/users/", Set.of("username", "include_groups", "attributes", "type", "path_startswith", "groups_by_name", "page", "page_size")));
rs.add(new Operation("get", "/core/users/", Set.of("username", "uuid", "include_groups", "include_roles", "attributes", "type", "path_startswith", "groups_by_name", "groups_by_pk", "is_active", "ordering", "page", "page_size", "last_updated__gt")));
rs.add(new Operation("get", "/core/users/{key}/", Set.of()));
rs.add(new Operation("get", "/managed/blueprints/", Set.of("page", "page_size")));
rs.add(new Operation("get", "/core/tokens/", Set.of("user__username", "intent", "page", "page_size")));
rs.add(new Operation("get", "/events/events/", Set.of("username", "ordering", "page", "page_size")));
rs.add(new Operation("get", "/events/events/", Set.of("username", "action", "ordering", "page", "page_size")));
rs.add(new Operation("get", "/events/events/stats/", Set.of("username", "action", "count_steps")));
rs.add(new Operation("get", "/authenticators/admin/all/", Set.of("user")));
return (rs);
}
@@ -37,10 +37,10 @@ public final class AuthentikRedactor
/** Path fragments whose request and response bodies are never logged. */
private static final List<String> SENSITIVE_PATH_FRAGMENTS = List.of(
"/recovery", "/recovery_email", "/set_password", "/set_password_hash", "/core/tokens/", "/service_account", "/impersonate", "/view_key", "/set_key");
"/application/o/token/", "/recovery", "/recovery_email", "/set_password", "/set_password_hash", "/core/tokens/", "/service_account", "/impersonate", "/view_key", "/set_key");
private static final Pattern SECRET_JSON_VALUE = Pattern.compile(
"(\"(?:key|token|link|password|secret|client_secret|password_hash|recovery_link)\"\\s*:\\s*)\"((?:[^\"\\\\]|\\\\.)*)\"", Pattern.CASE_INSENSITIVE);
"(\"(?:key|token|access_token|subject_token|link|password|secret|client_secret|password_hash|recovery_link)\"\\s*:\\s*)\"((?:[^\"\\\\]|\\\\.)*)\"", Pattern.CASE_INSENSITIVE);
private static final Pattern BEARER = Pattern.compile("(?i)(bearer\\s+)[A-Za-z0-9._~+/=-]+");
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.client;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Supplies the current operator's subject JWT for one token action.
*******************************************************************************/
@FunctionalInterface
public interface AuthentikSubjectTokenSource
{
/*******************************************************************************
** Return a subject JWT without retaining or logging it.
*******************************************************************************/
String subjectToken(QSession session) throws QException;
}
@@ -41,6 +41,7 @@ public class AuthentikTableSpec
private final Map<String, AuthentikFieldSpec> fields = new LinkedHashMap<>();
private final List<NameValuePair> defaultListParams = new ArrayList<>();
private final List<NameValuePair> defaultGetParams = new ArrayList<>();
private IdentityPermission alternativeReadPermission;
private IdentityPermission readPermission = IdentityPermission.USERS_READ;
private String defaultOrdering;
private List<String> recordLabelFields = new ArrayList<>();
@@ -298,4 +299,34 @@ public class AuthentikTableSpec
return (requiredFilterField);
}
/*******************************************************************************
** Add a second permission that also permits reads.
*******************************************************************************/
public AuthentikTableSpec withAlternativeReadPermission(IdentityPermission permission)
{
alternativeReadPermission = permission;
return (this);
}
/*******************************************************************************
** Getter for the optional alternative permission.
*******************************************************************************/
public IdentityPermission getAlternativeReadPermission()
{
return (alternativeReadPermission);
}
/*******************************************************************************
** Token tables need OR rules in core as well as in the API guard.
*******************************************************************************/
public com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules readRules()
{
return (alternativeReadPermission == null ? readPermission.rules() : com.kingsrook.qbits.authentik.security.TokenReadPermission.rules());
}
}
@@ -161,7 +161,7 @@ public final class AuthentikTables
spec.withField(new AuthentikFieldSpec("isProtected", "Protected", QFieldType.BOOLEAN, o -> null));
spec.withField(new AuthentikFieldSpec("groupNames", "Groups", QFieldType.STRING, joined("groups_obj", "name"))
.withFilter(QCriteriaOperator.EQUALS, "groups_by_name").withFilter(QCriteriaOperator.IN, "groups_by_name"));
spec.withField(new AuthentikFieldSpec("roleNames", "Roles", QFieldType.STRING, joined("roles_obj", "name"))
spec.withField(new AuthentikFieldSpec("roleNames", "Authentik Administrative Roles", QFieldType.STRING, joined("roles_obj", "name"))
.withFilter(QCriteriaOperator.EQUALS, "roles_by_name").withFilter(QCriteriaOperator.IN, "roles_by_name"));
spec.withField(new AuthentikFieldSpec("lastLogin", "Last Login", QFieldType.DATE_TIME, instant("last_login"))
.withFilter(QCriteriaOperator.GREATER_THAN, "last_login__gt").withFilter(QCriteriaOperator.LESS_THAN, "last_login__lt")
@@ -192,7 +192,7 @@ public final class AuthentikTables
*******************************************************************************/
static AuthentikTableSpec groups()
{
AuthentikTableSpec spec = new AuthentikTableSpec(GROUP, "Groups", "groups", "/core/groups/", "pk")
AuthentikTableSpec spec = new AuthentikTableSpec(GROUP, "Groups and Application Roles", "groups", "/core/groups/", "pk")
.withDefaultListParam("include_users", "true")
.withDefaultListParam("include_parents", "true")
.withDefaultListParam("include_children", "true")
@@ -205,6 +205,8 @@ public final class AuthentikTables
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name"))
.withFilter(QCriteriaOperator.EQUALS, "name").withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("accessType", "Access Type", QFieldType.STRING,
o -> com.kingsrook.qbits.authentik.permissions.ApplicationRole.isRole(o) ? "Application role" : "Group"));
spec.withField(new AuthentikFieldSpec("isSuperuser", "Superuser (own flag)", QFieldType.BOOLEAN, bool("is_superuser"))
.withFilter(QCriteriaOperator.EQUALS, "is_superuser"));
spec.withField(new AuthentikFieldSpec("isSuperuserInherited", "Superuser (incl. parents)", QFieldType.BOOLEAN, o -> null));
@@ -216,7 +218,7 @@ public final class AuthentikTables
spec.withField(new AuthentikFieldSpec("permissions", "QQQ Permissions", QFieldType.TEXT, attributeList(AuthentikJson.ATTR_PERMISSIONS)));
spec.withField(new AuthentikFieldSpec("effectivePermissions", "Effective QQQ Permissions (incl. parents)", QFieldType.TEXT, o -> null));
spec.withField(new AuthentikFieldSpec("appAccess", "App Access", QFieldType.TEXT, attributeList(AuthentikJson.ATTR_APP_ACCESS)));
spec.withField(new AuthentikFieldSpec("roleNames", "Roles", QFieldType.STRING, joined("roles_obj", "name")));
spec.withField(new AuthentikFieldSpec("roleNames", "Authentik Administrative Roles", QFieldType.STRING, joined("roles_obj", "name")));
spec.withField(new AuthentikFieldSpec("numPk", "Numeric Id", QFieldType.INTEGER, integer("num_pk")).withHidden());
spec.withField(new AuthentikFieldSpec("parentPks", "Parent Ids", QFieldType.TEXT, json("parents")).withHidden());
spec.withField(new AuthentikFieldSpec("attributes", "Attributes", QFieldType.TEXT, json("attributes")).withSensitive());
@@ -400,12 +402,13 @@ public final class AuthentikTables
/*******************************************************************************
** /core/tokens/ (identifier). Metadata only: there is NO key field, and the
** service account is never given token-key permissions. Needs audit.read.
** service account never reads token keys. Auditors and token managers may read.
*******************************************************************************/
static AuthentikTableSpec tokens()
{
AuthentikTableSpec spec = new AuthentikTableSpec(TOKEN, "Tokens", "key", "/core/tokens/", "identifier")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withAlternativeReadPermission(IdentityPermission.TOKENS_MANAGE)
.withDefaultOrdering("identifier")
.withRecordLabelFields("identifier");
spec.withField(new AuthentikFieldSpec("identifier", "Identifier", QFieldType.STRING, string("identifier")).withSearchable().withOrdering("identifier"));
@@ -414,6 +417,14 @@ public final class AuthentikTables
spec.withField(new AuthentikFieldSpec("description", "Description", QFieldType.STRING, string("description")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("expiring", "Expiring", QFieldType.BOOLEAN, bool("expiring")).withFilter(QCriteriaOperator.EQUALS, "expiring"));
spec.withField(new AuthentikFieldSpec("ownerPk", "Owner Id", QFieldType.INTEGER, intAt("user_obj", "pk")));
spec.withField(new AuthentikFieldSpec("ownerType", "Owner Type", QFieldType.STRING, at("user_obj", "type")));
spec.withField(new AuthentikFieldSpec("ownerName", "Owner", QFieldType.STRING, at("user_obj", "name")));
spec.withField(new AuthentikFieldSpec("managed", "Managed", QFieldType.STRING, string("managed")));
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, o -> null));
spec.withField(new AuthentikFieldSpec("lastUsedNote", "Last Used Note", QFieldType.STRING, o -> null));
spec.withField(new AuthentikFieldSpec("createdAt", "Created", QFieldType.DATE_TIME, o -> null));
spec.withField(new AuthentikFieldSpec("scopeSummary", "Scope", QFieldType.STRING, o -> null));
return (spec);
}
@@ -0,0 +1,112 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.client;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Non-secret local metadata and the most recent app-password login per token.
*******************************************************************************/
public final class AuthentikTokenActivity
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikTokenActivity()
{
}
/*******************************************************************************
** The pinned schema has no created__gt. Walk descending pages until the
** 90-day boundary instead of sending a silently ignored query parameter.
*******************************************************************************/
public static Map<String, Instant> lastUsed(AuthentikActionUtil util) throws QException
{
return (util.getRuntime().getTokenActivityCache().getResultThrowing("login", key ->
{
Map<String, Instant> latest = new LinkedHashMap<>();
Instant cutoff = Instant.now().minus(90, ChronoUnit.DAYS);
for(int page = 1; ; page++)
{
String path = "/events/events/?action=login&ordering=-created&page=" + page + "&page_size=100";
util.getRuntime().getResponseCache().clearKey(util.getRuntime().getConfig().getApiRoot() + path);
JSONObject response = util.getApiJson(path);
JSONArray events = response.getJSONArray("results");
for(int i = 0; i < events.length(); i++)
{
JSONObject event = events.getJSONObject(i);
Instant at = Instant.parse(event.getString("created"));
if(at.isBefore(cutoff))
{
return (Map.copyOf(latest));
}
JSONObject context = event.optJSONObject("context");
if("login".equals(event.optString("action")) && context != null && "token".equals(context.optString("method")))
{
JSONObject args = context.optJSONObject("method_args");
String identifier = args == null ? "" : args.optString("identifier");
if(!identifier.isBlank())
{
latest.merge(identifier, at, (a, b) -> a.isAfter(b) ? a : b);
}
}
}
if(events.isEmpty() || page >= response.getJSONObject("pagination").getInt("total_pages"))
{
return (Map.copyOf(latest));
}
}
}).orElseThrow());
}
/*******************************************************************************
** Enrich only token records, never leaking token keys into the table.
*******************************************************************************/
public static void enrich(AuthentikActionUtil util, QRecord record) throws QException
{
var config = util.getRuntime().getConfig();
QRecord scope = QContext.getQInstance().getTable(config.applyTablePrefix("token_scope")) == null ? null
: new TokenScopeStore(config).find(record.getValueString("identifier"));
record.setValue("createdAt", scope == null ? null : scope.getValue("createdAt"));
record.setValue("scopeSummary", TokenScopeStore.summary(scope));
if("api".equals(record.getValueString("intent")))
{
record.setValue("lastUsedNote", "not tracked");
record.setDisplayValue("lastUsed", "not tracked");
}
else
{
record.setValue("lastUsed", lastUsed(util).get(record.getValueString("identifier")));
record.setValue("lastUsedNote", record.getValue("lastUsed") == null ? "No login in the last 90 days" : null);
}
}
}
@@ -0,0 +1,101 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.client;
import java.nio.charset.StandardCharsets;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.json.JSONObject;
/*******************************************************************************
** RFC 8693 exchange for a two-minute API bearer, never cached or logged.
*******************************************************************************/
public class AuthentikTokenBroker
{
/*******************************************************************************
** Exchange the operator's JWT through the configured protected-user broker.
*******************************************************************************/
public String exchange(AuthentikActionUtil util, WriteAuthorization auth) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
String subject = config.getSubjectTokenSource().subjectToken(QContext.getQSession());
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
request.setEntity(new UrlEncodedFormEntity(List.of(
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret()),
new BasicNameValuePair("subject_token", subject),
new BasicNameValuePair("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"),
new BasicNameValuePair("scope", "goauthentik.io/api")), StandardCharsets.UTF_8));
JSONObject result = util.exchangeToken(auth, request);
String bearer = result.optString("access_token", "");
if(bearer.isBlank())
{
throw (new QUserFacingException("Authentik refused to act as you (invalid_response)"));
}
return (bearer);
}
/*******************************************************************************
** Prove the broker's client credentials with the grant it actually uses
** (token exchange), without a subject token, so nothing is issued and no
** user data is sent. Authentik checks the grant type, then the client
** secret, then the subject: invalid_request (missing subject) means both
** passed; invalid_client means a bad secret; invalid_grant means the
** provider does not allow token exchange.
*******************************************************************************/
public void probeCredentials(AuthentikActionUtil util) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
request.setEntity(new UrlEncodedFormEntity(List.of(
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret())), StandardCharsets.UTF_8));
String code;
try
{
util.requestBrokerCredentials(request);
code = "issued";
}
catch(BrokerRefusedException e)
{
code = e.getErrorCode();
}
switch(code)
{
case "invalid_request" ->
{
return;
}
case "invalid_client" -> throw (new QUserFacingException("Authentik rejected the token broker's client credentials"));
case "invalid_grant", "unsupported_grant_type" -> throw (new QUserFacingException("The token broker's Authentik provider does not allow token exchange"));
default -> throw (new QUserFacingException("Authentik answered the token broker probe unexpectedly (" + code + ")"));
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.client;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
/*******************************************************************************
** Authentik's token endpoint refused a broker request. Carries only the
** standard OAuth2 error code (never the untrusted response body).
*******************************************************************************/
public class BrokerRefusedException extends QUserFacingException
{
private final String errorCode;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public BrokerRefusedException(String errorCode)
{
super("Authentik refused to act as you (" + errorCode + ")");
this.errorCode = errorCode;
}
/*******************************************************************************
** The OAuth2 error code, or exchange_failed when it was not a standard one.
*******************************************************************************/
public String getErrorCode()
{
return (errorCode);
}
}
@@ -0,0 +1,75 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.client;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Reads the OAuth2 module's user-session row (uuid and accessToken in RC.2).
** No memoization: the credential lives only for the current action.
*******************************************************************************/
public class OAuth2SubjectTokenSource implements AuthentikSubjectTokenSource
{
public static final String UNAVAILABLE = "Self-service tokens need a signed-in Authentik session.";
/*******************************************************************************
** Look up the exact current session, including its hidden access token.
*******************************************************************************/
@Override
public String subjectToken(QSession session) throws QException
{
if(session != null && session == QContext.getQSession() && !(session instanceof QSystemUserSession) && session.getUser() != null && session.getUuid() != null && QContext.getQInstance() != null
&& QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth
&& oauth.getUserSessionTableName() != null)
{
QRecord row;
QSession previous = QContext.getQSession();
try
{
// Core uses a system read here too, avoiding recursive OAuth validation.
// The UUID is taken only from the already authorized current session.
QContext.setQSession(new QSystemUserSession());
row = new GetAction().executeForRecord(new GetInput(oauth.getUserSessionTableName())
.withUniqueKey(Map.of("uuid", session.getUuid())).withShouldMaskPasswords(false).withShouldOmitHiddenFields(false));
}
catch(QException e)
{
throw (new QUserFacingException(UNAVAILABLE));
}
finally
{
QContext.setQSession(previous);
}
if(row != null && row.getValueString("accessToken") != null && !row.getValueString("accessToken").isBlank())
{
return (row.getValueString("accessToken"));
}
}
throw (new QUserFacingException(UNAVAILABLE));
}
}
@@ -90,6 +90,30 @@ public class TokenBucketThrottle
/*******************************************************************************
** Reserve a token only when the health deadline permits the throttle delay.
*******************************************************************************/
public void acquire(java.time.Instant deadline) throws com.kingsrook.qqq.backend.core.exceptions.QException
{
long waitNanos;
synchronized(this)
{
refill();
waitNanos = (long) Math.ceil(Math.max(0, 1 - tokens) / tokensPerNano);
if(Thread.currentThread().isInterrupted() || waitNanos >= java.time.Duration.between(java.time.Instant.now(), deadline).toNanos())
{
throw (new com.kingsrook.qqq.backend.core.exceptions.QException("Authentik health deadline exceeded"));
}
tokens -= 1;
}
if(waitNanos > 0)
{
nanoSleeper.accept(waitNanos);
}
}
/*******************************************************************************
** Tokens currently available (may be negative while callers are waiting).
*******************************************************************************/
@@ -22,6 +22,7 @@ import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
@@ -32,9 +33,9 @@ import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
import org.json.JSONArray;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
@@ -61,6 +62,9 @@ public class AuthentikDriftReport
{
private static final QLogger LOG = QLogger.getLogger(AuthentikDriftReport.class);
private static final Pattern SAFE_PERMISSION = Pattern.compile("[A-Za-z0-9_.*-]{1,120}");
private static final Pattern SAFE_ATTRIBUTE = Pattern.compile("[A-Za-z0-9_./-]{1,60}");
private final AuthentikActionUtil client;
private final AuthentikQBitConfig config;
private final AuthentikGuard guard;
@@ -98,6 +102,19 @@ public class AuthentikDriftReport
/*******************************************************************************
** Reuse only mapper attributes and service-account lockout diagnostics.
*******************************************************************************/
public List<DriftFinding> runMapperChecks()
{
List<DriftFinding> findings = new ArrayList<>();
runCheck(findings, "group attributes", this::checkGroupAttributes);
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
return (findings);
}
/*******************************************************************************
** A check.
*******************************************************************************/
@@ -304,7 +321,7 @@ public class AuthentikDriftReport
void checkIdentityAdmins(List<DriftFinding> findings) throws QException
{
List<JSONObject> groups = client.getAllPages("/core/groups/?include_users=true&ordering=name", AuthentikActionUtil.MAX_SWEEP_OBJECTS);
GroupGraph graph = GroupGraph.fromGroups(groups);
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat(), client.getRuntime().getPolicyCatalog());
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
@@ -360,6 +377,19 @@ public class AuthentikDriftReport
/*******************************************************************************
** The attribute name of a GroupGraph malformed entry ("key: detail"), never
** the detail, which can echo whatever a person typed.
*******************************************************************************/
static String attributeOf(String malformedEntry)
{
int colon = malformedEntry == null ? -1 : malformedEntry.indexOf(": ");
String key = colon < 0 ? "" : malformedEntry.substring(0, colon);
return (SAFE_ATTRIBUTE.matcher(key).matches() ? key : "attribute");
}
/*******************************************************************************
** 6
*******************************************************************************/
@@ -375,14 +405,16 @@ public class AuthentikDriftReport
for(String bad : node.malformed())
{
issues++;
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings."));
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings.")
.withReason("malformed " + attributeOf(bad)));
}
for(String permission : node.permissions())
{
if(resolver.expand(permission).isEmpty())
{
issues++;
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard)."));
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard; a name the app checks only in code, not in its metadata, is not in the catalog).")
.withReason("grants nothing: " + (SAFE_PERMISSION.matcher(permission).matches() ? permission : "(unprintable name)")));
}
}
for(String app : node.appAccess())
@@ -396,7 +428,8 @@ public class AuthentikDriftReport
if(graph.isSuperuserInherited(node.pk()) && !node.permissions().isEmpty())
{
issues++;
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups."));
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups.")
.withReason("superuser group carries qqq.io/permissions"));
}
}
if(issues == 0)
@@ -25,10 +25,33 @@ package com.kingsrook.qbits.authentik.drift;
** @param check the check's short name, e.g. "blueprints"
** @param subject what the finding is about, e.g. a group name
** @param message plain-language explanation and what to do
** @param reason a short, safe summary for health outcomes (no attribute
** values a person typed beyond validated permission names);
** null when the finding has none
*******************************************************************************/
public record DriftFinding(Severity severity, String check, String subject, String message)
public record DriftFinding(Severity severity, String check, String subject, String message, String reason)
{
/*******************************************************************************
** A finding without a health reason.
*******************************************************************************/
public DriftFinding(Severity severity, String check, String subject, String message)
{
this(severity, check, subject, message, null);
}
/*******************************************************************************
** This finding with a short, safe reason for health outcomes.
*******************************************************************************/
public DriftFinding withReason(String reason)
{
return (new DriftFinding(severity, check, subject, message, reason));
}
/*******************************************************************************
** Severity, most severe first.
*******************************************************************************/
@@ -0,0 +1,78 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.qbits.authentik.esb;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.esb.model.EsbInstanceMetaData;
import com.kingsrook.qqq.esb.model.EsbProcessMetaData;
import com.kingsrook.qqq.esb.model.EsbTrigger;
import com.kingsrook.qqq.esb.model.EsbTriggerMode;
import com.kingsrook.qqq.esb.publish.EsbPublishAction;
import com.kingsrook.qqq.esb.publish.EsbPublishInput;
/*******************************************************************************
** Optional ESB types remain isolated from hosts using inline synchronization.
** The broker notification only wakes the durable inbox, the source of truth.
*******************************************************************************/
public final class AuthentikEsbDelivery
{
public static final String EVENT_TYPE = "qqq.authentik.inbox.ready";
/***************************************************************************
** Utility class.
***************************************************************************/
private AuthentikEsbDelivery()
{
}
/***************************************************************************
** Register the approved bounded trigger on the existing inbox process.
** The scheduled poll remains enabled to recover failed/lost broker sends.
***************************************************************************/
public static void attach(QInstance instance, AuthentikQBitConfig config, QProcessMetaData process)
{
requireDestination(instance, config);
EsbProcessMetaData.ofOrWithNew(process).withTrigger(new EsbTrigger()
.withDestinationName(config.getEsbDestinationName()).withMode(EsbTriggerMode.BATCH)
.withBatchSize(50).withBatchWaitMs(2000).withConcurrency(1).withMaxAttempts(5));
}
/***************************************************************************
** Send only a routing hint after SQL commit. Never include event payloads.
***************************************************************************/
public static void publish(AuthentikQBitConfig config) throws QException
{
if(!(QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession)
|| !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.MIRROR)
|| !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.WEBHOOK))
{
throw (new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("Identity event publication requires internal enabled synchronization."));
}
requireDestination(QContext.getQInstance(), config);
var result = new EsbPublishAction().execute(new EsbPublishInput().withDestinationName(config.getEsbDestinationName())
.withType(EVENT_TYPE).withSourcePath("authentik/inbox").withData(Map.of()));
if(!Boolean.TRUE.equals(result.getSuccess()))
{
throw (new QException("Identity inbox notification could not be published; polling will recover it."));
}
}
/***************************************************************************
** Host owns broker metadata and credentials; no implicit destination setup.
***************************************************************************/
private static void requireDestination(QInstance instance, AuthentikQBitConfig config)
{
if(!(instance.getSupplementalMetaData(EsbInstanceMetaData.NAME) instanceof EsbInstanceMetaData esb)
|| esb.getDestination(config.getEsbDestinationName()) == null)
{
throw (new IllegalArgumentException("Identity synchronization ESB destination is not registered."));
}
}
}
@@ -0,0 +1,266 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.health;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.TreeMap;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthDetails;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONObject;
/*******************************************************************************
** Central identity checks reuse the qbit client and configuration diagnostics.
*******************************************************************************/
public final class AuthentikHealthChecks
{
private static final String GROUP_ATTRIBUTES = "group attributes";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHealthChecks()
{
}
/*******************************************************************************
** Called only after the producer has validated and registered the runtime.
*******************************************************************************/
public static void register(QInstance instance, AuthentikQBitConfig config)
{
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
/////////////////////////////////////////////////////////////////////////////////////////////////
// the token broker is only used when tokens are on; with tokens off a registered check //
// could only ever report UNKNOWN "not configured", and core cannot remove a registered check. //
/////////////////////////////////////////////////////////////////////////////////////////////////
List<String> names = Boolean.TRUE.equals(config.getTokensEnabled())
? List.of("api", "api_token", "mapper", "token_broker") : List.of("api", "api_token", "mapper");
for(String name : names)
{
registry.withCheck(new HealthCheckMetaData().withName("authentik." + name).withLabel("Authentik " + name)
.withComponent("authentik").withCategory(HealthCategory.IDENTITY).withKind(HealthCheckKind.DEPENDENCY)
.withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : 21600)
.withTimeoutMs(10000).withCheckInstance(input ->
{
try
{
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName()).withHealthDeadline(input.getDeadline());
HealthCheckOutcome outcome;
switch(name)
{
case "api" -> outcome = api(util, config);
case "api_token" -> outcome = apiToken(util, Instant.now());
case "mapper" -> outcome = mapper(util, config);
default -> outcome = tokenBroker(util, config);
}
return (Instant.now().isBefore(input.getDeadline()) && !Thread.currentThread().isInterrupted()
? outcome : HealthCheckOutcome.down("Authentik health deadline exceeded"));
}
catch(Exception e)
{
// Never copy exception messages, URLs or credential values into outcomes.
return (HealthCheckOutcome.down("Authentik health check failed"));
}
}));
}
}
/*******************************************************************************
** Version compatibility follows the existing drift report's prefix rule.
*******************************************************************************/
private static HealthCheckOutcome api(AuthentikActionUtil util, AuthentikQBitConfig config)
{
String version = util.fetchServerVersion().orElse("");
if(!version.matches("[0-9]{1,4}(\\.[0-9]{1,4}){1,3}"))
{
return (HealthCheckOutcome.down("Authentik server version unavailable"));
}
String expected = config.getExpectedServerVersion();
boolean matches = !StringUtils.hasContent(expected) || version.equals(expected) || version.startsWith(expected + ".");
return ((matches ? HealthCheckOutcome.up("Authentik API reachable") : HealthCheckOutcome.degraded("Authentik server version differs"))
.withDetails(new HealthDetails().withValue("serverVersion", version)));
}
/*******************************************************************************
** The service account should have one API token; never read its key.
*******************************************************************************/
private static HealthCheckOutcome apiToken(AuthentikActionUtil util, Instant now) throws QException
{
List<JSONObject> tokens = util.getServiceAccountApiTokens();
if(tokens.isEmpty())
{
return (HealthCheckOutcome.down("API token missing"));
}
if(tokens.size() != 1)
{
return (HealthCheckOutcome.unknown("API token cannot be identified uniquely"));
}
JSONObject token = tokens.getFirst();
if(!token.optBoolean("expiring", true))
{
return (HealthCheckOutcome.up("API token present without expiry"));
}
Instant expires;
try
{
expires = AuthentikJson.parseInstant(token.optString("expires", null));
}
catch(java.time.format.DateTimeParseException e)
{
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
}
if(expires == null)
{
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
}
HealthCheckOutcome outcome = !expires.isAfter(now) ? HealthCheckOutcome.down("API token expired")
: expires.isBefore(now.plus(14, ChronoUnit.DAYS)) ? HealthCheckOutcome.degraded("API token expires within 14 days")
: HealthCheckOutcome.up("API token present");
return (outcome.withDetails(new HealthDetails().withValue("daysToExpiry", ChronoUnit.DAYS.between(now, expires))));
}
/*******************************************************************************
** Findings carry fixed codes, plus the flagged group names and each group
** finding's safe reason (DriftFinding.reason), so a degraded mapper says
** what to fix. Service-account activity subjects (people) stay private.
*******************************************************************************/
static HealthCheckOutcome mapper(AuthentikActionUtil util, AuthentikQBitConfig config)
{
return (mapperOutcome(new AuthentikDriftReport(util, config).runMapperChecks()));
}
/*******************************************************************************
** The mapper outcome for a set of drift findings.
*******************************************************************************/
static HealthCheckOutcome mapperOutcome(List<DriftFinding> findings)
{
HealthStatus status = HealthStatus.UP;
TreeSet<String> codes = new TreeSet<>();
TreeMap<String, TreeMap<String, TreeSet<String>>> byGroup = new TreeMap<>();
TreeSet<String> other = new TreeSet<>();
for(DriftFinding finding : findings)
{
if(finding.severity() != DriftFinding.Severity.ERROR && finding.severity() != DriftFinding.Severity.WARNING)
{
continue;
}
boolean groupCheck = GROUP_ATTRIBUTES.equals(finding.check());
codes.add((groupCheck ? "group_attributes" : "service_account_activity") + "." + finding.severity().name().toLowerCase(Locale.ROOT));
status = finding.severity() == DriftFinding.Severity.ERROR || status == HealthStatus.DOWN ? HealthStatus.DOWN : HealthStatus.DEGRADED;
if(groupCheck && finding.reason() != null)
{
String reason = finding.reason();
int colon = reason.indexOf(": ");
TreeSet<String> values = byGroup.computeIfAbsent(safeName(finding.subject()), k -> new TreeMap<>())
.computeIfAbsent(colon < 0 ? reason : reason.substring(0, colon), k -> new TreeSet<>());
if(colon >= 0)
{
values.add(reason.substring(colon + 2));
}
}
else
{
other.add(groupCheck ? "group attributes check could not run" : "service-account activity (see the identity drift report)");
}
}
if(status == HealthStatus.UP)
{
return (HealthCheckOutcome.up("Mapper checks passed").withDetails(new HealthDetails().withValue("findingCodes", "")));
}
List<String> parts = new ArrayList<>();
byGroup.forEach((group, reasons) ->
{
List<String> texts = new ArrayList<>();
reasons.forEach((label, values) -> texts.add(values.isEmpty() ? label : label + ": " + String.join(", ", values)));
parts.add(group + " (" + String.join("; ", texts) + ")");
});
parts.addAll(other);
String summary = String.join("; ", parts);
return (new HealthCheckOutcome().withStatus(status).withMessage(truncate("Mapper drift: " + summary, 500))
.withDetails(new HealthDetails()
.withValue("findingCodes", String.join(",", codes))
.withValue("flaggedGroups", truncate(String.join(", ", byGroup.keySet()), 500))
.withValue("summary", truncate(summary, 500))));
}
/*******************************************************************************
** A group name fit for a health message: printable and bounded.
*******************************************************************************/
private static String safeName(String name)
{
String printable = name == null ? "" : name.replaceAll("\\p{Cntrl}", "").trim();
return (printable.isEmpty() ? "(unnamed group)" : truncate(printable, 100));
}
/*******************************************************************************
** Cut a text to a length, marking the cut.
*******************************************************************************/
private static String truncate(String text, int max)
{
return (text.length() <= max ? text : text.substring(0, max - 3) + "...");
}
/*******************************************************************************
** Authenticate through the existing broker transport, retaining no bearer.
*******************************************************************************/
private static HealthCheckOutcome tokenBroker(AuthentikActionUtil util, AuthentikQBitConfig config) throws QException
{
if(!StringUtils.hasContent(config.getTokenBrokerClientId()) || !StringUtils.hasContent(config.getTokenBrokerClientSecret()))
{
return (HealthCheckOutcome.unknown("not configured"));
}
new AuthentikTokenBroker().probeCredentials(util);
return (HealthCheckOutcome.up("Token broker authenticated"));
}
}
@@ -83,7 +83,7 @@ public abstract class AbstractAuthentikTableProducer extends QBitComponentMetaDa
.withBackendName(config.getApiBackendName())
.withPrimaryKeyField(spec.getKeyFieldName())
.withBackendDetails(new APITableBackendDetails().withTablePath(spec.getPath()))
.withPermissionRules(spec.getReadPermission().rules())
.withPermissionRules(spec.readRules())
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_EXPORT)
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE, Capability.QUERY_STATS);
@@ -70,11 +70,12 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
// records as child lists //
///////////////////////////////////////////////////////////////////////////
addSection(app, produced, config, "people", "People", "person", AuthentikTables.USER, AuthentikTables.INVITATION);
addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikTables.APPLICATION);
addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikLocalTables.POLICY, AuthentikTables.APPLICATION);
addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION);
List<String> widgets = new ArrayList<>();
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME)))
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikTokenWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME),
config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.COMPARE)))
{
if(produced.get(QWidgetMetaData.class, widgetName) != null)
{
@@ -39,7 +39,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Renders the drift report as a TableData. Re-checks authentik.admin itself
** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
** for direct renderer calls as well as calls through core RenderWidgetAction.
*******************************************************************************/
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
{
@@ -0,0 +1,159 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.metadata;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-authentik.yaml (the QQQ help-bundle format) to the Identity
** app and its authentik_* tables: what each table is for and how its rows get
** created (for the API-backed tables: that they are read live, and what an
** empty list means), shown on the table screen and when it is empty.
**
** The bundle's keys use the default table prefix; a host's own tableNamePrefix
** is applied to them, so renamed tables get the same text.
** Inline fallback for hosts without help storage; core owns bundle validation
** and the producer also registers the bundle for startup synchronization.
*******************************************************************************/
public final class AuthentikHelp
{
public static final String RESOURCE = "/help/qbit-authentik.yaml";
public static final String SLOT = "help";
private static final String DEFAULT_TABLE_PREFIX = "authentik_";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
Map<String, JsonNode> entries = new LinkedHashMap<>();
var bundle = HelpBundles.load(RESOURCE.substring(1), AuthentikHelp.class.getClassLoader());
ObjectMapper mapper = new ObjectMapper();
for(var entry : bundle.getEntries())
{
entries.put(entry.getKey(), mapper.valueToTree(entry));
}
return (entries);
}
/*******************************************************************************
** Apply the help to the app and every table the qbit produced. A host's own
** help on a table or the app is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance qInstance, AuthentikQBitConfig config) throws QException
{
for(var bundle : HelpBundles.of(qInstance).getBundles())
{
if("qbit-authentik".equals(bundle.getId()))
{
for(var entry : bundle.getEntries())
{
entry.setKey(configuredNames(entry.getKey(), config));
entry.setSummary(configuredNames(entry.getSummary(), config));
entry.setContent(configuredNames(entry.getContent(), config));
}
}
}
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
{
String key = entry.getKey();
if(key.startsWith("table:") && key.endsWith(";slot:help"))
{
String tableKey = key.substring("table:".length(), key.indexOf(';'));
String unprefixed = tableKey.substring(DEFAULT_TABLE_PREFIX.length());
QTableMetaData table = qInstance.getTable(config.applyTablePrefix(unprefixed));
if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
else if(key.startsWith("app:") && key.endsWith(";slot:help"))
{
QAppMetaData app = qInstance.getApp(config.applyPrefix(AuthentikAppMetaDataProducer.APP_NAME));
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry, AuthentikQBitConfig config)
{
return (new QHelpContent().withContentAsMarkdown(configuredNames(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip(), config)));
}
/*******************************************************************************
** Follow the host prefix in keys and live references; status enums are fixed.
*******************************************************************************/
private static String configuredNames(String text, AuthentikQBitConfig config)
{
if(text == null)
{
return (null);
}
return (Pattern.compile("authentik_[a-zA-Z0-9_]+|authentik[A-Z][a-zA-Z0-9]*").matcher(text).replaceAll(match ->
{
String name = match.group();
if(name.equals("authentik_action_status") || name.equals("authentik_approval_status"))
{
return (name);
}
if(name.startsWith(DEFAULT_TABLE_PREFIX))
{
return (config.applyTablePrefix(name.substring(DEFAULT_TABLE_PREFIX.length())));
}
return (config.applyPrefix(name.substring("authentik".length())));
}));
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** Compare Access (DESIGN.md section 12.8): today's access against what the policies would grant, per active person. Read only.
*******************************************************************************/
public class AuthentikPolicyCompareWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyCutoverWidgets.COMPARE))
.withLabel("Compare Access")
.withType(WidgetType.TABLE.getType())
.withGridColumns(12)
.withIsCard(true)
.withCodeReference(new QCodeReference(PolicyCutoverWidgets.CompareRenderer.class))
.withPermissionRules(IdentityPermission.ADMIN.rules())
.withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (470);
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The policy editor widget (DESIGN.md section 12.4), shown on the Edit Policy
** step. Gated by authentik.policies.manage.
*******************************************************************************/
public class AuthentikPolicyEditorWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyEditorWidget.NAME))
.withLabel("Policy")
.withType(PolicyEditorWidget.TYPE)
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(PolicyEditorWidget.Renderer.class))
.withPermissionRules(IdentityPermission.POLICIES_MANAGE.rules())
.withDefaultValue(PolicyEditorWidget.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (460);
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The policy enforcement banner (DESIGN.md section 12.8): whether saved policies take effect at sign-in on this instance.
*******************************************************************************/
public class AuthentikPolicyEnforcementWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyCutoverWidgets.ENFORCEMENT))
.withLabel("Policy Enforcement")
.withType(WidgetType.HTML.getType())
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(PolicyCutoverWidgets.EnforcementRenderer.class))
.withPermissionRules(IdentityPermission.USERS_READ.rules())
.withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (465);
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** authentik_policy: the QQQ record (with change history) of each group policy.
*******************************************************************************/
public class AuthentikPolicyTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend and the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.policy(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -0,0 +1,72 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
/*******************************************************************************
** Daily background task, hidden from navigation; no scheduler dependency.
*******************************************************************************/
public class AuthentikTokenReminderProcessProducer extends QBitComponentMetaDataProducer<QProcessMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Reminders require local dedupe storage and the TOKENS feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
** Use the configured scheduler or the instance's first registered scheduler.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance instance)
{
AuthentikQBitConfig config = getQBitConfig();
QProcessMetaData process = new QProcessMetaData().withName(config.applyPrefix("tokenReminders"))
.withLabel("Access token reminders").withIsHidden(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules())
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(AuthentikTokenReminders.class))
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("backendName", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING)
.withDefaultValue(config.getApiBackendName()))));
String scheduler = config.getTokenReminderSchedulerName();
if(scheduler == null && !instance.getSchedulers().isEmpty())
{
scheduler = instance.getSchedulers().keySet().iterator().next();
}
if(scheduler != null)
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(scheduler).withRepeatSeconds(86400).withInitialDelaySeconds(3600));
}
return (process);
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Token scope metadata, with no credential material.
*******************************************************************************/
public class AuthentikTokenScopeTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend and the TOKENS feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.tokenScope(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** Access token administration uses a standard TABLE widget (guardrail G5).
*******************************************************************************/
public class AuthentikTokenWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
public static final String NAME = "accessTokens";
/*******************************************************************************
** Token inventory needs the local metadata table.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
** Register before the app, alongside the existing home widgets.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (500);
}
/*******************************************************************************
** Permission is checked again in the renderer.
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance instance)
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData().withName(config.applyPrefix(NAME)).withLabel("Access tokens").withType(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType())
.withGridColumns(12).withIsCard(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules())
.withCodeReference(new QCodeReference(AuthentikTokenWidget.class)).withDefaultValue("backendName", config.getApiBackendName()));
}
}
@@ -0,0 +1,329 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.permissions;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The cut-over from a host's current permission source to Authentik group
** policies (DESIGN.md section 12.8): for every active person, what they get
** today (the host source) against what the mapper would grant (their groups'
** policies and permissions, the static fallback and bootstrap subjects), and
** the guard that keeps the mapper inactive while a member of a guarded group
** would lose a permission.
*******************************************************************************/
public final class AccessComparison
{
private static final QLogger LOG = QLogger.getLogger(AccessComparison.class);
/** How long a computed mapper state is reused (sign-ins read it). */
public static final Duration STATE_TTL = Duration.ofSeconds(60);
/** How long a state may stand in for a guard that cannot run, after which the guard blocks. */
public static final Duration STATE_MAX_STALE = Duration.ofMinutes(15);
/** Banner shown wherever policies are edited while they are not enforced. */
public static final String NOT_ENFORCED = "Policies are saved but NOT enforced at sign-in on this instance (mapper off).";
/*******************************************************************************
** One person. removed excludes what a policy Deny deliberately takes away
** (unless an invalid policy voided the person, which removes everything and
** keeps the guard blocking); voided says an invalid policy voids their access.
*******************************************************************************/
public record Row(int pk, String username, String email, List<String> groups, Set<String> before, Set<String> after,
Set<String> added, Set<String> removed, boolean guarded, boolean voided)
{
}
/*******************************************************************************
** Whether the mapper is enforcing policies, and why not.
*******************************************************************************/
public enum Status
{
/** The host does not use the mapper: policies are saved, not enforced. */
OFF,
/** Enforced; the guard passed (or there is nothing to guard). */
ACTIVE,
/** Enforced because the owner overrode a failing guard. */
OVERRIDDEN,
/** Not enforced: the guard failed (or could not run); sign-in uses the host source. */
BLOCKED
}
/*******************************************************************************
** A mapper state with a plain message.
*******************************************************************************/
public record State(Status status, String message, Instant checkedAt)
{
/*******************************************************************************
** Whether sign-in permissions come from the policies.
*******************************************************************************/
public boolean enforced()
{
return (status == Status.ACTIVE || status == Status.OVERRIDDEN);
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AccessComparison()
{
}
/*******************************************************************************
** Compare every active person (internal and external users).
*******************************************************************************/
public static List<Row> run(AuthentikQBitConfig config, AuthentikActionUtil util, Collection<String> catalog) throws QException
{
GroupGraph graph = util.groupGraphWithMembers();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, catalog);
AuthentikHostPermissionSource source = config.getHostPermissionSource();
List<Row> rows = new ArrayList<>();
for(JSONObject user : util.allUsers())
{
String type = user.optString("type");
if(!user.optBoolean("is_active", true) || !("internal".equals(type) || "external".equals(type)))
{
continue;
}
int pk = user.optInt("pk");
List<String> groupPks = graph.groupsOfUser(pk);
List<String> names = new ArrayList<>();
for(String groupPk : groupPks)
{
names.add(graph.byPk(groupPk).name());
}
String email = user.optString("email", null);
Set<String> before = new TreeSet<>(source == null ? Set.of() : source.userPermissions(email, names));
Set<String> after = mapperPermissions(config, resolver, groupPks, names, String.valueOf(pk), user.optString("uuid"));
Set<String> added = new TreeSet<>(after);
added.removeAll(before);
Set<String> removed = new TreeSet<>(before);
removed.removeAll(after);
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(groupPks);
if(!resolution.voided())
{
removed.removeIf(resolution.denials()::denies);
}
boolean guarded = names.stream().anyMatch(n -> config.getCutoverGuardGroups() != null && config.getCutoverGuardGroups().contains(n));
rows.add(new Row(pk, user.optString("username"), email, names, before, after, added, removed, guarded, resolution.voided()));
}
return (rows);
}
/*******************************************************************************
** What the mapper grants for a person's direct groups: the groups' (and
** ancestors') qqq.io/permissions and policies, the static fallback of the
** direct groups, and every authentik.* permission for a bootstrap subject.
*******************************************************************************/
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid)
{
return (mapperPermissions(config, resolver, groupPks, directGroupNames, userPk, userUuid, null));
}
/*******************************************************************************
** Preview the mapper with one group's policy removed, preserving the
** static floor and immutable bootstrap-subject grants.
*******************************************************************************/
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid, String excludePolicyOfGroupPk)
{
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(groupPks, excludePolicyOfGroupPk);
Set<String> rs = new TreeSet<>(resolution.sessionPermissions());
///////////////////////////////////////////////////////////////////////
// the static fallback is a grant like any other: a Deny removes it, an //
// invalid policy voids it; only the bootstrap names below are exempt //
///////////////////////////////////////////////////////////////////////
Set<String> floor = new TreeSet<>(staticPermissions(config, directGroupNames));
if(resolution.voided())
{
floor.clear();
}
else
{
floor.removeIf(resolution.denials()::denies);
}
rs.addAll(floor);
Set<String> subjects = config.getResolvedBootstrapSubjects();
if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid)))
{
for(IdentityPermission permission : IdentityPermission.values())
{
rs.add(permission.getSessionPermission());
}
}
return (rs);
}
/*******************************************************************************
** The static fallback permissions of some direct groups.
*******************************************************************************/
public static Set<String> staticPermissions(AuthentikQBitConfig config, Collection<String> directGroupNames)
{
Set<String> rs = new TreeSet<>();
if(config.getStaticGroupPermissions() != null)
{
for(String name : directGroupNames)
{
rs.addAll(config.getStaticGroupPermissions().getOrDefault(name, List.of()));
}
}
return (rs);
}
/*******************************************************************************
** The people in guarded groups who would lose a permission (the guard fails
** when this is not empty).
*******************************************************************************/
public static List<Row> guardFailures(List<Row> rows)
{
return (rows.stream().filter(r -> r.guarded() && !r.removed().isEmpty()).toList());
}
/*******************************************************************************
** The last computed state while the guard cannot run: only a real enforcement
** state (ACTIVE, OVERRIDDEN or BLOCKED, never OFF), with its ORIGINAL time (not
** renewed), and only within STATE_MAX_STALE; otherwise null.
*******************************************************************************/
static State lastKnown(AuthentikRuntime runtime)
{
State last = runtime.getMapperState();
if(last == null || last.status() == Status.OFF || !last.checkedAt().plus(STATE_MAX_STALE).isAfter(Instant.now()))
{
return (null);
}
return (last);
}
/*******************************************************************************
** The last known state (see lastKnown), or BLOCKED with this reason.
*******************************************************************************/
public static State lastKnownOrBlocked(AuthentikRuntime runtime, String reason)
{
State last = lastKnown(runtime);
return (last != null ? last : new State(Status.BLOCKED, reason, Instant.now()));
}
/*******************************************************************************
** The mapper state of a backend (cached for STATE_TTL; sign-ins read it).
*******************************************************************************/
public static State state(AuthentikRuntime runtime, AuthentikActionUtil util)
{
State cached = runtime.getMapperState();
if(cached != null && cached.checkedAt().plus(STATE_TTL).isAfter(Instant.now()))
{
return (cached);
}
State state = compute(runtime, util);
runtime.setMapperState(state);
return (state);
}
/*******************************************************************************
** Compute the mapper state now.
*******************************************************************************/
static State compute(AuthentikRuntime runtime, AuthentikActionUtil util)
{
AuthentikQBitConfig config = runtime.getConfig();
if(!Boolean.TRUE.equals(config.getMapperEnabled()) && !runtime.isMapperInvoked())
{
return (new State(Status.OFF, NOT_ENFORCED, Instant.now()));
}
if(config.getCutoverGuardGroups() == null || config.getCutoverGuardGroups().isEmpty())
{
return (new State(Status.ACTIVE, "Policies are enforced at sign-in.", Instant.now()));
}
if(Boolean.TRUE.equals(config.getMapperOverride()))
{
return (new State(Status.OVERRIDDEN, "Policies are enforced at sign-in; the cut-over guard is overridden by the owner.", Instant.now()));
}
try
{
List<Row> failures = guardFailures(run(config, util, runtime.getPermissionCatalog()));
if(failures.isEmpty())
{
return (new State(Status.ACTIVE, "Policies are enforced at sign-in; the cut-over guard passed (no member of "
+ String.join(", ", config.getCutoverGuardGroups()) + " loses a permission).", Instant.now()));
}
List<String> names = failures.stream().map(Row::username).limit(5).toList();
long voided = failures.stream().filter(Row::voided).count();
return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard found " + failures.size() + " member(s) of "
+ String.join(", ", config.getCutoverGuardGroups()) + " who would lose permissions (" + String.join(", ", names)
+ ")" + (voided == 0 ? "" : "; " + voided + " of them have an invalid policy that voids their access")
+ ". Sign-in keeps today's access (Deny statements are not applied to it) until the policies cover it, or the owner overrides the guard.", Instant.now()));
}
catch(Exception e)
{
///////////////////////////////////////////////////////////////////
// an Authentik error must not flip the state: keep the last known //
// one (and say so); with none, block //
///////////////////////////////////////////////////////////////////
LOG.error("The cut-over guard could not run", logPair("errorType", e.getClass().getSimpleName()));
State last = lastKnown(runtime);
if(last != null)
{
return (last);
}
return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard could not check Authentik ("
+ e.getMessage() + "). Sign-in keeps today's access.", Instant.now()));
}
}
}
@@ -0,0 +1,37 @@
/*
* Copyright 2026 Kingsrook, LLC
* Licensed 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 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.
*/
package com.kingsrook.qbits.authentik.permissions;
import org.json.JSONObject;
/*******************************************************************************
** Application roles use ordinary Authentik group inheritance and memberships.
** The marker is descriptive; it never grants Authentik administrative RBAC.
*******************************************************************************/
public final class ApplicationRole
{
public static final String ATTRIBUTE = "qqq.io/application-role";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ApplicationRole()
{
}
/*******************************************************************************
** Recognize only an explicit boolean marker on a group.
*******************************************************************************/
public static boolean isRole(JSONObject group)
{
JSONObject attributes = group == null ? null : group.optJSONObject("attributes");
return (attributes != null && Boolean.TRUE.equals(attributes.opt(ATTRIBUTE)));
}
}
@@ -17,6 +17,9 @@
package com.kingsrook.qbits.authentik.permissions;
import java.io.Serializable;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
@@ -27,6 +30,11 @@ import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.policy.PolicyResult;
import com.kingsrook.qbits.authentik.policy.PolicyRules;
import com.kingsrook.qbits.authentik.security.BootstrapOperators;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
@@ -76,6 +84,16 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
return ("authentikApi");
}
/*******************************************************************************
** Static host grants for current direct groups, without consulting claims.
*******************************************************************************/
protected Set<String> configuredStaticPermissions(Collection<String> groups)
{
Set<String> permissions = new TreeSet<>();
groups.forEach(group -> permissions.addAll(staticGroupPermissions.getOrDefault(group, List.of())));
return (permissions);
}
/*******************************************************************************
@@ -101,17 +119,195 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
{
return;
}
List<String> groups = groupNames(claims);
String email = claims.optString("email", "").trim();
String sub = claims.optString("sub", "").trim();
List<String> groups = groupNames(claims);
String email = claims.optString("email", "").trim();
String sub = claims.optString("sub", "").trim();
boolean verified = claims.optBoolean("email_verified", false);
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, sub, email, verified));
if(qSession.getPermissions() != null)
AuthentikRuntime runtime = runtime();
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
PolicyCatalog catalog = qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance);
JSONObject withPolicy = PolicyPermissions.claimsWithPolicy(context, format);
PolicyRules claim = PolicyPermissions.rulesFromClaims(withPolicy, format, catalog);
qSession.setPermissions(compute(qInstance, runtime, groups, sub, email, verified, claim, withPolicy != null, claims.has("groups"), catalog, qSession.getPermissions(), false));
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims);
}
/*******************************************************************************
** The permissions of one sign-in, by the one shared rule
** (SessionPermissionRules): the policy claim carries the same group data as the
** graph read, and the union also covers an unreachable API; a Deny from either
** source removes the grant wherever it came from, also while the cut-over guard
** blocks. An invalid policy (or a missing group or parent) leaves only the
** bootstrap identity permissions; an unreadable graph with no claim leaves the
** configured outage fallbacks.
**
** @param groupsClaimPresent the token has a groups claim (an empty array counts)
** @param forcedVoid the caller already knows a group of the user could not be resolved
*******************************************************************************/
private Set<String> compute(QInstance qInstance, AuthentikRuntime runtime, List<String> groups, String sub, String email, boolean verified,
PolicyRules claim, boolean claimPresent, boolean groupsClaimPresent, PolicyCatalog catalog, Collection<String> sessionPermissions, boolean forcedVoid)
{
Detailed detailed = resolveDetailed(qInstance, groups, sub, email, verified);
boolean voided = forcedVoid || claim.isVoid() || detailed.denials().isVoid();
///////////////////////////////////////////////////////////////////////
// a token with no groups claim at all (not an empty list) and no //
// policy claim tells us nothing about the user's groups //
///////////////////////////////////////////////////////////////////////
boolean noPolicyInformation = (detailed.unavailable() || !groupsClaimPresent) && !claimPresent;
SessionPermissionRules.Knowledge knowledge = voided ? SessionPermissionRules.Knowledge.VOID
: noPolicyInformation ? SessionPermissionRules.Knowledge.UNAVAILABLE : SessionPermissionRules.Knowledge.KNOWN;
PolicyRules denials = voided ? PolicyRules.EMPTY : PolicyRules.merge(List.of(claim.denialsOnly(), detailed.denials().denialsOnly()));
if(voided)
{
permissions.addAll(qSession.getPermissions());
LOG.warn("An invalid policy (or a group missing from the graph) voids this user's permissions", logPair("source", forcedVoid ? "group lookup" : claim.isVoid() ? "token claim" : "group graph"));
}
qSession.setPermissions(permissions);
else if(noPolicyInformation)
{
LOG.warn("The group graph could not be read and the token has no policy claim: no Deny can be known; only the configured fallbacks are granted");
}
Set<String> fromPolicies = new TreeSet<>();
if(knowledge == SessionPermissionRules.Knowledge.KNOWN)
{
fromPolicies.addAll(detailed.fromGroups());
fromPolicies.addAll(claim.resolve(PolicyPermissions.universe(qInstance == null ? PolicyPermissions.instanceOf(catalog) : qInstance, Set.of())));
}
Set<String> staticFloor = new TreeSet<>(detailed.floor());
Set<String> host = new TreeSet<>();
boolean enforced = true;
if(runtime != null)
{
staticFloor.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups));
////////////////////////////////////////////////////////////////////////
// the cut-over guard (DESIGN.md section 12.8): while it blocks, the //
// session gets today's access from the host plus the floor, never //
// the policies' own grants; it only matters for valid policies //
////////////////////////////////////////////////////////////////////////
runtime.markMapperInvoked();
AccessComparison.State state = guardState(qInstance, runtime);
enforced = state.enforced();
if(!enforced)
{
AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource();
if(source != null)
{
host.addAll(source.userPermissions(email, groups));
}
if(voided)
{
LOG.error("A user with an invalid policy signs in while the cut-over guard blocks; they get no permissions beyond break-glass. Fix the policy.", logPair("groups", groups));
}
else
{
LOG.warn("Policies are not enforced at this sign-in; granting today's access", logPair("reason", state.message()));
}
}
}
return (SessionPermissionRules.compute(knowledge, denials, enforced, detailed.bootstrap(), staticFloor, host, fromPolicies, sessionPermissions));
}
/*******************************************************************************
** The cut-over guard state; when the guard cannot run, the last known state
** stands, and with none it blocks.
*******************************************************************************/
private AccessComparison.State guardState(QInstance qInstance, AuthentikRuntime runtime)
{
try
{
return (AccessComparison.state(runtime, client(qInstance)));
}
catch(Exception e)
{
LOG.error("The cut-over guard could not run", logPair("errorType", e.getClass().getSimpleName()));
return (AccessComparison.lastKnownOrBlocked(runtime, "The cut-over guard could not run"));
}
}
/*******************************************************************************
** Automated sessions have no claims. Resolve their email idReference to one
** active API user before trusting any groups or immutable bootstrap id.
*******************************************************************************/
@Override
public void customizeAutomatedSessionForUser(QInstance qInstance, QSession session, Serializable userId)
{
AuthentikRuntime runtime = runtime();
Set<String> permissions = new TreeSet<>();
String email = userId == null ? "" : userId.toString().trim();
try
{
JSONObject found = null;
int matches = 0;
List<JSONObject> users = email.isEmpty() ? List.of() : client(qInstance).getAllPages(
"/core/users/?email=" + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&include_groups=true&include_roles=false", 10);
for(JSONObject user : users)
{
if(email.equalsIgnoreCase(user.optString("email", "").trim()))
{
found = user;
matches++;
}
}
// A bounded, possibly truncated lookup cannot prove uniqueness.
if(users.size() < 10 && matches == 1 && found.optBoolean("is_active", false))
{
List<String> groups = new ArrayList<>();
boolean unresolvedGroup = false;
JSONArray groupObjects = found.optJSONArray("groups_obj");
if(groupObjects != null)
{
for(int i = 0; i < groupObjects.length(); i++)
{
groups.add(groupObjects.getJSONObject(i).getString("name"));
}
}
else if(found.opt("groups") instanceof JSONArray groupPks && !groupPks.isEmpty())
{
GroupGraph graph = client(qInstance).groupGraph();
for(int i = 0; i < groupPks.length(); i++)
{
GroupGraph.Node group = graph.byPk(groupPks.getString(i));
if(group == null)
{
unresolvedGroup = true;
}
else
{
groups.add(group.name());
}
}
}
String pk = found.optString("pk", "");
String sub = runtime != null && runtime.getConfig().getResolvedBootstrapSubjects().contains(pk) ? pk : found.optString("uuid", pk);
String userEmail = found.getString("email").trim();
boolean verified = found.optBoolean("email_verified", false);
PolicyCatalog catalog = qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance);
///////////////////////////////////////////////////////////////////
// a group pk the graph does not know may hold a Deny: void //
///////////////////////////////////////////////////////////////////
permissions = compute(qInstance, runtime, groups, sub, userEmail, verified, PolicyRules.EMPTY, false, true, catalog, session.getPermissions(), unresolvedGroup);
}
else
{
LOG.info("Automated session has no unique active Authentik user; granting no permissions");
}
}
catch(Exception e)
{
permissions.clear();
// Exception messages can contain the lookup email; log only the type.
LOG.warn("Authentik could not be read for an automated session; granting no permissions", logPair("errorType", e.getClass().getSimpleName()));
}
session.setPermissions(permissions);
}
@@ -119,22 +315,96 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
/*******************************************************************************
** The permissions for a user's group names and token identity (sub, email,
** email_verified). Bootstrap operators are matched on immutable ids only.
** The static group fallbacks are subject to a Deny like any other grant;
** the bootstrap identity permissions are not.
**
** NOT FOR SIGN-IN ASSEMBLY: sign-in, automated sessions and refresh go through
** SessionPermissionRules.compute (customizeSession); this is a read helper.
**
** @deprecated a plain set cannot tell void from absent or carry a Deny to the
** host's other sources; use resolveResult(...).apply(others)
*******************************************************************************/
@Deprecated
public Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
{
Set<String> rs = new TreeSet<>();
Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified);
Set<String> floor = new TreeSet<>(detailed.floor());
if(detailed.denials().isVoid())
{
floor.clear();
}
else
{
floor.removeIf(detailed.denials()::denies);
}
Set<String> rs = new TreeSet<>(detailed.bootstrap());
rs.addAll(floor);
rs.addAll(detailed.denials().isVoid() ? Set.of() : detailed.fromGroups());
return (rs);
}
/*******************************************************************************
** The typed policy result for a user's groups: the full rules (void when a
** policy is invalid or a group or parent is not in the graph) and what the
** groups' policies and qqq.io/permissions grant; UNAVAILABLE when the graph
** cannot be read. The static and bootstrap floor is NOT included; pass it, and
** the host's own service permissions, to PolicyResult.apply so a Deny cannot
** be bypassed. NOT FOR SIGN-IN ASSEMBLY: customizeSession applies the one shared
** rule (SessionPermissionRules.compute) itself; use this only for a host
** customizer that builds its own permission set.
*******************************************************************************/
public PolicyResult resolveResult(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
{
Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified);
if(detailed.unavailable())
{
return (PolicyResult.unreadable());
}
return (new PolicyResult(detailed.rules(), detailed.denials().isVoid() ? Set.of() : detailed.fromGroups()));
}
/*******************************************************************************
** What the graph read found, kept apart so a Deny, an invalid policy and an
** unreadable graph can each be applied across sources.
**
** @param floor static group permissions (host-owned, subject to a Deny)
** @param bootstrap bootstrap identity permissions (break-glass, exempt)
** @param fromGroups what the groups' policies and qqq.io/permissions grant, with
** Deny already applied; empty when voided or unavailable
** @param denials the Deny side of the user's policies (void when any is
** invalid or a group or parent is missing); empty when unavailable
** @param rules the full merged rules (Allow and Deny)
** @param unavailable the graph could not be read: nothing is known about Deny
*******************************************************************************/
private record Detailed(Set<String> floor, Set<String> bootstrap, Set<String> fromGroups, PolicyRules denials, PolicyRules rules, boolean unavailable)
{
}
/*******************************************************************************
** Resolve into floor, bootstrap, group-derived grants and denials.
*******************************************************************************/
private Detailed resolveDetailed(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
{
Set<String> floor = new TreeSet<>();
for(String group : groupNames)
{
rs.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
floor.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
}
AuthentikRuntime runtime = runtime();
Set<String> bootstrap = new TreeSet<>();
AuthentikRuntime runtime = runtime();
if(runtime != null && !runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, emailVerified))
{
LOG.warn("bootstrapOperatorSubjects grants all identity permissions; empty it once an identity administrator is set up in Authentik", logPair("sub", sub));
for(IdentityPermission permission : IdentityPermission.values())
{
rs.add(permission.getSessionPermission());
bootstrap.add(permission.getSessionPermission());
}
}
@@ -142,28 +412,35 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
{
AuthentikActionUtil client = client(qInstance);
GroupGraph graph = client.groupGraph();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog());
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog(), qInstance);
List<String> pks = new ArrayList<>();
for(String name : groupNames)
{
GroupGraph.Node node = graph.byName(name);
if(node != null)
if(node == null)
{
pks.add(node.pk());
///////////////////////////////////////////////////////////////////
// a group the graph does not know may carry a Deny we cannot see: //
// void the policy grants rather than skip it //
///////////////////////////////////////////////////////////////////
LOG.warn("A group of the user is not in the Authentik group graph; its policy cannot be read, so the user's policy grants are voided");
PolicyRules voided = PolicyRules.voided("a group of the user is not in the group graph");
return (new Detailed(floor, bootstrap, new TreeSet<>(), voided, voided, false));
}
pks.add(node.pk());
}
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(pks);
if(!resolution.unknown().isEmpty())
{
LOG.info("Group attributes name permissions this app does not have", logPair("unknown", resolution.unknown()));
}
rs.addAll(resolution.sessionPermissions());
return (new Detailed(floor, bootstrap, new TreeSet<>(resolution.sessionPermissions()), resolution.denials(), resolution.rules(), false));
}
catch(Exception e)
{
LOG.warn("Authentik groups could not be read at sign-in; granting only the static fallbacks", logPair("error", AuthentikRedactor.redactText(e.getMessage(), runtime == null ? null : runtime.getConfig().getResolvedApiToken())));
}
return (rs);
return (new Detailed(floor, bootstrap, new TreeSet<>(), PolicyRules.EMPTY, PolicyRules.EMPTY, true));
}
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.permissions;
import java.util.Collection;
import java.util.List;
import java.util.Set;
/*******************************************************************************
** SPI: the permissions a host grants TODAY, before Authentik group policies
** take over (DESIGN.md section 12.8). Typically a hard-coded grant such as
** "members of the admins group get adminPermissions()". The qbit uses it to
** - generate a group's policy from current access,
** - compare current access with what the mapper would grant, and
** - fall back to it while the cut-over guard keeps the mapper inactive.
*******************************************************************************/
public interface AuthentikHostPermissionSource
{
/*******************************************************************************
** The QQQ session permissions a user gets today.
**
** @param email the user's email (may be null)
** @param groupNames the user's direct Authentik group names
*******************************************************************************/
Set<String> userPermissions(String email, Collection<String> groupNames);
/*******************************************************************************
** The permissions members of a group get today because of that group. The
** default asks for a user in only that group.
*******************************************************************************/
default Set<String> groupPermissions(String groupName)
{
return (userPermissions(null, List.of(groupName)));
}
}
@@ -0,0 +1,244 @@
/*
* Copyright 2026 Kingsrook, LLC
* Licensed 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 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 com.kingsrook.qbits.authentik.permissions;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.security.AuthentikSessionIdentity;
import com.kingsrook.qbits.authentik.sync.AuthentikSessionBindings;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.OAuth2AuthenticationModule;
import org.json.JSONObject;
/*******************************************************************************
** Native OAuth verification plus durable immutable identity and current grants.
** Opt-in hosts must install the sync schema, a fresh mirror, signed identity
** mapping, refreshing customizer, disabled session cache and finite lifetime.
******************************************************************************/
public class AuthentikRefreshingOAuth2Module extends OAuth2AuthenticationModule
{
/***************************************************************************
** Authenticate through core before binding a new login or checking a resume.
***************************************************************************/
@Override
public QSession createSession(QInstance instance, Map<String, String> context) throws QAuthenticationException
{
mapper(instance);
QSession session = authenticate(instance, context);
boolean newLogin = context != null && context.containsKey("code")
&& (context.containsKey("state") || (context.containsKey("redirectUri") && context.containsKey("codeVerifier")));
return (check(instance, session, newLogin || bearer(session)));
}
/***************************************************************************
** Core also verifies the expected service username before this boundary.
***************************************************************************/
@Override
public QSession createServiceSession(QInstance instance, String token, String expectedUsername) throws QAuthenticationException
{
mapper(instance);
return (check(instance, super.createServiceSession(instance, token, expectedUsername), true));
}
/***************************************************************************
** Already-created session objects must not bypass binding or grant refresh.
***************************************************************************/
@Override
public boolean isSessionValid(QInstance instance, QSession session)
{
if(session instanceof QSystemUserSession)
{
return (true);
}
try
{
mapper(instance);
if(session == null || !authenticatedSessionValid(instance, session))
{
throw (new QAuthenticationException("Session is not valid."));
}
check(instance, session, false);
return (true);
}
catch(Exception failure)
{
if(session != null)
{
session.setPermissions(new TreeSet<>());
}
return (false);
}
}
/***************************************************************************
** Durable revocation precedes core cleanup, which still runs on failure.
***************************************************************************/
@Override
public void logout(QInstance instance, String sessionUuid)
{
var previous = QContext.capture();
try
{
var mapper = mapper(instance);
QContext.init(instance, new QSystemUserSession(instance));
bindings(mapper).revoke(sessionUuid);
}
catch(Exception failure)
{
throw (new IllegalStateException("Durable session revocation could not be completed."));
}
finally
{
QContext.init(previous);
super.logout(instance, sessionUuid);
}
}
/***************************************************************************
** Never use request claims or mutable email to establish source identity.
***************************************************************************/
private QSession check(QInstance instance, QSession session, boolean allowBind) throws QAuthenticationException
{
var previous = QContext.capture();
try
{
var mapper = mapper(instance);
JSONObject claims = new JSONObject(session.getValue(AuthentikRefreshingPermissionMapper.CLAIMS));
var identity = AuthentikSessionIdentity.fromVerifiedClaims(claims).orElseThrow();
if(!Objects.equals(mapper.getApiBackendName(), session.getValue(AuthentikRefreshingPermissionMapper.BACKEND)))
{
throw (new QAuthenticationException("Session backend changed."));
}
String reference = session.getUser().getIdReference();
QContext.init(instance, new QSystemUserSession(instance));
var store = bindings(mapper);
AuthentikSessionBindings.Binding binding;
Instant now = Instant.now();
long lifetime = ((OAuth2AuthenticationMetaData) instance.getAuthentication()).getMaxSessionLifetimeSeconds();
Instant deadline = now.plusSeconds(lifetime);
if(bearer(session))
{
// A deterministic deadline survives row/tombstone cleanup. Check it
// on resumes too, so an old binding cannot bypass a shortened limit.
Object rawIssued = claims.opt("iat");
if(!(rawIssued instanceof Number issued))
{
throw (new QAuthenticationException("Bearer credentials require a signed issue time."));
}
long issuedSeconds = new java.math.BigDecimal(issued.toString()).longValueExact();
if(issuedSeconds <= 0 || issuedSeconds > now.getEpochSecond())
{
throw (new QAuthenticationException("Bearer issue time is invalid."));
}
deadline = Instant.ofEpochSecond(issuedSeconds).plusSeconds(lifetime);
Instant tokenExpiry = Instant.ofEpochSecond(Long.parseLong(session.getValue(BEARER_SESSION_EXPIRES_AT_VALUE_KEY)));
if(tokenExpiry.isBefore(deadline))
{
deadline = tokenExpiry;
}
if(!deadline.isAfter(now))
{
throw (new QAuthenticationException("Bearer session lifetime has expired."));
}
}
if(allowBind)
{
binding = store.bind(session.getUuid(), new AuthentikSessionBindings.Binding(identity.pk(), identity.uuid(), reference, deadline));
}
else
{
binding = store.find(session.getUuid());
}
if(binding == null || !Objects.equals(binding.userPk(), identity.pk()) || !binding.userUuid().equals(identity.uuid())
|| !Objects.equals(binding.userReference(), reference))
{
throw (new QAuthenticationException("Session identity binding is unavailable."));
}
mapper.refresh(instance, session);
return (session);
}
catch(Exception failure)
{
if(session != null)
{
session.setPermissions(new TreeSet<>());
}
throw (new QAuthenticationException("Current Authentik session access could not be established."));
}
finally
{
QContext.init(previous);
}
}
/***************************************************************************
** Caching must be off: core bearer cache hits bypass the customizer.
***************************************************************************/
protected AuthentikRefreshingPermissionMapper mapper(QInstance instance) throws QAuthenticationException
{
if(!(instance.getAuthentication() instanceof OAuth2AuthenticationMetaData metadata)
|| Boolean.TRUE.equals(metadata.getSessionStoreEnabled()) || metadata.getMaxSessionLifetimeSeconds() == null
|| metadata.getMaxSessionLifetimeSeconds() <= 0
|| metadata.getMaxSessionLifetimeSeconds() > AuthentikSessionBindings.REVOCATION_RETENTION.toSeconds() || metadata.getCustomizer() == null)
{
throw (new QAuthenticationException("Refreshing OAuth requires a customizer, disabled session cache and a session lifetime of at most thirty days."));
}
try
{
return (QCodeLoader.getAdHoc(AuthentikRefreshingPermissionMapper.class, metadata.getCustomizer()));
}
catch(Exception failure)
{
throw (new QAuthenticationException("Refreshing OAuth requires AuthentikRefreshingPermissionMapper."));
}
}
/***************************************************************************
** Host-backed durable store; no additional pool or remote API writes.
***************************************************************************/
protected AuthentikSessionBindings bindings(AuthentikRefreshingPermissionMapper mapper)
{
return (new AuthentikSessionBindings(mapper.runtime().getConfig()));
}
/***************************************************************************
** Core owns token verification, authorization-code exchange and refresh.
***************************************************************************/
protected QSession authenticate(QInstance instance, Map<String, String> context) throws QAuthenticationException
{
return (super.createSession(instance, context));
}
/***************************************************************************
** Core validates expiry and stored OAuth credentials before current access.
***************************************************************************/
protected boolean authenticatedSessionValid(QInstance instance, QSession session)
{
return (super.isSessionValid(instance, session));
}
/***************************************************************************
** Only core's internal marker distinguishes verified stateless credentials.
***************************************************************************/
private static boolean bearer(QSession session)
{
return (session != null && "true".equals(session.getValue(BEARER_SESSION_VALUE_KEY)));
}
}
@@ -0,0 +1,232 @@
/*
* Copyright 2026 Kingsrook, LLC
* Licensed 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 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 com.kingsrook.qbits.authentik.permissions;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.policy.PolicyResult;
import com.kingsrook.qbits.authentik.policy.PolicyRules;
import com.kingsrook.qbits.authentik.security.AuthentikSessionIdentity;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qbits.authentik.sync.AuthentikMirrorAccess;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Opt-in customizer for AuthentikRefreshingOAuth2Module. Replaces directory
** grants from a fresh mirror; never unions token policies or old permissions.
** Host-specific access must use withHostPermissionSource/static group grants.
** Automated sessions retain the existing fresh API lookup in the superclass.
******************************************************************************/
public class AuthentikRefreshingPermissionMapper extends AuthentikGroupPermissionMapper
{
private static final QLogger LOG = QLogger.getLogger(AuthentikRefreshingPermissionMapper.class);
static final String CLAIMS = "authentik.verifiedSessionClaims";
static final String BACKEND = "authentik.sessionBackend";
/***************************************************************************
** Capture only verified identity/credential facts, never access tokens,
** old groups or policies. QSession values are internal, not frontend values.
***************************************************************************/
@Override
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
{
session.setPermissions(new TreeSet<>());
session.setValue(CLAIMS, null);
session.setValue(BACKEND, null);
try
{
Object source = context == null ? null : context.get(CLAIMS_CONTEXT_KEY);
JSONObject claims = source instanceof JSONObject object ? object : source instanceof Map<?, ?> map ? new JSONObject(map) : null;
if(AuthentikSessionIdentity.fromVerifiedClaims(claims).isEmpty())
{
throw (new QAuthenticationException("A signed immutable Authentik identity is required."));
}
String scopeClaim = runtime().getConfig().getTokenScopeClaim();
JSONObject retained = new JSONObject();
for(String key : java.util.List.of(AuthentikSessionIdentity.CLAIM, "sub", "iss", "iat", "exp", scopeClaim))
{
if(claims.has(key))
{
retained.put(key, claims.get(key));
}
}
session.setValue(CLAIMS, retained.toString());
session.setValue(BACKEND, getApiBackendName());
refresh(instance, session);
}
catch(Exception failure)
{
LOG.warn("The Authentik session could not be established", logPair("errorType", failure.getClass().getSimpleName()),
logPair("causeType", failure.getCause() == null ? "none" : failure.getCause().getClass().getSimpleName()));
session.setPermissions(new TreeSet<>());
session.setValue(CLAIMS, null);
throw (new IllegalStateException("Current Authentik session access could not be established."));
}
}
/***************************************************************************
** Reevaluate under a short internal read context and always restore it.
** Credential restrictions are reapplied AFTER all current grants and floors.
***************************************************************************/
public void refresh(QInstance instance, QSession session) throws QAuthenticationException
{
session.setPermissions(new TreeSet<>());
var previous = QContext.capture();
try
{
if(!getApiBackendName().equals(session.getValue(BACKEND)))
{
throw (new QAuthenticationException("Session authentication backend changed."));
}
JSONObject claims = new JSONObject(session.getValue(CLAIMS));
var identity = AuthentikSessionIdentity.fromVerifiedClaims(claims).orElseThrow();
QContext.init(instance, new QSystemUserSession(instance));
var runtime = runtime();
var config = runtime.getConfig();
if(!com.kingsrook.qbits.authentik.security.AuthentikIssuer.matches(config.getAuthentikPublicUrl(), claims.opt("iss")))
{
throw (new QAuthenticationException("Verified identity issuer does not match the configured Authentik instance."));
}
var current = readCurrent(identity);
if(current.status() != AuthentikMirrorAccess.Status.READY)
{
throw (new QAuthenticationException("Current Authentik access is unavailable or denied."));
}
var access = current.access();
Set<String> floor = new TreeSet<>(configuredStaticPermissions(access.groups()));
floor.addAll(AccessComparison.staticPermissions(config, access.groups()));
Set<String> bootstrapNames = new TreeSet<>();
var bootstrap = config.getResolvedBootstrapSubjects();
String sub = claims.optString("sub", "");
if(bootstrap.contains(Integer.toString(identity.pk())) || bootstrap.contains(identity.uuid())
|| (!sub.isBlank() && !sub.contains("@") && bootstrap.contains(sub)))
{
for(var permission : IdentityPermission.values())
{
bootstrapNames.add(permission.getSessionPermission());
}
}
runtime.markMapperInvoked();
boolean enforced = enforced(instance);
Set<String> host = new TreeSet<>();
if(!enforced && config.getHostPermissionSource() != null)
{
host.addAll(config.getHostPermissionSource().userPermissions(access.email() == null ? "" : access.email(), access.groups()));
}
///////////////////////////////////////////////////////////////////////
// the one shared rule (SessionPermissionRules): a Deny beats the //
// mirror's stored effective permissions, the floors and the host //
// source; an invalid policy leaves break-glass only; unreadable //
// policies leave only the configured outage fallbacks //
///////////////////////////////////////////////////////////////////////
PolicyResult policy = policyResult(instance, access, sub, access.email());
SessionPermissionRules.Knowledge knowledge = policy.isUnavailable() ? SessionPermissionRules.Knowledge.UNAVAILABLE
: policy.isVoid() ? SessionPermissionRules.Knowledge.VOID : SessionPermissionRules.Knowledge.KNOWN;
if(knowledge == SessionPermissionRules.Knowledge.UNAVAILABLE)
{
LOG.warn("The mirrored group policies could not be read, so no Deny can be known; only the configured fallbacks are granted");
}
else if(knowledge == SessionPermissionRules.Knowledge.VOID)
{
LOG.error("A user with an invalid policy refreshes; they get no permissions beyond break-glass. Fix the policy.", logPair("groups", access.groups()));
}
Set<String> permissions = SessionPermissionRules.compute(knowledge, policy.rules().denialsOnly(), enforced, bootstrapNames, floor, host, access.permissions(), Set.of());
session.setPermissions(permissions);
AuthentikTokenScopes.apply(instance, session, claims);
}
catch(Exception failure)
{
LOG.warn("The Authentik session access could not be refreshed", logPair("errorType", failure.getClass().getSimpleName()),
logPair("causeType", failure.getCause() == null ? "none" : failure.getCause().getClass().getSimpleName()));
session.setPermissions(new TreeSet<>());
throw (new QAuthenticationException("Current Authentik session access could not be established."));
}
finally
{
QContext.init(previous);
}
}
/***************************************************************************
** Shared immutable-identity SQL reader; an unavailable baseline fails closed.
***************************************************************************/
protected AuthentikMirrorAccess.Result readCurrent(AuthentikSessionIdentity identity) throws Exception
{
return (new AuthentikMirrorAccess(runtime().getConfig(), Duration.ofMinutes(15)).read(identity.pk(), identity.uuid()));
}
/***************************************************************************
** The typed policy result of the user's groups (for the Deny rules applied to
** the floors). A seam: the default reads the live group graph.
***************************************************************************/
protected PolicyResult policyResult(QInstance instance, AuthentikMirrorAccess.Access access, String sub, String email)
{
try
{
if(access.groupRows() == null)
{
return (PolicyResult.unreadable());
}
AuthentikRuntime runtime = runtime();
GroupGraph graph = GroupGraph.fromGroups(access.groupRows(), runtime.getConfig().getPolicyFormat(), PolicyCatalog.of(instance));
List<String> pks = new ArrayList<>();
for(String name : access.groups())
{
GroupGraph.Node node = graph.byName(name);
if(node == null)
{
return (new PolicyResult(PolicyRules.voided("a group of the user is not in the mirror"), Set.of()));
}
pks.add(node.pk());
}
EffectivePermissionResolver.Resolution resolution = new EffectivePermissionResolver(graph, runtime.getPermissionCatalog(), instance).resolveGroups(pks);
return (new PolicyResult(resolution.rules(), resolution.voided() ? Set.of() : resolution.sessionPermissions()));
}
catch(Exception e)
{
LOG.warn("The mirrored group policies could not be read", logPair("errorType", e.getClass().getSimpleName()));
return (PolicyResult.unreadable());
}
}
/***************************************************************************
** Preserve the existing policy cut-over guard and host-permission floor.
***************************************************************************/
protected boolean enforced(QInstance instance)
{
return (AccessComparison.state(runtime(), client(instance)).enforced());
}
/***************************************************************************
** Resolve only the configured API backend of this mapper.
***************************************************************************/
AuthentikRuntime runtime()
{
return (AuthentikRuntime.forBackend(getApiBackendName()));
}
}
@@ -17,6 +17,7 @@
package com.kingsrook.qbits.authentik.permissions;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -24,14 +25,28 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.policy.PolicyRules;
import com.kingsrook.qbits.authentik.policy.PolicyTerm;
import com.kingsrook.qbits.authentik.policy.PolicyUniverse;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Turns groups (and their ancestors) into QQQ permissions (DESIGN.md
** section 5.6). Pure: give it a GroupGraph and a permission catalog.
** section 5.6). Full access uses the explicit instance or current QContext.
**
** Rules:
** - Policies are typed rules (policy.PolicyRules). Allow terms expand against
** the live universe (full access, prefix resources); Deny terms subtract
** from EVERYTHING the user's groups grant, qqq.io/permissions included. A
** Deny anywhere wins; there is no ordering.
** - ONE invalid policy among the groups (and ancestors) voids all of them:
** the user gets no permissions and no app access. Dropping only the bad
** group could drop a Deny while other groups' Allow statements survive.
** - Full access expands to all current instance/catalog names.
** - A group confers the union of qqq.io/permissions on itself and every
** ancestor ("inherited parents").
** - "authentik.*" expands to every identity permission; an identity short
@@ -46,14 +61,43 @@ public class EffectivePermissionResolver
{
private final GroupGraph graph;
private final Set<String> catalog;
private final QInstance qInstance;
private final PolicyUniverse universe;
/*******************************************************************************
** Result for a set of groups.
*******************************************************************************/
public record Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess)
public record Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess, PolicyRules denials, PolicyRules rules)
{
/*******************************************************************************
** A resolution whose rules are only its denials.
*******************************************************************************/
public Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess, PolicyRules denials)
{
this(sessionPermissions, grantedBy, unknown, appAccess, denials, denials);
}
/*******************************************************************************
** A resolution with no policy denials.
*******************************************************************************/
public Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess)
{
this(sessionPermissions, grantedBy, unknown, appAccess, PolicyRules.EMPTY);
}
/*******************************************************************************
** Whether an invalid policy voided this user's access (the permissions are
** empty and the denials are void).
*******************************************************************************/
public boolean voided()
{
return (denials.isVoid());
}
}
@@ -68,7 +112,30 @@ public class EffectivePermissionResolver
*******************************************************************************/
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog)
{
this(graph, catalog, null);
}
/*******************************************************************************
** Resolve against an explicit sign-in instance, or the current QContext.
*******************************************************************************/
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog, QInstance qInstance)
{
this(graph, catalog, qInstance, null);
}
/*******************************************************************************
** Resolve with an explicit permission universe (the test seam for golden
** vectors); null means the live universe of the instance or QContext.
*******************************************************************************/
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog, QInstance qInstance, PolicyUniverse universe)
{
this.universe = universe;
this.graph = graph;
this.qInstance = qInstance;
this.catalog = catalog == null ? null : new TreeSet<>(catalog);
if(this.catalog != null)
{
@@ -85,13 +152,27 @@ public class EffectivePermissionResolver
** Effective permissions of a set of groups (e.g. a user's direct groups).
*******************************************************************************/
public Resolution resolveGroups(Collection<String> groupPks)
{
return (resolveGroups(groupPks, null));
}
/*******************************************************************************
** Resolve normally, except for one group's policy document. Its named
** permissions and every other group's policy still contribute.
*******************************************************************************/
public Resolution resolveGroups(Collection<String> groupPks, String excludePolicyOfGroupPk)
{
Set<String> permissions = new TreeSet<>();
Map<String, Set<String>> grantedBy = new LinkedHashMap<>();
Set<String> unknown = new TreeSet<>();
Set<String> appAccess = new TreeSet<>();
List<PolicyRules> policies = new ArrayList<>();
PolicyUniverse live = PolicyUniverse.once(universe());
Set<String> visited = new LinkedHashSet<>();
Set<String> visited = new LinkedHashSet<>();
boolean missingParent = false;
for(String pk : groupPks)
{
for(GroupGraph.Node node : graph.selfAndAncestors(pk))
@@ -100,23 +181,86 @@ public class EffectivePermissionResolver
{
continue;
}
for(String parent : node.parentPks())
{
missingParent |= graph.byPk(parent) == null;
}
appAccess.addAll(node.appAccess());
for(String raw : node.permissions())
{
Set<String> expanded = expand(raw);
if(expanded.isEmpty())
grant(expand(raw), raw, node, permissions, grantedBy, unknown);
}
if(!node.pk().equals(excludePolicyOfGroupPk))
{
policies.add(node.policy());
for(PolicyTerm term : node.policy().allowTerms())
{
unknown.add(raw);
}
for(String permission : expanded)
{
permissions.add(permission);
grantedBy.computeIfAbsent(permission, k -> new TreeSet<>()).add(node.name());
grant(term.expand(live), term instanceof PolicyTerm.Exact exact ? exact.name() : null, node, permissions, grantedBy, unknown);
}
}
}
}
return (new Resolution(permissions, grantedBy, unknown, appAccess));
////////////////////////////////////////////////////////////////////////
// one invalid policy voids every group of the user; a Deny anywhere //
// removes what any source (policy or qqq.io/permissions) granted //
////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////
// a parent the graph does not know may hold a Deny: void, like a //
// missing group //
//////////////////////////////////////////////////////////////////////
PolicyRules merged = missingParent ? PolicyRules.voided("a parent group is not in the group graph") : PolicyRules.merge(policies);
PolicyRules denials = merged.denialsOnly();
if(denials.isVoid())
{
return (new Resolution(new TreeSet<>(), new LinkedHashMap<>(), unknown, new TreeSet<>(), denials, merged));
}
permissions.removeIf(denials::denies);
grantedBy.keySet().removeIf(denials::denies);
return (new Resolution(permissions, grantedBy, unknown, appAccess, denials, merged));
}
/*******************************************************************************
** Record what a node granted; a name that expanded to nothing is unknown.
*******************************************************************************/
private static void grant(Set<String> expanded, String raw, GroupGraph.Node node, Set<String> permissions, Map<String, Set<String>> grantedBy, Set<String> unknown)
{
if(expanded.isEmpty() && raw != null)
{
unknown.add(raw);
}
for(String permission : expanded)
{
permissions.add(permission);
grantedBy.computeIfAbsent(permission, k -> new TreeSet<>()).add(node.name());
}
}
/*******************************************************************************
** The universe symbolic terms expand against: the explicit one, else every
** current permission of the instance (never cached across calls).
*******************************************************************************/
public PolicyUniverse universe()
{
if(universe != null)
{
return (universe);
}
return (() -> PolicyPermissions.allPermissions(qInstance == null ? QContext.getQInstance() : qInstance, catalog));
}
/*******************************************************************************
** Every permission full access grants now (the live universe).
*******************************************************************************/
public Set<String> fullAccessPermissions()
{
return (new TreeSet<>(universe().names()));
}
@@ -143,7 +287,6 @@ public class EffectivePermissionResolver
return (rs);
}
String name = raw.trim();
if(name.equals(IdentityPermission.WILDCARD))
{
for(IdentityPermission permission : IdentityPermission.values())
@@ -27,6 +27,11 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.policy.PolicyRules;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -42,12 +47,23 @@ public class GroupGraph
/*******************************************************************************
** One group.
*******************************************************************************/
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed,
PolicyRules policy)
{
/*******************************************************************************
** A group without a policy document.
*******************************************************************************/
public Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
{
this(pk, name, ownSuperuser, parentPks, permissions, appAccess, memberPks, malformed, PolicyRules.EMPTY);
}
}
public static final String USER_ACCESS_ATTRIBUTE = "qqq.io/user-access-owner";
private final Map<String, String> userAccessOwners = new LinkedHashMap<>();
private final Map<String, Node> byPk = new LinkedHashMap<>();
private final Map<String, Node> byName = new LinkedHashMap<>();
private final Map<String, Set<String>> ancestors = new java.util.concurrent.ConcurrentHashMap<>();
@@ -59,9 +75,42 @@ public class GroupGraph
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups)
{
GroupGraph graph = new GroupGraph();
return (fromGroups(groups, PolicyFormat.DEFAULT));
}
/*******************************************************************************
** Build from Authentik group objects, reading each group's policy document
** (docs/POLICY-FORMAT.md) from the format's attribute. A document that does
** not parse is listed in the node's malformed entries and its rules are VOID:
** the void spreads to every user who has the group, direct or inherited
** (see EffectivePermissionResolver), because dropping only the bad group
** could drop a Deny while other groups' Allow statements survive.
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format)
{
return (fromGroups(groups, format, null));
}
/*******************************************************************************
** Read group policies with the catalog of registered table features.
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format, PolicyCatalog catalog)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
GroupGraph graph = new GroupGraph();
for(JSONObject group : groups)
{
JSONObject attributes = group.optJSONObject("attributes");
if(attributes != null && attributes.has(USER_ACCESS_ATTRIBUTE))
{
graph.userAccessOwners.put(group.getString("pk"), attributes.optString(USER_ACCESS_ATTRIBUTE));
}
List<String> malformed = malformed(attributes);
PolicyRules policy = policyRules(attributes, f, malformed, catalog);
graph.add(new Node(
group.getString("pk"),
group.optString("name", ""),
@@ -70,7 +119,8 @@ public class GroupGraph
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)),
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)),
ints(group.optJSONArray("users")),
malformed(group.optJSONObject("attributes"))));
malformed,
policy));
}
return (graph);
}
@@ -80,6 +130,22 @@ public class GroupGraph
/*******************************************************************************
** Add a node.
*******************************************************************************/
public boolean isUserAccessGroup(String pk)
{
return (userAccessOwners.containsKey(pk));
}
/*******************************************************************************
** Find dedicated access groups by stable app and user identity, not by name.
*******************************************************************************/
public List<String> userAccessGroups(String appKey, String userPk)
{
return (userAccessOwners.entrySet().stream().filter(e -> e.getValue().equals(appKey + ":" + userPk)).map(Map.Entry::getKey).toList());
}
/*******************************************************************************
** Add a group to the graph.
*******************************************************************************/
public GroupGraph add(Node node)
{
byPk.put(node.pk(), node);
@@ -257,6 +323,30 @@ public class GroupGraph
/*******************************************************************************
** The typed rules of a group's policy document: empty without one, void
** (and a malformed entry) when it does not parse.
*******************************************************************************/
private static PolicyRules policyRules(JSONObject attributes, PolicyFormat format, List<String> malformed, PolicyCatalog catalog)
{
Object document = attributes == null ? null : attributes.opt(format.attribute());
if(document == null || JSONObject.NULL.equals(document) || "".equals(document))
{
return (PolicyRules.EMPTY);
}
try
{
return (PolicyParser.parseStrict(document, format, catalog).rules());
}
catch(PolicyParseException e)
{
malformed.add(format.attribute() + ": " + e.getMessage());
return (PolicyRules.voided(e.getMessage()));
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -0,0 +1,121 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.permissions;
import java.util.Collection;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.policy.PolicyRules;
/*******************************************************************************
** THE rule that turns what is known about a user's policies, and every source of
** permissions, into one session permission set. Sign-in, automated sessions and
** the refreshing mapper all call compute(), so they cannot disagree.
**
** - VOID (an invalid document, or a group or parent missing from the graph or
** mirror): only the bootstrap identity permissions (break-glass). The floors,
** the host source and the session's earlier permissions are cleared, even
** while the cut-over guard blocks.
** - UNAVAILABLE (the graph or mirror cannot be read and there is no claim, so
** no Deny can be known): the explicitly configured outage fallbacks (static
** group permissions) minus any Deny already known, plus bootstrap. The host
** source, the session's earlier permissions and any directory grants are
** withheld.
** - KNOWN: bootstrap, plus the static floor, plus the policies' grants while the
** policies are enforced (or the host source while the guard blocks), plus the
** session's earlier permissions, all minus every Deny. A Deny only removes
** access, so the guard's BLOCKED state never switches it off.
*******************************************************************************/
public final class SessionPermissionRules
{
/*******************************************************************************
** What is known about the user's policies.
*******************************************************************************/
public enum Knowledge
{
/** Rules were read; denials are valid. */
KNOWN,
/** A policy is invalid, or a group or parent is missing. */
VOID,
/** The policies could not be read at all. */
UNAVAILABLE
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SessionPermissionRules()
{
}
/*******************************************************************************
** The session permissions.
**
** @param knowledge what is known about the policies
** @param denials the valid Deny rules (ignored unless KNOWN or UNAVAILABLE with a claim)
** @param enforced whether policies are enforced (the guard is not blocking)
** @param bootstrap bootstrap identity permissions (break-glass, never subtracted)
** @param staticFloor the explicitly configured static group permissions
** @param host the host permission source's grants (used only while blocked)
** @param policyGrants what the policies (and the mirror's directory grants) grant
** @param existing permissions already on the session
*******************************************************************************/
public static Set<String> compute(Knowledge knowledge, PolicyRules denials, boolean enforced, Collection<String> bootstrap, Collection<String> staticFloor,
Collection<String> host, Collection<String> policyGrants, Collection<String> existing)
{
Set<String> rs = new TreeSet<>(bootstrap);
if(knowledge == Knowledge.VOID)
{
return (rs);
}
PolicyRules rules = denials == null ? PolicyRules.EMPTY : denials;
add(rs, staticFloor, rules);
if(knowledge == Knowledge.UNAVAILABLE)
{
return (rs);
}
add(rs, enforced ? policyGrants : host, rules);
add(rs, existing, rules);
return (rs);
}
/*******************************************************************************
** Add the names no Deny covers.
*******************************************************************************/
private static void add(Set<String> target, Collection<String> names, PolicyRules rules)
{
if(names == null)
{
return;
}
for(String name : names)
{
if(!rules.denies(name))
{
target.add(name);
}
}
}
}
@@ -0,0 +1,125 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.policy;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Authentication customizer that adds the QQQ permissions granted by the
** policy claim of the sign-in token (docs/POLICY-FORMAT.md). It needs no
** Authentik API access: Authentik merges the user's group policies into the
** claim at sign-in (the scope mapping in the format doc).
**
** Order: applySecurityKeys (a no-op hook for hosts), then permissions. The
** permissions are added to whatever the session already holds. A missing or
** invalid claim adds nothing (fail closed).
*******************************************************************************/
public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomizerInterface
{
private static final QLogger LOG = QLogger.getLogger(AuthentikPolicyClaimMapper.class);
private PolicyFormat format = PolicyFormat.DEFAULT;
/*******************************************************************************
** Use these names (the qbit config's getPolicyFormat()) instead of the
** defaults.
*******************************************************************************/
public AuthentikPolicyClaimMapper withFormat(PolicyFormat format)
{
this.format = format == null ? PolicyFormat.DEFAULT : format;
return (this);
}
/*******************************************************************************
** Getter for format.
*******************************************************************************/
public PolicyFormat getFormat()
{
return (format);
}
/*******************************************************************************
** Add the permissions from the policy claim.
*******************************************************************************/
@Override
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
{
JSONObject claims = PolicyPermissions.claimsWithPolicy(context, format);
JSONObject any = claims;
if(any == null && context != null)
{
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ID_TOKEN_CONTEXT_KEY));
if(any == null)
{
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY));
}
}
if(any != null)
{
applySecurityKeys(qSession, any);
}
if(claims == null)
{
LOG.info("No policy claim in the token; no policy permissions added", logPair("claim", format.claim()));
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession,
context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY)));
return;
}
///////////////////////////////////////////////////////////////////////////
// the session's existing permissions go through the same rules: a Deny //
// removes them too, and an invalid policy voids them //
///////////////////////////////////////////////////////////////////////////
PolicyResult result = PolicyPermissions.resultFromClaims(claims, format, qInstance == null ? null : PolicyCatalog.of(qInstance));
qSession.setPermissions(result.apply(qSession.getPermissions()));
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession,
context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY)));
}
/*******************************************************************************
** Extension point for record locks (security keys); a no-op here. A host
** overrides it to set security key values from its OWN explicit claims, and
** must never infer them from group names.
*******************************************************************************/
protected void applySecurityKeys(QSession qSession, JSONObject claims)
{
////////////////////////////////////////////////////////
// no record locks in policy format 1.1.0 (by design) //
////////////////////////////////////////////////////////
}
}
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.policy;
import java.util.Optional;
/*******************************************************************************
** The five standard actions, in canonical statement order. Table features use
** their registered string names separately. Each standard action
** maps to the QQQ permission suffix of the same name ({resource}.{action}).
*******************************************************************************/
public enum PolicyAction
{
HAS_ACCESS("hasAccess"),
READ("read"),
INSERT("insert"),
EDIT("edit"),
DELETE("delete");
/** "All five actions" in a statement's Action array. */
public static final String WILDCARD = "*";
private final String jsonName;
/*******************************************************************************
** Constructor.
*******************************************************************************/
PolicyAction(String jsonName)
{
this.jsonName = jsonName;
}
/*******************************************************************************
** The name used in documents and as the QQQ permission suffix.
*******************************************************************************/
public String getJsonName()
{
return (jsonName);
}
/*******************************************************************************
** The QQQ permission this action grants on a resource.
*******************************************************************************/
public String permission(String resource)
{
return (resource + "." + jsonName);
}
/*******************************************************************************
** Look up by document name (case-sensitive, like QQQ permission names).
*******************************************************************************/
public static Optional<PolicyAction> fromJsonName(String name)
{
for(PolicyAction action : values())
{
if(action.jsonName.equals(name))
{
return (Optional.of(action));
}
}
return (Optional.empty());
}
}
@@ -0,0 +1,891 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermission;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** What a policy can grant in a QInstance (DESIGN.md section 12.4): every app,
** table, process, report and protected widget, with its permission base name
** (the policy resource) and the actions QQQ checks for it, grouped by app.
**
** Unlike PermissionsHelper.getAllAvailablePermissions, hidden tables and
** processes are included: record actions are hidden from the nav but still
** permission-checked. Objects with a custom permission checker or that are
** NOT_PROTECTED are left out (a policy cannot grant them).
*******************************************************************************/
public final class PolicyCatalog
{
/** Group label of objects that are in no app. */
public static final String OTHER = "Other";
/** Kinds of resources. */
public enum Kind
{
APP, TABLE, PROCESS, REPORT, WIDGET
}
/*******************************************************************************
** One grantable resource.
**
** @param name the policy resource (permission base name)
** @param label what operators see
** @param kind app, table, process, report or widget
** @param actions the actions QQQ checks, in canonical order
** @param features explicitly grantable table features, sorted by name
** @param note a plain note (e.g. why an action is missing), or null
*******************************************************************************/
public record Resource(String name, String label, Kind kind, List<PolicyAction> actions, List<Feature> features, String note)
{
/*******************************************************************************
** A resource with only standard actions.
*******************************************************************************/
public Resource(String name, String label, Kind kind, List<PolicyAction> actions, String note)
{
this(name, label, kind, actions, List.of(), note);
}
}
/*******************************************************************************
** A table feature's permission suffix and display label from QQQ.
*******************************************************************************/
public record Feature(String name, String label)
{
}
/*******************************************************************************
** The resources of one app (or of no app: OTHER).
*******************************************************************************/
public record Group(String app, String label, List<Resource> resources)
{
}
private static final int CACHE_SIZE = 4;
/**
* The last few instances' catalogs, so sign-in does not rebuild one per call. The map holds
* strong references (a catalog refers to its instance, so weak keys would never clear), but
* it is bounded to CACHE_SIZE entries; clearCache() releases them (tests, hot reload).
*/
private static final Map<QInstance, Cached> CACHE = new LinkedHashMap<>(8, 0.75f, true)
{
@Override
protected boolean removeEldestEntry(Map.Entry<QInstance, Cached> eldest)
{
return (size() > CACHE_SIZE);
}
};
private final List<Group> groups;
private final Map<String, Resource> byName;
private final QInstance qInstance;
private final Set<String> availableNames;
/** permissionNames(), computed once: a catalog never changes after it is built. */
private final SortedSet<String> permissionNames;
/** availableNames and permissionNames together, sorted: what the catalog gives the permission universe. */
private final SortedSet<String> universeNames;
/*******************************************************************************
** A cached catalog with the metadata fingerprint it was built from.
*******************************************************************************/
private record Cached(String fingerprint, PolicyCatalog catalog)
{
}
/*******************************************************************************
** Receives the pieces of an instance's fingerprint, in the order describe()
** produces them.
*******************************************************************************/
private interface FingerprintSink
{
/*******************************************************************************
** The next piece.
*******************************************************************************/
void add(String piece);
}
/*******************************************************************************
** Compares the pieces of an instance's fingerprint with a fingerprint text as
** they are produced, so checking that a cached catalog is still current builds
** no text: the same comparison as building the fingerprint and calling equals.
*******************************************************************************/
private static final class FingerprintMatcher implements FingerprintSink
{
private final String expected;
private int position;
private boolean same = true;
/*******************************************************************************
** Constructor.
*******************************************************************************/
private FingerprintMatcher(String expected)
{
this.expected = expected;
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void add(String piece)
{
if(same)
{
same = expected.regionMatches(position, piece, 0, piece.length());
position += piece.length();
}
}
/*******************************************************************************
** Whether every piece matched and nothing of the expected text is left over.
*******************************************************************************/
private boolean matched()
{
return (same && position == expected.length());
}
}
/*******************************************************************************
** Constructor.
*******************************************************************************/
private PolicyCatalog(List<Group> groups, QInstance qInstance, Set<String> availableNames)
{
this.availableNames = Set.copyOf(availableNames);
this.qInstance = qInstance;
this.groups = groups;
this.byName = new LinkedHashMap<>();
for(Group group : groups)
{
for(Resource resource : group.resources())
{
byName.putIfAbsent(resource.name(), resource);
}
}
SortedSet<String> grantable = new TreeSet<>();
for(Resource resource : byName.values())
{
for(PolicyAction action : resource.actions())
{
grantable.add(action.permission(resource.name()));
}
resource.features().forEach(feature -> grantable.add(resource.name() + "." + feature.name()));
}
this.permissionNames = Collections.unmodifiableSortedSet(grantable);
SortedSet<String> all = new TreeSet<>(grantable);
all.addAll(this.availableNames);
this.universeNames = Collections.unmodifiableSortedSet(all);
}
/*******************************************************************************
** The catalog of an instance: the cached one while the metadata it was built
** from is unchanged (checked on every call, see describe), else a new one.
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance)
{
Cached cached;
synchronized(CACHE)
{
cached = CACHE.get(qInstance);
}
if(cached != null)
{
FingerprintMatcher matcher = new FingerprintMatcher(cached.fingerprint());
describe(qInstance, matcher);
if(matcher.matched())
{
return (cached.catalog());
}
}
String fingerprint = fingerprint(qInstance);
PolicyCatalog built = build(qInstance);
synchronized(CACHE)
{
CACHE.put(qInstance, new Cached(fingerprint, built));
}
return (built);
}
/*******************************************************************************
** Forget every cached catalog (a host that changes table features in place
** without adding or removing metadata calls this).
*******************************************************************************/
public static void clearCache()
{
synchronized(CACHE)
{
CACHE.clear();
}
}
/*******************************************************************************
** A cheap digest of what the catalog is built from: object names, table
** permission rules and supplemental (feature) metadata keys. A change in
** any of them rebuilds the catalog on the next use.
*******************************************************************************/
static String fingerprint(QInstance qInstance)
{
StringBuilder sb = new StringBuilder(16384);
describe(qInstance, sb::append);
return (sb.toString());
}
/*******************************************************************************
** The pieces of the fingerprint, in the instance's own (insertion) order:
** every table, process, app, report and widget with its permission rules, the
** tables' supplemental metadata keys, the processes' hidden flags and the
** registered table features. The same instance content always gives the same
** pieces; any change to it gives different ones. This runs on every
** PolicyCatalog.of call (every sign-in and session resume), so it creates no
** sorted copy and no text of its own.
*******************************************************************************/
private static void describe(QInstance qInstance, FingerprintSink sink)
{
for(Map.Entry<String, QTableMetaData> entry : qInstance.getTables().entrySet())
{
QTableMetaData table = entry.getValue();
sink.add("t:");
sink.add(String.valueOf(entry.getKey()));
sink.add("|");
describeRules(table.getPermissionRules(), sink);
sink.add("|");
if(table.getSupplementalMetaData() != null)
{
sink.add("[");
for(String key : table.getSupplementalMetaData().keySet())
{
sink.add(String.valueOf(key));
sink.add(",");
}
sink.add("]");
}
sink.add(";");
}
for(Map.Entry<String, QProcessMetaData> entry : qInstance.getProcesses().entrySet())
{
sink.add("p:");
sink.add(String.valueOf(entry.getKey()));
sink.add("|");
describeRules(entry.getValue().getPermissionRules(), sink);
sink.add("|");
sink.add(String.valueOf(entry.getValue().getIsHidden()));
sink.add(";");
}
for(Map.Entry<String, QAppMetaData> entry : qInstance.getApps().entrySet())
{
describeObject("a:", entry.getKey(), entry.getValue().getPermissionRules(), sink);
}
for(Map.Entry<String, QReportMetaData> entry : qInstance.getReports().entrySet())
{
describeObject("r:", entry.getKey(), entry.getValue().getPermissionRules(), sink);
}
for(Map.Entry<String, QWidgetMetaDataInterface> entry : qInstance.getWidgets().entrySet())
{
describeObject("w:", entry.getKey(), entry.getValue().getPermissionRules(), sink);
}
TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.of(qInstance);
if(features != null)
{
for(TableFeaturePermission feature : features.getPermissions())
{
sink.add("f:");
sink.add(String.valueOf(feature.getSuffix()));
sink.add("|");
sink.add(String.valueOf(feature.getLabel()));
sink.add(";");
}
}
}
/*******************************************************************************
** One app, report or widget: its kind, name and permission rules.
*******************************************************************************/
private static void describeObject(String kind, String name, QPermissionRules rules, FingerprintSink sink)
{
sink.add(kind);
sink.add(String.valueOf(name));
sink.add("|");
describeRules(rules, sink);
sink.add(";");
}
/*******************************************************************************
** A permission rules object: its level, base name and custom checker.
*******************************************************************************/
private static void describeRules(QPermissionRules rules, FingerprintSink sink)
{
if(rules == null)
{
sink.add("-");
return;
}
sink.add(String.valueOf(rules.getLevel()));
sink.add(":");
sink.add(String.valueOf(rules.getPermissionBaseName()));
sink.add(":");
sink.add(String.valueOf(rules.getCustomPermissionChecker()));
}
/*******************************************************************************
** Build the catalog of an instance without the cache.
*******************************************************************************/
public static PolicyCatalog build(QInstance qInstance)
{
Collection<AvailablePermission> availablePermissions = PermissionsHelper.getAllAvailablePermissions(qInstance);
Map<String, String> appOfObject = appsOfObjects(qInstance);
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
List<QAppMetaData> apps = new ArrayList<>(qInstance.getApps().values());
apps.sort(Comparator.comparing((QAppMetaData a) -> a.getSortOrder() == null ? 500 : a.getSortOrder()).thenComparing(a -> label(a.getLabel(), a.getName())));
for(QAppMetaData app : apps)
{
byApp.put(app.getName(), new LinkedHashMap<>());
}
byApp.put(null, new LinkedHashMap<>());
for(QAppMetaData app : apps)
{
add(byApp, app.getName(), qInstance, app, Kind.APP, label(app.getLabel(), app.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QTableMetaData> tables = new ArrayList<>(qInstance.getTables().values());
tables.sort(Comparator.comparing(t -> label(t.getLabel(), t.getName())));
for(QTableMetaData table : tables)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(table, qInstance);
Set<PolicyAction> actions = EnumSet.noneOf(PolicyAction.class);
String note = null;
if(rules.getLevel() == PermissionLevel.HAS_ACCESS_PERMISSION)
{
actions.add(PolicyAction.HAS_ACCESS);
}
else
{
QBackendMetaData backend = qInstance.getBackend(table.getBackendName());
if(table.isCapabilityEnabled(backend, Capability.TABLE_QUERY) || table.isCapabilityEnabled(backend, Capability.TABLE_GET))
{
actions.add(PolicyAction.READ);
}
if(rules.getLevel() == PermissionLevel.READ_WRITE_PERMISSIONS)
{
note = "This table uses one write permission instead of separate insert, edit and delete actions.";
}
else
{
if(table.isCapabilityEnabled(backend, Capability.TABLE_INSERT))
{
actions.add(PolicyAction.INSERT);
}
if(table.isCapabilityEnabled(backend, Capability.TABLE_UPDATE))
{
actions.add(PolicyAction.EDIT);
}
if(table.isCapabilityEnabled(backend, Capability.TABLE_DELETE))
{
actions.add(PolicyAction.DELETE);
}
}
}
String baseName = StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : table.getName();
Map<String, Feature> features = new TreeMap<>();
for(AvailablePermission permission : availablePermissions)
{
if(permission.getName().startsWith(baseName + "."))
{
String suffix = permission.getName().substring(baseName.length() + 1);
if(!suffix.isEmpty() && PolicyAction.fromJsonName(suffix).isEmpty())
{
features.putIfAbsent(suffix, new Feature(suffix, permission.getPermissionType()));
}
}
}
add(byApp, appOfObject.get("table:" + table.getName()), qInstance, table, Kind.TABLE, label(table.getLabel(), table.getName()), actions, features, note);
}
List<QProcessMetaData> processes = new ArrayList<>(qInstance.getProcesses().values());
processes.sort(Comparator.comparing(p -> label(p.getLabel(), p.getName())));
for(QProcessMetaData process : processes)
{
String app = appOfObject.get("process:" + process.getName());
if(app == null && process.getTableName() != null)
{
app = appOfObject.get("table:" + process.getTableName());
}
String label = label(process.getLabel(), process.getName());
if(process.getTableName() != null && qInstance.getTable(process.getTableName()) != null)
{
label = label + " (" + label(qInstance.getTable(process.getTableName()).getLabel(), process.getTableName()) + " action)";
}
add(byApp, app, qInstance, process, Kind.PROCESS, label, EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QReportMetaData> reports = new ArrayList<>(qInstance.getReports().values());
reports.sort(Comparator.comparing(r -> label(r.getLabel(), r.getName())));
for(QReportMetaData report : reports)
{
add(byApp, appOfObject.get("report:" + report.getName()), qInstance, report, Kind.REPORT, label(report.getLabel(), report.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QWidgetMetaDataInterface> widgets = new ArrayList<>(qInstance.getWidgets().values());
widgets.sort(Comparator.comparing(w -> label(w.getLabel(), w.getName())));
for(QWidgetMetaDataInterface widget : widgets)
{
add(byApp, appOfObject.get("widget:" + widget.getName()), qInstance, widget, Kind.WIDGET, label(widget.getLabel(), widget.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<Group> groups = new ArrayList<>();
for(Map.Entry<String, Map<String, ResourceDraft>> entry : byApp.entrySet())
{
if(entry.getValue().isEmpty())
{
continue;
}
String groupLabel = entry.getKey() == null ? OTHER : label(qInstance.getApp(entry.getKey()).getLabel(), entry.getKey());
List<Resource> resources = new ArrayList<>();
for(ResourceDraft draft : entry.getValue().values())
{
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), List.copyOf(draft.features.values()), draft.note));
}
groups.add(new Group(entry.getKey(), groupLabel, resources));
}
Set<String> available = new TreeSet<>();
availablePermissions.forEach(permission -> available.add(permission.getName()));
return (new PolicyCatalog(groups, qInstance, available));
}
/*******************************************************************************
** A catalog of a flat set of permission names ({base}.{action}): bases that
** are valid resources become resources, standard actions become actions and
** every other action a feature. The seam for tests and golden vectors; it
** has no instance, so it never expands full access by itself.
*******************************************************************************/
public static PolicyCatalog ofPermissionNames(Collection<String> names)
{
return (ofPermissionNames(names, List.of()));
}
/*******************************************************************************
** As ofPermissionNames, but the host-declared names are in no resource: a
** dotted service name is never a table feature (granted only through the
** Permission key).
*******************************************************************************/
public static PolicyCatalog ofPermissionNames(Collection<String> names, Collection<String> hostNames)
{
Map<String, ResourceDraft> drafts = new TreeMap<>();
for(String permission : names)
{
if(hostNames.contains(permission))
{
continue;
}
int dot = permission.lastIndexOf('.');
if(dot <= 0 || dot == permission.length() - 1)
{
continue;
}
String base = permission.substring(0, dot);
String action = permission.substring(dot + 1);
if(!PolicyParser.RESOURCE_PATTERN.matcher(base).matches())
{
continue;
}
ResourceDraft draft = drafts.computeIfAbsent(base, key -> new ResourceDraft(key, Kind.TABLE));
draft.labels.add(base);
PolicyAction.fromJsonName(action).ifPresentOrElse(draft.actions::add, () -> draft.features.put(action, new Feature(action, action)));
}
List<Resource> resources = new ArrayList<>();
for(ResourceDraft draft : drafts.values())
{
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), List.copyOf(draft.features.values()), null));
}
return (new PolicyCatalog(drafts.isEmpty() ? List.of() : List.of(new Group(null, OTHER, resources)), null, new TreeSet<>(names)));
}
/*******************************************************************************
** The source instance, used for live full-access expansion, not a snapshot.
*******************************************************************************/
public QInstance getQInstance()
{
return (qInstance);
}
/*******************************************************************************
** Accumulates one resource while building (several objects can share a
** permission base name).
*******************************************************************************/
private static class ResourceDraft
{
private final String name;
private final Kind kind;
private final Set<String> labels = new LinkedHashSet<>();
private final Set<PolicyAction> actions = new TreeSet<>();
private final Map<String, Feature> features = new TreeMap<>();
private String note;
/*******************************************************************************
** Constructor.
*******************************************************************************/
ResourceDraft(String name, Kind kind)
{
this.name = name;
this.kind = kind;
}
}
/*******************************************************************************
** Add an object under its app, unless a policy cannot grant it.
*******************************************************************************/
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
Set<PolicyAction> actions, String note)
{
add(byApp, app, qInstance, object, kind, label, actions, Map.of(), note);
}
/*******************************************************************************
** Add standard and feature actions, merging shared permission base names.
*******************************************************************************/
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
Set<PolicyAction> actions, Map<String, Feature> features, String note)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(object, qInstance);
if(rules == null || rules.getLevel() == null || rules.getLevel() == PermissionLevel.NOT_PROTECTED || rules.getCustomPermissionChecker() != null || (actions.isEmpty() && features.isEmpty()))
{
return;
}
String name = StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : nameOf(object);
if(!PolicyParser.RESOURCE_PATTERN.matcher(name).matches())
{
return;
}
////////////////////////////////////////////////////////////////////
// a base name shared with an object already listed (in any app) //
// joins that entry, so a resource appears once //
////////////////////////////////////////////////////////////////////
for(Map<String, ResourceDraft> resources : byApp.values())
{
ResourceDraft existing = resources.get(name);
if(existing != null)
{
existing.labels.add(label);
existing.actions.addAll(actions);
existing.features.putAll(features);
return;
}
}
ResourceDraft draft = new ResourceDraft(name, kind);
draft.labels.add(label);
draft.actions.addAll(actions);
draft.features.putAll(features);
draft.note = note;
byApp.computeIfAbsent(byApp.containsKey(app) ? app : null, k -> new LinkedHashMap<>()).put(name, draft);
}
/*******************************************************************************
** Which app directly lists each object ("table:x", "process:y", ...): app
** children (sections included) and app widgets. The first app wins.
*******************************************************************************/
private static Map<String, String> appsOfObjects(QInstance qInstance)
{
Map<String, String> rs = new LinkedHashMap<>();
for(QAppMetaData app : qInstance.getApps().values())
{
if(app.getChildren() != null)
{
for(QAppChildMetaData child : app.getChildren())
{
String type = child instanceof QTableMetaData ? "table" : child instanceof QProcessMetaData ? "process" : child instanceof QReportMetaData ? "report" : null;
if(type != null)
{
rs.putIfAbsent(type + ":" + child.getName(), app.getName());
}
}
}
if(app.getWidgets() != null)
{
for(String widget : app.getWidgets())
{
rs.putIfAbsent("widget:" + widget, app.getName());
}
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String nameOf(MetaDataWithPermissionRules object)
{
if(object instanceof QTableMetaData t)
{
return (t.getName());
}
if(object instanceof QProcessMetaData p)
{
return (p.getName());
}
if(object instanceof QAppMetaData a)
{
return (a.getName());
}
if(object instanceof QReportMetaData r)
{
return (r.getName());
}
if(object instanceof QWidgetMetaDataInterface w)
{
return (w.getName());
}
return ("");
}
/*******************************************************************************
**
*******************************************************************************/
private static String label(String label, String name)
{
return (StringUtils.hasContent(label) ? label : name);
}
/*******************************************************************************
** The groups, in app order, "Other" last.
*******************************************************************************/
public List<Group> getGroups()
{
return (groups);
}
/*******************************************************************************
** A resource by policy name, or null.
*******************************************************************************/
public Resource get(String name)
{
return (byName.get(name));
}
/*******************************************************************************
** Every QQQ permission a policy can grant here ({resource}.{action}).
*******************************************************************************/
public Set<String> permissionNames()
{
return (new TreeSet<>(permissionNames));
}
/*******************************************************************************
** The resources of a permission set this catalog does not know, or that
** grant an action the resource does not have (sorted, as permission names).
*******************************************************************************/
public Set<String> unknownPermissions(Set<String> permissions)
{
Set<String> rs = new TreeSet<>();
for(String permission : permissions)
{
if(!permissionNames.contains(permission))
{
rs.add(permission);
}
}
return (rs);
}
/*******************************************************************************
** Whether this resource has an explicitly registered feature action.
*******************************************************************************/
public boolean hasFeature(String resourceName, String actionName)
{
Resource resource = get(resourceName);
return (resource != null && resource.features().stream().anyMatch(feature -> feature.name().equals(actionName)));
}
/*******************************************************************************
** The names core reports as available permissions (the catalog source), which
** the universe starts from.
*******************************************************************************/
public Set<String> availableNames()
{
return (availableNames);
}
/*******************************************************************************
** availableNames() and permissionNames() together, sorted and read-only: the
** catalog's part of the permission universe (PolicyPermissions.allPermissions),
** kept so a universe is a copy of this set and not a walk over every resource.
*******************************************************************************/
SortedSet<String> universeNames()
{
return (universeNames);
}
/*******************************************************************************
** Whether at least one resource whose name starts with the prefix registers
** this feature action (a prefix resource's feature rule).
*******************************************************************************/
public boolean hasFeatureUnderPrefix(String prefix, String actionName)
{
return (byName.values().stream().anyMatch(resource -> resource.name().startsWith(prefix)
&& resource.features().stream().anyMatch(feature -> feature.name().equals(actionName))));
}
/*******************************************************************************
** As JSON for the editor widget.
*******************************************************************************/
public JSONArray toJson()
{
JSONArray rs = new JSONArray();
for(Group group : groups)
{
JSONArray resources = new JSONArray();
for(Resource resource : group.resources())
{
JSONArray actions = new JSONArray();
resource.actions().forEach(a -> actions.put(a.getJsonName()));
JSONObject json = new JSONObject()
.put("name", resource.name())
.put("label", resource.label())
.put("kind", resource.kind().name())
.put("actions", actions);
if(resource.kind() == Kind.TABLE || !resource.features().isEmpty())
{
JSONArray features = new JSONArray();
resource.features().forEach(feature -> features.put(new JSONObject().put("name", feature.name()).put("label", feature.label())));
json.put("features", features);
}
if(resource.note() != null)
{
json.put("note", resource.note());
}
resources.put(json);
}
rs.put(new JSONObject().put("app", group.app() == null ? JSONObject.NULL : group.app()).put("label", group.label()).put("resources", resources));
}
return (rs);
}
}
@@ -0,0 +1,176 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** The cut-over widgets (DESIGN.md section 12.8): the enforcement banner shown
** on group and policy screens and on Edit Policy, and the Compare Access
** report (read only).
*******************************************************************************/
public final class PolicyCutoverWidgets
{
/** Widget default value naming the qbit's API backend. */
public static final String BACKEND_NAME_KEY = "backendName";
/** Short name of the banner widget. */
public static final String ENFORCEMENT = "policyEnforcement";
/** Short name of the report widget. */
public static final String COMPARE = "compareAccess";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyCutoverWidgets()
{
}
/*******************************************************************************
** The mapper state for a widget's backend.
*******************************************************************************/
static AccessComparison.State state(RenderWidgetInput input) throws QException
{
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
return (AccessComparison.state(AuthentikRuntime.forBackend(backendName), AuthentikActionUtil.forBackend(backendName)));
}
/*******************************************************************************
** The banner as HTML (also used by the home's health card).
*******************************************************************************/
public static String bannerHtml(AccessComparison.State state)
{
String style = state.enforced()
? "border:1px solid #16a34a;background:rgba(22,163,74,.10);"
: "border:1px solid #d97706;background:rgba(217,119,6,.12);";
return ("<div role=\"status\" data-qqq-id=\"authentik-policy-enforcement\" data-status=\"" + state.status().name() + "\" style=\"" + style
+ "border-radius:.375rem;padding:.5rem .75rem;font-size:.875rem;line-height:1.4;color:inherit;\"><strong>"
+ (state.enforced() ? "Enforced. " : "Not enforced. ") + "</strong>" + escape(state.message()) + "</div>");
}
/*******************************************************************************
**
*******************************************************************************/
static String escape(String text)
{
return (text == null ? "" : text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"));
}
/*******************************************************************************
** The enforcement banner (authentik.users.read).
*******************************************************************************/
public static class EnforcementRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(new RawHTML(null, bannerHtml(state(input)))));
}
}
/*******************************************************************************
** Compare Access (authentik.admin): every active person's access today
** against what the mapper would grant. Read only.
*******************************************************************************/
public static class CompareRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN))
{
throw (new QPermissionDeniedException("You do not have permission to compare access."));
}
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
AuthentikRuntime runtime = AuthentikRuntime.forBackend(backendName);
List<AccessComparison.Row> rows = AccessComparison.run(runtime.getConfig(), AuthentikActionUtil.forBackend(backendName), runtime.getPermissionCatalog());
List<Map<String, Object>> data = new ArrayList<>();
int removedPeople = 0;
for(AccessComparison.Row row : rows)
{
Map<String, Object> map = new LinkedHashMap<>();
map.put("user", escape(row.username()) + (row.guarded() ? " (guarded)" : "") + (row.voided() ? " (INVALID POLICY: access voided)" : ""));
map.put("groups", escape(String.join(", ", row.groups())));
map.put("added", row.added().isEmpty() ? "-" : escape(String.join(", ", row.added())));
map.put("removed", row.removed().isEmpty() ? "-" : escape(String.join(", ", row.removed())));
map.put("unchanged", row.before().size() - row.removed().size());
data.add(map);
if(!row.removed().isEmpty())
{
removedPeople++;
}
}
List<TableData.Column> columns = List.of(
new TableData.Column("html", "User", "user", "15%", "left"),
new TableData.Column("html", "Groups", "groups", "20%", "left"),
new TableData.Column("html", "Added", "added", "27%", "left"),
new TableData.Column("html", "Removed", "removed", "28%", "left"),
new TableData.Column("default", "Unchanged", "unchanged", "10%", "right"));
List<AccessComparison.Row> failures = AccessComparison.guardFailures(rows);
long voidedPeople = rows.stream().filter(AccessComparison.Row::voided).count();
String summary = rows.size() + " active people; " + removedPeople + " would lose permissions"
+ (voidedPeople == 0 ? "" : " (" + voidedPeople + " because an invalid policy voids their access)")
+ "; names a Deny takes away are not counted as lost; guard "
+ (runtime.getConfig().getCutoverGuardGroups().isEmpty() ? "not configured" : failures.isEmpty() ? "passes" : "FAILS for " + failures.size())
+ (runtime.getConfig().getHostPermissionSource() == null ? ". No host permission source is configured, so today's access is shown as empty." : ".");
TableData table = new TableData("Compare Access (today vs policies)", columns, data)
.withRowsPerPage(25)
.withNoRowsFoundHTML("No active people.");
table.setFooterHTML("<p style=\"font-size:.875rem;\">" + escape(summary) + "</p>");
return (new RenderWidgetOutput(table));
}
}
}
@@ -0,0 +1,584 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** The policy editor widget (type AUTHENTIK_POLICY_EDITOR, DESIGN.md section
** 12.4). It is shown on the Edit Policy step; the Next UI widget of the same
** type draws the matrix and hands the generated document back to the step as
** the process value policyDocument. The data here: the catalog of what a
** policy can grant, the group's current document read live from Authentik,
** and the base permissions of members who inherit it.
*******************************************************************************/
public final class PolicyEditorWidget
{
/** The widget type the Next UI renders. */
public static final String TYPE = "AUTHENTIK_POLICY_EDITOR";
/** Short widget name (prefixed with the qbit namespace). */
public static final String NAME = "policyEditor";
/** Widget default value naming the qbit's API backend. */
public static final String BACKEND_NAME_KEY = "backendName";
/** Widget parameter (process value) with the group's pk. */
public static final String GROUP_PK_PARAM = "groupPk";
private static final int MAX_MEMBERS = 200;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyEditorWidget()
{
}
/*******************************************************************************
** The data for a group (or for none: an empty document).
*******************************************************************************/
public static Data load(AuthentikQBitConfig config, String groupPk) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.POLICIES_MANAGE))
{
throw (new QPermissionDeniedException("You do not have permission to edit policies (authentik.policies.manage)."));
}
AuthentikRuntime runtime = AuthentikRuntime.forBackend(config.getApiBackendName());
PolicyFormat format = config.getPolicyFormat();
Data data = new Data()
.withSpec(format.spec())
.withVersion(format.version())
.withCatalog(List.of())
.withCatalogWidget(config.applyPrefix(NAME))
.withMapperEnabled(config.getMapperEnabled())
.withOutputFieldName(AuthentikAdmin.POLICY_DOCUMENT);
if(!StringUtils.hasContent(groupPk))
{
return (data);
}
if(!groupPk.matches("[A-Za-z0-9-]+"))
{
throw (new QUserFacingException("Not a group id: " + groupPk));
}
JSONObject group = AuthentikActionUtil.forBackend(config.getApiBackendName()).getApiJsonOrNull("/core/groups/" + groupPk + "/?include_users=false&include_parents=false&include_children=false");
if(group == null)
{
throw (new QUserFacingException("That group no longer exists."));
}
data.withGroupLabel(group.optString("name"));
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
if(stored != null && !JSONObject.NULL.equals(stored) && !"".equals(stored))
{
try
{
/////////////////////////////////////////////////////////////////
// canonical form of what is stored (unknown resources survive) //
/////////////////////////////////////////////////////////////////
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format, runtime.getPolicyCatalog())));
PolicyParser.Parsed parsed = PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog());
data.withFullAccess(parsed.rules().hasFullAccess());
data.withDenied(parsed.rules().denyLabels());
Set<String> exact = new TreeSet<>();
for(PolicyTerm term : parsed.rules().allowTerms())
{
if(term instanceof PolicyTerm.Exact e && !parsed.named().contains(e.name()))
{
exact.add(e.name());
}
}
data.withUnknownPermissions(new ArrayList<>(runtime.getPolicyCatalog().unknownPermissions(exact)));
}
catch(PolicyParseException e)
{
data.withDocumentProblem(e.getMessage());
}
}
QRecord row = PolicyStore.find(config, groupPk);
if(row != null)
{
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format, runtime.getPolicyCatalog())));
}
loadMembers(config, groupPk, data);
return (data);
}
/*******************************************************************************
** Members who inherit this policy, with only the edited document removed
** from their sign-in grants. Fetch one extra eligible member to detect truncation.
*******************************************************************************/
private static void loadMembers(AuthentikQBitConfig config, String groupPk, Data data) throws QException
{
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName());
GroupGraph graph = util.groupGraph();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, util.getRuntime().getPermissionCatalog());
Set<String> affectedGroups = graph.nodes().stream()
.filter(node -> node.pk().equals(groupPk) || graph.ancestorPks(node.pk()).contains(groupPk))
.map(GroupGraph.Node::pk).collect(Collectors.toSet());
List<JSONObject> users = new ArrayList<>(util.policyPreviewUsers(affectedGroups, MAX_MEMBERS + 1));
users.sort(Comparator.comparing(user -> user.optString("username")));
List<Map<String, Object>> members = new ArrayList<>();
for(JSONObject user : users)
{
List<String> groupPks = new ArrayList<>();
JSONArray groups = user.optJSONArray("groups_obj");
for(int i = 0; groups != null && i < groups.length(); i++)
{
String pk = groups.getJSONObject(i).getString("pk");
if(graph.byPk(pk) != null)
{
groupPks.add(pk);
}
}
List<String> viaGroups = groupPks.stream().filter(affectedGroups::contains).map(pk -> graph.byPk(pk).name()).sorted().toList();
if(viaGroups.isEmpty())
{
continue;
}
if(members.size() == MAX_MEMBERS)
{
data.withMembersTruncated(true);
break;
}
List<String> names = groupPks.stream().map(pk -> graph.byPk(pk).name()).toList();
Set<String> basePermissions = AccessComparison.mapperPermissions(config, resolver, groupPks, names,
String.valueOf(user.getInt("pk")), user.optString("uuid"), groupPk);
String username = user.optString("username");
members.add(Map.of("username", username, "label", user.optString("name") + " (" + username + ")",
"viaGroups", viaGroups, "basePermissions", basePermissions.stream().sorted().toList()));
}
data.withMembers(members);
}
/*******************************************************************************
** Renderer. QQQ checks the widget's permission rules
** (authentik.policies.manage) before calling it.
*******************************************************************************/
public static class Renderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
AuthentikQBitConfig config = AuthentikRuntime.forBackend(backendName).getConfig();
Map<String, String> params = input.getQueryParams();
if(params != null && "true".equals(params.get("accessCatalog")))
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.POLICIES_MANAGE))
{
throw (new QPermissionDeniedException("You do not have permission to edit policies (authentik.policies.manage)."));
}
var catalog = AuthentikRuntime.forBackend(backendName).getPolicyCatalog();
return (new RenderWidgetOutput(new com.kingsrook.qbits.authentik.processes.AccessEditorWidget.Data(List.of(
com.kingsrook.qbits.authentik.processes.AccessCatalog.page(catalog.permissionNames(), catalog, params)))));
}
return (new RenderWidgetOutput(load(config, params == null ? null : params.get(GROUP_PK_PARAM))));
}
}
/*******************************************************************************
** The widget's data (see the Next UI's PolicyEditorPayload).
*******************************************************************************/
public static class Data extends QWidgetData
{
private String spec;
private String version;
private String groupLabel;
private List<Object> catalog;
private String catalogWidget;
private List<String> unknownPermissions = List.of();
private String document;
private String documentProblem;
private Boolean changedInAuthentik = false;
private String outputFieldName;
private List<Map<String, Object>> members = List.of();
private Boolean membersTruncated = false;
private Boolean mapperEnabled;
private Boolean fullAccess = false;
private List<String> denied = List.of();
/*******************************************************************************
** Operator-facing labels of the Deny terms of the stored document.
*******************************************************************************/
public List<String> getDenied()
{
return (denied);
}
/*******************************************************************************
** Fluent setter for denied.
*******************************************************************************/
public Data withDenied(List<String> denied)
{
this.denied = denied;
return (this);
}
/*******************************************************************************
** Whether the current valid document grants all current and future resources.
*******************************************************************************/
public Boolean getFullAccess()
{
return (fullAccess);
}
/*******************************************************************************
** Fluent setter for fullAccess.
*******************************************************************************/
public Data withFullAccess(Boolean fullAccess)
{
this.fullAccess = fullAccess;
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getType()
{
return (TYPE);
}
/*******************************************************************************
** Getter for spec
*******************************************************************************/
public String getSpec()
{
return (spec);
}
/*******************************************************************************
** Fluent setter for spec
*******************************************************************************/
public Data withSpec(String spec)
{
this.spec = spec;
return (this);
}
/*******************************************************************************
** Getter for version
*******************************************************************************/
public String getVersion()
{
return (version);
}
/*******************************************************************************
** Fluent setter for version
*******************************************************************************/
public Data withVersion(String version)
{
this.version = version;
return (this);
}
/*******************************************************************************
** Getter for groupLabel
*******************************************************************************/
public String getGroupLabel()
{
return (groupLabel);
}
/*******************************************************************************
** Fluent setter for groupLabel
*******************************************************************************/
public Data withGroupLabel(String groupLabel)
{
this.groupLabel = groupLabel;
return (this);
}
/*******************************************************************************
** Getter for catalog (groups of resources, as plain JSON values)
*******************************************************************************/
public List<Object> getCatalog()
{
return (catalog);
}
/*******************************************************************************
** The guarded widget endpoint supplying bounded catalog pages.
*******************************************************************************/
public String getCatalogWidget()
{
return (catalogWidget);
}
/*******************************************************************************
** Set the catalog endpoint.
*******************************************************************************/
public Data withCatalogWidget(String catalogWidget)
{
this.catalogWidget = catalogWidget;
return (this);
}
/*******************************************************************************
** Stored unknown grants, classified against the entire server catalog.
*******************************************************************************/
public List<String> getUnknownPermissions()
{
return (unknownPermissions);
}
/*******************************************************************************
** Keep unknown stored grants independently of catalog pagination.
*******************************************************************************/
public Data withUnknownPermissions(List<String> unknownPermissions)
{
this.unknownPermissions = unknownPermissions;
return (this);
}
/*******************************************************************************
** Fluent setter for catalog
*******************************************************************************/
public Data withCatalog(List<Object> catalog)
{
this.catalog = catalog;
return (this);
}
/*******************************************************************************
** Getter for document (the group's current document, pretty; null for none)
*******************************************************************************/
public String getDocument()
{
return (document);
}
/*******************************************************************************
** Fluent setter for document
*******************************************************************************/
public Data withDocument(String document)
{
this.document = document;
return (this);
}
/*******************************************************************************
** Getter for documentProblem (why the stored document is invalid)
*******************************************************************************/
public String getDocumentProblem()
{
return (documentProblem);
}
/*******************************************************************************
** Fluent setter for documentProblem
*******************************************************************************/
public Data withDocumentProblem(String documentProblem)
{
this.documentProblem = documentProblem;
return (this);
}
/*******************************************************************************
** Getter for changedInAuthentik (differs from the last save in QQQ)
*******************************************************************************/
public Boolean getChangedInAuthentik()
{
return (changedInAuthentik);
}
/*******************************************************************************
** Fluent setter for changedInAuthentik
*******************************************************************************/
public Data withChangedInAuthentik(Boolean changedInAuthentik)
{
this.changedInAuthentik = changedInAuthentik;
return (this);
}
/*******************************************************************************
** Getter for outputFieldName (the process value the editor writes)
*******************************************************************************/
public String getOutputFieldName()
{
return (outputFieldName);
}
/*******************************************************************************
** Fluent setter for outputFieldName
*******************************************************************************/
public Data withOutputFieldName(String outputFieldName)
{
this.outputFieldName = outputFieldName;
return (this);
}
/*******************************************************************************
** Getter for members.
*******************************************************************************/
public List<Map<String, Object>> getMembers()
{
return (members);
}
/*******************************************************************************
** Fluent setter for members.
*******************************************************************************/
public Data withMembers(List<Map<String, Object>> members)
{
this.members = members;
return (this);
}
/*******************************************************************************
** Getter for membersTruncated.
*******************************************************************************/
public Boolean getMembersTruncated()
{
return (membersTruncated);
}
/*******************************************************************************
** Fluent setter for membersTruncated.
*******************************************************************************/
public Data withMembersTruncated(Boolean membersTruncated)
{
this.membersTruncated = membersTruncated;
return (this);
}
/*******************************************************************************
** Getter for mapperEnabled.
*******************************************************************************/
public Boolean getMapperEnabled()
{
return (mapperEnabled);
}
/*******************************************************************************
** Fluent setter for mapperEnabled.
*******************************************************************************/
public Data withMapperEnabled(Boolean mapperEnabled)
{
this.mapperEnabled = mapperEnabled;
return (this);
}
}
}
@@ -0,0 +1,85 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.policy;
import java.util.regex.Pattern;
/*******************************************************************************
** The names a host uses for its policy documents (docs/POLICY-FORMAT.md): the
** PolicySpec it accepts, the one supported PolicyVersion, the Authentik group
** attribute that holds a group's document, and the token claim that holds the
** merged document.
**
** @param spec PolicySpec, e.g. "qqq-iam"
** @param version PolicyVersion (semver), e.g. "1.0.0"
** @param attribute group attribute key, e.g. "qqq.io/policy"
** @param claim token claim name, e.g. "qqq_policy"
*******************************************************************************/
public record PolicyFormat(String spec, String version, String attribute, String claim)
{
/** Default PolicySpec. */
public static final String DEFAULT_SPEC = "qqq-iam";
/** Default PolicyVersion of this qbit's format (1.1.0: Deny, prefix resources, named permissions). */
public static final String DEFAULT_VERSION = "1.1.0";
/** Default group attribute. */
public static final String DEFAULT_ATTRIBUTE = "qqq.io/policy";
/** Default token claim. */
public static final String DEFAULT_CLAIM = "qqq_policy";
/** The generic defaults. */
public static final PolicyFormat DEFAULT = new PolicyFormat(DEFAULT_SPEC, DEFAULT_VERSION, DEFAULT_ATTRIBUTE, DEFAULT_CLAIM);
/** A valid spec name: letters, digits, dot, dash and underscore. */
public static final Pattern SPEC_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
/** Semver MAJOR.MINOR.PATCH. */
public static final Pattern VERSION_PATTERN = Pattern.compile("(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)");
/** A valid claim name. */
public static final Pattern CLAIM_PATTERN = Pattern.compile("[A-Za-z_][A-Za-z0-9_.-]{0,63}");
/*******************************************************************************
** Whether every name is well formed (used by the config's validate()).
*******************************************************************************/
public String problem()
{
if(spec == null || !SPEC_PATTERN.matcher(spec).matches())
{
return ("policySpec must be letters, digits, '.', '-' or '_': " + spec);
}
if(version == null || !VERSION_PATTERN.matcher(version).matches())
{
return ("policyVersion must be semver MAJOR.MINOR.PATCH: " + version);
}
if(attribute == null || attribute.isBlank())
{
return ("policyAttribute is required");
}
if(claim == null || !CLAIM_PATTERN.matcher(claim).matches())
{
return ("policyClaim must be a plain claim name: " + claim);
}
return (null);
}
}
@@ -0,0 +1,333 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.policy;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Writes the canonical policy document (docs/POLICY-FORMAT.md, "Canonical
** form") for typed rules, and compact or pretty JSON in a fixed key order.
** Canonicalization NEVER drops a Deny; it drops only Allow terms that full
** access makes redundant.
*******************************************************************************/
public final class PolicyGenerator
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyGenerator()
{
}
/*******************************************************************************
** The canonical document that allows exactly these permission names (and
** full access when the set holds the internal FULL_ACCESS marker). Names
** that are not {resource}.{action} are written as named permissions.
**
** @throws PolicyParseException when the document does not round-trip
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format) throws PolicyParseException
{
return (generate(permissions, format, null));
}
/*******************************************************************************
** Generate with the catalog of explicitly grantable table features.
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
for(String permission : permissions)
{
if(permission == null)
{
throw (new PolicyParseException("null is not a permission name"));
}
}
return (generate(PolicyRules.allowing(permissions), format, catalog));
}
/*******************************************************************************
** The canonical document for typed rules.
**
** @throws PolicyParseException when the rules are void or the document does
** not round-trip (parsing it must give back exactly the same rules)
*******************************************************************************/
public static JSONObject generate(PolicyRules rules, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(rules.isVoid())
{
throw (new PolicyParseException("An invalid policy cannot be written: " + rules.voidReason()));
}
JSONArray statements = new JSONArray();
if(rules.hasFullAccess())
{
statements.put(statement(PolicyParser.ALLOW, new JSONArray().put("*"), new JSONArray().put("*")));
}
else
{
addStatements(statements, PolicyParser.ALLOW, rules.allowTerms(), catalog);
}
addStatements(statements, PolicyParser.DENY, rules.denyTerms(), catalog);
JSONObject document = new JSONObject()
.put("PolicySpec", f.spec())
.put("PolicyVersion", f.version())
.put("Statement", statements);
PolicyRules parsed = PolicyParser.parseStrict(toCompact(document), f, catalog).rules();
if(!parsed.equals(rules))
{
throw (new PolicyParseException("The generated document does not round-trip: " + rules + " became " + parsed));
}
return (document);
}
/*******************************************************************************
** The statements of one effect: per-action statements in the order hasAccess,
** read, insert, edit, delete then features by name, each with sorted
** resources; Deny additionally has an all-actions statement first; names
** that cannot be written as {resource}.{action} go in a Permission
** statement last.
*******************************************************************************/
private static void addStatements(JSONArray statements, String effect, Set<PolicyTerm> terms, PolicyCatalog catalog)
{
boolean isDeny = PolicyParser.DENY.equals(effect);
Set<String> all = new TreeSet<>();
Map<String, Set<String>> byAction = new LinkedHashMap<>();
Map<String, Set<String>> byFeature = new TreeMap<>();
Set<String> named = new TreeSet<>();
for(PolicyAction action : PolicyAction.values())
{
byAction.put(action.getJsonName(), new TreeSet<>());
}
for(PolicyTerm term : terms)
{
if(term instanceof PolicyTerm.Everything)
{
all.add("*");
}
else if(term instanceof PolicyTerm.Base base)
{
all.add(base.base());
}
else if(term instanceof PolicyTerm.Prefix prefix)
{
if(prefix.action() == null)
{
all.add(prefix.prefix() + "*");
}
else
{
bucket(byAction, byFeature, prefix.action()).add(prefix.prefix() + "*");
}
}
else if(term instanceof PolicyTerm.Exact exact)
{
int dot = exact.name().lastIndexOf('.');
String base = dot > 0 ? exact.name().substring(0, dot) : "";
String action = dot > 0 ? exact.name().substring(dot + 1) : "";
boolean written = PolicyParser.RESOURCE_PATTERN.matcher(base).matches()
&& (PolicyAction.fromJsonName(action).isPresent() || (catalog != null && catalog.hasFeature(base, action)));
if(written)
{
bucket(byAction, byFeature, action).add(base);
}
else
{
named.add(exact.name());
}
}
}
if(isDeny && !all.isEmpty())
{
statements.put(statement(effect, new JSONArray().put("*"), new JSONArray(all)));
}
byAction.putAll(byFeature);
for(Map.Entry<String, Set<String>> entry : byAction.entrySet())
{
if(!entry.getValue().isEmpty())
{
statements.put(statement(effect, new JSONArray().put(entry.getKey()), new JSONArray(entry.getValue())));
}
}
if(!named.isEmpty())
{
statements.put(new JSONObject().put("Effect", effect).put("Permission", new JSONArray(named)));
}
}
/*******************************************************************************
** The resource set of an action: the five standard actions keep their
** order, features sort after them.
*******************************************************************************/
private static Set<String> bucket(Map<String, Set<String>> byAction, Map<String, Set<String>> byFeature, String action)
{
Map<String, Set<String>> target = byAction.containsKey(action) ? byAction : byFeature;
return (target.computeIfAbsent(action, key -> new TreeSet<>()));
}
/*******************************************************************************
** An Action and Resource statement.
*******************************************************************************/
private static JSONObject statement(String effect, JSONArray actions, JSONArray resources)
{
return (new JSONObject().put("Effect", effect).put("Action", actions).put("Resource", resources));
}
/*******************************************************************************
** Re-generate any valid document in canonical form (parse, then generate).
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format) throws PolicyParseException
{
return (canonical(document, format, null));
}
/*******************************************************************************
** Canonicalize a document using the registered table features.
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
return (generate(PolicyParser.parseStrict(document, format, catalog).rules(), format, catalog));
}
/*******************************************************************************
** Compact JSON in a fixed key order (PolicySpec, PolicyVersion, Statement;
** Effect, Action, Resource or Effect, Permission), as stored in the group
** attribute.
*******************************************************************************/
public static String toCompact(JSONObject document)
{
return (write(document, false));
}
/*******************************************************************************
** Pretty JSON (2-space indent, LF, trailing newline) in the fixed key order.
*******************************************************************************/
public static String toPretty(JSONObject document)
{
return (write(document, true));
}
/*******************************************************************************
** Write in the fixed key order (org.json does not keep insertion order).
*******************************************************************************/
private static String write(JSONObject document, boolean pretty)
{
String nl = pretty ? "\n" : "";
String i1 = pretty ? " " : "";
String i2 = pretty ? " " : "";
String i3 = pretty ? " " : "";
String sep = pretty ? ": " : ":";
StringBuilder sb = new StringBuilder("{").append(nl);
sb.append(i1).append(JSONObject.quote("PolicySpec")).append(sep).append(JSONObject.quote(document.optString("PolicySpec"))).append(",").append(nl);
sb.append(i1).append(JSONObject.quote("PolicyVersion")).append(sep).append(JSONObject.quote(document.optString("PolicyVersion"))).append(",").append(nl);
sb.append(i1).append(JSONObject.quote("Statement")).append(sep).append("[");
JSONArray statements = document.optJSONArray("Statement");
int count = statements == null ? 0 : statements.length();
for(int s = 0; s < count; s++)
{
JSONObject statement = statements.getJSONObject(s);
sb.append(s == 0 ? nl : "," + nl).append(i2).append("{").append(nl);
sb.append(i3).append(JSONObject.quote("Effect")).append(sep).append(JSONObject.quote(statement.optString("Effect"))).append(",").append(nl);
if(statement.has("Permission"))
{
sb.append(i3).append(JSONObject.quote("Permission")).append(sep).append(list(statement.optJSONArray("Permission"), pretty, i3)).append(nl);
}
else
{
sb.append(i3).append(JSONObject.quote("Action")).append(sep).append(inline(statement.optJSONArray("Action"), pretty)).append(",").append(nl);
sb.append(i3).append(JSONObject.quote("Resource")).append(sep).append(list(statement.optJSONArray("Resource"), pretty, i3)).append(nl);
}
sb.append(i2).append("}");
}
sb.append(count == 0 ? "" : nl + i1).append("]").append(nl);
sb.append("}").append(nl);
return (sb.toString());
}
/*******************************************************************************
** A string array with one entry per line when pretty.
*******************************************************************************/
private static String list(JSONArray array, boolean pretty, String indent)
{
String nl = pretty ? "\n" : "";
String in = pretty ? indent + " " : "";
StringBuilder sb = new StringBuilder("[");
int n = array == null ? 0 : array.length();
for(int i = 0; i < n; i++)
{
sb.append(i == 0 ? nl : "," + nl).append(in).append(JSONObject.quote(array.optString(i)));
}
return (sb.append(n == 0 ? "" : nl + indent).append("]").toString());
}
/*******************************************************************************
** A short string array on one line.
*******************************************************************************/
private static String inline(JSONArray array, boolean pretty)
{
StringBuilder sb = new StringBuilder("[");
int n = array == null ? 0 : array.length();
for(int i = 0; i < n; i++)
{
sb.append(i == 0 ? "" : (pretty ? ", " : ",")).append(JSONObject.quote(array.optString(i)));
}
return (sb.append("]").toString());
}
}
@@ -0,0 +1,35 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.policy;
/*******************************************************************************
** A policy document that cannot be accepted; the message names the problem
** in plain words (shown to operators in the editor's Review step).
*******************************************************************************/
public class PolicyParseException extends Exception
{
/*******************************************************************************
** Constructor.
*******************************************************************************/
public PolicyParseException(String message)
{
super(message);
}
}
@@ -0,0 +1,535 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed 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 com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Parses policy documents (docs/POLICY-FORMAT.md) into typed PolicyRules,
** never into plain permission names: a Deny or a prefix cannot be mistaken
** for a grant.
**
** parse() FAILS CLOSED: any problem (unknown spec, unsupported version, an
** unknown effect or key, an unknown action, a bad resource or prefix,
** malformed JSON, ...) gives void rules, which grant nothing, and logs a
** warning. parseStrict() throws a PolicyParseException naming the problem
** instead (for the editor). parseAll() merges the documents of one user and
** voids the whole merge when any one is invalid.
*******************************************************************************/
public final class PolicyParser
{
private static final QLogger LOG = QLogger.getLogger(PolicyParser.class);
/** A valid resource: a QQQ permission base name. */
public static final Pattern RESOURCE_PATTERN = Pattern.compile("[A-Za-z0-9_]+");
/** A valid prefix resource: at least two characters before the "*", the literal part ending in an underscore. */
public static final Pattern PREFIX_PATTERN = Pattern.compile("([A-Za-z0-9_]+_)\\*");
/** A valid exact permission name: dot-separated segments of [A-Za-z0-9_-]; a bare name has no dot. */
public static final Pattern PERMISSION_PATTERN = Pattern.compile("[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)*");
/** The effect that grants. */
public static final String ALLOW = "Allow";
/** The effect that withholds; it always wins. */
public static final String DENY = "Deny";
private static final Set<String> STATEMENT_KEYS = Set.of("Effect", "Action", "Resource", "Permission");
private static final Set<String> DOCUMENT_KEYS = Set.of("PolicySpec", "PolicyVersion", "Statement");
/*******************************************************************************
** The result of a successful parse.
**
** @param rules the typed Allow and Deny terms
** @param resources every resource named, in document order
** @param named exact names given through the Permission key
*******************************************************************************/
public record Parsed(PolicyRules rules, Set<String> resources, Set<String> named)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyParser()
{
}
/*******************************************************************************
** The rules of a document, or void rules (with a warning) when it is
** invalid in any way. A null or JSON-null document is valid and empty.
**
** @param document a JSONObject, a Map, or a JSON string (as stored in a group
** attribute or a token claim)
*******************************************************************************/
public static PolicyRules parse(Object document, PolicyFormat format)
{
return (parse(document, format, null));
}
/*******************************************************************************
** Parse with the catalog of explicitly grantable table features; an invalid
** document gives void rules.
*******************************************************************************/
public static PolicyRules parse(Object document, PolicyFormat format, PolicyCatalog catalog)
{
if(document == null || JSONObject.NULL.equals(document))
{
return (PolicyRules.EMPTY);
}
try
{
return (parseStrict(document, format, catalog).rules());
}
catch(PolicyParseException e)
{
LOG.warn("Policy document rejected; it voids the user's policies", logPair("reason", e.getMessage()));
return (PolicyRules.voided(e.getMessage()));
}
}
/*******************************************************************************
** Parse every document of one user (the policies of their groups, direct
** and inherited) and merge. ONE invalid document voids the lot.
*******************************************************************************/
public static PolicyRules parseAll(Collection<?> documents, PolicyFormat format, PolicyCatalog catalog)
{
List<PolicyRules> all = new ArrayList<>();
for(Object document : documents)
{
all.add(parse(document, format, catalog));
}
return (PolicyRules.merge(all));
}
/*******************************************************************************
** Parse, throwing a PolicyParseException that names the first problem.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format) throws PolicyParseException
{
return (parseStrict(document, format, null));
}
/*******************************************************************************
** Parse standard actions and features registered for every listed resource.
** Without a catalog only standard actions and full access are accepted.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
return (parseStrict(document, format, catalog, null));
}
/*******************************************************************************
** The editor's save-time parse: as parseStrict, and additionally refuses a
** prefix, a named permission, or a Deny of a named resource that matches
** nothing in the universe (a mistyped Deny that silently does nothing is
** the dangerous case).
*******************************************************************************/
public static Parsed parseForSave(Object document, PolicyFormat format, PolicyCatalog catalog, PolicyUniverse universe) throws PolicyParseException
{
if(universe == null)
{
throw (new IllegalArgumentException("A universe is required for save-time checks"));
}
///////////////////////////////////////////////////////////////////////
// one reading of the universe for the whole document, not one for //
// each named permission, prefix and Deny it checks //
///////////////////////////////////////////////////////////////////////
return (parseStrict(document, format, catalog, PolicyUniverse.once(universe)));
}
/*******************************************************************************
** The shared parse; a non-null universe turns on the save-time checks.
*******************************************************************************/
private static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog, PolicyUniverse universe) throws PolicyParseException
{
JSONObject json = asObject(document);
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
Object spec = json.opt("PolicySpec");
if(!(spec instanceof String s) || !s.equals(f.spec()))
{
throw (new PolicyParseException("PolicySpec must be \"" + f.spec() + "\" (found " + describe(spec) + ")"));
}
Object version = json.opt("PolicyVersion");
if(!(version instanceof String v) || !v.equals(f.version()))
{
throw (new PolicyParseException("PolicyVersion must be \"" + f.version() + "\" (found " + describe(version) + ")"));
}
for(String key : json.keySet())
{
if(!DOCUMENT_KEYS.contains(key))
{
throw (new PolicyParseException("unknown top-level key \"" + shorten(key) + "\" (only PolicySpec, PolicyVersion and Statement are supported; a misspelled Statement would otherwise read as an empty policy)"));
}
}
Set<PolicyTerm> allow = new LinkedHashSet<>();
Set<PolicyTerm> deny = new LinkedHashSet<>();
Set<String> resources = new LinkedHashSet<>();
Set<String> named = new LinkedHashSet<>();
Object statements = json.opt("Statement");
if(statements == null)
{
throw (new PolicyParseException("Statement is required (an array; an empty array grants nothing)"));
}
if(!(statements instanceof JSONArray array))
{
throw (new PolicyParseException("Statement must be an array"));
}
for(int i = 0; i < array.length(); i++)
{
if(!(array.opt(i) instanceof JSONObject statement))
{
throw (new PolicyParseException("Statement " + (i + 1) + " must be an object"));
}
parseStatement("Statement " + (i + 1), statement, catalog, universe, allow, deny, resources, named);
}
return (new Parsed(PolicyRules.of(allow, deny), resources, named));
}
/*******************************************************************************
** One statement into the Allow or Deny terms.
*******************************************************************************/
private static void parseStatement(String where, JSONObject statement, PolicyCatalog catalog, PolicyUniverse universe,
Set<PolicyTerm> allow, Set<PolicyTerm> deny, Set<String> resources, Set<String> named) throws PolicyParseException
{
for(String key : statement.keySet())
{
if(!STATEMENT_KEYS.contains(key))
{
throw (new PolicyParseException(where + ": unknown key \"" + shorten(key) + "\" (only Effect, Action, Resource and Permission are supported)"));
}
}
Object effect = statement.opt("Effect");
if(!ALLOW.equals(effect) && !DENY.equals(effect))
{
throw (new PolicyParseException(where + ": Effect must be \"Allow\" or \"Deny\" (found " + describe(effect) + ")"));
}
boolean isDeny = DENY.equals(effect);
Set<PolicyTerm> target = isDeny ? deny : allow;
if(statement.has("Permission"))
{
if(statement.has("Action") || statement.has("Resource"))
{
throw (new PolicyParseException(where + ": Permission cannot be combined with Action or Resource"));
}
Set<String> names = strings(statement.opt("Permission"), where + ": Permission");
if(names.isEmpty())
{
throw (new PolicyParseException(where + ": Permission is empty (a statement that names nothing does nothing)"));
}
for(String name : names)
{
if(!PERMISSION_PATTERN.matcher(name).matches())
{
throw (new PolicyParseException(where + ": permission \"" + shorten(name) + "\" must be dot-separated names of [A-Za-z0-9_-]"));
}
if(universe != null && !universe.names().contains(name))
{
throw (new PolicyParseException(where + ": permission \"" + name + "\" matches nothing in this app"));
}
target.add(new PolicyTerm.Exact(name));
named.add(name);
}
return;
}
Set<String> statementResources = strings(statement.opt("Resource"), where + ": Resource");
Set<String> statementActions = strings(statement.opt("Action"), where + ": Action");
if(statementResources.contains("*") && !statementActions.equals(Set.of("*")))
{
throw (new PolicyParseException(where + ": Resource \"*\" requires Action [\"*\"] (full access)"));
}
if(statementResources.isEmpty() || statementActions.isEmpty())
{
throw (new PolicyParseException(where + ": " + (statementResources.isEmpty() ? "Resource" : "Action") + " is empty (a statement that names nothing does nothing)"));
}
for(String resource : statementResources)
{
if(!resource.equals("*") && resource.indexOf('*') >= 0 && !PREFIX_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException(where + ": \"" + shorten(resource) + "\" is not a valid prefix (at least two characters before the \"*\", the literal part ending in \"_\", and the \"*\" last)"));
}
}
checkActions(where, statementActions, statementResources, catalog);
boolean star = statementActions.contains(PolicyAction.WILDCARD);
Set<String> listed = concreteActions(statementActions);
for(String resource : statementResources)
{
if(resource.equals("*"))
{
target.add(new PolicyTerm.Everything());
resources.add(resource);
continue;
}
Matcher prefixMatch = PREFIX_PATTERN.matcher(resource);
if(prefixMatch.matches())
{
String prefix = prefixMatch.group(1);
resources.add(resource);
if(isDeny && star)
{
target.add(new PolicyTerm.Prefix(prefix, null));
}
else
{
for(String action : listed)
{
target.add(new PolicyTerm.Prefix(prefix, action));
}
}
if(universe != null && !prefixMatchesSomething(universe, prefix, listed, isDeny && star))
{
throw (new PolicyParseException(where + ": prefix \"" + resource + "\" matches nothing in this app"));
}
continue;
}
if(!RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException(where + ": resource \"" + shorten(resource) + "\" must match [A-Za-z0-9_]+"));
}
resources.add(resource);
if(isDeny && star)
{
target.add(new PolicyTerm.Base(resource));
}
else
{
for(String action : listed)
{
target.add(new PolicyTerm.Exact(resource + "." + action));
}
}
if(isDeny && universe != null && !namedResourceExists(universe, resource, statementActions))
{
throw (new PolicyParseException(where + ": Deny of \"" + resource + "\" matches nothing in this app"));
}
}
}
/*******************************************************************************
** The concrete action names of a statement: "*" is the five standard
** actions (a Deny gets a Base or Prefix term for everything else besides,
** added by the caller).
*******************************************************************************/
private static Set<String> concreteActions(Set<String> statementActions)
{
Set<String> actions = new LinkedHashSet<>();
for(String name : statementActions)
{
if(PolicyAction.WILDCARD.equals(name))
{
for(PolicyAction action : PolicyAction.values())
{
actions.add(action.getJsonName());
}
}
else
{
actions.add(name);
}
}
return (actions);
}
/*******************************************************************************
** Every action must be standard, "*", or a feature registered for every
** listed resource (for a prefix: for at least one match).
*******************************************************************************/
private static void checkActions(String where, Set<String> statementActions, Set<String> statementResources, PolicyCatalog catalog) throws PolicyParseException
{
for(String name : statementActions)
{
if(PolicyAction.WILDCARD.equals(name) || PolicyAction.fromJsonName(name).isPresent())
{
continue;
}
boolean registered = catalog != null && !statementResources.isEmpty() && statementResources.stream().allMatch(resource ->
{
Matcher prefixMatch = PREFIX_PATTERN.matcher(resource);
return (prefixMatch.matches() ? catalog.hasFeatureUnderPrefix(prefixMatch.group(1), name) : catalog.hasFeature(resource, name));
});
if(!registered)
{
throw (new PolicyParseException(where + ": unknown action \"" + shorten(name) + "\""));
}
}
}
/*******************************************************************************
** Whether a prefix matches any name of the universe, for the listed actions
** (or any action).
*******************************************************************************/
private static boolean prefixMatchesSomething(PolicyUniverse universe, String prefix, Set<String> actions, boolean anyAction)
{
if(anyAction)
{
return (!new PolicyTerm.Prefix(prefix, null).expand(universe).isEmpty());
}
return (actions.stream().anyMatch(action -> !new PolicyTerm.Prefix(prefix, action).expand(universe).isEmpty()));
}
/*******************************************************************************
** Whether the universe has any permission of a named resource for the
** listed actions ("*" counts as any action).
*******************************************************************************/
private static boolean namedResourceExists(PolicyUniverse universe, String resource, Set<String> statementActions)
{
boolean any = statementActions.contains(PolicyAction.WILDCARD);
for(String name : universe.names())
{
if(new PolicyTerm.Base(resource).matches(name) && (any || statementActions.contains(name.substring(name.lastIndexOf('.') + 1))))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** The document as a JSON object.
*******************************************************************************/
static JSONObject asObject(Object document) throws PolicyParseException
{
if(document instanceof JSONObject object)
{
return (object);
}
if(document instanceof Map<?, ?> map)
{
return (new JSONObject(map));
}
if(document instanceof String text)
{
if(text.isBlank())
{
throw (new PolicyParseException("The document is empty"));
}
try
{
return (new JSONObject(text));
}
catch(JSONException e)
{
throw (new PolicyParseException("The document is not a JSON object"));
}
}
throw (new PolicyParseException("The document is not a JSON object"));
}
/*******************************************************************************
** An array of strings, or a PolicyParseException.
*******************************************************************************/
private static Set<String> strings(Object value, String what) throws PolicyParseException
{
if(!(value instanceof JSONArray array))
{
throw (new PolicyParseException(what + " must be an array of strings"));
}
Set<String> rs = new LinkedHashSet<>();
for(int i = 0; i < array.length(); i++)
{
if(!(array.opt(i) instanceof String text))
{
throw (new PolicyParseException(what + " must be an array of strings"));
}
rs.add(text);
}
return (rs);
}
/*******************************************************************************
** A name shortened for messages (never the whole value).
*******************************************************************************/
private static String shorten(String text)
{
return (text.length() > 40 ? text.substring(0, 40) + "..." : text);
}
/*******************************************************************************
** A short description of a found value for messages (never the whole value).
*******************************************************************************/
private static String describe(Object value)
{
if(value == null)
{
return ("nothing");
}
if(value instanceof String text)
{
return ("\"" + shorten(text) + "\"");
}
return (value.getClass().getSimpleName());
}
}
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff. Show more