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 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
175 changed files with 25195 additions and 1035 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
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\/.*)/
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+$/]
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
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
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]
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.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
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 }}
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
+11 -6
View File
@@ -61,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.5` (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.
+18
View File
@@ -4,6 +4,17 @@ 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.
@@ -13,6 +24,13 @@ All notable changes to this project are documented here. Format: [Keep a Changel
- 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.
+11 -6
View File
@@ -61,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.5` (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.
+59 -3
View File
@@ -14,7 +14,7 @@ Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065)
| **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.
@@ -45,6 +45,15 @@ Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill s
| 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; 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
QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute `qqq.io/permissions` and read by `AuthentikGroupPermissionMapper`:
@@ -83,7 +92,7 @@ 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.0-RC.6 (released BOM, no core SNAPSHOT). 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`, `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)`. |
@@ -93,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
@@ -119,7 +134,7 @@ Access-token metadata is readable with `authentik.audit.read` or `authentik.toke
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 temporary session-permission intersection is marked `STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7)`; a reflection bridge also attaches `QCredentialScope` when available. It does not add permissions.
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`.
@@ -127,3 +142,44 @@ The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at
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.
+12
View File
@@ -1,5 +1,17 @@
# 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`
+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.
+31 -8
View File
@@ -114,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
@@ -340,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) │
└──────────────┬───────────────────────────────────────────────────────────┘
@@ -377,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) |
@@ -613,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 |
@@ -704,7 +705,7 @@ 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. |
| 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 |
@@ -794,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` |
@@ -830,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`
@@ -853,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.
@@ -1038,7 +1061,7 @@ 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 |
| 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 |
+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`.
+67 -15
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`, `*`, or a table feature name registered for every listed resource |
| `Statement[].Resource` | array of strings | Either `*` with exactly `Action: ["*"]`, or 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. `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.
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 |
|---|---|---|
@@ -56,7 +57,50 @@ Features are generic strings, discovered by matching `PermissionsHelper.getAllAv
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`, prefix resources, wildcard resources other than the full-access form below, conditions, and field-level rules. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots. Explicit named grants stay in the group attribute `qqq.io/permissions`; full access includes them in session form.
**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
@@ -71,7 +115,7 @@ It grants **Full access (all current and future resources)**. A complete documen
```json
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.0.0",
"PolicyVersion": "1.1.0",
"Statement": [
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
]
@@ -105,16 +149,22 @@ 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
- 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
A document containing full access canonicalizes to exactly one full-access statement. 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.
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)
@@ -131,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.
@@ -147,6 +197,8 @@ Authentik puts the merged document into the token through a scope mapping. Steps
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
The expanded catalog is cached per `QInstance` (the last few instances, keyed by a digest of table permission rules, supplemental metadata and object names); `PolicyCatalog.clearCache()` forgets them. The cache lives in each JVM and is derived only from that JVM's own instance, so nodes of a cluster need no invalidation between them. Every `PolicyCatalog.of` call compares the instance with the digest, so an in-place metadata change is seen by the next call. The permission universe is never cached: it is built from the catalog's names, the host-declared names and the identity names at each resolution, once per resolution however many terms the policy has (`PolicyRules` reads the universe it is given once per call; `PolicyUniverse.once` does the same for a caller's own loop). No decision is cached: the same claim is resolved again on every request. Host-declared names are never cached, and a host-name function runs once per resolution, so it should be cheap. Host names must be dot-separated segments of `[A-Za-z0-9_-]`; a function that returns anything else fails the expansion loudly. The cache key also covers the permission rules of processes, apps, reports and widgets, hidden flags and the registered table-feature list; a feature's applies-to predicate is opaque, so a host that changes only that in place calls `clearCache()`.
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog accept standard actions and full access; `PolicyPermissions.fromClaims` uses the current `QContext` instance for full-access expansion when no catalog is supplied. Raw `PolicyParser` results are symbolic and must be resolved before entering a session. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the available-permission API from the pinned RC.6 core; registered `TableFeaturePermissionsMetaData` features appear in that API.
The mapping merges the statements of every group in `request.user.all_groups()` (direct groups and their ancestors) whose document has the right spec and version. It always emits a document, with an empty `Statement` array when the user has no policies.
+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.
+35
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 |
@@ -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);
+41 -16
View File
@@ -18,6 +18,7 @@
<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>
@@ -38,7 +39,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.1.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -78,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>
@@ -90,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>
@@ -98,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>
@@ -133,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>
@@ -141,6 +176,7 @@
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-authentik.properties</include>
</includes>
</resource>
@@ -148,6 +184,7 @@
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-authentik.properties</exclude>
</excludes>
</resource>
@@ -239,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.5 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>
@@ -247,7 +284,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -260,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; });
@@ -46,7 +46,8 @@ public class AuthentikQBitConfig implements QBitConfig
{
/** Features implemented in this release. Others are refused by validate(). */
public static final Set<AuthentikFeature> AVAILABLE_FEATURES = EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE, AuthentikFeature.TOKENS);
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE, AuthentikFeature.TOKENS,
AuthentikFeature.MIRROR, AuthentikFeature.WEBHOOK);
/** Features that change Authentik data; they need a local backend for the audit trail. */
public static final Set<AuthentikFeature> WRITE_FEATURES = EnumSet.of(AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
@@ -66,6 +67,7 @@ public class AuthentikQBitConfig implements QBitConfig
private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}";
private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}";
private String tokenEndpointUrl = null;
private String maintenancePrincipal = "maintenance";
private String appPublicUrl = null;
private String authentikPublicUrl = null;
private String appClientId = null;
@@ -89,6 +91,7 @@ public class AuthentikQBitConfig implements QBitConfig
private String localBackendName;
private Boolean twoPersonApproval = true;
private String schedulerName;
private String esbDestinationName;
private String linkPrefix = "/app";
private String recoveryEmailStage;
private String invitationFlow;
@@ -236,6 +239,22 @@ public class AuthentikQBitConfig implements QBitConfig
errors.add("feature WEBHOOK needs webhookSecret");
}
if(requested.contains(AuthentikFeature.WEBHOOK) && !requested.contains(AuthentikFeature.MIRROR))
{
errors.add("feature WEBHOOK needs MIRROR");
}
if(requested.contains(AuthentikFeature.MIRROR))
{
try
{
com.kingsrook.qbits.authentik.sync.AuthentikSyncRegistration.validate(qInstance, this, errors);
}
catch(LinkageError missingDependency)
{
errors.add("MIRROR/WEBHOOK requires the host RDBMS and enabled middleware/ESB dependencies");
}
}
for(String subject : getResolvedBootstrapSubjects())
{
if(subject.contains("@"))
@@ -954,6 +973,31 @@ public class AuthentikQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** Optional host ESB destination; absent means inline inbox processing.
*******************************************************************************/
public String getEsbDestinationName()
{
return (esbDestinationName);
}
/*******************************************************************************
** Select an existing host destination, without creating a broker or queue.
*******************************************************************************/
public void setEsbDestinationName(String value)
{
esbDestinationName = value;
}
/*******************************************************************************
** Fluent optional destination selection.
*******************************************************************************/
public AuthentikQBitConfig withEsbDestinationName(String value)
{
esbDestinationName = value;
return (this);
}
/*******************************************************************************
@@ -1737,6 +1781,29 @@ public class AuthentikQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for maintenancePrincipal: the service principal (declared by the
** host, docs/SERVICE-IDENTITY.md in qqq) that AuthentikMaintenance runs as.
** Its Authentik policy must hold policies.manage and every grant it applies.
*******************************************************************************/
public String getMaintenancePrincipal()
{
return (this.maintenancePrincipal);
}
/*******************************************************************************
** Fluent setter for maintenancePrincipal (default maintenance).
*******************************************************************************/
public AuthentikQBitConfig withMaintenancePrincipal(String maintenancePrincipal)
{
this.maintenancePrincipal = maintenancePrincipal;
return (this);
}
/*******************************************************************************
** Getter for appPublicUrl.
*******************************************************************************/
@@ -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,12 +191,28 @@ 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.getEffectiveFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
@@ -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);
}
}
}
}
@@ -190,13 +190,19 @@ public class AuthentikRuntime
public PolicyCatalog getPolicyCatalog()
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(policyCatalog == null || policyCatalog.getQInstance() != qInstance)
if(qInstance == null)
{
if(qInstance == null)
{
return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance()));
}
policyCatalog = PolicyCatalog.of(qInstance);
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);
@@ -45,6 +45,7 @@ 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),
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());
}
}
}
@@ -21,20 +21,17 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
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.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.qbits.authentik.security.IdentityPermission;
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.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONObject;
@@ -60,13 +57,18 @@ public final class AuthentikMaintenance
** plan and execute path. Requires an initialized QContext with the host's
** QInstance. The caller's context is restored on success, refusal or failure.
**
** The operator must start with maintenance: or run-once: and have a nonblank
** suffix. Its name and the required reason are recorded verbatim. A dedicated
** non-system session holds policies.manage and the document's current grants.
** 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.
** 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
{
@@ -87,18 +89,16 @@ public final class AuthentikMaintenance
CapturedContext caller = QContext.capture();
try
{
MaintenanceSession session = new MaintenanceSession();
session.setUser(new QUser().withIdReference(operatorName).withFullName("Maintenance (" + operatorName + ")"));
session.setPermissions(new TreeSet<>(Set.of(IdentityPermission.POLICIES_MANAGE.getSessionPermission())));
QContext.init(caller.qInstance(), session);
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);
Set<String> grants = StringUtils.hasContent(policyJson)
? PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog()).permissions() : Set.of();
session.getPermissions().addAll(grants.contains(PolicyPermissions.FULL_ACCESS)
? PolicyPermissions.allPermissions(caller.qInstance(), runtime.getPermissionCatalog()) : grants);
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)
@@ -108,9 +108,17 @@ public final class AuthentikMaintenance
throw (new QUserFacingException("Expected exactly one Authentik group named " + groupName + "."));
}
JSONObject group = groups.get(0);
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), reason)
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));
.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group, runtime.getConfig().getPolicyFormat().attribute()));
return (admin.execute(request));
}
catch(PolicyParseException e)
@@ -125,6 +133,29 @@ public final class AuthentikMaintenance
/*******************************************************************************
** 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.
*******************************************************************************/
@@ -139,23 +170,4 @@ public final class AuthentikMaintenance
throw (new QException("Could not generate the full-access policy.", e));
}
}
/*******************************************************************************
** Recognize only sessions created here, never an ordinary user's name prefix.
*******************************************************************************/
static boolean isMaintenanceSession(QSession session)
{
return (session instanceof MaintenanceSession);
}
/*******************************************************************************
** A local job identity, never an Authentik user or a QSystemUserSession.
*******************************************************************************/
private static final class MaintenanceSession extends QSession
{
}
}
@@ -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(), node.policyPermissions()));
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(), node.policyPermissions()));
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(), node.policyPermissions()));
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)));
}
@@ -221,7 +221,7 @@ public class AuthentikTokens
*******************************************************************************/
private List<GroupGraph.Node> serviceGroups(AdminRequest request) throws QException
{
Set<String> names = new LinkedHashSet<>(AuthentikSafety.parseList(request.str("groups")));
Set<String> names = new LinkedHashSet<>(admin.requestedGroupNames(request, "groups"));
GroupGraph graph = util.groupGraph();
List<GroupGraph.Node> groups = new ArrayList<>();
for(String name : names)
@@ -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,31 +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");
if(data.getConfig().hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.POLICIES))
{
String state;
try
{
com.kingsrook.qbits.authentik.permissions.AccessComparison.State mapper = com.kingsrook.qbits.authentik.permissions.AccessComparison.state(
com.kingsrook.qbits.authentik.AuthentikRuntime.forBackend(data.getConfig().getApiBackendName()),
com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(data.getConfig().getApiBackendName()));
state = (mapper.enforced() ? "Enforced. " : "Not enforced. ") + mapper.message();
}
catch(Exception e)
{
state = "Unknown (" + e.getMessage() + ")";
}
row(html, "Policies at sign-in", state);
}
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);
}
@@ -82,7 +82,7 @@ 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");
@@ -64,6 +64,7 @@ import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.apache.http.Header;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.NameValuePair;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
@@ -75,7 +76,6 @@ import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.util.EntityUtils;
import org.json.JSONArray;
import org.json.JSONException;
@@ -116,6 +116,8 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/** Display value for derived fields when the group graph cannot be read. */
public static final String UNAVAILABLE = "Unavailable (Authentik groups could not be read)";
private java.time.Instant deadline;
private EffectivePermissionResolver resolverForCall;
@@ -138,6 +140,94 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** Bound this dedicated health client; ordinary action clients are unchanged.
*******************************************************************************/
public AuthentikActionUtil withHealthDeadline(java.time.Instant deadline) throws QException
{
runtime().getGuard().checkHealth();
this.deadline = java.util.Objects.requireNonNull(deadline);
remainingMillis();
return (this);
}
/*******************************************************************************
** Remaining health budget; zero must never become an unlimited HTTP timeout.
*******************************************************************************/
private int remainingMillis() throws QException
{
long remaining = deadline == null ? Integer.MAX_VALUE : java.time.Duration.between(java.time.Instant.now(), deadline).toMillis();
if(remaining <= 0 || (deadline != null && Thread.currentThread().isInterrupted()))
{
throw (new QException("Authentik health deadline exceeded"));
}
return ((int) Math.min(Integer.MAX_VALUE, remaining));
}
/*******************************************************************************
** Configured zero means unlimited normally, but health always has a deadline.
*******************************************************************************/
private static int healthTimeout(int remaining, int configured)
{
return (configured <= 0 ? remaining : Math.min(remaining, configured));
}
/*******************************************************************************
** Keep retry delays inside the health budget and preserve cancellation.
*******************************************************************************/
private void retrySleep(long millis) throws QException
{
if(deadline == null)
{
SleepUtils.sleep(millis, java.util.concurrent.TimeUnit.MILLISECONDS);
return;
}
if(millis >= remainingMillis())
{
throw (new QException("Authentik health deadline exceeded"));
}
try
{
Thread.sleep(millis);
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Authentik health check interrupted"));
}
}
/*******************************************************************************
** Read the service account API token metadata through the existing paged path.
*******************************************************************************/
public List<JSONObject> getServiceAccountApiTokens() throws QException
{
runtime().getGuard().checkHealth();
return (getAllPages("/core/tokens/?intent=api&user__username=" + java.net.URLEncoder.encode(
runtime().getConfig().getEffectiveServiceAccountUsername(), StandardCharsets.UTF_8), 50));
}
/*******************************************************************************
** Broker credentials use the same secret-safe request loop as token exchange.
*******************************************************************************/
JSONObject requestBrokerCredentials(HttpPost request) throws QException
{
runtime().getGuard().checkHealth();
return (brokerResponse(request));
}
/*******************************************************************************
** The runtime for the backend this util serves.
*******************************************************************************/
@@ -463,6 +553,16 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public JSONObject exchangeToken(WriteAuthorization auth, HttpPost request) throws QException
{
checkAuthorization(auth);
return (brokerResponse(request));
}
/*******************************************************************************
** Parse a broker response without exposing its body.
*******************************************************************************/
private JSONObject brokerResponse(HttpPost request) throws QException
{
if(!request.getURI().toString().equals(runtime().getConfig().getTokenEndpointUrl()))
{
throw (new QException("Bad token endpoint"));
@@ -826,7 +926,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public GroupGraph groupGraph() throws QException
{
AuthentikRuntime runtime = runtime();
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
if(cache)
{
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("all").map(m -> m.getResult());
@@ -880,7 +980,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public GroupGraph groupGraphWithMembers() throws QException
{
AuthentikRuntime runtime = runtime();
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
if(cache)
{
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("members").map(m -> m.getResult());
@@ -931,6 +1031,531 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** Reconciliation requires a complete result, never the truncated bounded
** preview returned by allUsers. Authentik applies scope and watermark;
** an unexpected out-of-scope row aborts instead of being filtered locally.
** A null watermark requests the full directory for deletion reconciliation.
*******************************************************************************/
public List<JSONObject> syncUsers(Instant watermark, int maxObjects) throws QException
{
return (syncUsers(watermark, maxObjects, () -> { }));
}
/*******************************************************************************
** Renewal/cancellation checkpoint around each synchronization HTTP request.
** This callback performs local work only and must not log source identities.
*******************************************************************************/
@FunctionalInterface
public interface SyncProgress
{
/***************************************************************************
** Throw to stop the scan without returning a partial directory.
***************************************************************************/
void check() throws QException;
}
/*******************************************************************************
** Optional health counts. Null means unknown, not zero and not no MFA.
*******************************************************************************/
public record SyncMetrics(Integer mfaDeviceCount, Integer failedLogins24h)
{
}
/*******************************************************************************
** Internal synchronization only: the worker supplies a previously scoped
** directory record. Do not expose this method as a user-supplied lookup.
** Authentik computes the rolling 24-hour window and username match; no event
** bodies, authenticator attributes or usernames are persisted by this call.
*******************************************************************************/
public SyncMetrics syncMetrics(JSONObject user, SyncProgress progress) throws QException
{
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.USER));
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.EVENT));
if(!(com.kingsrook.qqq.backend.core.context.QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession))
{
throw (new QPermissionDeniedException("Synchronization metrics require the internal session."));
}
if(user == null || !runtime().getGuard().isUserInScope(user) || user.optInt("pk", 0) < 1 || user.optString("username", "").isBlank())
{
throw (new QException("Synchronization metrics require a scoped user identity."));
}
Integer mfa = null;
String devices = syncMetricBody("/authenticators/admin/all/?user=" + user.getInt("pk"), progress);
if(devices != null)
{
try
{
JSONArray rows = new JSONArray(devices);
java.util.Set<String> seen = new java.util.HashSet<>();
int confirmed = 0;
if(rows.length() > 1000)
{
throw (new JSONException("Too many devices"));
}
for(int i = 0; i < rows.length(); i++)
{
JSONObject row = rows.getJSONObject(i);
String model = row.getString("meta_model_name");
String pk = row.getString("pk");
String key = model + ":" + pk;
if(model.isBlank() || pk.isBlank() || !(row.get("confirmed") instanceof Boolean) || !seen.add(key))
{
throw (new JSONException("Invalid device count response"));
}
if(row.getBoolean("confirmed"))
{
confirmed++;
}
}
mfa = confirmed;
}
catch(JSONException malformed)
{
// Unknown, never a fabricated zero or a persisted source body.
}
}
Integer failures = null;
try
{
URIBuilder builder = new URIBuilder("/events/events/stats/");
builder.addParameter("action", "login_failed");
builder.addParameter("username", user.getString("username"));
builder.addParameter("count_steps", "hours=24");
String body = syncMetricBody(builder.build().toString(), progress);
if(body != null)
{
Object value = new JSONObject(body).getJSONObject("count_step").get("hours24");
if(value instanceof Integer count && count >= 0)
{
failures = count;
}
}
}
catch(JSONException malformed)
{
// Unknown: incorrect/missing count shape must not become zero.
}
catch(java.net.URISyntaxException invalid)
{
throw (new QException("Bad Authentik metrics URL."));
}
return (new SyncMetrics(mfa, failures));
}
/*******************************************************************************
** Metrics are optional when their API is unavailable. Keep lease checks
** outside the catch: losing ownership must stop synchronization, not become
** an unknown metric that the worker accidentally publishes.
*******************************************************************************/
private String syncMetricBody(String path, SyncProgress progress) throws QException
{
java.util.Objects.requireNonNull(progress).check();
String body = null;
try
{
body = getBody(runtime().getConfig().getApiRoot() + path, false, false);
}
catch(QException unavailable)
{
if(Thread.currentThread().isInterrupted())
{
throw (unavailable);
}
// No stale count fallback and no extra logging of upstream details.
}
progress.check();
return (body);
}
/*******************************************************************************
** Leased variant used by the synchronization worker for large directories.
*******************************************************************************/
public List<JSONObject> syncUsers(Instant watermark, int maxObjects, SyncProgress progress) throws QException
{
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.USER));
try
{
URIBuilder builder = new URIBuilder("/core/users/");
builder.addParameter("include_groups", "true");
builder.addParameter("include_roles", "false");
builder.addParameter("ordering", "pk");
addUserScope(builder, List.of());
if(watermark != null)
{
builder.addParameter("last_updated__gt", watermark.toString());
}
List<JSONObject> users = completeSyncPages(builder.build().toString(), maxObjects, progress);
if(users.stream().anyMatch(user -> !runtime().getGuard().isUserInScope(user)))
{
throw (new QException("Directory synchronization returned a user outside the configured scope; no snapshot is usable."));
}
return (users);
}
catch(java.net.URISyntaxException e)
{
throw (new QException("Bad Authentik synchronization URL"));
}
}
/*******************************************************************************
** Groups have no last_updated; always read their complete membership graph.
*******************************************************************************/
public List<JSONObject> syncGroups(int maxObjects) throws QException
{
return (syncGroups(maxObjects, () -> { }));
}
/*******************************************************************************
** Resolve a username-only event with an exact scoped, uncached server query.
** Reject ignored filters or ambiguous results rather than filtering locally.
*******************************************************************************/
public JSONObject syncUserByUsername(String username, SyncProgress progress) throws QException
{
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.USER));
if(username == null || username.isBlank() || username.length() > 255)
{
throw (new QUserFacingException("Invalid synchronization username."));
}
try
{
URIBuilder builder = new URIBuilder("/core/users/");
builder.addParameter("username", username).addParameter("include_groups", "true").addParameter("include_roles", "false").addParameter("ordering", "pk");
addUserScope(builder, List.of());
var users = completeSyncPages(builder.build().toString(), 2, progress);
if(users.size() > 1 || users.stream().anyMatch(user -> !username.equals(user.optString("username")) || !runtime().getGuard().isUserInScope(user)))
{
throw (new QException("Synchronization username lookup is ambiguous or outside scope."));
}
return (users.isEmpty() ? null : users.getFirst());
}
catch(java.net.URISyntaxException failure)
{
throw (new QException("Bad Authentik synchronization URL"));
}
}
/*******************************************************************************
** Refresh ownership between complete group pages.
*******************************************************************************/
public List<JSONObject> syncGroups(int maxObjects, SyncProgress progress) throws QException
{
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.GROUP));
return (completeSyncPages("/core/groups/?include_users=true&include_parents=false&include_children=false&ordering=pk", maxObjects, progress));
}
/*******************************************************************************
** Recheck apparent deletions after a full sweep, before opening the local
** write transaction. Pagination can omit a live object even when counts
** match. An uncached detail 404 proves absence. Existing users must also
** be absent from a fresh server-scoped query by their immutable UUID.
** Authorization errors, outages and ambiguous responses abort the refresh.
** The caller must hold the directory sync lease across the sweep, these
** checks and publication. This is eventual reconciliation, not an atomic
** snapshot of the remote directory.
*******************************************************************************/
public void confirmSyncDeletions(List<String> userPks, List<String> groupPks) throws QException
{
confirmSyncDeletions(userPks, groupPks, () -> { });
}
/*******************************************************************************
** Keep ownership throughout potentially large deletion-preflight batches.
*******************************************************************************/
public void confirmSyncDeletions(List<String> userPks, List<String> groupPks, SyncProgress progress) throws QException
{
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.USER));
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.GROUP));
if(userPks == null || groupPks == null || (long) userPks.size() + groupPks.size() > MAX_SWEEP_OBJECTS
|| userPks.stream().anyMatch(pk -> pk == null || !pk.matches("[1-9][0-9]{0,18}"))
|| groupPks.stream().anyMatch(pk -> pk == null || !pk.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")))
{
throw (new QUserFacingException("Invalid directory synchronization deletion candidates."));
}
for(String pk : new java.util.LinkedHashSet<>(userPks))
{
confirmSyncUserRemoval(pk, progress);
}
for(String pk : new java.util.LinkedHashSet<>(groupPks))
{
confirmSyncDeletion("/core/groups/" + pk + "/", progress);
}
}
/*******************************************************************************
** A user can leave the configured scope without being deleted. Confirm
** that using a server-filtered immutable identity query, never a username
** that may have changed during the sweep or local filtering of API rows.
*******************************************************************************/
private void confirmSyncUserRemoval(String pk, SyncProgress progress) throws QException
{
if(syncUser(pk, progress) != null)
{
throw (new QException("Directory changed during synchronization; retry before removing mirror records."));
}
}
/*******************************************************************************
** Resolve a newly discovered group member using an uncached detail identity
** and a server-scoped immutable-UUID query. Null means absent or out of scope.
*******************************************************************************/
public JSONObject syncUser(String pk) throws QException
{
return (syncUser(pk, () -> { }));
}
/***************************************************************************
** Verify an event hint with an uncached authoritative detail read. Retain
** the event table's audit-read guard even for direct client callers.
***************************************************************************/
public JSONObject syncEvent(String eventId) throws QException
{
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.EVENT));
if(eventId == null || !eventId.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"))
{
throw (new QUserFacingException("Invalid synchronization event identity."));
}
String url = runtime().getConfig().getApiRoot() + "/events/events/" + eventId + "/";
String body = getBody(url, true, false);
return (body == null ? null : parse(url, body));
}
/***************************************************************************
** Uncached, bounded enrollment configuration for correlation verification.
** Every list is complete and server-filtered; never infer missing policies.
***************************************************************************/
public JSONObject invitationConfiguration(String slug) throws QException
{
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.EVENT));
if(slug == null || !slug.matches("[A-Za-z0-9_-]{1,100}"))
{
throw (new QUserFacingException("Invalid invitation flow."));
}
String root = runtime().getConfig().getApiRoot();
String url = root + "/flows/instances/" + slug + "/";
JSONObject flow = parse(url, getBody(url, false, false));
String flowPk = configurationUuid(flow.getString("pk"));
JSONArray bindings = new JSONArray();
for(JSONObject binding : completeSyncPages("/flows/bindings/?target=" + flowPk, 100, () -> { }))
{
JSONObject copy = new JSONObject(binding.toString());
if("authentik_stages_user_write.userwritestage".equals(binding.getJSONObject("stage_obj").optString("meta_model_name")))
{
String target = configurationUuid(binding.getString("policybindingmodel_ptr_id"));
JSONArray policies = new JSONArray();
for(JSONObject policy : completeSyncPages("/policies/bindings/?target=" + target, 100, () -> { }))
{
JSONObject saved = new JSONObject(policy.toString());
if(!policy.isNull("policy"))
{
String policyUrl = root + "/policies/expression/" + configurationUuid(policy.getString("policy")) + "/";
saved.put("definition", parse(policyUrl, getBody(policyUrl, false, false)));
}
policies.put(saved);
}
copy.put("qqqPolicies", policies);
}
bindings.put(copy);
}
return (new JSONObject().put("flow", flow).put("bindings", bindings));
}
/***************************************************************************
** Preserve configured UUID support while saving the executor's actual slug.
***************************************************************************/
public String invitationFlowSlug(String configured) throws QException
{
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.EVENT));
if(configured == null || !configured.matches("[A-Za-z0-9_-]{1,100}"))
{
throw (new QUserFacingException("Invalid invitation flow."));
}
if(!configured.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"))
{
return (configured);
}
var flows = completeSyncPages("/flows/instances/?flow_uuid=" + configured, 1, () -> { });
if(flows.size() != 1 || !configured.equalsIgnoreCase(flows.getFirst().optString("pk"))
|| !flows.getFirst().optString("slug").matches("[A-Za-z0-9_-]{1,100}"))
{
throw (new QUserFacingException("The configured invitation flow could not be resolved."));
}
return (flows.getFirst().getString("slug"));
}
/***************************************************************************
** Provider configuration identifiers must never become arbitrary URL paths.
***************************************************************************/
private static String configurationUuid(String value) throws QException
{
if(value == null || !value.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"))
{
throw (new QException("Invalid invitation configuration identity."));
}
return (value);
}
/*******************************************************************************
** Keep ownership during both identity-detail and scoped-list requests.
*******************************************************************************/
public JSONObject syncUser(String pk, SyncProgress progress) throws QException
{
runtime().getGuard().checkRead(AuthentikTables.get(AuthentikTables.USER));
if(pk == null || !pk.matches("[1-9][0-9]{0,18}"))
{
throw (new QUserFacingException("Invalid synchronization user identity."));
}
String url = runtime().getConfig().getApiRoot() + "/core/users/" + pk + "/";
String body = syncBody(url, true, progress);
if(body == null)
{
return (null);
}
JSONObject user = parse(url, body);
String uuid = user.optString("uuid", "");
if(!pk.equals(user.optString("pk", "")) || !uuid.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"))
{
throw (new QException("Directory changed or returned an invalid identity during synchronization; retry before removing mirror records."));
}
try
{
URIBuilder builder = new URIBuilder("/core/users/");
builder.addParameter("uuid", uuid);
builder.addParameter("include_groups", "true");
builder.addParameter("include_roles", "false");
addUserScope(builder, List.of());
List<JSONObject> users = completeSyncPages(builder.build().toString(), 1, progress);
if(users.isEmpty())
{
return (null);
}
JSONObject scoped = users.getFirst();
if(!pk.equals(scoped.optString("pk", "")) || !uuid.equalsIgnoreCase(scoped.optString("uuid", ""))
|| !runtime().getGuard().isUserInScope(scoped))
{
throw (new QException("Directory changed or violated the synchronization identity scope."));
}
return (scoped);
}
catch(java.net.URISyntaxException e)
{
throw (new QException("Bad Authentik synchronization URL"));
}
}
/*******************************************************************************
** Do not use cached detail reads or interpret an unreadable record as gone.
** A non-null response is enough to stop, regardless of its JSON shape.
*******************************************************************************/
private void confirmSyncDeletion(String path, SyncProgress progress) throws QException
{
if(syncBody(runtime().getConfig().getApiRoot() + path, true, progress) != null)
{
throw (new QException("Directory changed during synchronization; retry before removing mirror records."));
}
}
/*******************************************************************************
** Refuse missing pagination, changing counts, duplicate keys, or a size
** ceiling. This detects observable inconsistency, not a transactional API
** snapshot: Authentik can change between pages without changing the count.
** Reconcile therefore remains eventual and repeats after webhook hints.
*******************************************************************************/
private List<JSONObject> completeSyncPages(String path, int maxObjects, SyncProgress progress) throws QException
{
if(maxObjects < 1 || maxObjects > MAX_SWEEP_OBJECTS)
{
throw (new QUserFacingException("Invalid directory synchronization size limit."));
}
List<JSONObject> rows = new ArrayList<>();
java.util.Set<String> seen = new java.util.HashSet<>();
int expectedCount = -1;
int expectedPages = -1;
for(int page = 1; ; page++)
{
String url = runtime().getConfig().getApiRoot() + path + "&page=" + page + "&page_size=" + MAX_PAGE_SIZE;
JSONObject response = parse(url, syncBody(url, false, progress));
JSONObject paging = response.optJSONObject("pagination");
JSONArray values = response.optJSONArray("results");
int count = paging == null ? -1 : paging.optInt("count", -1);
int pages = paging == null ? -1 : paging.optInt("total_pages", -1);
if(values == null || paging == null || paging.optInt("current", -1) != page || count < 0 || count > maxObjects
|| pages < 1 || pages > Math.max(1, count) || page > pages
|| (expectedCount != -1 && (count != expectedCount || pages != expectedPages)))
{
throw (new QException("Incomplete or changing directory pagination; no snapshot is usable."));
}
expectedCount = count;
expectedPages = pages;
for(int i = 0; i < values.length(); i++)
{
JSONObject row = values.optJSONObject(i);
String pk = row == null ? "" : row.optString("pk", "");
if(pk.isBlank() || !seen.add(pk) || rows.size() >= count)
{
throw (new QException("Invalid or duplicate directory record; no snapshot is usable."));
}
rows.add(row);
}
if(page == pages)
{
if(rows.size() != count)
{
throw (new QException("Directory snapshot is shorter than its advertised count."));
}
return (List.copyOf(rows));
}
if(values.isEmpty())
{
throw (new QException("Directory snapshot contains an incomplete page."));
}
}
}
/*******************************************************************************
** Renew/check both before and after HTTP. If a request outlasts its lease,
** discard its result rather than returning stale data to the publisher.
*******************************************************************************/
private String syncBody(String url, boolean nullOn404, SyncProgress progress) throws QException
{
java.util.Objects.requireNonNull(progress).check();
String body = getBody(url, nullOn404, false);
progress.check();
return (body);
}
/*******************************************************************************
** A bounded, username-ordered policy-preview lookup. Membership, active
** status and user scope are evaluated by Authentik before the limit.
@@ -1300,9 +1925,19 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
**
*******************************************************************************/
private String getBody(String url, boolean nullOn404) throws QException
{
return (getBody(url, nullOn404, true));
}
/*******************************************************************************
** Reconciliation bypasses memoization without evicting concurrent UI reads.
*******************************************************************************/
private String getBody(String url, boolean nullOn404, boolean useCache) throws QException
{
AuthentikRuntime runtime = runtime();
if(runtime.isCacheEnabled())
if(useCache && deadline == null && runtime.isCacheEnabled())
{
Optional<String> cached = runtime.getResponseCache().getMemoizedResult(url).map(m -> m.getResult());
if(cached.isPresent())
@@ -1322,7 +1957,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
String body = response.getContent();
if(runtime.isCacheEnabled() && body != null)
if(useCache && deadline == null && runtime.isCacheEnabled() && body != null)
{
runtime.getResponseCache().storeResult(url, body);
}
@@ -1376,7 +2011,20 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
while(true)
{
attempts++;
runtime().getThrottle().acquire();
remainingMillis();
if(deadline == null)
{
runtime().getThrottle().acquire();
}
else
{
runtime().getThrottle().acquire(deadline);
int remaining = remainingMillis();
request.setConfig(RequestConfig.custom()
.setConnectTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
.setConnectionRequestTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
.setSocketTimeout(healthTimeout(remaining, runtime().getConfig().getSocketTimeoutMillis())).build());
}
if(!exchange)
{
request.setHeader("Authorization", "Bearer " + token);
@@ -1392,6 +2040,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
try
{
response = send(request);
remainingMillis();
}
catch(Exception e)
{
@@ -1399,7 +2048,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
LOG.warn("Authentik API call failed", logPair("method", request.getMethod()), logPair("path", path), logPair("error", message));
if(attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(backoffMs);
backoffMs *= 2;
continue;
}
@@ -1412,17 +2061,20 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
logOutboundCall(request, path, response, token);
}
checkVersionOnce(status);
if(deadline == null)
{
checkVersionOnce(status);
}
if(status == 429 && attempts <= MAX_RETRIES)
{
SleepUtils.sleep(retryAfterMillis(response, backoffMs), java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(retryAfterMillis(response, backoffMs));
backoffMs *= 2;
continue;
}
if(status >= 500 && attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(backoffMs);
backoffMs *= 2;
continue;
}
@@ -1446,7 +2098,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
// Never echo an untrusted broker error body.
}
throw (new QUserFacingException("Authentik refused to act as you (" + error + ")"));
throw (new BrokerRefusedException(error));
}
throw (mapError(request, path, status, response.getContent(), token));
}
@@ -58,6 +58,11 @@ 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()));
@@ -70,11 +75,13 @@ public final class AuthentikApiSurface
///////////////////////////////////////////////////////////
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", "include_roles", "attributes", "type", "path_startswith", "groups_by_name", "groups_by_pk", "is_active", "ordering", "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", "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);
}
@@ -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());
@@ -58,4 +58,44 @@ public class AuthentikTokenBroker
}
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);
}
}
@@ -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.
*******************************************************************************/
@@ -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"));
}
}
@@ -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())));
}));
}
}
@@ -49,16 +49,21 @@ public final class AccessComparison
/** 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.
** 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)
Set<String> added, Set<String> removed, boolean guarded, boolean voided)
{
}
@@ -136,8 +141,13 @@ public final class AccessComparison
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));
rows.add(new Row(pk, user.optString("username"), email, names, before, after, added, removed, guarded, resolution.voided()));
}
return (rows);
}
@@ -164,8 +174,22 @@ public final class AccessComparison
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid, String excludePolicyOfGroupPk)
{
Set<String> rs = new TreeSet<>(resolver.resolveGroups(groupPks, excludePolicyOfGroupPk).sessionPermissions());
rs.addAll(staticPermissions(config, directGroupNames));
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)))
{
@@ -208,6 +232,34 @@ public final class AccessComparison
/*******************************************************************************
** 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).
*******************************************************************************/
@@ -252,13 +304,24 @@ public final class AccessComparison
+ 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)
+ "). Sign-in keeps today's access until the policies cover it, or the owner overrides the guard.", Instant.now()));
+ ")" + (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)
{
LOG.warn("The cut-over guard could not run; the mapper stays inactive", logPair("error", e.getMessage()));
///////////////////////////////////////////////////////////////////
// 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)));
}
}
@@ -33,6 +33,8 @@ 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;
@@ -82,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);
}
/*******************************************************************************
@@ -107,70 +119,116 @@ 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));
//////////////////////////////////////////////////////////////////////////
// the policy claim (docs/POLICY-FORMAT.md) carries the same group data //
// as the graph read above; the union also covers an unreachable API //
//////////////////////////////////////////////////////////////////////////
AuthentikRuntime runtime = runtime();
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format,
qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance)));
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)
{
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"));
}
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)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups));
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 (static //
// fallbacks, bootstrap subjects), never the policies alone //
// 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;
try
AccessComparison.State state = guardState(qInstance, runtime);
enforced = state.enforced();
if(!enforced)
{
state = AccessComparison.state(runtime, client(qInstance));
}
catch(Exception e)
{
state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now());
}
if(!state.enforced())
{
Set<String> floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups));
for(String group : groups)
{
floor.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
}
if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified))
{
for(IdentityPermission permission : IdentityPermission.values())
{
floor.add(permission.getSessionPermission());
}
}
AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource();
if(source != null)
{
floor.addAll(source.userPermissions(email, groups));
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()));
}
LOG.warn("Policies are not enforced at this sign-in; granting today's access", logPair("reason", state.message()));
permissions = floor;
}
}
if(qSession.getPermissions() != null)
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
{
permissions.addAll(qSession.getPermissions());
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"));
}
qSession.setPermissions(permissions);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims);
}
@@ -203,6 +261,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
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)
{
@@ -217,7 +276,11 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
for(int i = 0; i < groupPks.length(); i++)
{
GroupGraph.Node group = graph.byPk(groupPks.getString(i));
if(group != null)
if(group == null)
{
unresolvedGroup = true;
}
else
{
groups.add(group.name());
}
@@ -227,31 +290,22 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
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);
permissions.addAll(resolve(qInstance, groups, sub, userEmail, verified));
if(runtime != null)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups));
permissions = automatedSessionPermissions(qInstance, runtime, permissions, groups, sub, userEmail, verified);
}
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 only the static fallbacks");
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 only the static fallbacks", logPair("errorType", e.getClass().getSimpleName()));
}
if(runtime != null)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), List.of()));
}
if(session.getPermissions() != null)
{
permissions.addAll(session.getPermissions());
LOG.warn("Authentik could not be read for an automated session; granting no permissions", logPair("errorType", e.getClass().getSimpleName()));
}
session.setPermissions(permissions);
}
@@ -259,67 +313,98 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
/*******************************************************************************
** Mirror the sign-in cut-over floor without changing the claims path.
** 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)
*******************************************************************************/
private Set<String> automatedSessionPermissions(QInstance qInstance, AuthentikRuntime runtime, Set<String> permissions,
List<String> groups, String sub, String email, boolean verified)
@Deprecated
public Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
{
runtime.markMapperInvoked();
AccessComparison.State state;
try
Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified);
Set<String> floor = new TreeSet<>(detailed.floor());
if(detailed.denials().isVoid())
{
state = AccessComparison.state(runtime, client(qInstance));
floor.clear();
}
catch(Exception e)
else
{
state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now());
floor.removeIf(detailed.denials()::denies);
}
if(state.enforced())
{
return (permissions);
}
Set<String> floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups));
for(String group : groups)
{
floor.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
}
if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified))
{
for(IdentityPermission permission : IdentityPermission.values())
{
floor.add(permission.getSessionPermission());
}
}
AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource();
if(source != null)
{
floor.addAll(source.userPermissions(email, groups));
}
LOG.warn("Policies are not enforced for this automated session; granting today's access", logPair("status", state.status()));
return (floor);
Set<String> rs = new TreeSet<>(detailed.bootstrap());
rs.addAll(floor);
rs.addAll(detailed.denials().isVoid() ? Set.of() : detailed.fromGroups());
return (rs);
}
/*******************************************************************************
** The permissions for a user's group names and token identity (sub, email,
** email_verified). Bootstrap operators are matched on immutable ids only.
** 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 Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
public PolicyResult resolveResult(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
{
Set<String> rs = new TreeSet<>();
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());
}
}
@@ -332,23 +417,30 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
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,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()));
}
}
@@ -26,6 +26,9 @@ 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;
@@ -36,7 +39,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
** section 5.6). Full access uses the explicit instance or current QContext.
**
** Rules:
** - A full-access policy marker expands to all current instance/catalog names.
** - 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
@@ -52,14 +62,42 @@ 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());
}
}
@@ -84,6 +122,18 @@ public class EffectivePermissionResolver
*******************************************************************************/
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);
@@ -118,8 +168,11 @@ public class EffectivePermissionResolver
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))
@@ -128,28 +181,86 @@ public class EffectivePermissionResolver
{
continue;
}
for(String parent : node.parentPks())
{
missingParent |= graph.byPk(parent) == null;
}
appAccess.addAll(node.appAccess());
List<String> raws = new ArrayList<>(node.permissions());
for(String raw : node.permissions())
{
grant(expand(raw), raw, node, permissions, grantedBy, unknown);
}
if(!node.pk().equals(excludePolicyOfGroupPk))
{
raws.addAll(node.policyPermissions());
}
for(String raw : raws)
{
Set<String> expanded = expand(raw);
if(expanded.isEmpty())
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()));
}
@@ -176,11 +287,6 @@ public class EffectivePermissionResolver
return (rs);
}
String name = raw.trim();
if(name.equals(PolicyPermissions.FULL_ACCESS))
{
return (PolicyPermissions.allPermissions(qInstance == null ? QContext.getQInstance() : qInstance, catalog));
}
if(name.equals(IdentityPermission.WILDCARD))
{
for(IdentityPermission permission : IdentityPermission.values())
@@ -31,6 +31,7 @@ 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;
@@ -47,19 +48,22 @@ 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,
List<String> policyPermissions)
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, List.of());
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<>();
@@ -79,7 +83,10 @@ public class GroupGraph
/*******************************************************************************
** 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 grants nothing and is listed in the node's malformed entries.
** 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)
{
@@ -98,8 +105,12 @@ public class 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);
List<String> policy = policyPermissions(attributes, f, malformed, catalog);
PolicyRules policy = policyRules(attributes, f, malformed, catalog);
graph.add(new Node(
group.getString("pk"),
group.optString("name", ""),
@@ -119,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);
@@ -297,24 +324,24 @@ public class GroupGraph
/*******************************************************************************
** The permissions of a group's policy document; none (and a malformed entry)
** when it does not parse.
** The typed rules of a group's policy document: empty without one, void
** (and a malformed entry) when it does not parse.
*******************************************************************************/
private static List<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed, PolicyCatalog catalog)
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 (List.of());
return (PolicyRules.EMPTY);
}
try
{
return (List.copyOf(PolicyParser.parseStrict(document, format, catalog).permissions()));
return (PolicyParser.parseStrict(document, format, catalog).rules());
}
catch(PolicyParseException e)
{
malformed.add(format.attribute() + ": " + e.getMessage());
return (List.of());
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);
}
}
}
}
@@ -98,12 +98,12 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
return;
}
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format, qInstance == null ? null : PolicyCatalog.of(qInstance)));
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
///////////////////////////////////////////////////////////////////////////
// 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)));
@@ -119,7 +119,7 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
protected void applySecurityKeys(QSession qSession, JSONObject claims)
{
////////////////////////////////////////////////////////
// no record locks in policy format 1.0.0 (by design) //
// no record locks in policy format 1.1.0 (by design) //
////////////////////////////////////////////////////////
}
}
@@ -19,6 +19,7 @@ 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;
@@ -26,6 +27,7 @@ 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;
@@ -38,6 +40,8 @@ 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;
@@ -111,17 +115,114 @@ public final class PolicyCatalog
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)
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<>();
@@ -132,14 +233,191 @@ public final class PolicyCatalog
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);
}
/*******************************************************************************
** Build the catalog of an instance.
** 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);
@@ -258,7 +536,61 @@ public final class PolicyCatalog
}
groups.add(new Group(entry.getKey(), groupLabel, resources));
}
return (new PolicyCatalog(groups, qInstance));
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)));
}
@@ -452,16 +784,7 @@ public final class PolicyCatalog
*******************************************************************************/
public Set<String> permissionNames()
{
Set<String> rs = new TreeSet<>();
for(Resource resource : byName.values())
{
for(PolicyAction action : resource.actions())
{
rs.add(action.permission(resource.name()));
}
resource.features().forEach(feature -> rs.add(resource.name() + "." + feature.name()));
}
return (rs);
return (new TreeSet<>(permissionNames));
}
@@ -472,11 +795,10 @@ public final class PolicyCatalog
*******************************************************************************/
public Set<String> unknownPermissions(Set<String> permissions)
{
Set<String> known = permissionNames();
Set<String> rs = new TreeSet<>();
Set<String> rs = new TreeSet<>();
for(String permission : permissions)
{
if(!known.contains(permission) && !PolicyPermissions.FULL_ACCESS.equals(permission))
if(!permissionNames.contains(permission))
{
rs.add(permission);
}
@@ -497,6 +819,41 @@ public final class PolicyCatalog
/*******************************************************************************
** 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.
*******************************************************************************/
@@ -142,7 +142,7 @@ public final class PolicyCutoverWidgets
for(AccessComparison.Row row : rows)
{
Map<String, Object> map = new LinkedHashMap<>();
map.put("user", escape(row.username()) + (row.guarded() ? " (guarded)" : ""));
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())));
@@ -160,7 +160,10 @@ public final class PolicyCutoverWidgets
new TableData.Column("html", "Removed", "removed", "28%", "left"),
new TableData.Column("default", "Unchanged", "unchanged", "10%", "right"));
List<AccessComparison.Row> failures = AccessComparison.guardFailures(rows);
String summary = rows.size() + " active people; " + removedPeople + " would lose permissions; guard "
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)
@@ -23,6 +23,7 @@ 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;
@@ -97,7 +98,8 @@ public final class PolicyEditorWidget
Data data = new Data()
.withSpec(format.spec())
.withVersion(format.version())
.withCatalog(runtime.getPolicyCatalog().toJson().toList())
.withCatalog(List.of())
.withCatalogWidget(config.applyPrefix(NAME))
.withMapperEnabled(config.getMapperEnabled())
.withOutputFieldName(AuthentikAdmin.POLICY_DOCUMENT);
@@ -126,7 +128,18 @@ public final class PolicyEditorWidget
// canonical form of what is stored (unknown resources survive) //
/////////////////////////////////////////////////////////////////
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format, runtime.getPolicyCatalog())));
data.withFullAccess(PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).permissions().contains(PolicyPermissions.FULL_ACCESS));
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)
{
@@ -209,6 +222,16 @@ public final class PolicyEditorWidget
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))));
}
}
@@ -224,6 +247,8 @@ public final class PolicyEditorWidget
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;
@@ -232,6 +257,28 @@ public final class PolicyEditorWidget
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);
}
@@ -339,6 +386,44 @@ public final class PolicyEditorWidget
}
/*******************************************************************************
** 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
@@ -36,8 +36,8 @@ public record PolicyFormat(String spec, String version, String attribute, String
/** Default PolicySpec. */
public static final String DEFAULT_SPEC = "qqq-iam";
/** Default (and only) PolicyVersion of this qbit's format. */
public static final String DEFAULT_VERSION = "1.0.0";
/** 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";
@@ -28,13 +28,10 @@ import org.json.JSONObject;
/*******************************************************************************
** Builds the canonical policy document for a set of QQQ permissions
** (docs/POLICY-FORMAT.md, "Canonical form"): one Allow statement per granted
** action in the order hasAccess, read, insert, edit, delete, then features
** sorted by name; resources sorted;
** full access is a single Action/Resource "*" statement. Every document is checked to round-trip through
** PolicyParser to exactly the permissions it came from; one that does not is
** never returned.
** 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
{
@@ -49,11 +46,11 @@ public final class PolicyGenerator
/*******************************************************************************
** The canonical document for these permissions ({resource}.{action} names).
** 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 a permission is not {resource}.{action}
** with a known action and a valid resource, or the document does not
** round-trip
** @throws PolicyParseException when the document does not round-trip
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format) throws PolicyParseException
{
@@ -67,81 +64,158 @@ public final class PolicyGenerator
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(permissions.contains(PolicyPermissions.FULL_ACCESS))
{
JSONObject document = new JSONObject().put("PolicySpec", f.spec()).put("PolicyVersion", f.version())
.put("Statement", new JSONArray().put(new JSONObject().put("Effect", PolicyParser.ALLOW)
.put("Action", new JSONArray().put("*")).put("Resource", new JSONArray().put("*"))));
if(!PolicyParser.parseStrict(toCompact(document), f, catalog).permissions().equals(Set.of(PolicyPermissions.FULL_ACCESS)))
{
throw (new PolicyParseException("The full-access document does not round-trip"));
}
return (document);
}
Map<String, Set<String>> byAction = new LinkedHashMap<>();
Map<String, Set<String>> byFeature = new TreeMap<>();
for(PolicyAction action : PolicyAction.values())
{
byAction.put(action.getJsonName(), new TreeSet<>());
}
Set<String> expected = new TreeSet<>();
for(String permission : permissions)
{
int dot = permission == null ? -1 : permission.lastIndexOf('.');
if(dot <= 0 || dot == permission.length() - 1)
if(permission == null)
{
throw (new PolicyParseException("\"" + permission + "\" is not {resource}.{action}"));
throw (new PolicyParseException("null is not a permission name"));
}
String resource = permission.substring(0, dot);
String name = permission.substring(dot + 1);
if(PolicyAction.fromJsonName(name).isEmpty() && (catalog == null || !catalog.hasFeature(resource, name)))
{
throw (new PolicyParseException("\"" + permission + "\": unknown action \"" + name + "\""));
}
if(!PolicyParser.RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException("\"" + permission + "\": resource must match [A-Za-z0-9_]+"));
}
Map<String, Set<String>> target = byAction.containsKey(name) ? byAction : byFeature;
target.computeIfAbsent(name, key -> new TreeSet<>()).add(resource);
expected.add(permission);
}
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();
byAction.putAll(byFeature);
for(Map.Entry<String, Set<String>> entry : byAction.entrySet())
if(rules.hasFullAccess())
{
if(!entry.getValue().isEmpty())
{
statements.put(new JSONObject()
.put("Effect", PolicyParser.ALLOW)
.put("Action", new JSONArray().put(entry.getKey()))
.put("Resource", new JSONArray(entry.getValue())));
}
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);
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f, catalog).permissions();
if(!parsed.equals(expected))
PolicyRules parsed = PolicyParser.parseStrict(toCompact(document), f, catalog).rules();
if(!parsed.equals(rules))
{
Set<String> missing = new TreeSet<>(expected);
missing.removeAll(parsed);
Set<String> extra = new TreeSet<>(parsed);
extra.removeAll(expected);
throw (new PolicyParseException("The generated document does not round-trip. Missing: " + missing + "; unexpected: " + extra));
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).
*******************************************************************************/
@@ -157,14 +231,15 @@ public final class PolicyGenerator
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
return (generate(PolicyParser.parseStrict(document, format, catalog).permissions(), format, catalog));
return (generate(PolicyParser.parseStrict(document, format, catalog).rules(), format, catalog));
}
/*******************************************************************************
** Compact JSON in a fixed key order (PolicySpec, PolicyVersion, Statement;
** Effect, Action, Resource), as stored in the group attribute.
** Effect, Action, Resource or Effect, Permission), as stored in the group
** attribute.
*******************************************************************************/
public static String toCompact(JSONObject document)
{
@@ -192,7 +267,6 @@ public final class PolicyGenerator
String i1 = pretty ? " " : "";
String i2 = pretty ? " " : "";
String i3 = pretty ? " " : "";
String i4 = pretty ? " " : "";
String sep = pretty ? ": " : ":";
StringBuilder sb = new StringBuilder("{").append(nl);
@@ -207,15 +281,15 @@ public final class PolicyGenerator
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);
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("[");
JSONArray resources = statement.optJSONArray("Resource");
int n = resources == null ? 0 : resources.length();
for(int r = 0; r < n; r++)
if(statement.has("Permission"))
{
sb.append(r == 0 ? nl : "," + nl).append(i4).append(JSONObject.quote(resources.optString(r)));
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(n == 0 ? "" : nl + i3).append("]").append(nl);
sb.append(i2).append("}");
}
sb.append(count == 0 ? "" : nl + i1).append("]").append(nl);
@@ -225,6 +299,24 @@ public final class PolicyGenerator
/*******************************************************************************
** 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.
*******************************************************************************/
@@ -17,11 +17,13 @@
package com.kingsrook.qbits.authentik.policy;
import java.util.Collections;
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.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONArray;
@@ -31,12 +33,16 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Parses a policy document (docs/POLICY-FORMAT.md) into QQQ permission names.
** 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, a
** statement that is not Allow, an unknown action, a bad resource, malformed
** JSON, ...) grants zero permissions and logs a warning. parseStrict() throws
** a PolicyParseException naming the problem instead (for the editor).
** 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
{
@@ -45,18 +51,32 @@ public final class PolicyParser
/** A valid resource: a QQQ permission base name. */
public static final Pattern RESOURCE_PATTERN = Pattern.compile("[A-Za-z0-9_]+");
/** The only effect in 1.0.0. */
/** 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 permissions QQQ permission names, or the symbolic FULL_ACCESS marker
** @param resources every resource named, in document order
** @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(Set<String> permissions, Set<String> resources)
public record Parsed(PolicyRules rules, Set<String> resources, Set<String> named)
{
}
@@ -72,13 +92,13 @@ public final class PolicyParser
/*******************************************************************************
** The permissions a document grants, or none (with a warning) when it is
** missing or invalid in any way.
** 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); null grants nothing
** attribute or a token claim)
*******************************************************************************/
public static Set<String> parse(Object document, PolicyFormat format)
public static PolicyRules parse(Object document, PolicyFormat format)
{
return (parse(document, format, null));
}
@@ -87,27 +107,43 @@ public final class PolicyParser
/*******************************************************************************
** Parse with the catalog of explicitly grantable table features; an invalid
** document still grants nothing.
** document gives void rules.
*******************************************************************************/
public static Set<String> parse(Object document, PolicyFormat format, PolicyCatalog catalog)
public static PolicyRules parse(Object document, PolicyFormat format, PolicyCatalog catalog)
{
if(document == null || JSONObject.NULL.equals(document))
{
return (Collections.emptySet());
return (PolicyRules.EMPTY);
}
try
{
return (parseStrict(document, format, catalog).permissions());
return (parseStrict(document, format, catalog).rules());
}
catch(PolicyParseException e)
{
LOG.warn("Policy document rejected; it grants no permissions", logPair("reason", e.getMessage()));
return (Collections.emptySet());
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.
*******************************************************************************/
@@ -124,8 +160,39 @@ public final class PolicyParser
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
JSONObject json = asObject(document);
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
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()))
@@ -138,12 +205,22 @@ public final class PolicyParser
throw (new PolicyParseException("PolicyVersion must be \"" + f.version() + "\" (found " + describe(version) + ")"));
}
Set<String> permissions = new TreeSet<>();
Set<String> resources = new LinkedHashSet<>();
Object statements = json.opt("Statement");
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)
{
return (new Parsed(permissions, resources));
throw (new PolicyParseException("Statement is required (an array; an empty array grants nothing)"));
}
if(!(statements instanceof JSONArray array))
{
@@ -156,59 +233,220 @@ public final class PolicyParser
{
throw (new PolicyParseException("Statement " + (i + 1) + " must be an object"));
}
Object effect = statement.opt("Effect");
if(!ALLOW.equals(effect))
{
throw (new PolicyParseException("Statement " + (i + 1) + ": Effect must be \"Allow\" (found " + describe(effect) + "); Deny is not supported yet"));
}
parseStatement("Statement " + (i + 1), statement, catalog, universe, allow, deny, resources, named);
}
return (new Parsed(PolicyRules.of(allow, deny), resources, named));
}
Set<String> statementResources = strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource");
Set<String> statementActions = strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action");
if(statementResources.contains("*") && !statementActions.equals(Set.of("*")))
/*******************************************************************************
** 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("Statement " + (i + 1) + ": Resource \"*\" requires Action [\"*\"] (full access)"));
throw (new PolicyParseException(where + ": unknown key \"" + shorten(key) + "\" (only Effect, Action, Resource and Permission are supported)"));
}
Set<String> actions = new LinkedHashSet<>();
for(String name : statementActions)
}
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"))
{
if(PolicyAction.WILDCARD.equals(name))
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())
{
for(PolicyAction action : PolicyAction.values())
{
actions.add(action.getJsonName());
}
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
{
if(PolicyAction.fromJsonName(name).isEmpty()
&& (catalog == null || statementResources.isEmpty() || !statementResources.stream().allMatch(resource -> catalog.hasFeature(resource, name))))
for(String action : listed)
{
throw (new PolicyParseException("Statement " + (i + 1) + ": unknown action \"" + name + "\""));
target.add(new PolicyTerm.Prefix(prefix, action));
}
actions.add(name);
}
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));
}
}
for(String resource : statementResources)
if(isDeny && universe != null && !namedResourceExists(universe, resource, statementActions))
{
if(resource.equals("*"))
{
permissions.add(PolicyPermissions.FULL_ACCESS);
resources.add(resource);
continue;
}
if(!RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException("Statement " + (i + 1) + ": resource \"" + resource + "\" must match [A-Za-z0-9_]+"));
}
resources.add(resource);
for(String action : actions)
{
permissions.add(resource + "." + action);
}
throw (new PolicyParseException(where + ": Deny of \"" + resource + "\" matches nothing in this app"));
}
}
return (new Parsed(permissions.contains(PolicyPermissions.FULL_ACCESS) ? Set.of(PolicyPermissions.FULL_ACCESS) : permissions, resources));
}
/*******************************************************************************
** 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);
}
@@ -269,6 +507,16 @@ public final class PolicyParser
/*******************************************************************************
** 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).
*******************************************************************************/
@@ -280,7 +528,7 @@ public final class PolicyParser
}
if(value instanceof String text)
{
return ("\"" + (text.length() > 40 ? text.substring(0, 40) + "..." : text) + "\"");
return ("\"" + shorten(text) + "\"");
}
return (value.getClass().getSimpleName());
}
@@ -19,11 +19,13 @@ package com.kingsrook.qbits.authentik.policy;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Function;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONObject;
@@ -38,6 +40,8 @@ public final class PolicyPermissions
/** Internal parse marker, never a session permission or persisted permission list. */
public static final String FULL_ACCESS = "<full-access>";
private static final List<Function<QInstance, Collection<String>>> REGISTERED_HOST_PERMISSIONS = new CopyOnWriteArrayList<>();
/** Operator-facing description of the wildcard statement. */
public static final String FULL_ACCESS_LABEL = "Full access (all current and future resources)";
@@ -61,7 +65,11 @@ public final class PolicyPermissions
/*******************************************************************************
** The permissions granted by the policy claim in these token claims; none
** when the claim is missing or invalid (fail closed).
**
** @deprecated a plain set cannot tell void from absent or carry a Deny to the
** host's other sources; use resultFromClaims(...).apply(others)
*******************************************************************************/
@Deprecated
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format)
{
return (fromClaims(claims, format, null));
@@ -70,18 +78,76 @@ public final class PolicyPermissions
/*******************************************************************************
** Read a policy claim with the catalog of registered table features.
** The typed result of the policy claim: the rules (void when the claim is
** invalid, empty when there is none) and what they grant now. Use apply() to
** union the host's own permissions so a Deny cannot be bypassed.
*******************************************************************************/
public static PolicyResult resultFromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog)
{
PolicyRules rules = rulesFromClaims(claims, format, catalog);
if(rules.isVoid() || rules.isEmpty())
{
return (PolicyResult.of(rules, () -> Set.of()));
}
return (PolicyResult.of(rules, universe(instanceOf(catalog), Set.of())));
}
/*******************************************************************************
** Read a policy claim with the catalog of registered table features:
** Allow expansions minus Deny coverage, expanded against the live instance
** now. None when the claim is missing or invalid (fail closed).
*******************************************************************************/
@Deprecated
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog)
{
PolicyRules rules = rulesFromClaims(claims, format, catalog);
if(rules.isVoid() || rules.isEmpty())
{
return (Collections.emptySet());
}
return (rules.resolve(universe(instanceOf(catalog), Set.of())));
}
/*******************************************************************************
** The typed rules of the policy claim: empty when the claim is missing,
** void when it is invalid. The mapper uses these to apply Deny to every
** source of a session, not only to the claim.
*******************************************************************************/
public static PolicyRules rulesFromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(claims == null || !claims.has(f.claim()))
{
return (Collections.emptySet());
return (PolicyRules.EMPTY);
}
Set<String> parsed = PolicyParser.parse(claims.opt(f.claim()), f, catalog);
return (parsed.contains(FULL_ACCESS)
? allPermissions(catalog == null ? QContext.getQInstance() : catalog.getQInstance(), Set.of()) : parsed);
return (PolicyParser.parse(claims.opt(f.claim()), f, catalog));
}
/*******************************************************************************
** The instance a catalog was built from, else the current QContext's.
*******************************************************************************/
public static QInstance instanceOf(PolicyCatalog catalog)
{
return (catalog == null || catalog.getQInstance() == null ? QContext.getQInstance() : catalog.getQInstance());
}
/*******************************************************************************
** The live universe of an instance: allPermissions, built again on EVERY
** names() call and never cached, so instance changes are always visible.
** PolicyRules reads it once per resolution (PolicyUniverse.once); a caller
** that reads names() itself more than once should wrap it the same way.
*******************************************************************************/
public static PolicyUniverse universe(QInstance qInstance, Collection<String> additional)
{
return (() -> allPermissions(qInstance, additional));
}
@@ -94,16 +160,24 @@ public final class PolicyPermissions
public static Set<String> allPermissions(QInstance qInstance, Collection<String> additional)
{
Set<String> permissions = new TreeSet<>();
if(qInstance != null)
{
/////////////////////////////////////////////////////////////////////
// the catalog's names first: they are already sorted, so the set //
// is built from them in one pass (the catalog is checked against //
// the instance on every call; its names are not walked again) //
/////////////////////////////////////////////////////////////////////
permissions.addAll(PolicyCatalog.of(qInstance).universeNames());
permissions.addAll(hostPermissions(qInstance));
}
else
{
permissions.addAll(registeredHostPermissions(null));
}
if(additional != null)
{
permissions.addAll(additional);
}
if(qInstance != null)
{
PermissionsHelper.getAllAvailablePermissions(qInstance).forEach(permission -> permissions.add(permission.getName()));
permissions.addAll(PolicyCatalog.of(qInstance).permissionNames());
permissions.addAll(hostPermissions(qInstance));
}
for(IdentityPermission permission : IdentityPermission.values())
{
permissions.add(permission.getSessionPermission());
@@ -120,17 +194,13 @@ public final class PolicyPermissions
*******************************************************************************/
static Set<String> hostPermissions(QInstance qInstance)
{
Set<String> rs = new TreeSet<>();
Set<String> rs = registeredHostPermissions(qInstance);
for(com.kingsrook.qbits.authentik.AuthentikRuntime runtime : com.kingsrook.qbits.authentik.AuthentikRuntime.all())
{
com.kingsrook.qbits.authentik.AuthentikQBitConfig config = runtime.getConfig();
if(config.getFullAccessHostPermissions() != null && config.getApiBackendName() != null && qInstance.getBackend(config.getApiBackendName()) != null)
{
java.util.Collection<String> names = config.getFullAccessHostPermissions().apply(qInstance);
if(names != null)
{
names.stream().filter(n -> n != null && !n.isBlank()).forEach(rs::add);
}
addNames(rs, config.getFullAccessHostPermissions().apply(qInstance));
}
}
return (rs);
@@ -138,6 +208,71 @@ public final class PolicyPermissions
/*******************************************************************************
** The names of every registered host-name function (no runtime needed).
*******************************************************************************/
private static Set<String> registeredHostPermissions(QInstance qInstance)
{
Set<String> rs = new TreeSet<>();
for(Function<QInstance, Collection<String>> supplier : REGISTERED_HOST_PERMISSIONS)
{
addNames(rs, supplier.apply(qInstance));
}
return (rs);
}
/*******************************************************************************
** Register a host's permission names (custom checkers, named permissions such
** as cdn.purge) for the policy universe WITHOUT any Authentik runtime, so a
** host that runs only the policy mapper (user management off) keeps its
** names. The function runs at every expansion, so names added later are
** covered. Registering the same function twice has no extra effect.
** Names declared through AuthentikQBitConfig.withFullAccessHostPermissions
** still apply, and the two are unioned.
*******************************************************************************/
public static void registerHostPermissions(Function<QInstance, Collection<String>> supplier)
{
if(supplier != null && !REGISTERED_HOST_PERMISSIONS.contains(supplier))
{
REGISTERED_HOST_PERMISSIONS.add(supplier);
}
}
/*******************************************************************************
** Forget every registered host-name function (tests, hot reload).
*******************************************************************************/
public static void clearRegisteredHostPermissions()
{
REGISTERED_HOST_PERMISSIONS.clear();
}
/*******************************************************************************
** Add non-blank names.
*******************************************************************************/
private static void addNames(Set<String> target, Collection<String> names)
{
if(names == null)
{
return;
}
for(String name : names)
{
if(name == null || !PolicyParser.PERMISSION_PATTERN.matcher(name).matches())
{
throw (new IllegalArgumentException("A host permission name must be dot-separated segments of [A-Za-z0-9_-] (a name was null, blank or malformed)"));
}
target.add(name);
}
}
/*******************************************************************************
** The claims that carry the policy in a customizer context: the ID token's
** when it has the claim (Authentik puts scope-mapping claims there), else the
@@ -0,0 +1,143 @@
/*
* 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.Set;
import java.util.TreeSet;
/*******************************************************************************
** What the policies of one user grant, keeping the typed rules so a host
** customizer can tell VOID (an invalid policy), EMPTY (no policy) and DENY
** apart, which a plain set of names cannot.
**
** A host that adds permissions of its own (service permissions, a static
** floor) must pass them through apply(), so a Deny can never be bypassed by a
** union done afterwards.
**
** @param rules the typed Allow and Deny terms (void when any policy was invalid)
** @param granted Allow minus Deny, expanded against the live universe now; empty when void
** @param unavailable the policies could not be read, so nothing is known about Deny
*******************************************************************************/
public record PolicyResult(PolicyRules rules, Set<String> granted, boolean unavailable)
{
/*******************************************************************************
** A readable result (not unavailable).
*******************************************************************************/
public PolicyResult(PolicyRules rules, Set<String> granted)
{
this(rules, granted, false);
}
/*******************************************************************************
** The result of resolving rules against a universe.
*******************************************************************************/
public static PolicyResult of(PolicyRules rules, PolicyUniverse universe)
{
return (new PolicyResult(rules, rules.isVoid() || rules.isEmpty() ? Set.of() : rules.resolve(universe)));
}
/*******************************************************************************
** The policies could not be read (for example the group graph is unreachable),
** so no Deny can be known: apply() gives nothing.
*******************************************************************************/
public static PolicyResult unreadable()
{
return (new PolicyResult(PolicyRules.EMPTY, Set.of(), true));
}
/*******************************************************************************
** Whether an invalid policy voided the user (they get no permissions).
*******************************************************************************/
public boolean isVoid()
{
return (rules.isVoid());
}
/*******************************************************************************
** Whether the policies could not be read.
*******************************************************************************/
public boolean isUnavailable()
{
return (unavailable);
}
/*******************************************************************************
** Whether the user has no policy at all (neither Allow nor Deny; not void,
** not unavailable).
*******************************************************************************/
public boolean isAbsent()
{
return (!unavailable && !rules.isVoid() && rules.isEmpty());
}
/*******************************************************************************
** The permissions granted here plus the other sources the host supplies,
** minus every Deny. Empty when void or unavailable: an invalid or unreadable
** policy never lets other sources through unfiltered. The other sources are
** not modified. NOT FOR SIGN-IN ASSEMBLY by this qbit's mappers (they use
** SessionPermissionRules.compute); it is for a host customizer's own sources.
**
** This is a UNION: apply(session.getPermissions()) keeps everything the session
** already held that no Deny covers. A host whose sessions take their permissions
** from the policies alone must call replacement() instead.
*******************************************************************************/
public Set<String> apply(Collection<String> otherSources)
{
if(rules.isVoid() || unavailable)
{
return (new TreeSet<>());
}
Set<String> rs = new TreeSet<>(granted);
if(otherSources != null)
{
rs.addAll(otherSources);
}
rs.removeIf(rules::denies);
return (rs);
}
/*******************************************************************************
** The complete permission set of a session whose ONLY source is these
** policies: exactly what they grant (Allow minus Deny) and nothing else, so
** nothing the session held before survives. Empty when void or unavailable.
** For a host that replaces the session's permissions at every authentication
** ("permissions come only from the token"): session.setPermissions(replacement()).
** Unlike apply(), it takes no other source, so it cannot union by accident.
** The set is new on every call; the caller may keep or change it.
*******************************************************************************/
public Set<String> replacement()
{
return (apply(null));
}
}
@@ -0,0 +1,417 @@
/*
* 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.Objects;
import java.util.Set;
import java.util.TreeSet;
/*******************************************************************************
** The typed result of parsing policy documents: Allow terms and Deny terms,
** or VOID when any document was invalid. There is no way to read these as a
** plain set of permission names: the only ways to get permissions are
** resolve() (Allow minus Deny, against a universe) and, for tests and
** documents known to be plain, exactGrants(), which refuses to answer when a
** Deny or a symbolic term is present.
**
** The rules of docs/POLICY-FORMAT.md: result = union(Allow expansions) minus
** union(Deny expansions); a Deny anywhere wins; no ordering. Merging rules
** (several groups, several documents) voids the lot when any one is void.
*******************************************************************************/
public final class PolicyRules
{
/** Nothing allowed, nothing denied, valid. */
public static final PolicyRules EMPTY = new PolicyRules(Set.of(), Set.of(), null);
private final Set<PolicyTerm> allow;
private final Set<PolicyTerm> deny;
private final String voidReason;
/*******************************************************************************
** Constructor. Full access makes every other Allow redundant.
*******************************************************************************/
private PolicyRules(Set<PolicyTerm> allow, Set<PolicyTerm> deny, String voidReason)
{
this.voidReason = voidReason;
this.deny = Set.copyOf(deny);
this.allow = allow.contains(new PolicyTerm.Everything()) ? Set.of(new PolicyTerm.Everything()) : Set.copyOf(allow);
}
/*******************************************************************************
** Rules from terms.
*******************************************************************************/
public static PolicyRules of(Collection<PolicyTerm> allow, Collection<PolicyTerm> deny)
{
return (new PolicyRules(new LinkedHashSet<>(allow), new LinkedHashSet<>(deny), null));
}
/*******************************************************************************
** Allow exactly these permission names (and full access when the set holds
** the internal FULL_ACCESS marker). The one bridge from plain name sets,
** used to generate documents; it never carries a Deny.
*******************************************************************************/
public static PolicyRules allowing(Collection<String> names)
{
Set<PolicyTerm> allow = new LinkedHashSet<>();
for(String name : names)
{
allow.add(PolicyPermissions.FULL_ACCESS.equals(name) ? new PolicyTerm.Everything() : new PolicyTerm.Exact(name));
}
return (new PolicyRules(allow, Set.of(), null));
}
/*******************************************************************************
** Rules that grant nothing and void every set they are merged into.
*******************************************************************************/
public static PolicyRules voided(String reason)
{
return (new PolicyRules(Set.of(), Set.of(), reason == null || reason.isBlank() ? "invalid policy" : reason));
}
/*******************************************************************************
** Merge the rules of every document of one user. ONE void member voids the
** result: dropping only a bad group could drop a Deny while the other
** groups' Allow statements survive.
*******************************************************************************/
public static PolicyRules merge(Collection<PolicyRules> all)
{
Set<PolicyTerm> allow = new LinkedHashSet<>();
Set<PolicyTerm> deny = new LinkedHashSet<>();
for(PolicyRules rules : all)
{
if(rules.isVoid())
{
return (voided(rules.voidReason));
}
allow.addAll(rules.allow);
deny.addAll(rules.deny);
}
return (new PolicyRules(allow, deny, null));
}
/*******************************************************************************
** Whether a document was invalid (the user gets nothing).
*******************************************************************************/
public boolean isVoid()
{
return (voidReason != null);
}
/*******************************************************************************
** Why the rules are void, or null.
*******************************************************************************/
public String voidReason()
{
return (voidReason);
}
/*******************************************************************************
** Whether no Allow and no Deny term exists.
*******************************************************************************/
public boolean isEmpty()
{
return (allow.isEmpty() && deny.isEmpty());
}
/*******************************************************************************
** Whether the Allow side is full access (all current and future resources).
*******************************************************************************/
public boolean hasFullAccess()
{
return (allow.contains(new PolicyTerm.Everything()));
}
/*******************************************************************************
** Whether any Deny term exists.
*******************************************************************************/
public boolean hasDeny()
{
return (!deny.isEmpty());
}
/*******************************************************************************
** The Allow terms.
*******************************************************************************/
public Set<PolicyTerm> allowTerms()
{
return (allow);
}
/*******************************************************************************
** The Deny terms.
*******************************************************************************/
public Set<PolicyTerm> denyTerms()
{
return (deny);
}
/*******************************************************************************
** Only the Deny side (and the void state): what to subtract from sources
** that are not policy documents, such as static fallbacks.
*******************************************************************************/
public PolicyRules denialsOnly()
{
return (new PolicyRules(Set.of(), deny, voidReason));
}
/*******************************************************************************
** Whether a Deny term covers this permission. A void rule set denies
** everything (fail closed).
*******************************************************************************/
public boolean denies(String permission)
{
if(isVoid())
{
return (true);
}
for(PolicyTerm term : deny)
{
if(term.matches(permission))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** The Allow expansion alone (before Deny), against the universe. Empty
** when void. Callers that hold other sources must still subtract denies().
*******************************************************************************/
public Set<String> allowed(PolicyUniverse universe)
{
Set<String> rs = new TreeSet<>();
if(isVoid())
{
return (rs);
}
////////////////////////////////////////////////////////////////////////
// every term expands against ONE reading of the universe: a live //
// universe builds its names on each names() call, so reading it per //
// term made the cost grow with the number of terms (one catalog check //
// and one full name set for each named permission of a role) //
////////////////////////////////////////////////////////////////////////
PolicyUniverse once = PolicyUniverse.once(universe);
for(PolicyTerm term : allow)
{
rs.addAll(term.expand(once));
}
return (rs);
}
/*******************************************************************************
** What these rules grant: Allow expansions minus Deny coverage. Empty when
** void.
*******************************************************************************/
public Set<String> resolve(PolicyUniverse universe)
{
Set<String> rs = allowed(universe);
rs.removeIf(this::denies);
return (rs);
}
/*******************************************************************************
** The permission names the Deny side withholds: names in the universe or
** granted by an Allow that a Deny term covers. Removing a Deny releases
** these (the subset rule).
*******************************************************************************/
public Set<String> withheld(PolicyUniverse universe)
{
Set<String> rs = new TreeSet<>();
if(isVoid())
{
///////////////////////////////////////////////////////////////////////
// an invalid document voids its members: it may have held a Deny, so //
// making it valid (or removing it) releases everything //
///////////////////////////////////////////////////////////////////////
rs.addAll(universe.names());
return (rs);
}
if(deny.isEmpty())
{
return (rs);
}
PolicyUniverse once = PolicyUniverse.once(universe);
Set<String> candidates = new TreeSet<>(once.names());
candidates.addAll(allowed(once));
for(String name : candidates)
{
if(denies(name))
{
rs.add(name);
}
}
return (rs);
}
/*******************************************************************************
** The exact permission names of rules that are plain: no Deny, no prefix,
** no full access. Throws rather than silently ignoring a Deny or a
** symbolic term, so a caller that cannot handle them fails loudly. Void
** rules give the empty set.
*******************************************************************************/
public Set<String> exactGrants()
{
Set<String> rs = new TreeSet<>();
if(isVoid())
{
return (rs);
}
if(!deny.isEmpty())
{
throw (new IllegalStateException("The policy has a Deny; resolve() it instead of reading names"));
}
for(PolicyTerm term : allow)
{
if(!(term instanceof PolicyTerm.Exact exact))
{
throw (new IllegalStateException("The policy has a symbolic term; resolve() it instead of reading names"));
}
rs.add(exact.name());
}
return (rs);
}
/*******************************************************************************
** Operator-facing labels of the Deny terms, sorted ("billing_*: all
** actions", "billing_invoice.delete").
*******************************************************************************/
public List<String> denyLabels()
{
return (labels(deny));
}
/*******************************************************************************
** Operator-facing labels of the Allow terms, sorted.
*******************************************************************************/
public List<String> allowLabels()
{
return (labels(allow));
}
/*******************************************************************************
** Sorted labels of terms.
*******************************************************************************/
private static List<String> labels(Set<PolicyTerm> terms)
{
List<String> rs = new ArrayList<>();
for(PolicyTerm term : terms)
{
if(term instanceof PolicyTerm.Exact exact)
{
rs.add(exact.name());
}
else if(term instanceof PolicyTerm.Everything)
{
rs.add("* (all current and future resources)");
}
else if(term instanceof PolicyTerm.Base base)
{
rs.add(base.base() + ": all actions");
}
else if(term instanceof PolicyTerm.Prefix prefix)
{
rs.add(prefix.prefix() + "*: " + (prefix.action() == null ? "all actions" : prefix.action()));
}
}
rs.sort(String::compareTo);
return (rs);
}
/*******************************************************************************
** Same terms and same void state.
*******************************************************************************/
@Override
public boolean equals(Object other)
{
return (other instanceof PolicyRules rules && allow.equals(rules.allow) && deny.equals(rules.deny) && Objects.equals(voidReason, rules.voidReason));
}
/*******************************************************************************
** Hash of the terms.
*******************************************************************************/
@Override
public int hashCode()
{
return (Objects.hash(allow, deny, voidReason));
}
/*******************************************************************************
** Never prints terms into logs beyond their count.
*******************************************************************************/
@Override
public String toString()
{
return (isVoid() ? "PolicyRules[void]" : "PolicyRules[allow=" + allow.size() + ", deny=" + deny.size() + "]");
}
}
@@ -0,0 +1,216 @@
/*
* 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.Set;
/*******************************************************************************
** One symbolic term of a parsed policy. A term is NOT a permission name: it
** can only be turned into permissions through PolicyRules, which applies Deny
** across all terms. Allow terms expand against a PolicyUniverse; Deny terms
** are predicates over permission names, so a Deny is at least as broad as
** what it names whether or not the name exists in the universe.
*******************************************************************************/
public sealed interface PolicyTerm
{
/*******************************************************************************
** The resource a permission name belongs to: the text before its final dot up
** to the first dot ("billing_audit" for "billing_audit.export.run"), or null
** for a name with no action. A Deny of a wildcard or named resource covers the
** dotted host names it owns, which an Allow of the same resource never grants.
*******************************************************************************/
static String ownerOf(String permission)
{
int dot = permission == null ? -1 : permission.lastIndexOf('.');
if(dot <= 0)
{
return (null);
}
int first = permission.indexOf('.');
return (permission.substring(0, first));
}
/*******************************************************************************
** Whether this term covers the permission name (the Deny test).
*******************************************************************************/
boolean matches(String permission);
/*******************************************************************************
** The permission names this term grants (the Allow expansion).
*******************************************************************************/
Set<String> expand(PolicyUniverse universe);
/*******************************************************************************
** An exact permission name: "billing_invoice.read", "cdn.purge". It grants
** only when the universe has it (an action a resource does not have is
** silently dropped); as a Deny it matches by name, universe or not.
*******************************************************************************/
record Exact(String name) implements PolicyTerm
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public boolean matches(String permission)
{
return (name.equals(permission));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public Set<String> expand(PolicyUniverse universe)
{
return (universe.names().contains(name) ? Set.of(name) : Set.of());
}
}
/*******************************************************************************
** Every permission that exists (Resource "*"). As Deny, every name.
*******************************************************************************/
record Everything() implements PolicyTerm
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public boolean matches(String permission)
{
return (true);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public Set<String> expand(PolicyUniverse universe)
{
return (universe.names());
}
}
/*******************************************************************************
** Every action of one named resource, features included (Deny "*" on a
** named resource). Never an Allow term.
*******************************************************************************/
record Base(String base) implements PolicyTerm
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public boolean matches(String permission)
{
String owner = ownerOf(permission);
return (owner != null && owner.equals(base));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public Set<String> expand(PolicyUniverse universe)
{
return (Set.of());
}
}
/*******************************************************************************
** A prefix resource ("utils_" for "utils_*") with one action, or every
** action when action is null (Deny "*"). Matches names whose base (the
** text before the last dot) starts with the prefix and has no dot.
*******************************************************************************/
record Prefix(String prefix, String action) implements PolicyTerm
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public boolean matches(String permission)
{
String owner = ownerOf(permission);
return (owner != null && owner.startsWith(prefix) && actionMatches(permission));
}
/*******************************************************************************
** Whether the Allow expansion grants this name: the base (the text before
** the final dot) starts with the prefix and has no dot itself.
*******************************************************************************/
boolean grants(String permission)
{
int dot = permission == null ? -1 : permission.lastIndexOf('.');
if(dot <= 0)
{
return (false);
}
String base = permission.substring(0, dot);
return (base.startsWith(prefix) && base.indexOf('.') < 0 && actionMatches(permission));
}
/*******************************************************************************
** Whether the name's final segment is this term's action (or any action).
*******************************************************************************/
private boolean actionMatches(String permission)
{
return (action == null || action.equals(permission.substring(permission.lastIndexOf('.') + 1)));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public Set<String> expand(PolicyUniverse universe)
{
Set<String> rs = new java.util.TreeSet<>();
for(String name : universe.names())
{
if(grants(name))
{
rs.add(name);
}
}
return (rs);
}
}
}
@@ -0,0 +1,92 @@
/*
* 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.Collections;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
/*******************************************************************************
** The flat set of QQQ permission names that exist right now, which symbolic
** policy terms (full access, prefix resources) expand against. Evaluated
** lazily so a policy with only exact names never needs the instance.
**
** This is also the test seam: the golden vectors give a fixed flat universe.
*******************************************************************************/
@FunctionalInterface
public interface PolicyUniverse
{
/*******************************************************************************
** Every permission name that exists, in session form.
*******************************************************************************/
Set<String> names();
/*******************************************************************************
** A fixed universe.
*******************************************************************************/
static PolicyUniverse of(Collection<String> names)
{
Set<String> copy = new TreeSet<>(names);
return (() -> copy);
}
/*******************************************************************************
** A universe that asks the given one for its names at most once, and then
** answers with that same read-only set. For ONE resolution: a live universe
** (PolicyPermissions.universe) builds its names again on every names() call,
** and one resolution must cost one universe however many terms it expands.
** Nothing is read until the first names() call. Never keep the result after
** the resolution, or instance changes would be hidden. Not thread-safe: a
** resolution runs on one thread.
*******************************************************************************/
static PolicyUniverse once(PolicyUniverse universe)
{
return (new PolicyUniverse()
{
private Set<String> names;
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public Set<String> names()
{
if(names == null)
{
//////////////////////////////////////////////////////////////////
// a sorted set stays a sorted set, so a result built from the //
// whole universe (full access) is built in one pass //
//////////////////////////////////////////////////////////////////
Set<String> read = universe.names();
names = read instanceof SortedSet<String> sorted ? Collections.unmodifiableSortedSet(sorted) : Collections.unmodifiableSet(read);
}
return (names);
}
});
}
}
@@ -0,0 +1,108 @@
/*
* 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.processes;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
/*******************************************************************************
** Bounded resource pages for direct grants. Bulk edits never imply wildcards.
*******************************************************************************/
public final class AccessCatalog
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AccessCatalog()
{
}
/*******************************************************************************
** Search local QQQ metadata, not the Authentik directory. Return 25 resources
** with explicit permission IDs and stable ordering, plus application filters.
*******************************************************************************/
public static Map<String, Object> page(Collection<String> permissions, PolicyCatalog catalog, Map<String, String> query)
{
Map<String, PolicyCatalog.Resource> resources = new TreeMap<>();
Map<String, String> apps = new TreeMap<>();
Map<String, String> appLabels = new TreeMap<>();
for(var group : catalog.getGroups())
{
String app = group.app() == null ? "" : group.app();
appLabels.put(app, group.label());
for(var resource : group.resources())
{
resources.put(resource.name(), resource);
apps.put(resource.name(), app);
}
}
Map<String, List<Map<String, String>>> byResource = new TreeMap<>();
for(String permission : permissions)
{
int dot = permission.lastIndexOf('.');
String base = dot < 0 ? permission : permission.substring(0, dot);
String action = dot < 0 ? "Grant" : permission.substring(dot + 1);
byResource.computeIfAbsent(base, ignored -> new ArrayList<>()).add(Map.of("id", permission, "label", action));
}
String search = query.getOrDefault("search", "").toLowerCase(Locale.ROOT).strip();
String appFilter = query.getOrDefault("app", "*");
String kindFilter = query.getOrDefault("kind", "*");
List<Map<String, Object>> matches = new ArrayList<>();
for(var entry : byResource.entrySet())
{
var resource = resources.get(entry.getKey());
String label = resource == null ? entry.getKey() : resource.label();
String kind = resource == null ? "OTHER" : resource.kind().name();
String app = apps.getOrDefault(entry.getKey(), "");
if(!appFilter.equals("*") && !appFilter.equals(app) || !kindFilter.equals("*") && !kindFilter.equals(kind))
{
continue;
}
if(!(entry.getKey() + " " + label + " " + entry.getValue()).toLowerCase(Locale.ROOT).contains(search))
{
continue;
}
entry.getValue().sort(java.util.Comparator.comparing(option -> option.get("id")));
matches.add(Map.of("name", entry.getKey(), "label", label, "kind", kind, "app", app, "permissions", entry.getValue()));
}
int requested;
try
{
requested = Math.max(0, Integer.parseInt(query.getOrDefault("page", "0")));
}
catch(NumberFormatException e)
{
requested = 0;
}
int page = Math.min(requested, Math.max(0, (matches.size() - 1) / 25));
int start = page * 25;
appLabels.putIfAbsent("", PolicyCatalog.OTHER);
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", matches.subList(start, Math.min(start + 25, matches.size())));
result.put("total", matches.size());
result.put("page", page);
result.put("pageSize", 25);
result.put("apps", appLabels.entrySet().stream().map(entry -> Map.of("id", entry.getKey(), "label", entry.getValue())).toList());
return (result);
}
}
@@ -0,0 +1,250 @@
/*
* 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.processes;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
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.client.AuthentikTables;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Metadata-driven grant checkboxes and server-searched assignment selectors.
** These produce ordinary admin requests; the widget never writes to Authentik.
*******************************************************************************/
public final class AccessEditorWidget
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AccessEditorWidget()
{
}
/*******************************************************************************
** Processes with structured access fields.
*******************************************************************************/
public static boolean supports(AuthentikProcesses.Def def)
{
return (def.fields().stream().anyMatch(f -> List.of("permissions", "appAccess", "parentIds", "groupIds", "userIds").contains(f.getName())));
}
/*******************************************************************************
** Each process has its own immutable editor configuration.
*******************************************************************************/
public static String name(AuthentikQBitConfig config, AuthentikProcesses.Def def)
{
return (config.applyPrefix(def.key() + "AccessEditor"));
}
/*******************************************************************************
** Register a widget with the same permission as its process.
*******************************************************************************/
public static QWidgetMetaData metadata(AuthentikQBitConfig config, AuthentikProcesses.Def def)
{
return (new QWidgetMetaData().withName(name(config, def)).withLabel("Permissions and assignments")
.withType("AUTHENTIK_ACCESS_EDITOR").withGridColumns(12).withIsCard(false)
.withCodeReference(new QCodeReference(Renderer.class)).withPermissionRules(def.action().getPermission().rules())
.withDefaultValue("backendName", config.getApiBackendName()).withDefaultValue("processKey", def.key()));
}
/*******************************************************************************
** Load live initial grants. Inherited permissions remain separate from edits.
*******************************************************************************/
public static Data load(AuthentikQBitConfig config, AuthentikProcesses.Def def, String pk) throws QException
{
AuthentikRuntime runtime = AuthentikRuntime.forBackend(config.getApiBackendName());
runtime.getGuard().authorize(def.action());
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName());
GroupGraph graph = util.groupGraph();
GroupGraph.Node node = null;
boolean userAccess = def.action() == com.kingsrook.qbits.authentik.admin.AdminAction.EDIT_USER_PERMISSIONS;
if(userAccess)
{
if(pk == null || !pk.matches("[1-9][0-9]*"))
{
throw (new QUserFacingException("Select a user and open the editor again."));
}
var user = util.getObject(AuthentikTables.get(AuthentikTables.USER), pk);
if(user == null || !runtime.getGuard().isUserInScope(user))
{
throw (new QUserFacingException("That user is unavailable."));
}
var group = AuthentikAdmin.forBackend(config.getApiBackendName()).userAccessGroup(pk);
node = group == null ? null : graph.byPk(group.getString("pk"));
}
else if(def.prefill())
{
if(pk == null || graph.byPk(pk) == null)
{
throw (new QUserFacingException("Select a group or application role and open the editor again."));
}
node = graph.byPk(pk);
}
List<Map<String, Object>> fields = new ArrayList<>();
for(var field : def.fields())
{
String key = field.getName();
if(!List.of("permissions", "appAccess", "parentIds", "groupIds", "userIds").contains(key))
{
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
item.put("name", key);
List<Map<String, String>> selected = new ArrayList<>();
if(key.equals("permissions"))
{
item.put("label", "Direct permissions");
item.put("catalogWidget", name(config, def));
if(node != null)
{
node.permissions().forEach(value -> selected.add(Map.of("id", value, "label", value)));
var inherited = new EffectivePermissionResolver(graph, runtime.getPermissionCatalog()).resolveGroups(node.parentPks());
item.put("inherited", inherited.grantedBy());
///////////////////////////////////////////////////////////////////////
// the group's own policy, resolved (Allow minus Deny) for display; //
// the Deny terms and the void state travel separately so the UI //
// never shows a denied permission as granted //
///////////////////////////////////////////////////////////////////////
EffectivePermissionResolver own = new EffectivePermissionResolver(graph, runtime.getPermissionCatalog());
item.put("policyPermissions", node.policy().resolve(own.universe()).stream().sorted().toList());
item.put("policyFullAccess", node.policy().hasFullAccess());
item.put("policyDeny", node.policy().denyLabels());
item.put("policyInvalid", node.policy().isVoid());
}
if(userAccess)
{
List<String> inheritedGroups = new ArrayList<>(util.groupGraphWithMembers().groupsOfUser(Integer.parseInt(pk)));
if(node != null)
{
inheritedGroups.remove(node.pk());
}
item.put("inherited", new EffectivePermissionResolver(graph, runtime.getPermissionCatalog()).resolveGroups(inheritedGroups).grantedBy());
}
}
else if(key.equals("appAccess"))
{
item.put("label", "Application sign-in access");
TreeSet<String> choices = new TreeSet<>();
choices.add(config.getAppKey());
graph.nodes().forEach(group -> choices.addAll(group.appAccess()));
for(var application : util.getAllPages("/core/applications/?ordering=name", 1000))
{
choices.add(application.getString("slug"));
}
if(node != null)
{
node.appAccess().forEach(value -> selected.add(Map.of("id", value, "label", value)));
}
item.put("choices", choices.stream().map(value -> Map.of("id", value, "label", value)).toList());
}
else
{
boolean users = key.equals("userIds");
item.put("label", users ? "Users" : key.equals("parentIds") ? "Inherit from groups and application roles" : "Groups and application roles");
item.put("source", config.applyTablePrefix(users ? AuthentikTables.USER : AuthentikTables.GROUP));
if(key.equals("parentIds") && node != null)
{
for(String parentPk : node.parentPks())
{
GroupGraph.Node parent = graph.byPk(parentPk);
selected.add(Map.of("id", parentPk, "label", parent == null ? parentPk : parent.name()));
}
}
}
item.put("selected", selected);
fields.add(item);
}
return (new Data(fields));
}
/*******************************************************************************
** Render the configured editor; process values supply only the selected key.
*******************************************************************************/
public static class Renderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Resolve configuration on the server and enforce the action guard.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
var defaults = ((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues();
AuthentikQBitConfig config = AuthentikRuntime.forBackend(String.valueOf(defaults.get("backendName"))).getConfig();
String key = String.valueOf(defaults.get("processKey"));
AuthentikProcesses.Def def = AuthentikProcesses.definitions(config).stream().filter(d -> d.key().equals(key)).findFirst()
.orElseThrow(() -> new QUserFacingException("This access editor is no longer available."));
if(input.getQueryParams() != null && "true".equals(input.getQueryParams().get("accessCatalog")))
{
AuthentikRuntime runtime = AuthentikRuntime.forBackend(config.getApiBackendName());
runtime.getGuard().authorize(def.action());
return (new RenderWidgetOutput(new Data(List.of(AccessCatalog.page(runtime.getPermissionCatalog(), runtime.getPolicyCatalog(), input.getQueryParams())))));
}
return (new RenderWidgetOutput(load(config, def, input.getQueryParams() == null ? null : input.getQueryParams().get("accessEditorPk"))));
}
}
/*******************************************************************************
** Serializable metadata for the frontend; choices are not authorization.
*******************************************************************************/
public static class Data extends QWidgetData
{
private final List<Map<String, Object>> fields;
/*******************************************************************************
** Frontend renderer type.
*******************************************************************************/
@Override
public String getType()
{
return ("AUTHENTIK_ACCESS_EDITOR");
}
/*******************************************************************************
** Create the editor payload.
*******************************************************************************/
public Data(List<Map<String, Object>> fields)
{
this.fields = fields;
}
/*******************************************************************************
** Fields rendered by the access editor.
*******************************************************************************/
public List<Map<String, Object>> getFields()
{
return (fields);
}
}
}
@@ -37,6 +37,7 @@ import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
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.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
@@ -159,7 +160,7 @@ public final class AuthentikProcesses
new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true), reason), null));
defs.add(new Def("createServiceAccount", "Create Service Account", AdminAction.CREATE_SERVICE_ACCOUNT, Kind.CREATE, users, "person_add", false, List.of(
new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("groups", QFieldType.TEXT).withLabel("Groups (names, one per line)"),
hidden("groupIds"),
new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(365), reason),
"Creates a service account with one app password, shown once."));
@@ -167,13 +168,21 @@ public final class AuthentikProcesses
new QFieldMetaData("username", QFieldType.STRING).withLabel("Username").withIsRequired(true),
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name").withIsRequired(true),
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email").withIsRequired(true),
new QFieldMetaData("groups", QFieldType.TEXT).withLabel("Groups (names, one per line)"),
hidden("groupIds"),
new QFieldMetaData("sendRecovery", QFieldType.BOOLEAN).withLabel("Email them a link to set their own password").withDefaultValue(true),
reason), "No password is set here. Authentik emails the person a link to set their own."));
defs.add(new Def("inviteUser", "Invite User", AdminAction.INVITE_USER, Kind.CREATE, users, "mail", false, List.of(
List<QFieldMetaData> invitationFields = new ArrayList<>(List.of(
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email").withIsRequired(true),
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"),
reason), "Authentik emails a single-use sign-up invitation that expires in 7 days."));
reason));
if(config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.MIRROR)
&& config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.WEBHOOK)
&& config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.MEMBERSHIP))
{
invitationFields.add(2, hidden("groupIds"));
}
defs.add(new Def("inviteUser", "Invite User", AdminAction.INVITE_USER, Kind.CREATE, users, "mail", false, invitationFields,
"Authentik emails a single-use sign-up invitation that expires in 7 days. Selected groups/roles are saved for assignment after verified enrollment."));
QFieldMetaData otherAttributes = new QFieldMetaData(AuthentikAdmin.OTHER_ATTRIBUTES, QFieldType.TEXT).withLabel("Other attributes (JSON; leave blank to keep them)");
defs.add(new Def("editUser", "Edit User", AdminAction.EDIT_USER, Kind.USERS, users, "edit", true, List.of(
new QFieldMetaData("username", QFieldType.STRING).withLabel("Username"),
@@ -183,16 +192,18 @@ public final class AuthentikProcesses
otherAttributes,
hidden("expectedFingerprint"), reason), "Change a person's username, name, email, path or other attributes. "
+ "Attributes starting with qqq.io/ or goauthentik.io/ are set elsewhere; editing attributes needs authentik.audit.read."));
defs.add(new Def("editUserPermissions", "Edit User Permissions", AdminAction.EDIT_USER_PERMISSIONS, Kind.USERS, users, "admin_panel_settings", true,
List.of(hidden("permissions"), hidden("expectedFingerprint"), reason), "Edit grants that apply only to this user. Inherited permissions must be changed at their source."));
defs.add(new Def("deactivateUser", "Deactivate User", AdminAction.DEACTIVATE_USER, Kind.USERS, users, "block", false, List.of(
new QFieldMetaData("revokeAppPasswords", QFieldType.BOOLEAN).withLabel("Also revoke their app passwords").withDefaultValue(false),
typed, reason), "Deactivating ends their sessions immediately and deletes nothing. Type the username to confirm (when you select several people, type the username of the first one)."));
defs.add(new Def("deleteUser", "Delete User", AdminAction.DELETE_USER, Kind.USERS, users, "delete_forever", false, List.of(typed, reason),
"Deletes the account in Authentik permanently. To keep the account and its history, use Deactivate User instead."));
defs.add(new Def("reactivateUser", "Reactivate User", AdminAction.REACTIVATE_USER, Kind.USERS, users, "check_circle", false, List.of(reason), null));
defs.add(new Def("addToGroup", "Add To Group", AdminAction.ADD_MEMBER, Kind.USERS, users, "group_add", false, List.of(group,
defs.add(new Def("addToGroup", "Add To Group Or Role", AdminAction.ADD_MEMBER, Kind.USERS, users, "group_add", false, List.of(group,
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason),
"You can only add people to a group whose permissions you hold yourself."));
defs.add(new Def("removeFromGroup", "Remove From Group", AdminAction.REMOVE_MEMBER, Kind.USERS, users, "group_remove", false, List.of(group,
defs.add(new Def("removeFromGroup", "Remove From Group Or Role", AdminAction.REMOVE_MEMBER, Kind.USERS, users, "group_remove", false, List.of(group,
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null));
defs.add(new Def("sendPasswordReset", "Send Password Reset Email", AdminAction.SEND_RECOVERY_EMAIL, Kind.USERS, users, "lock_reset", false, List.of(reason),
"Authentik emails the person a link. You never see the link."));
@@ -204,21 +215,25 @@ public final class AuthentikProcesses
defs.add(new Def("createGroup", "Create Group", AdminAction.CREATE_GROUP, Kind.CREATE, groups, "group_add", false, List.of(
new QFieldMetaData("name", QFieldType.STRING).withLabel("Group Name").withIsRequired(true),
new QFieldMetaData("parents", QFieldType.TEXT).withLabel("Parent groups (names, one per line)"),
new QFieldMetaData("permissions", QFieldType.TEXT).withLabel("QQQ permissions (one per line)"),
new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"),
hidden("parentIds"),
hidden("permissions"),
hidden("appAccess"),
reason), "A group's QQQ permissions apply to everyone in it, and to groups below it."));
defs.add(new Def("createApplicationRole", "Create Application Role", AdminAction.CREATE_GROUP, Kind.CREATE, groups, "badge", false, List.of(
new QFieldMetaData("name", QFieldType.STRING).withLabel("Role Name").withIsRequired(true),
hidden("parentIds"), hidden("permissions"), reason),
"A reusable QQQ application role. Assign users as members, or assign this role as a parent of a group."));
defs.add(new Def("editGroup", "Edit Group", AdminAction.EDIT_GROUP, Kind.GROUPS, groups, "edit", true, List.of(
new QFieldMetaData("name", QFieldType.STRING).withLabel("Group Name"),
new QFieldMetaData("parents", QFieldType.TEXT).withLabel("Parent groups (names, one per line; none to clear)"),
hidden("parentIds"),
otherAttributes,
hidden("expectedFingerprint"), reason), "Rename a group, change its parents or its other attributes."));
defs.add(new Def("deleteGroup", "Delete Group", AdminAction.DELETE_GROUP, Kind.GROUPS, groups, "delete_forever", false, List.of(
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Type the group name to confirm").withIsRequired(true), reason),
"Deletes the group in Authentik permanently, with its permissions, app access and policy. Remove its members and child groups first."));
defs.add(new Def("editPermissions", "Edit Permissions And App Access", AdminAction.EDIT_PERMISSIONS, Kind.GROUPS, groups, "admin_panel_settings", true, List.of(
new QFieldMetaData("permissions", QFieldType.TEXT).withLabel("QQQ permissions (one per line)"),
new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"),
hidden("permissions"),
hidden("appAccess"),
hidden("expectedFingerprint"), reason),
"You can only add permissions you hold yourself. Adding authentik.* needs a second person to approve."));
String policyHelp = "Tick what members of this group, and of the groups below it, may do. The document is what is saved to Authentik. "
@@ -230,10 +245,10 @@ public final class AuthentikProcesses
+ "Review shows the generated document and what changes before anything is saved."));
defs.add(new Def("editPolicyRecord", "Edit Policy", AdminAction.SAVE_POLICY, Kind.POLICIES, AuthentikLocalTables.POLICY, "policy", true, policyFields, policyHelp));
defs.add(new Def("addMembers", "Add Members", AdminAction.ADD_MEMBER, Kind.GROUP_USERNAMES, groups, "person_add", false, List.of(
new QFieldMetaData("usernames", QFieldType.TEXT).withLabel("Usernames (one per line)").withIsRequired(true),
hidden("userIds"),
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null));
defs.add(new Def("removeMembers", "Remove Members", AdminAction.REMOVE_MEMBER, Kind.GROUP_USERNAMES, groups, "person_remove", false, List.of(
new QFieldMetaData("usernames", QFieldType.TEXT).withLabel("Usernames (one per line)").withIsRequired(true),
hidden("userIds"),
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null));
defs.add(new Def("cancelInvitation", "Cancel Invitation", AdminAction.CANCEL_INVITATION, Kind.INVITATIONS, "invitation", "cancel", false, List.of(reason), null));
@@ -241,6 +256,9 @@ public final class AuthentikProcesses
defs.add(new Def("reject", "Reject", null, Kind.APPROVALS, AuthentikLocalTables.APPROVAL_REQUEST, "close", false, List.of(), null));
defs.removeIf(def -> def.action() != null && !config.hasFeature(def.action().getFeature()));
defs.removeIf(def -> def.action() == AdminAction.EDIT_USER_PERMISSIONS
&& (!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.USERS)
|| !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.MEMBERSHIP)));
defs.removeIf(def -> (def.action() == AdminAction.DELETE_USER && !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.USERS))
|| (def.action() == AdminAction.DELETE_GROUP && !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS)));
if((!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS) || !config.getTwoPersonApproval())
@@ -334,7 +352,7 @@ public final class AuthentikProcesses
{
process.setMinInputRecords(1);
process.setMaxInputRecords(def.action() == AdminAction.EDIT_USER || def.action() == AdminAction.EDIT_GROUP || def.action() == AdminAction.EDIT_PERMISSIONS
|| def.action() == AdminAction.SAVE_POLICY || def.action() == AdminAction.DELETE_USER || def.action() == AdminAction.DELETE_GROUP ? 1 : AuthentikAdmin.MAX_BULK);
|| def.action() == AdminAction.EDIT_USER_PERMISSIONS || def.action() == AdminAction.SAVE_POLICY || def.action() == AdminAction.DELETE_USER || def.action() == AdminAction.DELETE_GROUP ? 1 : AuthentikAdmin.MAX_BULK);
}
default ->
{
@@ -356,6 +374,10 @@ public final class AuthentikProcesses
{
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", config.applyPrefix(PolicyEditorWidget.NAME)));
}
if(AccessEditorWidget.supports(def))
{
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", AccessEditorWidget.name(config, def)));
}
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
process.withStep(0, details);
if(def.prefill())
@@ -405,6 +427,7 @@ public final class AuthentikProcesses
case REMOVE_MEMBER -> w("membership will be removed", "memberships will be removed", "membership was removed", "memberships were removed");
case CREATE_GROUP -> w("group will be created", "groups will be created", "group was created", "groups were created");
case EDIT_GROUP -> w("group will be updated", "groups will be updated", "group was updated", "groups were updated");
case EDIT_USER_PERMISSIONS -> w("user permissions will be updated", "users permissions will be updated", "user permissions were updated", "users permissions were updated");
case EDIT_PERMISSIONS -> w("group's permissions will be updated", "groups' permissions will be updated", "group's permissions were updated", "groups' permissions were updated");
case DELETE_USER -> w("user will be deleted", "users will be deleted", "user was deleted", "users were deleted");
case DELETE_GROUP -> w("group will be deleted", "groups will be deleted", "group was deleted", "groups were deleted");
@@ -473,6 +496,14 @@ public final class AuthentikProcesses
case GROUP_USERNAMES ->
{
String groupPk = String.valueOf(record.getValue("pk"));
for(String userId : AuthentikSafety.parseList(input.getValueString("userIds")))
{
if(!userId.matches("[1-9][0-9]*"))
{
throw (new QUserFacingException("Select a valid user."));
}
rs.add(withParams(AdminRequest.of(def.action(), groupPk + ":" + userId, reason), def, input));
}
for(String username : AuthentikSafety.parseList(input.getValueString("usernames")))
{
Integer userPk = admin.userPkByUsername(username);
@@ -521,6 +552,17 @@ public final class AuthentikProcesses
}
}
}
for(String legacy : List.of("parents", "groups"))
{
if(input.getValue(legacy) != null)
{
request.with(legacy, input.getValueString(legacy));
}
}
if("createApplicationRole".equals(def.key()))
{
request.with("applicationRole", true);
}
for(QFieldMetaData field : def.fields())
{
if(field.getName().equals("reason"))
@@ -601,15 +643,23 @@ public final class AuthentikProcesses
QRecord record = input.getRecords().get(0);
AuthentikAdmin admin = admin(input);
String pk = def.kind() == Kind.POLICIES ? record.getValueString("groupPk") : String.valueOf(record.getValue("pk"));
output.addValue("accessEditorPk", pk);
switch(def.action())
{
case EDIT_USER_PERMISSIONS ->
{
JSONObject group = admin.userAccessGroup(pk);
output.addValue("expectedFingerprint", group == null ? "absent" : AuthentikSafety.groupFingerprint(group, admin.getUtil().getRuntime().getConfig().getPolicyFormat().attribute()));
List<String> permissions = group == null ? null : AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS);
output.addValue("permissions", permissions == null ? "" : String.join("\n", permissions));
}
case SAVE_POLICY ->
{
JSONObject group = admin.getUtil().getApiJsonOrNull("/core/groups/" + AuthentikAdmin.safeKey(pk) + "/?include_users=true&include_parents=true&include_children=true");
output.addValue(PolicyEditorWidget.GROUP_PK_PARAM, pk);
if(group != null)
{
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group, admin.getUtil().getRuntime().getConfig().getPolicyFormat().attribute()));
}
}
case EDIT_USER ->
@@ -620,7 +670,7 @@ public final class AuthentikProcesses
output.addValue("username", user.optString("username"));
if(canSeeAttributes())
{
output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(user.optJSONObject("attributes")).toString(2));
output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(user.optJSONObject("attributes"), admin.getUtil().getRuntime().getConfig().getPolicyFormat().attribute()).toString(2));
}
output.addValue("name", user.optString("name"));
output.addValue("email", user.optString("email"));
@@ -633,12 +683,12 @@ public final class AuthentikProcesses
JSONObject group = admin.getUtil().getApiJsonOrNull("/core/groups/" + pk + "/?include_users=true&include_parents=true&include_children=true");
if(group != null)
{
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group, admin.getUtil().getRuntime().getConfig().getPolicyFormat().attribute()));
if(def.action() == AdminAction.EDIT_GROUP)
{
if(canSeeAttributes())
{
output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(group.optJSONObject("attributes")).toString(2));
output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(group.optJSONObject("attributes"), admin.getUtil().getRuntime().getConfig().getPolicyFormat().attribute()).toString(2));
}
output.addValue("name", group.optString("name"));
JSONArray parents = group.optJSONArray("parents_obj");
@@ -19,6 +19,7 @@ package com.kingsrook.qbits.authentik.processes;
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
/*******************************************************************************
@@ -43,11 +44,11 @@ public interface TokenKeyDelivery
/*******************************************************************************
** Current adapter; null restores the safe reflection adapter.
** Current adapter; null restores the core response-only adapter.
*******************************************************************************/
static void set(TokenKeyDelivery delivery)
{
Holder.delivery = delivery == null ? new ReflectiveDelivery() : delivery;
Holder.delivery = delivery == null ? new CoreDelivery() : delivery;
}
@@ -90,7 +91,7 @@ public interface TokenKeyDelivery
*******************************************************************************/
final class Holder
{
private static volatile TokenKeyDelivery delivery = new ReflectiveDelivery();
private static volatile TokenKeyDelivery delivery = new CoreDelivery();
/*******************************************************************************
** Utility holder.
@@ -103,56 +104,18 @@ public interface TokenKeyDelivery
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7)
** Reflection keeps RC.2 binary-compatible and never falls back to values.
** Core response-only delivery; credentials never enter process values.
*******************************************************************************/
final class ReflectiveDelivery implements TokenKeyDelivery
final class CoreDelivery implements TokenKeyDelivery
{
/*******************************************************************************
** Check the complete core contract before any irreversible key mutation.
*******************************************************************************/
@Override
public boolean available()
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
type.getConstructor();
for(String field : new String[] { "Label", "Value", "Masked", "UsageText" })
{
type.getMethod("with" + field, String.class);
}
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type);
return (true);
}
catch(ReflectiveOperationException e)
{
return (false);
}
}
/*******************************************************************************
** Add to core's response-only channel; never include failures' contents.
** Add to core's response-only channel.
*******************************************************************************/
@Override
public void deliver(RunBackendStepOutput output, OneTimeKey key)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
Object secret = type.getConstructor().newInstance();
type.getMethod("withLabel", String.class).invoke(secret, key.label());
type.getMethod("withValue", String.class).invoke(secret, key.value());
type.getMethod("withMasked", String.class).invoke(secret, key.masked());
type.getMethod("withUsageText", String.class).invoke(secret, key.usageText());
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type).invoke(output, secret);
}
catch(ReflectiveOperationException e)
{
throw (new IllegalStateException("One-time output unavailable"));
}
output.addOneTimeSecret(new OneTimeSecret().withLabel(key.label()).withValue(key.value())
.withMasked(key.masked()).withUsageText(key.usageText()));
}
}
}
@@ -20,8 +20,8 @@ package com.kingsrook.qbits.authentik.security;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
@@ -65,6 +65,21 @@ public class AuthentikGuard
/*******************************************************************************
** Health probes expose only safe outcomes, including to the central runner.
*******************************************************************************/
public void checkHealth() throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
requireUser(session);
if(!has(session, IdentityPermission.ADMIN) && (session.getPermissions() == null || !session.getPermissions().contains("health.read")))
{
throw (new QPermissionDeniedException("Health read permission is required."));
}
}
/*******************************************************************************
** Check that the current session may read a table.
*******************************************************************************/
@@ -138,6 +153,11 @@ public class AuthentikGuard
{
throw (new QPermissionDeniedException("Creating a service account also needs authentik.users.manage."));
}
if(action == AdminAction.EDIT_USER_PERMISSIONS && (!config.hasFeature(AuthentikFeature.USERS)
|| !config.hasFeature(AuthentikFeature.MEMBERSHIP) || !has(session, IdentityPermission.MEMBERSHIPS_MANAGE)))
{
throw (new QPermissionDeniedException("Direct user grants require user and membership management to be enabled, and authentik.memberships.manage."));
}
return (new WriteAuthorization(action.name(), operatorEmail()));
}
@@ -0,0 +1,74 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.qbits.authentik.security;
import java.net.URI;
/*******************************************************************************
** Source-instance boundary after native OAuth signature/issuer verification.
** The host declares the public URL of its API-backed Authentik installation.
** Core still owns the exact provider/audience allowlist; this adds no trust.
*******************************************************************************/
public final class AuthentikIssuer
{
/***************************************************************************
** Utility class.
***************************************************************************/
private AuthentikIssuer()
{
}
/***************************************************************************
** Accept the configured public origin and deployment path only. Both global
** and per-provider issuer paths are supported within that installation.
** Reject ambiguous URL spellings rather than interpreting them differently
** from the provider or proxy. Never fetch a URL supplied by a claim.
***************************************************************************/
public static boolean matches(String publicUrl, Object verifiedIssuer)
{
if(publicUrl == null || !(verifiedIssuer instanceof String issuer) || issuer.isBlank()
|| issuer.length() > 2048 || publicUrl.length() > 2048)
{
return (false);
}
try
{
URI base = URI.create(publicUrl);
URI source = URI.create(issuer);
if(!canonical(base) || !canonical(source) || !base.getScheme().equalsIgnoreCase(source.getScheme())
|| !base.getHost().equalsIgnoreCase(source.getHost()) || port(base) != port(source))
{
return (false);
}
String root = base.getPath().replaceAll("/+$", "");
String path = source.getPath().replaceAll("/+$", "");
return (path.equals(root) || path.startsWith(root + "/"));
}
catch(IllegalArgumentException malformed)
{
return (false);
}
}
/***************************************************************************
** Only unambiguous absolute HTTP(S) origins and unescaped paths are allowed.
***************************************************************************/
private static boolean canonical(URI uri)
{
return (uri.getScheme() != null && (uri.getScheme().equalsIgnoreCase("https") || uri.getScheme().equalsIgnoreCase("http"))
&& uri.getHost() != null && uri.getPort() <= 65535 && uri.getPort() != 0 && uri.getRawUserInfo() == null
&& uri.getRawQuery() == null && uri.getRawFragment() == null && uri.getPath() != null
&& uri.getPath().equals(uri.getRawPath()) && !uri.getPath().contains("//")
&& uri.getPath().equals(uri.normalize().getPath()));
}
/***************************************************************************
** Explicit default ports and implicit default ports denote the same origin.
***************************************************************************/
private static int port(URI uri)
{
return (uri.getPort() == -1 ? (uri.getScheme().equalsIgnoreCase("https") ? 443 : 80) : uri.getPort());
}
}
@@ -0,0 +1,74 @@
/*
* 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.security;
import java.util.Optional;
import java.util.UUID;
import org.json.JSONObject;
/*******************************************************************************
** Immutable source identity carried by the managed profile scope mapping.
** This parser does NOT authenticate a token. Only the OAuth module may supply
** claims after signature, issuer, audience and lifetime verification. Mutable
** sub/email/username claims are never used as an alternative identity source.
******************************************************************************/
public record AuthentikSessionIdentity(int pk, String uuid)
{
public static final String CLAIM = "qqq_identity";
/***************************************************************************
** Canonical identity shared with the mirror and persistent session binding.
***************************************************************************/
public AuthentikSessionIdentity
{
if(pk < 1 || uuid == null || !UUID.fromString(uuid).toString().equalsIgnoreCase(uuid))
{
throw (new IllegalArgumentException("A positive Authentik pk and canonical UUID are required."));
}
uuid = UUID.fromString(uuid).toString();
}
/***************************************************************************
** Strict claim parsing: strings, floating point values, overflow, partial
** objects and unknown shapes cannot establish an identity. No coercion.
***************************************************************************/
public static Optional<AuthentikSessionIdentity> fromVerifiedClaims(JSONObject claims)
{
if(claims == null || !(claims.opt(CLAIM) instanceof JSONObject identity))
{
return (Optional.empty());
}
Object pk = identity.opt("pk");
Object uuid = identity.opt("uuid");
if(!(pk instanceof Integer || pk instanceof Long) || !(uuid instanceof String text))
{
return (Optional.empty());
}
long number = ((Number) pk).longValue();
if(number < 1 || number > Integer.MAX_VALUE)
{
return (Optional.empty());
}
try
{
return (Optional.of(new AuthentikSessionIdentity((int) number, text)));
}
catch(IllegalArgumentException malformed)
{
return (Optional.empty());
}
}
}
@@ -17,11 +17,9 @@
package com.kingsrook.qbits.authentik.security;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
@@ -39,6 +37,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
@@ -262,7 +261,7 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7): intersect, never grant.
** Intersect permissions for direct session consumers and attach core scope.
*******************************************************************************/
public static void narrow(QSession session, TokenScope scope)
{
@@ -272,8 +271,8 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** Keep only permitted bases and read operations, then attach core's scope
** when available. Management hasAccess permissions are not read operations.
** Keep conservative permission narrowing for direct session consumers, then
** attach core scope for framework enforcement. Management is not read-only.
*******************************************************************************/
private static void narrow(QInstance instance, QSession session, TokenScope scope)
{
@@ -410,10 +409,6 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** Optional core bridge. An incompatible installed contract fails closed;
** an absent class uses the permission intersection above.
*******************************************************************************/
/*******************************************************************************
** An empty scope dimension means "not restricted" (null for core).
*******************************************************************************/
@@ -425,51 +420,18 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1168, removeBy=4.1.0-RC.7): until the qbit's QQQ
** version has QCredentialScope (qqq#1153), narrow() above enforces the scope
** by intersecting the session's permissions; this attaches the core scope by
** reflection when it exists. Replace both with a direct withCredentialScope.
** Attach the released core scope contract; a revoked token allows nothing.
*******************************************************************************/
private static void attachCoreScope(QSession session, TokenScope scope)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.session.QCredentialScope");
Object value = type.getConstructor().newInstance();
///////////////////////////////////////////////////////////////////////
// core treats a null set as unrestricted and an empty set as //
// "nothing": an unlisted dimension must therefore be passed as null //
///////////////////////////////////////////////////////////////////////
Map<String, Object> fields = new java.util.LinkedHashMap<>();
fields.put("CredentialId", scope.identifier());
fields.put("ReadOnly", scope.readOnly());
fields.put("Apps", nullIfEmpty(scope.apps()));
fields.put("Tables", nullIfEmpty(scope.tables()));
fields.put("Processes", nullIfEmpty(scope.processes()));
fields.put("Reports", nullIfEmpty(scope.reports()));
fields.put("Widgets", nullIfEmpty(scope.widgets()));
fields.put("OperationIds", nullIfEmpty(scope.operationIds()));
for(var field : fields.entrySet())
{
Method setter = Arrays.stream(type.getMethods()).filter(m -> m.getName().equals("with" + field.getKey()) && m.getParameterCount() == 1)
.findFirst().orElseThrow(NoSuchMethodException::new);
Object arg = field.getValue();
if(arg instanceof Set<?> set && List.class.isAssignableFrom(setter.getParameterTypes()[0]))
{
arg = List.copyOf(set);
}
setter.invoke(value, arg);
}
session.getClass().getMethod("withCredentialScope", type).invoke(session, value);
}
catch(ClassNotFoundException ignored)
{
// RC.2 has no credential scope; the stand-in above is active.
}
catch(ReflectiveOperationException | IllegalArgumentException e)
{
session.setPermissions(new TreeSet<>());
LOG.warn("The installed credential-scope contract is incompatible; denying credential permissions.");
}
boolean denied = scope.mode() == Mode.DENY_ALL;
session.withCredentialScope(new QCredentialScope().withCredentialId(scope.identifier())
.withReadOnly(denied || scope.readOnly())
.withApps(denied ? Set.of() : nullIfEmpty(scope.apps()))
.withTables(denied ? Set.of() : nullIfEmpty(scope.tables()))
.withProcesses(denied ? Set.of() : nullIfEmpty(scope.processes()))
.withReports(denied ? Set.of() : nullIfEmpty(scope.reports()))
.withWidgets(denied ? Set.of() : nullIfEmpty(scope.widgets()))
.withOperationIds(denied ? Set.of() : nullIfEmpty(scope.operationIds())));
}
}
@@ -0,0 +1,161 @@
/*
* 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.sync;
import java.sql.SQLException;
import java.util.Objects;
/*******************************************************************************
** One bounded batch for either the scheduled worker, ESB trigger, or inline
** fallback. Delivery is at least once; handlers must re-read live state and
** must not apply permission values from the hint itself.
*******************************************************************************/
public final class AuthentikApplyChangeEvents
{
private final AuthentikWebhookInbox inbox;
private final Handler handler;
/*******************************************************************************
** Mirror refresh plus session invalidation; either failure retries the hint.
*******************************************************************************/
@FunctionalInterface
public interface Handler
{
/***************************************************************************
** Read current Authentik state outside a database transaction. Use
** inbox.withClaim for local mirror/session-marker writes so a reclaimed
** worker cannot publish stale data. A failed ownership check must not
** perform any unfenced side effects.
***************************************************************************/
void apply(AuthentikWebhookEnvelope event, AuthentikWebhookInbox.Claim claim) throws Exception;
}
/*******************************************************************************
** Counts only state transitions actually committed by this worker.
*******************************************************************************/
public record Result(int succeeded, int retriedOrFailed, int lostLease, int deferred)
{
}
/*******************************************************************************
** Normal directory contention must not exhaust a durable event's attempts.
*******************************************************************************/
private static final class PublicationDeferred extends Exception
{
private static final long serialVersionUID = 1L;
}
/*******************************************************************************
** All callers share the same durable inbox implementation.
*******************************************************************************/
public AuthentikApplyChangeEvents(AuthentikWebhookInbox inbox, Handler handler)
{
this.inbox = Objects.requireNonNull(inbox);
this.handler = Objects.requireNonNull(handler);
}
/*******************************************************************************
** Host processing path: current-state refresh and inbox fencing share the
** configured database. Contention is retried, never acknowledged as applied.
*******************************************************************************/
public static AuthentikApplyChangeEvents forHost(com.kingsrook.qbits.authentik.AuthentikQBitConfig config,
AuthentikMirrorWorker.Publication publication) throws com.kingsrook.qqq.backend.core.exceptions.QException
{
var inbox = new AuthentikWebhookInbox(config, java.time.Duration.ofMinutes(15));
var worker = new AuthentikMirrorWorker(config, com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(config.getApiBackendName()));
var invitations = new AuthentikInvitationEvents(com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(config.getApiBackendName()),
new AuthentikInvitationAssignments(config));
return (new AuthentikApplyChangeEvents(inbox, (event, claim) ->
{
if(!invitations.apply(inbox, claim))
{
throw (new PublicationDeferred());
}
if("invitation_used".equals(event.action()))
{
// Consumption precedes account creation and has no enrollee to mirror.
return;
}
if(!worker.applyEvent(publication, inbox, claim))
{
throw (new PublicationDeferred());
}
}));
}
/*******************************************************************************
** Process at most fifty hints. Exceptions are intentionally not persisted
** or logged because upstream messages may contain credentials or PII.
*******************************************************************************/
public Result runBatch() throws SQLException
{
int succeeded = 0;
int failed = 0;
int lost = 0;
int deferred = 0;
for(var claim : inbox.claim(AuthentikWebhookInbox.MAX_BATCH))
{
if(!inbox.renew(claim))
{
lost++;
continue;
}
try
{
handler.apply(AuthentikWebhookEnvelope.fromStorage(claim.payload()), claim);
}
catch(PublicationDeferred contention)
{
if(inbox.defer(claim))
{
deferred++;
}
else
{
lost++;
}
continue;
}
catch(Exception failure)
{
if(inbox.fail(claim))
{
failed++;
}
else
{
lost++;
}
if(failure instanceof InterruptedException)
{
Thread.currentThread().interrupt();
break;
}
continue;
}
if(inbox.complete(claim))
{
succeeded++;
}
else
{
lost++;
}
}
return (new Result(succeeded, failed, lost, deferred));
}
}
@@ -0,0 +1,48 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.qbits.authentik.sync;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Wake processing after durable receipt. Polling recovers lost notifications;
** ESB carries no user profile, grants, credentials, or webhook payload.
*******************************************************************************/
public final class AuthentikEventDelivery
{
/***************************************************************************
** Utility class.
***************************************************************************/
private AuthentikEventDelivery()
{
}
/***************************************************************************
** Called only after inbox commit. A notification failure cannot roll back
** receipt; callers retain the scheduled recovery path.
***************************************************************************/
public static void dispatch(AuthentikQBitConfig config) throws Exception
{
if(!(QContext.getQSession() instanceof QSystemUserSession) || !config.hasFeature(AuthentikFeature.MIRROR)
|| !config.hasFeature(AuthentikFeature.WEBHOOK))
{
throw (new QPermissionDeniedException("Event delivery requires internal enabled synchronization."));
}
if(StringUtils.hasContent(config.getEsbDestinationName()))
{
com.kingsrook.qbits.authentik.esb.AuthentikEsbDelivery.publish(config);
}
else
{
// The request-time mapper reads the committed mirror on every request.
AuthentikApplyChangeEvents.forHost(config, (snapshot, transaction) -> { }).runBatch();
}
}
}
@@ -0,0 +1,552 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.qbits.authentik.sync;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.Instant;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikInvitationEvidence;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.json.JSONObject;
/*******************************************************************************
** Internal durable intent and at-most-once automatic grant attempts. This store
** does not authorize writes or verify events; the admin integration must do so
** before using it. An interrupted remote write requires review, never replay.
*******************************************************************************/
public final class AuthentikInvitationAssignments
{
private final AuthentikQBitConfig config;
private final AuthentikSyncLease.Connections connections;
private final String table;
/***************************************************************************
** Read-only facts for admin processing; suppress implicit PII in diagnostics.
***************************************************************************/
public record Intent(String reference, String invitationId, String status, Integer operatorPk, String operatorUuid,
String flow, String reason, Map<String, String> groups, Integer userPk, String userUuid, Instant createdAt, Instant expiresAt)
{
/***********************************************************************
** No mutable progress maps escape storage.
***********************************************************************/
public Intent
{
groups = Map.copyOf(groups);
}
/***********************************************************************
** Safe diagnostics.
***********************************************************************/
@Override
public String toString()
{
return ("AuthentikInvitationIntent[" + status + "]");
}
}
/***************************************************************************
** A persisted grant attempt can finish once and cannot be started again.
***************************************************************************/
public record Attempt(String reference, String groupId, String id)
{
}
/***************************************************************************
** Reuse the host provider rather than creating a private connection pool.
***************************************************************************/
public AuthentikInvitationAssignments(AuthentikQBitConfig config)
{
this(config, () -> ConnectionManager.getConnection((RDBMSBackendMetaData) QContext.getQInstance().getBackend(config.getLocalBackendName())));
}
/***************************************************************************
** Isolated database seam for the shared H2/PostgreSQL contracts.
***************************************************************************/
AuthentikInvitationAssignments(AuthentikQBitConfig config, AuthentikSyncLease.Connections connections)
{
this.config = config;
this.connections = connections;
table = config.applyTablePrefix(AuthentikSyncTables.INVITATION_ASSIGNMENT);
if(!table.matches("[A-Za-z][A-Za-z0-9_]*"))
{
throw (new IllegalArgumentException("Invalid invitation storage table."));
}
}
/***************************************************************************
** Create immutable intent before remote invitation/email side effects. The
** admin caller must have validated and audited the operator's selected groups.
***************************************************************************/
public String create(Integer operatorPk, String operatorUuid, String flow, String reason, List<String> groupIds) throws Exception
{
guard();
if(operatorPk == null || operatorPk < 1 || flow == null || !flow.matches("[A-Za-z0-9_-]{1,100}")
|| reason == null || reason.isBlank() || reason.length() > 2048 || groupIds == null || groupIds.isEmpty() || groupIds.size() > 100)
{
throw (new IllegalArgumentException("Invalid invitation assignment intent."));
}
JSONObject groups = new JSONObject();
for(String group : groupIds)
{
String id = uuid(group);
if(groups.has(id))
{
throw (new IllegalArgumentException("Duplicate invitation group."));
}
groups.put(id, new JSONObject().put("state", "PENDING"));
}
JSONObject payload = new JSONObject().put("operatorPk", operatorPk).put("operatorUuid", uuid(operatorUuid))
.put("flow", flow).put("reason", reason).put("groups", groups);
String reference = UUID.randomUUID().toString();
try(var connection = connections.open(); var statement = connection.prepareStatement("INSERT INTO " + table
+ " (reference,scope_fingerprint,status,payload_json,created_at,expires_at) VALUES (?,?,'WAITING',?,?,?)"))
{
Instant now = AuthentikSyncLease.now(connection);
statement.setString(1, reference);
statement.setString(2, AuthentikSyncLease.scopeFingerprint(config));
statement.setString(3, payload.toString());
statement.setObject(4, now.atOffset(java.time.ZoneOffset.UTC));
statement.setObject(5, now.plus(Duration.ofDays(7)).atOffset(java.time.ZoneOffset.UTC));
statement.executeUpdate();
}
return (reference);
}
/***************************************************************************
** Attach the provider-created invitation once, before its email is sent.
***************************************************************************/
public boolean attach(String reference, String invitationId) throws Exception
{
String id = uuid(invitationId);
return (mutate(reference, row ->
{
if(!"WAITING".equals(row.status) || (row.invitationId != null && !row.invitationId.equals(id)))
{
return (false);
}
row.invitationId = id;
return (true);
}));
}
/***************************************************************************
** Persist verified consumption independently of enrollment delivery. The
** caller verifies the authoritative event and protected flow before this.
***************************************************************************/
public boolean recordConsumption(String reference, AuthentikInvitationEvidence.Consumption evidence) throws Exception
{
return (recordConsumption(null, reference, evidence));
}
/***************************************************************************
** Join the inbox ownership transaction; never commit or close its connection.
***************************************************************************/
boolean recordConsumption(Connection connection, String reference, AuthentikInvitationEvidence.Consumption evidence) throws Exception
{
guard();
if(evidence == null || evidence.createdAt() == null)
{
return (false);
}
JSONObject fact = new JSONObject().put("eventId", uuid(evidence.eventId()))
.put("invitationId", uuid(evidence.invitationId())).put("createdAt", evidence.createdAt().toString());
return (mutate(connection, reference, true, row -> java.util.Objects.equals(row.invitationId, evidence.invitationId())
&& withinWindow(row, evidence.createdAt()) && recordFact(row, "consumption", fact)));
}
/***************************************************************************
** Persist the verified new account and its live immutable UUID even when
** the consumption hint has not arrived. No assignment starts until paired.
***************************************************************************/
public boolean recordEnrollment(String reference, AuthentikInvitationEvidence.Enrollment evidence, String userUuid) throws Exception
{
return (recordEnrollment(null, reference, evidence, userUuid));
}
/***************************************************************************
** Enrollment and inbox ownership share the caller's transaction boundary.
***************************************************************************/
boolean recordEnrollment(Connection connection, String reference, AuthentikInvitationEvidence.Enrollment evidence, String userUuid) throws Exception
{
guard();
if(evidence == null || evidence.createdAt() == null || evidence.userPk() == null || evidence.userPk() < 1
|| !java.util.Objects.equals(reference, evidence.reference()))
{
return (false);
}
JSONObject fact = new JSONObject().put("eventId", uuid(evidence.eventId())).put("reference", uuid(evidence.reference()))
.put("userPk", evidence.userPk()).put("userUuid", uuid(userUuid)).put("createdAt", evidence.createdAt().toString());
return (mutate(connection, reference, true, row -> row.invitationId != null && withinWindow(row, evidence.createdAt())
&& recordFact(row, "enrollment", fact)));
}
/***************************************************************************
** First verified facts are immutable. Either arrival order atomically binds
** the same pair; duplicates cannot reset completed group attempts. Conflicting
** chronology is retained for review instead of becoming grant authority.
***************************************************************************/
private static boolean recordFact(Row row, String key, JSONObject fact)
{
if(!List.of("WAITING", "READY", "COMPLETE").contains(row.status))
{
return (false);
}
if(row.payload.has(key))
{
return (row.payload.getJSONObject(key).similar(fact));
}
if(!"WAITING".equals(row.status))
{
return (false);
}
row.payload.put(key, fact);
if(row.payload.has("consumption") && row.payload.has("enrollment"))
{
JSONObject consumed = row.payload.getJSONObject("consumption");
JSONObject enrolled = row.payload.getJSONObject("enrollment");
if(Instant.parse(enrolled.getString("createdAt")).isBefore(Instant.parse(consumed.getString("createdAt"))))
{
row.status = "REVIEW";
}
else
{
row.payload.put("binding", new JSONObject().put("userPk", enrolled.getInt("userPk")).put("userUuid", enrolled.getString("userUuid"))
.put("consumptionEvent", consumed.getString("eventId")).put("enrollmentEvent", enrolled.getString("eventId")));
row.status = "READY";
}
}
return (true);
}
/***************************************************************************
** Evidence belongs to this saved intent's bounded enrollment window.
***************************************************************************/
private static boolean withinWindow(Row row, Instant created)
{
return (!created.isBefore(row.createdAt) && !created.isAfter(row.expiresAt));
}
/***************************************************************************
** Bind one verified enrollment once. Evidence must have been fetched for
** this intent's flow; the caller also verifies the live user's immutable UUID.
** A conflicting replay cannot overwrite the first binding or reset progress.
***************************************************************************/
public boolean bind(String reference, AuthentikInvitationEvidence.Consumption consumed,
AuthentikInvitationEvidence.Enrollment enrolled, String userUuid) throws Exception
{
guard();
if(consumed == null || enrolled == null || consumed.createdAt() == null || enrolled.createdAt() == null)
{
return (false);
}
String identity = uuid(userUuid);
return (mutate(reference, row ->
{
if(consumed == null || enrolled == null || !reference.equals(enrolled.reference())
|| !java.util.Objects.equals(row.invitationId, consumed.invitationId()) || enrolled.userPk() == null || enrolled.userPk() < 1
|| consumed.createdAt().isBefore(row.createdAt) || enrolled.createdAt().isBefore(consumed.createdAt())
|| enrolled.createdAt().isAfter(row.expiresAt))
{
return (false);
}
JSONObject consumption = new JSONObject().put("eventId", uuid(consumed.eventId()))
.put("invitationId", uuid(consumed.invitationId())).put("createdAt", consumed.createdAt().toString());
JSONObject enrollment = new JSONObject().put("eventId", uuid(enrolled.eventId())).put("reference", uuid(enrolled.reference()))
.put("userPk", enrolled.userPk()).put("userUuid", identity).put("createdAt", enrolled.createdAt().toString());
return (recordFact(row, "consumption", consumption) && recordFact(row, "enrollment", enrollment));
}));
}
/***************************************************************************
** Persist IN_FLIGHT before calling Authentik. No automatic reset exists:
** losing the response or crashing requires review of the remote outcome.
***************************************************************************/
public Attempt begin(String reference, String groupId) throws Exception
{
return (begin(null, reference, groupId));
}
/***************************************************************************
** Persist the exclusive remote attempt while the event is still owned. Its
** ticket remains finishable after lease expiry; no other worker may replay it.
***************************************************************************/
public Attempt begin(String reference, String groupId, AuthentikWebhookInbox inbox, AuthentikWebhookInbox.Claim claim) throws Exception
{
guard();
var ticket = new java.util.concurrent.atomic.AtomicReference<Attempt>();
boolean owned = inbox.withClaim(claim, connection -> ticket.set(begin(connection, reference, groupId)));
return (owned ? ticket.get() : null);
}
/***************************************************************************
** Share the transition with standalone and inbox-owned transactions.
***************************************************************************/
private Attempt begin(Connection connection, String reference, String groupId) throws Exception
{
String group = uuid(groupId);
String attempt = UUID.randomUUID().toString();
boolean started = mutate(connection, reference, true, row ->
{
JSONObject groups = row.payload.getJSONObject("groups");
if(!"READY".equals(row.status) || !groups.has(group) || !"PENDING".equals(groups.getJSONObject(group).getString("state"))
|| groups.keySet().stream().anyMatch(key -> "IN_FLIGHT".equals(groups.getJSONObject(key).getString("state"))))
{
return (false);
}
groups.put(group, new JSONObject().put("state", "IN_FLIGHT").put("attempt", attempt));
return (true);
});
return (started ? new Attempt(reference, group, attempt) : null);
}
/***************************************************************************
** Record known success once; failure requires review and stops later groups.
***************************************************************************/
public boolean finish(Attempt attempt, boolean success) throws Exception
{
return (mutate(attempt.reference(), false, row ->
{
JSONObject groups = row.payload.getJSONObject("groups");
JSONObject group = groups.optJSONObject(attempt.groupId());
if(!"READY".equals(row.status) || group == null || !"IN_FLIGHT".equals(group.optString("state"))
|| !attempt.id().equals(group.optString("attempt")))
{
return (false);
}
group.put("state", success ? "APPLIED" : "REVIEW");
if(!success)
{
row.status = "REVIEW";
}
else if(groups.keySet().stream().allMatch(key -> "APPLIED".equals(groups.getJSONObject(key).getString("state"))))
{
row.status = "COMPLETE";
}
return (true);
}));
}
/***************************************************************************
** Stop pending work; an in-flight call cannot be promised cancelled.
***************************************************************************/
public boolean cancel(String reference) throws Exception
{
return (mutate(reference, false, row ->
{
if(!List.of("WAITING", "READY").contains(row.status))
{
return (false);
}
var groups = row.payload.getJSONObject("groups");
if(groups.keySet().stream().anyMatch(key -> "IN_FLIGHT".equals(groups.getJSONObject(key).getString("state"))))
{
return (false);
}
row.status = "CANCELLED";
return (true);
}));
}
/***************************************************************************
** Locate the intent for an administrative cancellation without email or
** mutable username matching. Scope and internal-session checks still apply.
***************************************************************************/
public Intent findByInvitation(String invitationId) throws Exception
{
guard();
String reference = null;
try(var connection = connections.open(); var query = connection.prepareStatement("SELECT reference FROM " + table
+ " WHERE invitation_id=? AND scope_fingerprint=?"))
{
query.setString(1, uuid(invitationId));
query.setString(2, AuthentikSyncLease.scopeFingerprint(config));
try(var result = query.executeQuery())
{
if(result.next())
{
reference = result.getString(1);
}
}
}
return (reference == null ? null : find(reference));
}
/***************************************************************************
** Return saved intent even after expiry for review, but never across scope.
***************************************************************************/
public Intent find(String reference) throws Exception
{
guard();
try(var connection = connections.open())
{
Row row = load(connection, uuid(reference), false);
if(row == null)
{
return (null);
}
var groups = new TreeMap<String, String>();
row.payload.getJSONObject("groups").keySet().forEach(key -> groups.put(key, row.payload.getJSONObject("groups").getJSONObject(key).getString("state")));
var binding = row.payload.optJSONObject("binding");
return (new Intent(reference, row.invitationId, row.status, row.payload.getInt("operatorPk"), row.payload.getString("operatorUuid"),
row.payload.getString("flow"), row.payload.getString("reason"), groups, binding == null ? null : binding.getInt("userPk"),
binding == null ? null : binding.getString("userUuid"), row.createdAt, row.expiresAt));
}
}
/***************************************************************************
** Serialize transitions in host SQL; no network work happens under this lock.
***************************************************************************/
private boolean mutate(String reference, java.util.function.Predicate<Row> change) throws Exception
{
return (mutate(reference, true, change));
}
/***************************************************************************
** Expiry stops new work, but cannot discard the outcome of an existing call.
***************************************************************************/
private boolean mutate(String reference, boolean requireUnexpired, java.util.function.Predicate<Row> change) throws Exception
{
guard();
try(var connection = connections.open())
{
connection.setAutoCommit(false);
try
{
if(!mutate(connection, reference, requireUnexpired, change))
{
connection.rollback();
return (false);
}
connection.commit();
return (true);
}
catch(Exception failure)
{
connection.rollback();
throw (failure);
}
}
}
/***************************************************************************
** Apply a local transition under the caller's lock/commit boundary. A null
** connection selects the standalone transaction used by admin operations.
***************************************************************************/
private boolean mutate(Connection connection, String reference, boolean requireUnexpired, java.util.function.Predicate<Row> change) throws Exception
{
guard();
if(connection == null)
{
return (mutate(reference, requireUnexpired, change));
}
if(connection.getAutoCommit())
{
throw (new SQLException("Invitation evidence requires a transaction."));
}
Row row = load(connection, uuid(reference), true);
if(row == null || (requireUnexpired && !row.expiresAt.isAfter(AuthentikSyncLease.now(connection))) || !change.test(row))
{
return (false);
}
try(var statement = connection.prepareStatement("UPDATE " + table + " SET invitation_id=?,status=?,payload_json=? WHERE reference=?"))
{
statement.setString(1, row.invitationId);
statement.setString(2, row.status);
statement.setString(3, row.payload.toString());
statement.setString(4, reference);
statement.executeUpdate();
}
return (true);
}
/***************************************************************************
** Retain settled intent for thirty days after expiry. Pending and ambiguous
** outcomes remain available for review; cleanup never resets grant progress.
***************************************************************************/
public int purgeSettled() throws SQLException, QPermissionDeniedException
{
guard();
try(var connection = connections.open(); var statement = connection.prepareStatement("DELETE FROM " + table
+ " WHERE scope_fingerprint=? AND status IN ('COMPLETE','CANCELLED') AND expires_at < ?"))
{
connection.setAutoCommit(true);
statement.setString(1, AuthentikSyncLease.scopeFingerprint(config));
statement.setObject(2, AuthentikSyncLease.now(connection).minus(Duration.ofDays(30)).atOffset(java.time.ZoneOffset.UTC));
return (statement.executeUpdate());
}
}
/***************************************************************************
** Read one scope-bound row, optionally under the transition lock.
***************************************************************************/
private Row load(Connection connection, String reference, boolean lock) throws SQLException
{
try(var statement = connection.prepareStatement("SELECT invitation_id,status,payload_json,created_at,expires_at FROM " + table
+ " WHERE reference=? AND scope_fingerprint=?" + (lock ? " FOR UPDATE" : "")))
{
statement.setString(1, reference);
statement.setString(2, AuthentikSyncLease.scopeFingerprint(config));
try(var result = statement.executeQuery())
{
if(!result.next())
{
return (null);
}
var row = new Row();
row.invitationId = result.getString(1);
row.status = result.getString(2);
row.payload = new JSONObject(result.getString(3));
row.createdAt = result.getTimestamp(4).toInstant();
row.expiresAt = result.getTimestamp(5).toInstant();
return (row);
}
}
}
/***************************************************************************
** Private mutable transaction snapshot, never returned to callers.
***************************************************************************/
private static final class Row
{
private String invitationId;
private String status;
private JSONObject payload;
private Instant createdAt;
private Instant expiresAt;
}
/***************************************************************************
** No interactive caller may create or advance deferred authorization state.
***************************************************************************/
private static void guard() throws QPermissionDeniedException
{
if(!(QContext.getQSession() instanceof QSystemUserSession))
{
throw (new QPermissionDeniedException("Invitation assignment storage requires the internal session."));
}
}
/***************************************************************************
** Strict canonical identifiers prevent ambiguous keys and path injection.
***************************************************************************/
private static String uuid(String value)
{
if(value == null || !value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))
{
throw (new IllegalArgumentException("Invalid invitation assignment identifier."));
}
return (value);
}
}
@@ -0,0 +1,123 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.qbits.authentik.sync;
import java.util.UUID;
import com.kingsrook.qbits.authentik.admin.AuthentikInvitationEvidence;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
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.session.QSystemUserSession;
import org.json.JSONObject;
/*******************************************************************************
** Re-fetch enrollment evidence and persist it under inbox ownership. This
** Paired intent is passed to the admin service for live authorization and
** audited, durable membership attempts.
*******************************************************************************/
public final class AuthentikInvitationEvents
{
private final AuthentikActionUtil client;
private final AuthentikInvitationAssignments assignments;
/***************************************************************************
** Share the configured directory and host database with the inbox processor.
***************************************************************************/
public AuthentikInvitationEvents(AuthentikActionUtil client, AuthentikInvitationAssignments assignments)
{
this.client = java.util.Objects.requireNonNull(client);
this.assignments = java.util.Objects.requireNonNull(assignments);
}
/***************************************************************************
** The envelope supplies only an event locator. All association, identity and
** chronology facts come from guarded authoritative API reads before locking.
** False means ownership was lost; rejected evidence must not be acknowledged.
***************************************************************************/
public boolean apply(AuthentikWebhookInbox inbox, AuthentikWebhookInbox.Claim claim) throws Exception
{
if(!(QContext.getQSession() instanceof QSystemUserSession))
{
throw (new QPermissionDeniedException("Invitation events require the internal session."));
}
var hint = AuthentikWebhookEnvelope.fromStorage(claim.payload());
if(!"invitation_used".equals(hint.action()) && !"user_write".equals(hint.action()))
{
return (true);
}
JSONObject event = client.syncEvent(hint.eventPk());
if(event == null || !hint.eventPk().equals(event.optString("pk")) || !hint.action().equals(event.optString("action")))
{
throw (new QException("Invitation event could not be verified."));
}
JSONObject context = event.optJSONObject("context");
boolean consumption = "invitation_used".equals(hint.action());
String locator = identifier(context == null ? null : context.opt(consumption ? "invitation_uuid" : AuthentikInvitationEvidence.REFERENCE), consumption);
if(locator == null)
{
// Ordinary account edits and invitations without saved assignments
// are unrelated to this workflow. Never infer identity from email.
return (true);
}
var intent = consumption ? assignments.findByInvitation(locator) : assignments.find(locator);
if(intent == null || !java.util.List.of("WAITING", "READY").contains(intent.status()))
{
return (true);
}
var evidence = new AuthentikInvitationEvidence(client);
if(consumption)
{
var fact = evidence.consumed(hint.eventPk(), intent.invitationId(), intent.flow(), intent.createdAt(), intent.expiresAt());
boolean saved = inbox.withClaim(claim, connection ->
{
requireRecorded(assignments.recordConsumption(connection, intent.reference(), fact));
});
return (saved && new com.kingsrook.qbits.authentik.admin.AuthentikAdmin(client)
.applyInvitationAssignments(intent.reference(), inbox, claim));
}
var fact = evidence.enrolled(hint.eventPk(), intent.reference(), intent.flow(), intent.createdAt(), intent.expiresAt());
JSONObject user = client.syncUser(fact.userPk().toString());
String userUuid = identifier(user == null ? null : user.opt("uuid"), false);
if(userUuid == null || !Boolean.TRUE.equals(user.opt("is_active")))
{
throw (new QException("Invitation account is unavailable or inactive."));
}
boolean saved = inbox.withClaim(claim, connection ->
{
requireRecorded(assignments.recordEnrollment(connection, intent.reference(), fact, userUuid));
});
return (saved && new com.kingsrook.qbits.authentik.admin.AuthentikAdmin(client)
.applyInvitationAssignments(intent.reference(), inbox, claim));
}
/***************************************************************************
** Refusal rolls back the inbox transaction and follows its bounded retries.
***************************************************************************/
private static void requireRecorded(boolean recorded) throws QException
{
if(!recorded)
{
throw (new QException("Invitation evidence was not accepted by the saved intent."));
}
}
/***************************************************************************
** Only canonical UUIDs or native invitation UUID.hex are lookup candidates.
** A candidate locates saved intent; it does not establish trust in the event.
***************************************************************************/
private static String identifier(Object value, boolean allowHex)
{
if(!(value instanceof String text))
{
return (null);
}
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);
}
return (text.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") ? UUID.fromString(text).toString() : null);
}
}
@@ -0,0 +1,254 @@
/*
* 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.sync;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.json.JSONArray;
/*******************************************************************************
** Uncached authorization projection for session reevaluation and fallback.
** Identity must already be bound to an immutable Authentik pk AND uuid by the
** authentication adapter. An email, username or caller-supplied token claim
** is not an identity binding. No caller may merge stale claims after denial.
******************************************************************************/
public final class AuthentikMirrorAccess
{
private final AuthentikQBitConfig config;
private final AuthentikSyncLease.Connections connections;
private final Duration maximumAge;
private final String userTable;
private final String stateTable;
private final String groupTable;
/** The most mirrored groups one read accepts; more makes the projection unavailable. Package-visible for tests. */
static volatile int maxGroups = 50_000;
/***************************************************************************
** READY supplies current grants; DENIED is an authoritative inactive or
** absent identity; UNAVAILABLE has no usable completed/fresh projection.
***************************************************************************/
public enum Status { READY, DENIED, UNAVAILABLE }
/***************************************************************************
** Immutable sets prevent cached session mutations from altering this read.
** This includes only directory grants, not host/static/bootstrap grants.
***************************************************************************/
public record Access(Set<String> groups, Set<String> permissions, Instant refreshedAt, String email, java.util.List<org.json.JSONObject> groupRows)
{
/***********************************************************************
** Defensive copies at the authorization boundary.
***********************************************************************/
public Access
{
groups = Set.copyOf(groups);
permissions = Set.copyOf(permissions);
groupRows = groupRows == null ? null : java.util.List.copyOf(groupRows);
}
/***********************************************************************
** Without the mirrored group rows (the policies cannot be read from it).
***********************************************************************/
public Access(Set<String> groups, Set<String> permissions, Instant refreshedAt, String email)
{
this(groups, permissions, refreshedAt, email, null);
}
}
/***************************************************************************
** No grants accompany an unavailable or denied read.
***************************************************************************/
public record Result(Status status, Access access)
{
}
/***************************************************************************
** Reuse the configured host database and its existing connection provider.
***************************************************************************/
public AuthentikMirrorAccess(AuthentikQBitConfig config, Duration maximumAge)
{
this(config, () ->
{
if(!(QContext.getQInstance().getBackend(config.getLocalBackendName()) instanceof RDBMSBackendMetaData backend))
{
throw (new SQLException("Session mirror requires the configured host RDBMS backend."));
}
return (ConnectionManager.getConnection(backend));
}, maximumAge);
}
/***************************************************************************
** Injectable host connection seam, shared by H2/PostgreSQL acceptance tests.
** Maximum staleness is explicit and cannot exceed fifteen minutes, even
** when reconciliation stalls or a worker retains its directory lease.
***************************************************************************/
AuthentikMirrorAccess(AuthentikQBitConfig config, AuthentikSyncLease.Connections connections, Duration maximumAge)
{
this.config = Objects.requireNonNull(config);
this.connections = Objects.requireNonNull(connections);
if(maximumAge == null || maximumAge.isNegative() || maximumAge.isZero() || maximumAge.compareTo(Duration.ofMinutes(15)) > 0)
{
throw (new IllegalArgumentException("Mirror access freshness must be positive and at most fifteen minutes."));
}
this.maximumAge = maximumAge;
userTable = config.applyTablePrefix(AuthentikSyncTables.USER);
stateTable = config.applyTablePrefix(AuthentikSyncTables.STATE);
groupTable = config.applyTablePrefix(AuthentikSyncTables.GROUP);
if(config.getLocalBackendName() == null || !userTable.matches("[A-Za-z][A-Za-z0-9_]*") || !stateTable.matches("[A-Za-z][A-Za-z0-9_]*") || !groupTable.matches("[A-Za-z][A-Za-z0-9_]*"))
{
throw (new IllegalArgumentException("A valid local mirror backend and table prefix are required."));
}
}
/***************************************************************************
** Read checkpoint and identity in ONE SQL statement so concurrent commit
** cannot pair old grants with a new checkpoint. Missing/inactive identities
** never fall back to old tokens. SQL failures propagate to the adapter.
***************************************************************************/
public Result read(Integer pk, String uuid) throws SQLException, QPermissionDeniedException
{
if(!(QContext.getQSession() instanceof QSystemUserSession))
{
throw (new QPermissionDeniedException("Session mirror reads require the internal authentication context."));
}
if(pk == null || pk < 1 || uuid == null || uuid.isBlank())
{
return (new Result(Status.DENIED, null));
}
try(var connection = connections.open())
{
Instant now = AuthentikSyncLease.now(connection);
String query = "SELECT s.watermark, s.last_full_sync, s.scope_fingerprint, u.pk, u.is_active, u.group_names, u.effective_permissions, u.refreshed_at, u.email"
+ " FROM " + stateTable + " s LEFT JOIN " + userTable + " u ON u.pk = ? AND u.uuid = ? WHERE s.sync_key = 'directory'";
try(var statement = connection.prepareStatement(query))
{
statement.setInt(1, pk);
statement.setString(2, uuid);
try(var rows = statement.executeQuery())
{
if(!rows.next() || rows.getTimestamp(1) == null || rows.getTimestamp(2) == null
|| !AuthentikSyncLease.scopeFingerprint(config).equals(rows.getString(3)))
{
return (new Result(Status.UNAVAILABLE, null));
}
Instant watermark = rows.getTimestamp(1).toInstant();
if(watermark.isAfter(now) || watermark.isBefore(now.minus(maximumAge)) || rows.getTimestamp(2).toInstant().isAfter(watermark))
{
return (new Result(Status.UNAVAILABLE, null));
}
if(rows.getObject(4) == null || !Boolean.TRUE.equals(rows.getObject(5)))
{
return (new Result(Status.DENIED, null));
}
// QQQ DATE_TIME columns store UTC LocalDateTime, while the
// JDBC-owned checkpoint is a TIMESTAMP WITH TIME ZONE instant.
var refreshed = rows.getObject(8, java.time.LocalDateTime.class);
if(refreshed == null || !watermark.equals(refreshed.toInstant(java.time.ZoneOffset.UTC)))
{
return (new Result(Status.UNAVAILABLE, null));
}
try
{
Set<String> groupNames = strings(rows.getString(6));
Set<String> permissions = strings(rows.getString(7));
String email = rows.getString(9);
if(rows.next())
{
return (new Result(Status.UNAVAILABLE, null));
}
java.util.List<org.json.JSONObject> groupRows = readGroupRows(connection, watermark);
return (groupRows == null ? new Result(Status.UNAVAILABLE, null) : new Result(Status.READY, new Access(groupNames, permissions, watermark, email, groupRows)));
}
catch(IllegalArgumentException | org.json.JSONException malformed)
{
return (new Result(Status.UNAVAILABLE, null));
}
}
}
}
}
/***************************************************************************
** The mirrored groups as Authentik-shaped objects (pk, name, parents and the
** mirrored attributes, which include the policy document), read on the SAME
** connection as the user row so the Deny rules of a user's groups can be read
** without the Authentik API. Every group row must carry the checkpoint's
** refresh time and the table must not exceed the bound: otherwise null (the
** projection is unusable, never silently partial).
***************************************************************************/
private java.util.List<org.json.JSONObject> readGroupRows(java.sql.Connection connection, Instant watermark) throws SQLException
{
java.util.List<org.json.JSONObject> groups = new java.util.ArrayList<>();
try(var statement = connection.prepareStatement("SELECT pk, name, parent_pks, attributes, refreshed_at FROM " + groupTable); var rows = statement.executeQuery())
{
while(rows.next())
{
if(groups.size() >= maxGroups)
{
return (null);
}
var refreshed = rows.getObject(5, java.time.LocalDateTime.class);
if(refreshed == null || !watermark.equals(refreshed.toInstant(java.time.ZoneOffset.UTC)))
{
return (null);
}
try
{
org.json.JSONObject attributes = rows.getString(4) == null || rows.getString(4).isBlank() ? new org.json.JSONObject() : new org.json.JSONObject(rows.getString(4));
JSONArray parents = rows.getString(3) == null || rows.getString(3).isBlank() ? new JSONArray() : new JSONArray(rows.getString(3));
groups.add(new org.json.JSONObject().put("pk", rows.getString(1)).put("name", rows.getString(2)).put("parents", parents).put("attributes", attributes));
}
catch(org.json.JSONException malformed)
{
return (null);
}
}
}
return (groups);
}
/***************************************************************************
** Strict bounded JSON, never coercing objects/numbers into permission IDs.
***************************************************************************/
private static Set<String> strings(String json)
{
if(json == null || json.length() > 2_000_000)
{
throw (new IllegalArgumentException("Invalid mirror authorization data."));
}
JSONArray array = new JSONArray(json);
if(array.length() > 20_000)
{
throw (new IllegalArgumentException("Mirror authorization data exceeds the supported bound."));
}
Set<String> values = new TreeSet<>();
for(Object value : array)
{
if(!(value instanceof String text) || text.isBlank() || text.length() > 1024)
{
throw (new IllegalArgumentException("Invalid mirror authorization entry."));
}
values.add(text);
}
return (values);
}
}
@@ -0,0 +1,255 @@
/*
* 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.sync;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.permissions.ApplicationRole;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
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.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Allowlisted directory projection for one complete refresh. Authentik remains
** authoritative. No raw user attributes, event context, IPs, or user agents
** enter the mirror. Build succeeds fully before any local writes are attempted.
*******************************************************************************/
public record AuthentikMirrorSnapshot(List<QRecord> users, List<QRecord> groups, List<QRecord> memberships, Instant refreshedAt)
{
/*******************************************************************************
** Optional separately fetched metrics. Null means unavailable, never zero.
*******************************************************************************/
public record Metrics(Integer mfaDeviceCount, Integer failedLogins24h)
{
}
/*******************************************************************************
** Resolve permissions from the complete group graph. User scope and both
** sides of memberships are checked; disagreement aborts for a fresh retry.
** Email is normalized for case-insensitive identity lookup and uniqueness.
*******************************************************************************/
public static AuthentikMirrorSnapshot build(AuthentikQBitConfig config, QInstance instance,
List<JSONObject> rawUsers, List<JSONObject> rawGroups, Map<Integer, Metrics> metrics, Instant refreshedAt)
{
AuthentikGuard guard = new AuthentikGuard(config);
PolicyCatalog catalog = PolicyCatalog.of(instance);
GroupGraph graph = GroupGraph.fromGroups(rawGroups, config.getPolicyFormat(), catalog);
Set<String> permissionNames = new TreeSet<>(catalog.permissionNames());
permissionNames.addAll(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(instance));
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, permissionNames, instance);
List<QRecord> users = new ArrayList<>();
List<QRecord> groups = new ArrayList<>();
List<QRecord> memberships = new ArrayList<>();
Set<Integer> userKeys = new HashSet<>();
Set<String> usernames = new HashSet<>();
Set<String> emails = new HashSet<>();
Set<String> groupKeys = new HashSet<>();
for(JSONObject group : rawGroups)
{
String pk = group.getString("pk");
if(!groupKeys.add(pk) || group.optJSONArray("users") == null)
{
throw (new IllegalArgumentException("Group snapshot has duplicate keys or missing membership data"));
}
GroupGraph.Node node = graph.byPk(pk);
if(node.parentPks().stream().anyMatch(parent -> graph.byPk(parent) == null))
{
throw (new IllegalArgumentException("Group snapshot contains an unresolved parent"));
}
JSONObject attributes = new JSONObject();
JSONObject raw = group.optJSONObject("attributes");
// Persist only attributes understood by this QBit, including the
// configured policy document. Unknown host attributes are not mirrored.
for(String key : Set.of(AuthentikJson.ATTR_PERMISSIONS, AuthentikJson.ATTR_APP_ACCESS, AuthentikJson.ATTR_PROTECTED,
ApplicationRole.ATTRIBUTE, GroupGraph.USER_ACCESS_ATTRIBUTE, config.getPolicyFormat().attribute()))
{
if(raw != null && raw.has(key))
{
attributes.put(key, raw.get(key));
}
}
groups.add(new QRecord().withValue("pk", pk).withValue("name", node.name())
.withValue("parentPks", json(new TreeSet<>(node.parentPks())))
.withValue("attributes", redactedAttributes(attributes, config.getPolicyFormat(), catalog))
.withValue("memberCount", node.memberPks().size()).withValue("isSuperuser", graph.isSuperuserInherited(pk))
.withValue("refreshedAt", refreshedAt));
}
for(JSONObject user : rawUsers)
{
int pk = user.getInt("pk");
String username = user.getString("username");
String email = user.optString("email", "").trim().toLowerCase(Locale.ROOT);
if(!guard.isUserInScope(user) || !userKeys.add(pk) || username.isBlank() || !usernames.add(username)
|| (!email.isEmpty() && !emails.add(email)))
{
throw (new IllegalArgumentException("User snapshot contains an out-of-scope or ambiguous identity"));
}
JSONArray direct = user.optJSONArray("groups");
if(direct == null)
{
throw (new IllegalArgumentException("User snapshot is missing membership data"));
}
Set<String> directPks = new TreeSet<>();
for(int i = 0; i < direct.length(); i++)
{
directPks.add(direct.getString(i));
}
if(!directPks.equals(new TreeSet<>(graph.groupsOfUser(pk))))
{
throw (new IllegalArgumentException("Directory membership changed during refresh; retry the snapshot"));
}
Set<String> groupNames = new TreeSet<>();
for(String groupPk : directPks)
{
String name = graph.byPk(groupPk).name();
groupNames.add(name);
memberships.add(new QRecord().withValue("userPk", pk).withValue("groupPk", groupPk).withValue("groupName", name)
.withValue("refreshedAt", refreshedAt));
}
boolean active = user.optBoolean("is_active", false);
Metrics userMetrics = metrics == null ? null : metrics.get(pk);
users.add(new QRecord().withValue("pk", pk).withValue("uuid", user.optString("uuid", null))
.withValue("username", username).withValue("email", email.isEmpty() ? null : email)
.withValue("name", user.optString("name", "")).withValue("type", user.getString("type"))
.withValue("path", user.optString("path", ""))
.withValue("sourceIsSuperuser", user.optBoolean("is_superuser", false))
.withValue("sourceIsProtected", AuthentikGuard.isMarkedProtected(user))
.withValue("isActive", active).withValue("lastLogin", AuthentikJson.instant("last_login").apply(user))
.withValue("isSuperuser", user.optBoolean("is_superuser", false) || directPks.stream().anyMatch(graph::isSuperuserInherited))
.withValue("isProtected", guard.protection(user).isProtected() || directPks.stream().anyMatch(graph::isSuperuserInherited))
.withValue("groupNames", json(groupNames))
.withValue("effectivePermissions", json(active ? resolver.resolveUser(pk).sessionPermissions() : Set.of()))
.withValue("mfaDeviceCount", userMetrics == null ? null : userMetrics.mfaDeviceCount())
.withValue("failedLogins24h", userMetrics == null ? null : userMetrics.failedLogins24h())
.withValue("refreshedAt", refreshedAt));
}
return (new AuthentikMirrorSnapshot(List.copyOf(users), List.copyOf(groups), List.copyOf(memberships), refreshedAt));
}
/*******************************************************************************
** The mirrored attributes with secret-looking values redacted EXCEPT a VALID policy
** document, which must survive byte for byte: the mirror is where the refreshing
** mapper reads Deny rules, and a redacted Deny would be a lost Deny.
*******************************************************************************/
static String redactedAttributes(JSONObject attributes, com.kingsrook.qbits.authentik.policy.PolicyFormat format, PolicyCatalog catalog)
{
String policyAttribute = format.attribute();
JSONObject rest = new JSONObject(attributes.toString());
Object policy = rest.has(policyAttribute) ? rest.remove(policyAttribute) : null;
if(policy != null)
{
try
{
com.kingsrook.qbits.authentik.policy.PolicyParser.parseStrict(policy, format, catalog);
}
catch(com.kingsrook.qbits.authentik.policy.PolicyParseException e)
{
///////////////////////////////////////////////////////////////////
// not a valid policy: it is just an attribute, redact it as any //
///////////////////////////////////////////////////////////////////
rest.put(policyAttribute, policy);
policy = null;
}
}
JSONObject safe = new JSONObject(AuthentikRedactor.redactText(rest.toString(), null));
if(policy != null)
{
safe.put(policyAttribute, policy);
}
return (safe.toString());
}
/*******************************************************************************
** Stable JSON arrays avoid audit churn caused only by set iteration order.
*******************************************************************************/
private static String json(Set<String> values)
{
return (AuthentikRedactor.redactText(new JSONArray(new TreeSet<>(values)).toString(), null));
}
/*******************************************************************************
** Merge changed API users with the last complete local directory. Rebuild
** retained users' membership from the newly fetched group graph, so role,
** parent and membership edits recompute grants even without a user timestamp
** change. Scope exits from local rows require the worker's live preflight.
** Never use derived protection/superuser values as source attributes: doing
** so would make a removed inherited privilege permanent in the mirror.
** The worker separately refreshes profiles whose API is_superuser was true,
** because Authentik can include inherited status in that source value too.
*******************************************************************************/
public static List<JSONObject> incrementalUsers(AuthentikQBitConfig config, List<QRecord> existing,
List<JSONObject> changed, List<JSONObject> groups)
{
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat());
Map<Integer, JSONObject> merged = new java.util.LinkedHashMap<>();
AuthentikGuard guard = new AuthentikGuard(config);
for(QRecord row : existing)
{
int pk = row.getValueInteger("pk");
if(row.getValue("path") == null || row.getValue("sourceIsSuperuser") == null || row.getValue("sourceIsProtected") == null)
{
throw (new IllegalArgumentException("Mirror requires a full refresh before incremental reconciliation."));
}
List<String> memberships = graph.groupsOfUser(pk);
JSONArray groupObjects = new JSONArray();
memberships.forEach(group -> groupObjects.put(new JSONObject().put("pk", group).put("name", graph.byPk(group).name())));
JSONObject user = new JSONObject().put("pk", pk).put("uuid", row.getValueString("uuid"))
.put("username", row.getValueString("username")).put("email", row.getValueString("email"))
.put("name", row.getValueString("name")).put("type", row.getValueString("type"))
.put("path", row.getValueString("path")).put("is_active", row.getValue("isActive"))
.put("is_superuser", row.getValue("sourceIsSuperuser"))
.put("attributes", new JSONObject().put(AuthentikJson.ATTR_PROTECTED, row.getValue("sourceIsProtected")))
.put("groups", new JSONArray(memberships)).put("groups_obj", groupObjects);
if(row.getValue("lastLogin") != null)
{
user.put("last_login", row.getValue("lastLogin").toString());
}
if(guard.isUserInScope(user))
{
if(merged.put(pk, user) != null)
{
throw (new IllegalArgumentException("Mirror contains duplicate source identities."));
}
}
}
Set<Integer> changedKeys = new HashSet<>();
for(JSONObject user : changed)
{
int pk = user.getInt("pk");
if(!changedKeys.add(pk) || !guard.isUserInScope(user))
{
throw (new IllegalArgumentException("Incremental API users contain duplicate or out-of-scope identities."));
}
merged.put(pk, user);
}
return (List.copyOf(merged.values()));
}
}
@@ -0,0 +1,377 @@
/*
* 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.sync;
import java.time.Duration;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
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.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
/*******************************************************************************
** Full reconciliation on the configured host backend. The directory lease
** spans remote reads and local publication. No remote write is ever performed.
** Metadata/scheduling remain disabled until session integration is complete.
*******************************************************************************/
public final class AuthentikMirrorWorker
{
private final AuthentikQBitConfig config;
private final AuthentikActionUtil client;
private final AuthentikSyncLease.Connections connections;
private final AuthentikSyncLease lease;
/*******************************************************************************
** Mandatory publication hook for persistent session reevaluation markers.
** It runs in the mirror/audit/checkpoint transaction, with no remote I/O.
*******************************************************************************/
@FunctionalInterface
public interface Publication
{
/***************************************************************************
** Do not commit or close the transaction. Failure rolls back everything.
***************************************************************************/
void apply(AuthentikMirrorSnapshot snapshot, RDBMSTransaction transaction) throws Exception;
}
/*******************************************************************************
** Reuse QQQ's configured connection provider, including vendor/schema setup.
*******************************************************************************/
public AuthentikMirrorWorker(AuthentikQBitConfig config, AuthentikActionUtil client)
{
this.config = Objects.requireNonNull(config);
this.client = Objects.requireNonNull(client);
if(!(QContext.getQInstance().getBackend(config.getLocalBackendName()) instanceof RDBMSBackendMetaData backend))
{
throw (new IllegalArgumentException("Mirror synchronization requires the configured host RDBMS backend."));
}
connections = () -> ConnectionManager.getConnection(backend);
lease = new AuthentikSyncLease(connections, config, Duration.ofMinutes(15));
}
/*******************************************************************************
** Both scheduled and webhook refreshes use the same global lease. For a
** webhook publication also fence its inbox claim, on the same database.
** False means another worker owns the refresh or this worker lost ownership.
*******************************************************************************/
public boolean fullRefresh(Publication publication, AuthentikWebhookInbox inbox, AuthentikWebhookInbox.Claim event) throws Exception
{
return (refresh(true, publication, inbox, event, false));
}
/*******************************************************************************
** Fifteen-minute reconcile. First use needs a complete baseline. Subsequent
** runs overlap the previous watermark by a minute to cover timestamp
** precision and ordinary clock skew; nightly full refresh repairs omissions.
*******************************************************************************/
public boolean incrementalRefresh(Publication publication) throws Exception
{
return (refresh(false, publication, null, null, false));
}
/*******************************************************************************
** A durable event is a hint to re-read live state, never a grant document.
** Existing baselines retain their freshness deadline; a missing baseline
** needs a full refresh before any targeted publication is safe.
*******************************************************************************/
public boolean applyEvent(Publication publication, AuthentikWebhookInbox inbox, AuthentikWebhookInbox.Claim event) throws Exception
{
Objects.requireNonNull(inbox);
Objects.requireNonNull(event);
return (refresh(false, publication, inbox, event, true));
}
/*******************************************************************************
** Common lease/preflight/publication boundary for full and incremental work.
*******************************************************************************/
private boolean refresh(boolean full, Publication publication, AuthentikWebhookInbox inbox, AuthentikWebhookInbox.Claim event, boolean eventOnly) throws Exception
{
if(!(QContext.getQSession() instanceof QSystemUserSession))
{
throw (new QPermissionDeniedException("Mirror synchronization requires the internal session."));
}
Objects.requireNonNull(publication);
if((inbox == null) != (event == null))
{
throw (new IllegalArgumentException("An inbox and event claim must be supplied together."));
}
var claim = lease.acquire();
if(claim == null)
{
return (false);
}
try
{
AuthentikActionUtil.SyncProgress progress = () -> renew(claim, inbox, event);
AuthentikRuntime.forBackend(config.getApiBackendName()).invalidateCache();
var existing = rows(AuthentikSyncTables.USER, null);
full = full || claim.watermark() == null || !claim.sameScope() || existing.stream().anyMatch(row -> row.getValue("path") == null
|| row.getValue("sourceIsSuperuser") == null || row.getValue("sourceIsProtected") == null);
boolean targeted = eventOnly && !full;
List<org.json.JSONObject> users;
Set<Integer> touched = new HashSet<>();
if(targeted)
{
var hint = AuthentikWebhookEnvelope.fromStorage(event.payload());
String pk = "user".equals(hint.modelName()) ? hint.modelPk() : null;
if(pk == null && hint.modelName() == null && hint.username() != null)
{
var matching = existing.stream().filter(row -> hint.username().equals(row.getValueString("username"))).toList();
if(matching.size() > 1)
{
throw (new QException("Event username is ambiguous in the mirror."));
}
pk = matching.isEmpty() ? null : matching.getFirst().getValueString("pk");
}
org.json.JSONObject user = pk != null ? client.syncUser(pk, progress)
: hint.modelName() == null && hint.username() != null ? client.syncUserByUsername(hint.username(), progress) : null;
if(hint.modelName() == null && (hint.username() == null || hint.username().isBlank()))
{
throw (new QException("Event has no resolvable directory identity."));
}
if(pk != null && user == null)
{
String absentPk = pk;
existing = existing.stream().filter(row -> !absentPk.equals(row.getValueString("pk"))).toList();
}
users = user == null ? List.of() : List.of(user);
users.forEach(value -> touched.add(value.getInt("pk")));
}
else
{
users = client.syncUsers(full ? null : claim.watermark().minusSeconds(60), AuthentikActionUtil.MAX_SWEEP_OBJECTS, progress);
}
if(!lease.renew(claim))
{
return (false);
}
var groups = client.syncGroups(AuthentikActionUtil.MAX_SWEEP_OBJECTS, progress);
if(!full)
{
users = incrementalUsers(existing, users, groups, progress);
}
Map<Integer, AuthentikMirrorSnapshot.Metrics> metrics = new java.util.HashMap<>();
if(targeted)
{
existing.forEach(row -> metrics.put(row.getValueInteger("pk"), new AuthentikMirrorSnapshot.Metrics(
row.getValueInteger("mfaDeviceCount"), row.getValueInteger("failedLogins24h"))));
}
for(var user : users)
{
if(targeted && metrics.containsKey(user.getInt("pk")) && !touched.contains(user.getInt("pk")))
{
continue;
}
var counts = client.syncMetrics(user, progress);
metrics.put(user.getInt("pk"), new AuthentikMirrorSnapshot.Metrics(counts.mfaDeviceCount(), counts.failedLogins24h()));
}
var snapshot = AuthentikMirrorSnapshot.build(config, QContext.getQInstance(), users, groups, metrics, targeted ? claim.watermark() : claim.startedAt());
Set<String> oldUsers = keys(AuthentikSyncTables.USER, null);
Set<String> oldGroups = keys(AuthentikSyncTables.GROUP, null);
client.confirmSyncDeletions(missing(oldUsers, snapshot.users()), missing(oldGroups, snapshot.groups()), progress);
if(!lease.renew(claim))
{
return (false);
}
try(var transaction = new RDBMSTransaction(connections.open()))
{
try
{
if(!lease.lock(transaction.getConnection(), claim) || (event != null && !inbox.lockClaim(transaction.getConnection(), event)))
{
transaction.rollback();
return (false);
}
if(!oldUsers.equals(keys(AuthentikSyncTables.USER, transaction)) || !oldGroups.equals(keys(AuthentikSyncTables.GROUP, transaction)))
{
throw (new QException("Mirror identities changed after deletion preflight; retry synchronization."));
}
new AuthentikMirrorWriter(config).fullRefresh(snapshot, transaction);
publication.apply(snapshot, transaction);
if(!(targeted ? lease.completeHint(transaction.getConnection(), claim) : lease.complete(transaction.getConnection(), claim, full)))
{
transaction.rollback();
return (false);
}
transaction.commit();
return (true);
}
catch(Exception failure)
{
transaction.rollback();
throw (failure);
}
}
}
catch(OwnershipLost lost)
{
return (false);
}
finally
{
lease.release(claim);
}
}
/*******************************************************************************
** The directory lease and optional inbox lease must both remain current.
** No background thread or shared mutable client callback is needed.
*******************************************************************************/
private void renew(AuthentikSyncLease.Claim claim, AuthentikWebhookInbox inbox, AuthentikWebhookInbox.Claim event) throws QException
{
try
{
if(!lease.renew(claim) || (event != null && !inbox.renew(event)))
{
throw (new OwnershipLost());
}
}
catch(java.sql.SQLException failure)
{
String state = failure.getSQLState();
String diagnostic = state != null && state.matches("[A-Z0-9]{5}") ? state : "unknown";
throw (new QException("Unable to renew directory synchronization ownership (SQL state " + diagnostic + ")."));
}
}
/*******************************************************************************
** Expected contention/cancellation, distinct from API or storage failure.
*******************************************************************************/
private static final class OwnershipLost extends QException
{
private static final long serialVersionUID = 1L;
/***************************************************************************
** No identity, ownership token or upstream response in this exception.
***************************************************************************/
private OwnershipLost()
{
super("Directory synchronization ownership was lost.");
}
}
/*******************************************************************************
** Read local identities completely; a truncated result cannot prove absence.
*******************************************************************************/
private Set<String> keys(String name, RDBMSTransaction transaction) throws QException
{
Set<String> keys = new HashSet<>();
for(QRecord row : rows(name, transaction))
{
String key = row.getValueString("pk");
if(key == null || !keys.add(key))
{
throw (new QException("Mirror has missing or duplicate source identities."));
}
}
return (keys);
}
/*******************************************************************************
** Only complete bounded local reads may participate in reconciliation.
*******************************************************************************/
private List<QRecord> rows(String name, RDBMSTransaction transaction) throws QException
{
var rows = new QueryAction().execute(new QueryInput().withTableName(config.applyTablePrefix(name)).withTransaction(transaction)
.withFilter(new QQueryFilter().withLimit(1_000_001))).getRecords();
if(rows.size() > 1_000_000)
{
throw (new QException("Mirror exceeds the synchronization row limit."));
}
return (rows);
}
/*******************************************************************************
** Group membership writes need not update a user's last_updated. Discover
** newly visible members using live scoped reads, then recompute all retained
** users from the new graph. Unchanged known users require no detail request.
*******************************************************************************/
private List<org.json.JSONObject> incrementalUsers(List<QRecord> existing, List<org.json.JSONObject> changed,
List<org.json.JSONObject> groups, AuthentikActionUtil.SyncProgress progress) throws Exception
{
Set<Integer> known = new HashSet<>();
existing.forEach(row -> known.add(row.getValueInteger("pk")));
changed.forEach(user -> known.add(user.getInt("pk")));
Set<Integer> changedKeys = new HashSet<>();
changed.forEach(user -> changedKeys.add(user.getInt("pk")));
Set<Integer> discovered = new java.util.TreeSet<>();
// Authentik's is_superuser can itself include inherited status. Refresh
// these source profiles rather than retaining a stale true after demotion.
existing.stream().filter(row -> Boolean.TRUE.equals(row.getValueBoolean("sourceIsSuperuser")))
.map(row -> row.getValueInteger("pk")).filter(pk -> !changedKeys.contains(pk)).forEach(discovered::add);
for(var group : groups)
{
var members = group.optJSONArray("users");
if(members == null)
{
throw (new QException("Group synchronization is missing membership data."));
}
for(int i = 0; i < members.length(); i++)
{
int pk = members.getInt(i);
if(!known.contains(pk))
{
discovered.add(pk);
}
}
}
if(discovered.size() > AuthentikActionUtil.MAX_SWEEP_OBJECTS)
{
throw (new QException("Group synchronization exceeds the user discovery limit."));
}
List<org.json.JSONObject> updates = new java.util.ArrayList<>(changed);
Set<Integer> absent = new HashSet<>();
for(Integer pk : discovered)
{
var user = client.syncUser(pk.toString(), progress);
if(user != null)
{
updates.add(user);
}
else
{
absent.add(pk);
}
}
return (AuthentikMirrorSnapshot.incrementalUsers(config,
existing.stream().filter(row -> !absent.contains(row.getValueInteger("pk"))).toList(), updates, groups));
}
/*******************************************************************************
** Only candidates absent from the complete new snapshot need live checks.
*******************************************************************************/
private static List<String> missing(Set<String> existing, List<QRecord> desired)
{
Set<String> missing = new HashSet<>(existing);
desired.forEach(row -> missing.remove(row.getValueString("pk")));
return (missing.stream().sorted().toList());
}
}
@@ -0,0 +1,220 @@
/*
* 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.sync;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
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.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.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Reconcile local mirrors through standard QQQ actions, retaining RECORD audit.
** The caller owns a real host-backend transaction and must hold the sync lease
** on that same connection. This class neither commits nor closes it.
*******************************************************************************/
public final class AuthentikMirrorWriter
{
private static final int MAX_LOCAL_ROWS = 1_000_000;
private static final int BATCH_SIZE = 100;
private final AuthentikQBitConfig config;
/*******************************************************************************
** Only the configured local tables are writable by this component.
*******************************************************************************/
public AuthentikMirrorWriter(AuthentikQBitConfig config)
{
this.config = Objects.requireNonNull(config);
}
/*******************************************************************************
** Full snapshots may remove missing rows. Incremental user snapshots must
** never enter this method. Any failure requires caller rollback of all
** mirrors, audit writes, checkpoint and session invalidation markers.
*******************************************************************************/
public void fullRefresh(AuthentikMirrorSnapshot snapshot, QBackendTransaction transaction) throws QException
{
if(!(QContext.getQSession() instanceof QSystemUserSession))
{
throw (new QPermissionDeniedException("Mirror writes require the internal synchronization session."));
}
Objects.requireNonNull(transaction, "Mirror refresh requires a host transaction");
for(String name : List.of(AuthentikSyncTables.USER, AuthentikSyncTables.GROUP, AuthentikSyncTables.MEMBERSHIP))
{
var table = QContext.getQInstance().getTable(config.applyTablePrefix(name));
if(table == null || !Objects.equals(config.getLocalBackendName(), table.getBackendName()) || table.getAuditRules() == null
|| table.getAuditRules().getAuditLevel() != com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel.RECORD)
{
throw (new QException("Mirror metadata must use the configured local backend and audit rules."));
}
}
reconcile(AuthentikSyncTables.GROUP, snapshot.groups(), List.of("pk"), transaction);
reconcile(AuthentikSyncTables.USER, snapshot.users(), List.of("pk"), transaction);
reconcile(AuthentikSyncTables.MEMBERSHIP, snapshot.memberships(), List.of("userPk", "groupPk"), transaction);
}
/*******************************************************************************
** Preserve local record IDs across source renames and attribute changes.
** Never delete rows when the existing directory query might be truncated.
*******************************************************************************/
private void reconcile(String name, List<QRecord> desired, List<String> keyFields, QBackendTransaction transaction) throws QException
{
String table = config.applyTablePrefix(name);
List<QRecord> existing = new QueryAction().execute(new QueryInput().withTableName(table).withTransaction(transaction)
.withFilter(new QQueryFilter().withLimit(MAX_LOCAL_ROWS + 1))).getRecords();
if(existing.size() > MAX_LOCAL_ROWS || desired.size() > MAX_LOCAL_ROWS)
{
throw (new QException("Mirror refresh exceeds the supported local row limit; no changes may be committed."));
}
Map<List<Serializable>, QRecord> oldByKey = new HashMap<>();
for(QRecord row : existing)
{
if(oldByKey.put(key(row, keyFields), row) != null)
{
throw (new QException("Mirror contains duplicate identity keys."));
}
}
List<QRecord> inserts = new ArrayList<>();
List<QRecord> updates = new ArrayList<>();
List<QRecord> reserveIdentities = new ArrayList<>();
java.util.Set<String> reservedNames = new java.util.HashSet<>();
if(AuthentikSyncTables.USER.equals(name))
{
existing.forEach(row -> reservedNames.add(row.getValueString("username")));
desired.forEach(row -> reservedNames.add(row.getValueString("username")));
}
java.util.Set<List<Serializable>> seen = new java.util.HashSet<>();
for(QRecord row : desired)
{
List<Serializable> key = key(row, keyFields);
if(!seen.add(key))
{
throw (new QException("Mirror snapshot contains duplicate identity keys."));
}
QRecord old = oldByKey.remove(key);
QRecord write = new QRecord();
row.getValues().forEach(write::setValue);
if(old == null)
{
inserts.add(write);
}
else if(!row.getValues().entrySet().stream().allMatch(e -> Objects.equals(e.getValue(), old.getValue(e.getKey()))))
{
write.setValue("id", old.getValue("id"));
updates.add(write);
if(AuthentikSyncTables.USER.equals(name))
{
QRecord reservation = new QRecord().withValue("id", old.getValue("id"));
if(row.getValues().containsKey("username") && !Objects.equals(row.getValue("username"), old.getValue("username")))
{
String temporary;
do
{
temporary = "__qqq_sync_" + java.util.UUID.randomUUID();
}
while(!reservedNames.add(temporary));
reservation.withValue("username", temporary);
}
if(row.getValues().containsKey("email") && !Objects.equals(row.getValue("email"), old.getValue("email")))
{
reservation.withValue("email", null);
}
if(reservation.getValues().size() > 1)
{
reserveIdentities.add(reservation);
}
}
}
}
// Delete obsolete identities first to allow a rename/replacement to reuse
// an old unique username/email. Transaction isolation hides intermediate rows.
List<Serializable> deletes = oldByKey.values().stream().map(r -> r.getValue("id")).toList();
for(int offset = 0; offset < deletes.size(); offset += BATCH_SIZE)
{
var batch = deletes.subList(offset, Math.min(offset + BATCH_SIZE, deletes.size()));
var output = new DeleteAction().execute(new DeleteInput().withTableName(table).withTransaction(transaction).withPrimaryKeys(batch));
if(output.getDeletedRecordCount() != batch.size() || (output.getRecordsWithErrors() != null && !output.getRecordsWithErrors().isEmpty()))
{
throw (new QException("Mirror delete failed; roll back this refresh."));
}
}
// Release changed unique names/emails before applying the final values,
// allowing swaps and reuse by new identities. These temporary values are
// isolated inside the caller's transaction and use audited QQQ updates.
for(int offset = 0; offset < reserveIdentities.size(); offset += BATCH_SIZE)
{
var batch = reserveIdentities.subList(offset, Math.min(offset + BATCH_SIZE, reserveIdentities.size()));
check(new UpdateAction().execute(new UpdateInput().withTableName(table).withTransaction(transaction).withRecords(batch)).getRecords(), batch.size());
}
for(int offset = 0; offset < inserts.size(); offset += BATCH_SIZE)
{
var batch = inserts.subList(offset, Math.min(offset + BATCH_SIZE, inserts.size()));
check(new InsertAction().execute(new InsertInput().withTableName(table).withTransaction(transaction).withRecords(batch)).getRecords(), batch.size());
}
for(int offset = 0; offset < updates.size(); offset += BATCH_SIZE)
{
var batch = updates.subList(offset, Math.min(offset + BATCH_SIZE, updates.size()));
check(new UpdateAction().execute(new UpdateInput().withTableName(table).withTransaction(transaction).withRecords(batch)).getRecords(), batch.size());
}
}
/*******************************************************************************
** Stable source identity, independent of user-editable display labels.
*******************************************************************************/
private static List<Serializable> key(QRecord record, List<String> fields) throws QException
{
List<Serializable> values = new ArrayList<>();
for(String field : fields)
{
Serializable value = record.getValue(field);
if(value == null)
{
throw (new QException("Mirror identity key is missing."));
}
values.add(value);
}
return (List.copyOf(values));
}
/*******************************************************************************
** QQQ may return per-record errors without throwing; never commit them.
*******************************************************************************/
private static void check(List<QRecord> records, int expected) throws QException
{
if(records == null || records.size() != expected || records.stream().anyMatch(r -> r.getErrors() != null && !r.getErrors().isEmpty()))
{
throw (new QException("Mirror write failed; roll back this refresh."));
}
}
}
@@ -0,0 +1,276 @@
/*
* 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.sync;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Objects;
import java.util.UUID;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
/*******************************************************************************
** Durable, write-once identity for an authenticated QQQ cookie session.
** Only the authentication adapter may bind a NEW verified login. Resumption
** must use find(), never recreate a missing binding from an old token/email.
** The UUID's SHA-256 digest is stored; no reusable session credential is kept.
******************************************************************************/
public final class AuthentikSessionBindings
{
public static final Duration REVOCATION_RETENTION = Duration.ofDays(30);
private final AuthentikSyncLease.Connections connections;
private final String table;
private final AuthentikQBitConfig config;
/***************************************************************************
** Stable source identity, original core user reference and absolute login
** lifetime. Token refresh does not extend or reassign this binding.
***************************************************************************/
public record Binding(Integer userPk, String userUuid, String userReference, Instant expiresAt)
{
/***********************************************************************
** Avoid implicit identity disclosure in exceptions and diagnostics.
***********************************************************************/
@Override
public String toString()
{
return ("AuthentikSessionBinding");
}
}
/***************************************************************************
** Borrow from the host's configured provider, without a second pool.
***************************************************************************/
public AuthentikSessionBindings(AuthentikQBitConfig config)
{
this(config, () ->
{
if(!(QContext.getQInstance().getBackend(config.getLocalBackendName()) instanceof RDBMSBackendMetaData backend))
{
throw (new SQLException("Session bindings require the configured host RDBMS backend."));
}
return (ConnectionManager.getConnection(backend));
});
}
/***************************************************************************
** Isolated host database seam for shared H2/PostgreSQL acceptance tests.
***************************************************************************/
AuthentikSessionBindings(AuthentikQBitConfig config, AuthentikSyncLease.Connections connections)
{
this.config = Objects.requireNonNull(config);
this.connections = Objects.requireNonNull(connections);
table = config.applyTablePrefix(AuthentikSyncTables.SESSION_BINDING);
if(config.getLocalBackendName() == null || !table.matches("[A-Za-z][A-Za-z0-9_]*") || config.getApiRoot() == null)
{
throw (new IllegalArgumentException("A local backend, valid table prefix and Authentik API root are required."));
}
}
/***************************************************************************
** First writer fixes identity and deadline. A duplicate same-identity login
** returns the original deadline; competing identities, expired bindings
** and revoked bindings are rejected. Unique-key enforcement handles races.
***************************************************************************/
public Binding bind(String sessionUuid, Binding binding) throws SQLException, QPermissionDeniedException
{
guard();
String key = sessionHash(sessionUuid);
if(binding == null || binding.userPk() == null || binding.userPk() < 1 || binding.userUuid() == null
|| binding.userUuid().isBlank() || binding.userUuid().length() > 64 || binding.userReference() == null
|| binding.userReference().isBlank() || binding.userReference().length() > 1024 || binding.expiresAt() == null)
{
throw (new IllegalArgumentException("A verified immutable identity and finite session lifetime are required."));
}
try(var connection = connections.open())
{
connection.setAutoCommit(false);
if(!binding.expiresAt().isAfter(AuthentikSyncLease.now(connection)))
{
throw (new IllegalArgumentException("Session binding has already expired."));
}
try(var insert = connection.prepareStatement("INSERT INTO " + table
+ " (session_hash,issuer_fingerprint,user_pk,user_uuid,user_reference,expires_at,revoked) VALUES (?,?,?,?,?,?,FALSE)"))
{
insert.setString(1, key);
insert.setString(2, digest(config.getApiRoot()));
insert.setInt(3, binding.userPk());
insert.setString(4, binding.userUuid());
insert.setString(5, binding.userReference());
insert.setTimestamp(6, Timestamp.from(binding.expiresAt()));
insert.executeUpdate();
connection.commit();
}
catch(SQLException failure)
{
connection.rollback();
if(!"23505".equals(failure.getSQLState()))
{
throw (failure);
}
}
Binding existing = find(connection, key);
if(existing == null || !Objects.equals(existing.userPk(), binding.userPk()) || !existing.userUuid().equals(binding.userUuid())
|| !existing.userReference().equals(binding.userReference()))
{
throw (new QPermissionDeniedException("Session identity binding cannot be reassigned or revived."));
}
return (existing);
}
}
/***************************************************************************
** Uncached lookup shared by all application nodes. Missing/expired/revoked
** bindings never authorize recreating identity from mutable user references.
***************************************************************************/
public Binding find(String sessionUuid) throws SQLException, QPermissionDeniedException
{
guard();
String key = sessionHash(sessionUuid);
try(var connection = connections.open())
{
return (find(connection, key));
}
}
/***************************************************************************
** Retain a tombstone so a delayed duplicate login cannot undo revocation.
***************************************************************************/
public void revoke(String sessionUuid) throws SQLException, QPermissionDeniedException
{
guard();
String key = sessionHash(sessionUuid);
try(var connection = connections.open())
{
connection.setAutoCommit(false);
// A logout arriving before a delayed login must still leave a tombstone.
try(var insert = connection.prepareStatement("INSERT INTO " + table
+ " (session_hash,issuer_fingerprint,expires_at,revoked) VALUES (?,?,?,TRUE)"))
{
insert.setString(1, key);
insert.setString(2, digest(config.getApiRoot()));
insert.setTimestamp(3, Timestamp.from(AuthentikSyncLease.now(connection)));
insert.executeUpdate();
connection.commit();
}
catch(SQLException failure)
{
connection.rollback();
if(!"23505".equals(failure.getSQLState()))
{
throw (failure);
}
}
try(var update = connection.prepareStatement("UPDATE " + table + " SET revoked=TRUE WHERE session_hash=?"))
{
update.setString(1, key);
update.executeUpdate();
connection.commit();
}
}
}
/***************************************************************************
** Bounded retention by lifetime: preserve tombstones for thirty days after
** expiry. The adapter must deny resumed sessions whose bindings are absent.
***************************************************************************/
public int purgeExpired() throws SQLException, QPermissionDeniedException
{
guard();
try(var connection = connections.open(); var delete = connection.prepareStatement("DELETE FROM " + table + " WHERE expires_at < ?"))
{
connection.setAutoCommit(true);
delete.setTimestamp(1, Timestamp.from(AuthentikSyncLease.now(connection).minus(REVOCATION_RETENTION)));
return (delete.executeUpdate());
}
}
/***************************************************************************
** Database-clock expiry and issuer validation, with no process-local cache.
***************************************************************************/
private Binding find(Connection connection, String key) throws SQLException
{
Instant now = AuthentikSyncLease.now(connection);
try(var query = connection.prepareStatement("SELECT user_pk,user_uuid,user_reference,expires_at FROM " + table
+ " WHERE session_hash=? AND issuer_fingerprint=? AND revoked=FALSE AND expires_at>?"))
{
query.setString(1, key);
query.setString(2, digest(config.getApiRoot()));
query.setTimestamp(3, Timestamp.from(now));
try(var rows = query.executeQuery())
{
return (rows.next() ? new Binding(rows.getInt(1), rows.getString(2), rows.getString(3), rows.getTimestamp(4).toInstant()) : null);
}
}
}
/***************************************************************************
** Authentication internals only; normal admin permissions are insufficient.
***************************************************************************/
private static void guard() throws QPermissionDeniedException
{
if(!(QContext.getQSession() instanceof QSystemUserSession))
{
throw (new QPermissionDeniedException("Session identity storage requires the internal authentication context."));
}
}
/***************************************************************************
** Canonical UUIDs prevent different hashes for the same cookie identifier.
***************************************************************************/
private static String sessionHash(String value)
{
try
{
String canonical = UUID.fromString(value).toString();
if(!canonical.equalsIgnoreCase(value))
{
throw (new IllegalArgumentException());
}
return (digest(canonical));
}
catch(RuntimeException invalid)
{
throw (new IllegalArgumentException("A canonical QQQ session UUID is required."));
}
}
/***************************************************************************
** Store only a digest of the cookie identifier and configured source root.
***************************************************************************/
private static String digest(String value)
{
try
{
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))));
}
catch(NoSuchAlgorithmException impossible)
{
throw (new IllegalStateException("SHA-256 is unavailable", impossible));
}
}
}
@@ -0,0 +1,289 @@
/*
* 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.sync;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
/*******************************************************************************
** One host-database lease serializes all directory publishers, including
** different webhook hints and scheduled reconciliation. Remote reads happen
** without a database transaction; publication locks and fences the lease row.
*******************************************************************************/
public final class AuthentikSyncLease
{
private final Connections connections;
private final String table;
private final Duration duration;
private final AuthentikQBitConfig config;
/*******************************************************************************
** Borrow from the host connection provider; no additional pool or secrets.
*******************************************************************************/
@FunctionalInterface
public interface Connections
{
/***************************************************************************
** The caller owns and closes this borrowed connection.
***************************************************************************/
Connection open() throws SQLException;
}
/*******************************************************************************
** Database time captured before remote reads is the next safe watermark.
*******************************************************************************/
public record Claim(String owner, Instant startedAt, Instant watermark, String scopeFingerprint, boolean sameScope)
{
/***************************************************************************
** Do not expose ownership tokens in diagnostics.
***************************************************************************/
@Override
public String toString()
{
return ("DirectorySyncClaim[startedAt=" + startedAt + "]");
}
}
/*******************************************************************************
** The local-backend adapter is responsible for selecting the host database.
*******************************************************************************/
public AuthentikSyncLease(Connections connections, AuthentikQBitConfig config, Duration duration)
{
this.connections = Objects.requireNonNull(connections);
this.config = Objects.requireNonNull(config);
table = config.applyTablePrefix(AuthentikSyncTables.STATE);
if(config.getLocalBackendName() == null || !table.matches("[A-Za-z][A-Za-z0-9_]*") || duration == null
|| duration.compareTo(Duration.ofSeconds(1)) < 0 || duration.compareTo(Duration.ofMinutes(15)) > 0)
{
throw (new IllegalArgumentException("A local sync backend and lease of one second to fifteen minutes are required."));
}
this.duration = duration;
}
/*******************************************************************************
** Initialize once using the unique key, then claim under a short row lock.
** A contender returns null without reading or publishing remote state.
*******************************************************************************/
public Claim acquire() throws SQLException
{
try(var connection = connections.open())
{
connection.setAutoCommit(false);
try(var insert = connection.prepareStatement("INSERT INTO " + table + " (sync_key) VALUES ('directory')"))
{
insert.executeUpdate();
connection.commit();
}
catch(SQLException failure)
{
connection.rollback();
if(!"23505".equals(failure.getSQLState()))
{
throw (failure);
}
}
try
{
Instant watermark;
String fingerprint = scopeFingerprint(config);
boolean sameScope;
try(var query = connection.prepareStatement("SELECT lease_until,watermark,scope_fingerprint FROM " + table + " WHERE sync_key='directory' FOR UPDATE");
var result = query.executeQuery())
{
if(!result.next())
{
throw (new SQLException("Missing directory synchronization state."));
}
Instant now = now(connection);
if(result.getTimestamp(1) != null && result.getTimestamp(1).toInstant().isAfter(now))
{
connection.rollback();
return (null);
}
watermark = result.getTimestamp(2) == null ? null : result.getTimestamp(2).toInstant();
sameScope = fingerprint.equals(result.getString(3));
}
Instant started = now(connection);
Claim claim = new Claim(UUID.randomUUID().toString(), started, watermark, fingerprint, sameScope);
try(var update = connection.prepareStatement("UPDATE " + table + " SET lease_owner=?,lease_until=? WHERE sync_key='directory'"))
{
update.setString(1, claim.owner());
update.setTimestamp(2, Timestamp.from(started.plus(duration)));
update.executeUpdate();
}
connection.commit();
return (claim);
}
catch(SQLException | RuntimeException failure)
{
connection.rollback();
throw (failure);
}
}
}
/*******************************************************************************
** An expired owner cannot renew or publish, even if no one replaced it yet.
*******************************************************************************/
public boolean renew(Claim claim) throws SQLException
{
try(var connection = connections.open())
{
connection.setAutoCommit(false);
try
{
if(!lock(connection, claim))
{
connection.rollback();
return (false);
}
try(var update = connection.prepareStatement("UPDATE " + table + " SET lease_until=? WHERE sync_key='directory'"))
{
update.setTimestamp(1, Timestamp.from(now(connection).plus(duration)));
update.executeUpdate();
}
connection.commit();
return (true);
}
catch(SQLException | RuntimeException failure)
{
connection.rollback();
throw (failure);
}
}
}
/*******************************************************************************
** Caller-owned transaction: keep this row locked through mirror, audit,
** checkpoint and session-marker changes, then commit using QQQ's lifecycle.
*******************************************************************************/
public boolean lock(Connection connection, Claim claim) throws SQLException
{
if(connection.getAutoCommit())
{
throw (new SQLException("Directory publication requires a transaction."));
}
try(var query = connection.prepareStatement("SELECT lease_owner,lease_until FROM " + table + " WHERE sync_key='directory' FOR UPDATE");
var result = query.executeQuery())
{
return (result.next() && claim.owner().equals(result.getString(1)) && result.getTimestamp(2) != null
&& result.getTimestamp(2).toInstant().isAfter(now(connection)));
}
}
/*******************************************************************************
** Advance only inside the successfully fenced publication transaction.
** Recheck ownership; the caller must roll back if this returns false.
*******************************************************************************/
public boolean complete(Connection connection, Claim claim, boolean full) throws SQLException
{
if(!lock(connection, claim))
{
return (false);
}
try(var update = connection.prepareStatement("UPDATE " + table + " SET watermark=?,scope_fingerprint=?,"
+ (full ? "last_full_sync=?," : "") + "lease_owner=NULL,lease_until=NULL WHERE sync_key='directory'"))
{
update.setTimestamp(1, Timestamp.from(claim.startedAt()));
update.setString(2, claim.scopeFingerprint());
if(full)
{
update.setTimestamp(3, Timestamp.from(claim.startedAt()));
}
return (update.executeUpdate() == 1);
}
}
/*******************************************************************************
** Failed work releases only its own lease, preserving the prior checkpoint.
*******************************************************************************/
public void release(Claim claim) throws SQLException
{
try(var connection = connections.open())
{
connection.setAutoCommit(false);
try(var update = connection.prepareStatement("UPDATE " + table + " SET lease_owner=NULL,lease_until=NULL WHERE sync_key='directory' AND lease_owner=?"))
{
update.setString(1, claim.owner());
update.executeUpdate();
connection.commit();
}
catch(SQLException failure)
{
connection.rollback();
throw (failure);
}
}
}
/*******************************************************************************
** Targeted events do not certify unrelated profiles as newly reconciled.
** Release ownership atomically with publication, preserving the watermark.
*******************************************************************************/
public boolean completeHint(Connection connection, Claim claim) throws SQLException
{
if(!lock(connection, claim))
{
return (false);
}
try(var update = connection.prepareStatement("UPDATE " + table + " SET lease_owner=NULL,lease_until=NULL WHERE sync_key='directory'"))
{
return (update.executeUpdate() == 1);
}
}
/*******************************************************************************
** Database clock keeps different hosts from disagreeing on ownership.
*******************************************************************************/
static Instant now(Connection connection) throws SQLException
{
String clock = "PostgreSQL".equals(connection.getMetaData().getDatabaseProductName()) ? "clock_timestamp()" : "CURRENT_TIMESTAMP";
try(var query = connection.prepareStatement("SELECT " + clock); var result = query.executeQuery())
{
result.next();
return (result.getTimestamp(1).toInstant());
}
}
/*******************************************************************************
** Scope/configuration changes require a full baseline, including expansion
** to users whose source timestamps did not change. Persist only a digest.
*******************************************************************************/
static String scopeFingerprint(AuthentikQBitConfig config)
{
var groups = config.getUserScopeGroupNames() == null ? java.util.List.<String>of()
: config.getUserScopeGroupNames().stream().sorted().distinct().toList();
String value = new org.json.JSONArray().put(config.getApiRoot()).put(config.getUserScopePathStartsWith())
.put(new org.json.JSONArray(groups)).put(new org.json.JSONArray(com.kingsrook.qbits.authentik.client.AuthentikTables.DEFAULT_USER_TYPES))
.put(config.getPolicyFormat().spec()).put(config.getPolicyFormat().version()).put(config.getPolicyFormat().attribute()).toString();
try
{
return (java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))));
}
catch(java.security.NoSuchAlgorithmException impossible)
{
throw (new IllegalStateException("SHA-256 is unavailable", impossible));
}
}
}
@@ -0,0 +1,171 @@
/*
* 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.sync;
import java.time.Duration;
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.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
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.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
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.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Native background processes; database ownership, not scheduler locality,
** fences publication. The QBit producer registers these when MIRROR is enabled.
*******************************************************************************/
public class AuthentikSyncProcesses implements BackendStep
{
public static final String INBOX = "syncInbox";
public static final String INCREMENTAL = "syncIncremental";
public static final String FULL = "syncFull";
public static final String RETENTION = "syncRetention";
/***************************************************************************
** No threads or extra scheduler are created. Ambiguous scheduler selection
** is a configuration error rather than silently scheduling on another host.
***************************************************************************/
public static List<QProcessMetaData> processes(QInstance instance, AuthentikQBitConfig config)
{
if(!config.hasFeature(AuthentikFeature.MIRROR))
{
return (List.of());
}
String scheduler = config.getSchedulerName();
if(scheduler == null && instance.getSchedulers().size() == 1)
{
scheduler = instance.getSchedulers().keySet().iterator().next();
}
if(scheduler == null || !instance.getSchedulers().containsKey(scheduler))
{
throw (new IllegalArgumentException("Mirror synchronization requires an unambiguous registered scheduler."));
}
if(!instance.getSchedulers().get(scheduler).supportsCronSchedules())
{
throw (new IllegalArgumentException("Mirror synchronization requires a scheduler supporting the nightly cron schedule."));
}
List<QProcessMetaData> processes = new ArrayList<>();
// Reconcile before the fifteen-minute authorization snapshot expires.
// The previous scan-start watermark stays authoritative until commit;
// starting only at its expiry would deny access during every next scan.
processes.add(process(config, INCREMENTAL, "Reconcile identity changes", new QScheduleMetaData()
.withSchedulerName(scheduler).withRepeatSeconds(300).withInitialDelaySeconds(1)));
processes.add(process(config, FULL, "Reconcile complete identity directory", new QScheduleMetaData()
.withSchedulerName(scheduler).withCronExpression("0 0 2 * * ?").withCronTimeZoneId("UTC")));
processes.add(process(config, RETENTION, "Expire identity synchronization history", new QScheduleMetaData()
.withSchedulerName(scheduler).withRepeatSeconds(86400).withInitialDelaySeconds(3600)));
if(config.hasFeature(AuthentikFeature.WEBHOOK))
{
var inbox = process(config, INBOX, "Process identity change events", new QScheduleMetaData()
.withSchedulerName(scheduler).withRepeatSeconds(2).withInitialDelaySeconds(2));
if(com.kingsrook.qqq.backend.core.utils.StringUtils.hasContent(config.getEsbDestinationName()))
{
com.kingsrook.qbits.authentik.esb.AuthentikEsbDelivery.attach(instance, config, inbox);
}
processes.add(inbox);
}
return (List.copyOf(processes));
}
/***************************************************************************
** Inputs contain routing names only, never credentials or event payloads.
***************************************************************************/
private static QProcessMetaData process(AuthentikQBitConfig config, String operation, String label, QScheduleMetaData schedule)
{
return (new QProcessMetaData().withName(config.applyPrefix(operation)).withLabel(label).withIsHidden(true)
.withPermissionRules(IdentityPermission.ADMIN.rules()).withSchedule(schedule)
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(AuthentikSyncProcesses.class))
.withInputData(new QFunctionInputMetaData()
.withField(new QFieldMetaData("backendName", QFieldType.STRING).withDefaultValue(config.getApiBackendName()))
.withField(new QFieldMetaData("operation", QFieldType.STRING).withDefaultValue(operation)))));
}
/***************************************************************************
** Only the host's internal scheduler/ESB context may run directory jobs.
** Never elevate an interactive caller. Feature disablement applies live.
***************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(!(QContext.getQSession() instanceof QSystemUserSession))
{
throw (new QPermissionDeniedException("Directory synchronization requires the internal session."));
}
var config = AuthentikRuntime.forBackend(input.getValueString("backendName")).getConfig();
String operation = input.getValueString("operation");
if(!config.hasFeature(AuthentikFeature.MIRROR) || (INBOX.equals(operation) && !config.hasFeature(AuthentikFeature.WEBHOOK)))
{
throw (new QPermissionDeniedException("Directory synchronization feature is disabled."));
}
try
{
// The refreshing OAuth adapter reads committed mirror permissions on
// every request. Publication itself is its durable reevaluation signal;
// there are no node-local session caches or separate markers to update.
AuthentikMirrorWorker.Publication publication = (snapshot, transaction) -> { };
switch(operation == null ? "" : operation)
{
case INBOX ->
{
var result = AuthentikApplyChangeEvents.forHost(config, publication).runBatch();
output.addValue("succeeded", result.succeeded());
output.addValue("retriedOrFailed", result.retriedOrFailed());
output.addValue("lostLease", result.lostLease());
output.addValue("deferred", result.deferred());
}
case INCREMENTAL, FULL ->
{
var worker = new AuthentikMirrorWorker(config, AuthentikActionUtil.forBackend(config.getApiBackendName()));
boolean published = FULL.equals(operation) ? worker.fullRefresh(publication, null, null) : worker.incrementalRefresh(publication);
output.addValue("published", published);
}
case RETENTION ->
{
output.addValue("eventsPurged", new AuthentikWebhookInbox(config, Duration.ofMinutes(15)).purge());
output.addValue("bindingsPurged", new AuthentikSessionBindings(config).purgeExpired());
output.addValue("invitationIntentsPurged", new AuthentikInvitationAssignments(config).purgeSettled());
}
default -> throw (new QException("Unknown directory synchronization operation."));
}
}
catch(Exception failure)
{
if(failure instanceof InterruptedException)
{
Thread.currentThread().interrupt();
}
// Process persistence/logging must not capture upstream payloads or SQL.
throw (new QException("Directory synchronization did not complete."));
}
}
}
@@ -0,0 +1,89 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.qbits.authentik.sync;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
/*******************************************************************************
** Opt-in host registration. No database migrations or network calls run here;
** the host owns physical storage, audit metadata and scheduler startup.
*******************************************************************************/
public final class AuthentikSyncRegistration
{
/***************************************************************************
** Utility class.
***************************************************************************/
private AuthentikSyncRegistration()
{
}
/***************************************************************************
** Validate host dependencies; safe to repeat after registration for health.
***************************************************************************/
public static void validate(QInstance instance, AuthentikQBitConfig config, List<String> errors)
{
if(instance == null || config.getLocalBackendName() == null
|| !(instance.getBackend(config.getLocalBackendName()) instanceof RDBMSBackendMetaData))
{
errors.add("feature MIRROR needs localBackendName naming a registered RDBMS backend");
}
if(instance == null)
{
errors.add("feature MIRROR needs a host instance with a registered scheduler");
return;
}
try
{
AuthentikSyncProcesses.processes(instance, config);
}
catch(IllegalArgumentException invalid)
{
errors.add(invalid.getMessage());
}
}
/***************************************************************************
** Producer-only collision checks, before any host metadata is mutated.
***************************************************************************/
public static void validateNames(QInstance instance, AuthentikQBitConfig config, List<String> errors)
{
for(var table : AuthentikSyncTables.all(config))
{
if(instance.getTable(table.getName()) != null)
{
errors.add("Mirror table name already registered: " + table.getName());
}
}
for(var process : AuthentikSyncProcesses.processes(instance, config))
{
if(instance.getProcess(process.getName()) != null)
{
errors.add("Mirror process name already registered: " + process.getName());
}
}
if(config.hasFeature(AuthentikFeature.WEBHOOK))
{
com.kingsrook.qbits.authentik.webhook.AuthentikWebhookRouteProvider.validateRegistration(instance, config, errors);
}
}
/***************************************************************************
** Add the complete storage set and native background processes. Features
** are opt-in; read-only controls upstream edits, not local synchronization.
***************************************************************************/
public static void register(QInstance instance, AuthentikQBitConfig config)
{
AuthentikSyncTables.all(config).forEach(instance::addTable);
AuthentikSyncProcesses.processes(instance, config).forEach(instance::addProcess);
if(config.hasFeature(AuthentikFeature.WEBHOOK))
{
com.kingsrook.qbits.authentik.webhook.AuthentikWebhookRouteProvider.register(instance, config);
}
}
}
@@ -0,0 +1,224 @@
/*
* 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.sync;
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.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Local sync storage. These definitions are not produced until the complete
** MIRROR/WEBHOOK implementation is available. No generic writes are allowed.
*******************************************************************************/
public final class AuthentikSyncTables
{
public static final String USER = "user_mirror";
public static final String GROUP = "group_mirror";
public static final String MEMBERSHIP = "membership_mirror";
public static final String INBOX = "webhook_inbox";
public static final String STATE = "sync_state";
public static final String SESSION_BINDING = "session_binding";
public static final String INVITATION_ASSIGNMENT = "invitation_assignment";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikSyncTables()
{
}
/*******************************************************************************
** Directory projection; no raw user attributes, IPs, or user agents.
** Blank email is stored as null; ambiguous identities must fail sync and
** must never be used to grant fallback permissions.
*******************************************************************************/
public static QTableMetaData user(AuthentikQBitConfig config)
{
return (base(config, USER, "User Mirror", IdentityPermission.USERS_READ)
.withUniqueKey(new UniqueKey("pk"))
.withUniqueKey(new UniqueKey("username"))
.withUniqueKey(new UniqueKey("email"))
.withField(field("pk", QFieldType.INTEGER).withIsRequired(true))
.withField(text("uuid", 64))
.withField(text("username", 255).withIsRequired(true))
.withField(text("email", 255))
.withField(text("name", 255))
.withField(text("type", 40))
.withField(text("path", 1024))
.withField(field("sourceIsSuperuser", QFieldType.BOOLEAN))
.withField(field("sourceIsProtected", QFieldType.BOOLEAN))
.withField(field("isActive", QFieldType.BOOLEAN))
.withField(field("lastLogin", QFieldType.DATE_TIME))
.withField(field("isSuperuser", QFieldType.BOOLEAN))
.withField(field("isProtected", QFieldType.BOOLEAN))
.withField(field("groupNames", QFieldType.TEXT))
.withField(field("effectivePermissions", QFieldType.TEXT))
.withField(field("mfaDeviceCount", QFieldType.INTEGER))
.withField(field("failedLogins24h", QFieldType.INTEGER)));
}
/*******************************************************************************
** Group projection, including only qqq.io/* attributes.
*******************************************************************************/
public static QTableMetaData group(AuthentikQBitConfig config)
{
return (base(config, GROUP, "Group Mirror", IdentityPermission.USERS_READ)
.withUniqueKey(new UniqueKey("pk"))
.withField(text("pk", 64).withIsRequired(true))
.withField(text("name", 255))
.withField(field("parentPks", QFieldType.TEXT))
.withField(field("attributes", QFieldType.TEXT))
.withField(field("memberCount", QFieldType.INTEGER))
.withField(field("isSuperuser", QFieldType.BOOLEAN)));
}
/*******************************************************************************
** Stable user/group keys, independent of display-name changes.
*******************************************************************************/
public static QTableMetaData membership(AuthentikQBitConfig config)
{
return (base(config, MEMBERSHIP, "Membership Mirror", IdentityPermission.USERS_READ)
.withUniqueKey(new UniqueKey("userPk", "groupPk"))
.withField(field("userPk", QFieldType.INTEGER).withIsRequired(true))
.withField(text("groupPk", 64).withIsRequired(true))
.withField(text("groupName", 255)));
}
/*******************************************************************************
** Durable event deduplication and retry state. Only the validated compact
** webhook envelope is retained, never the source event's full context.
*******************************************************************************/
public static QTableMetaData inbox(AuthentikQBitConfig config)
{
QTableMetaData table = base(config, INBOX, "Webhook Inbox", IdentityPermission.ADMIN)
.withUniqueKey(new UniqueKey("eventPk"))
.withField(text("eventPk", 64).withIsRequired(true))
.withField(field("payloadJson", QFieldType.TEXT).withIsRequired(true))
.withField(text("status", 30).withIsRequired(true))
.withField(field("attempts", QFieldType.INTEGER).withIsRequired(true))
.withField(field("receivedAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(field("nextAttemptAt", QFieldType.DATE_TIME))
.withField(field("processedAt", QFieldType.DATE_TIME))
.withField(text("leaseOwner", 64))
.withField(field("leaseUntil", QFieldType.DATE_TIME))
.withField(text("errorCode", 100));
table.getFields().remove("refreshedAt");
return (table);
}
/*******************************************************************************
** Checkpoints advance only after successful synchronization. Lease fields
** support exclusive work claims across host processes using a transaction.
*******************************************************************************/
public static QTableMetaData state(AuthentikQBitConfig config)
{
QTableMetaData table = base(config, STATE, "Sync State", IdentityPermission.ADMIN)
.withUniqueKey(new UniqueKey("syncKey"))
.withField(text("syncKey", 64).withIsRequired(true))
.withField(field("watermark", QFieldType.DATE_TIME))
.withField(field("lastFullSync", QFieldType.DATE_TIME))
.withField(text("scopeFingerprint", 64))
.withField(text("leaseOwner", 64))
.withField(field("leaseUntil", QFieldType.DATE_TIME));
table.getFields().remove("refreshedAt");
return (table);
}
/*******************************************************************************
** Complete storage set, for migration/schema validation.
*******************************************************************************/
public static List<QTableMetaData> all(AuthentikQBitConfig config)
{
return (List.of(user(config), group(config), membership(config), inbox(config), state(config), sessionBinding(config), invitationAssignment(config)));
}
/***************************************************************************
** Internal pending grants and replay protection; no generic mutation/export.
***************************************************************************/
public static QTableMetaData invitationAssignment(AuthentikQBitConfig config)
{
var table = base(config, INVITATION_ASSIGNMENT, "Invitation Assignments", IdentityPermission.ADMIN)
.withUniqueKey(new UniqueKey("reference")).withUniqueKey(new UniqueKey("invitationId"))
.withField(text("reference", 36).withIsRequired(true))
.withField(text("scopeFingerprint", 64).withIsRequired(true))
.withField(text("invitationId", 36))
.withField(text("status", 20).withIsRequired(true))
.withField(field("payloadJson", QFieldType.TEXT).withIsRequired(true))
.withField(field("createdAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(field("expiresAt", QFieldType.DATE_TIME).withIsRequired(true));
table.getFields().remove("refreshedAt");
return (table);
}
/*******************************************************************************
** Internal authentication identity storage; no token or session credential.
*******************************************************************************/
public static QTableMetaData sessionBinding(AuthentikQBitConfig config)
{
QTableMetaData table = base(config, SESSION_BINDING, "Session Identity Binding", IdentityPermission.ADMIN)
.withUniqueKey(new UniqueKey("sessionHash"))
.withField(text("sessionHash", 64).withIsRequired(true))
.withField(text("issuerFingerprint", 64).withIsRequired(true))
.withField(field("userPk", QFieldType.INTEGER))
.withField(text("userUuid", 64))
.withField(text("userReference", 1024))
.withField(field("expiresAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(field("revoked", QFieldType.BOOLEAN).withIsRequired(true));
table.getFields().remove("refreshedAt");
return (table);
}
/*******************************************************************************
** Explicit column mapping keeps host naming strategies out of the contract.
*******************************************************************************/
private static QFieldMetaData field(String name, QFieldType type)
{
return (new QFieldMetaData(name, type).withIsEditable(false)
.withBackendName(name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(java.util.Locale.ROOT)));
}
/*******************************************************************************
** Bounded string column.
*******************************************************************************/
private static QFieldMetaData text(String name, int length)
{
return (field(name, QFieldType.STRING).withMaxLength(length));
}
/*******************************************************************************
** Read-only metadata; sync's internal persistence must bypass generic writes
** deliberately. Mirror changes retain RECORD-level QQQ audit.
*******************************************************************************/
private static QTableMetaData base(AuthentikQBitConfig config, String name, String label, IdentityPermission permission)
{
return (new QTableMetaData().withName(config.applyTablePrefix(name)).withLabel(label)
.withBackendName(config.getLocalBackendName()).withPrimaryKeyField("id")
.withPermissionRules(permission.rules())
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT)
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE, Capability.TABLE_EXPORT)
.withField(field("id", QFieldType.INTEGER))
.withField(field("refreshedAt", QFieldType.DATE_TIME).withIsRequired(true)));
}
}
@@ -0,0 +1,187 @@
/*
* 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.sync;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.Set;
import java.util.UUID;
import org.json.JSONObject;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
/*******************************************************************************
** Authenticated, bounded webhook hints. Never persist the original event body:
** Authentik model context can contain arbitrary attributes and sensitive data.
** A hint identifies what to re-read; it is never authority for a permission.
*******************************************************************************/
public record AuthentikWebhookEnvelope(String eventPk, String action, Instant created,
String username, String modelApp, String modelName, String modelPk, String app)
{
public static final int MAX_BODY_BYTES = 65536;
private static final Set<String> ACTIONS = Set.of("model_created", "model_updated", "model_deleted",
"login", "login_failed", "password_set", "invitation_used", "user_write");
/*******************************************************************************
** Authenticate before parsing. Hashes have fixed equal length for the
** constant-time comparison. Fail closed on missing configured credentials.
** Callers must also enforce the body limit while reading the HTTP request.
*******************************************************************************/
public static AuthentikWebhookEnvelope receive(String configuredSecret, String suppliedSecret, byte[] body)
{
authenticate(configuredSecret, suppliedSecret);
return (parse(body));
}
/*******************************************************************************
** HTTP adapters authenticate before reading an untrusted request stream.
*******************************************************************************/
public static void authenticate(String configuredSecret, String suppliedSecret)
{
if(configuredSecret == null || configuredSecret.isBlank())
{
throw (new IllegalStateException("Webhook secret is not configured"));
}
if(suppliedSecret == null || suppliedSecret.length() > 4096 || !MessageDigest.isEqual(digest(configuredSecret), digest(suppliedSecret)))
{
throw (new SecurityException("Webhook authentication failed"));
}
}
/*******************************************************************************
** Revalidate a previously authenticated inbox row. Never use this method
** on an HTTP request; receive performs the required authentication first.
*******************************************************************************/
public static AuthentikWebhookEnvelope fromStorage(String json)
{
return (parse(json == null ? null : json.getBytes(StandardCharsets.UTF_8)));
}
/*******************************************************************************
** Validate the bounded, allowlisted envelope shared by ingress and replay.
*******************************************************************************/
private static AuthentikWebhookEnvelope parse(byte[] body)
{
if(body == null || body.length == 0 || body.length > MAX_BODY_BYTES)
{
throw (new IllegalArgumentException("Invalid webhook body size"));
}
try
{
JSONObject object = new JSONObject(new String(body, StandardCharsets.UTF_8));
String eventPk = string(object, "eventPk", 64, true);
if(!UUID.fromString(eventPk).toString().equalsIgnoreCase(eventPk))
{
throw (new IllegalArgumentException());
}
eventPk = UUID.fromString(eventPk).toString();
String action = string(object, "action", 40, true);
if(!ACTIONS.contains(action))
{
throw (new IllegalArgumentException());
}
Instant created = OffsetDateTime.parse(string(object, "created", 64, true)).toInstant();
JSONObject model = object.optJSONObject("model");
// Login/invitation events may name another model. They still trigger
// a re-read, but unrelated model context is never retained.
if(!action.startsWith("model_") && model != null
&& (!"authentik_core".equals(model.optString("app")) || !Set.of("user", "group").contains(model.optString("model_name"))))
{
model = null;
}
String modelApp = null;
String modelName = null;
String modelPk = null;
if(model != null)
{
modelApp = string(model, "app", 64, true);
modelName = string(model, "model_name", 64, true);
Object pk = model.opt("pk");
modelPk = pk instanceof Number ? pk.toString() : string(model, "pk", 64, true);
if(!"authentik_core".equals(modelApp) || !Set.of("user", "group").contains(modelName)
|| modelPk.length() > 64 || !modelPk.matches("[A-Za-z0-9_-]+"))
{
throw (new IllegalArgumentException());
}
}
if(action.startsWith("model_") && model == null)
{
throw (new IllegalArgumentException());
}
return (new AuthentikWebhookEnvelope(eventPk, action, created, string(object, "username", 255, false),
modelApp, modelName, modelPk, string(object, "app", 255, false)));
}
catch(RuntimeException e)
{
// JSON/date exceptions can echo payload values. Do not retain causes.
throw (new IllegalArgumentException("Invalid webhook envelope"));
}
}
/*******************************************************************************
** Persist only the allowlisted transport mapping fields.
*******************************************************************************/
public String storageJson()
{
JSONObject object = new JSONObject().put("eventPk", eventPk).put("action", action)
.put("created", created.toString()).put("username", username).put("app", app);
if(modelPk != null)
{
object.put("model", new JSONObject().put("app", modelApp).put("model_name", modelName).put("pk", modelPk));
}
return (AuthentikRedactor.redactText(object.toString(), null));
}
/*******************************************************************************
** Strict strings only; JSON objects are never coerced into stored text.
*******************************************************************************/
private static String string(JSONObject object, String key, int max, boolean required)
{
Object raw = object.opt(key);
if(raw == null || JSONObject.NULL.equals(raw))
{
if(required)
{
throw (new IllegalArgumentException());
}
return (null);
}
if(!(raw instanceof String value) || value.length() > max || (required && value.isBlank()))
{
throw (new IllegalArgumentException());
}
return (value);
}
/*******************************************************************************
** SHA-256 is required by every Java platform; no secret is logged or held
** in the returned envelope.
*******************************************************************************/
private static byte[] digest(String value)
{
try
{
return (MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
}
catch(NoSuchAlgorithmException e)
{
throw (new IllegalStateException("SHA-256 is unavailable"));
}
}
}
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more