Compare commits

..
Author SHA1 Message Date
james.maesandCodex d72ab492b3 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:24:37 -05:00
james.maesandCodex 53c54c659c ci: use the shared orb pipeline on main
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 09:00:59 -05:00
296 changed files with 295 additions and 128470 deletions

No files matched your search

+3 -3
View File
@@ -1,14 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
qqq-orb: kingsrook/qqq-orb@0.7.12
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
orb_version: 0.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
ignore: /.*/
-20
View File
@@ -1,20 +0,0 @@
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 3
tab_width = 3
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{cmd,bat}]
end_of_line = crlf
-3
View File
@@ -1,3 +0,0 @@
* text=auto eol=lf
*.cmd text eol=crlf
mvnw text eol=lf
-10
View File
@@ -1,10 +0,0 @@
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
-25
View File
@@ -1,25 +0,0 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once. Security updates are enabled in the repository settings and are
# not limited by this schedule.
version: 2
updates:
- package-ecosystem: maven
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
patterns: ['*']
-74
View File
@@ -1,74 +0,0 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
-16
View File
@@ -1,16 +0,0 @@
name: authentik-schema-drift
'on':
schedule:
- cron: 17 6 * * 1
workflow_dispatch:
inputs:
version:
description: Authentik version (e.g. 2026.10.0); empty = latest release
required: false
permissions:
contents: read
jobs:
drift:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-schema-drift.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
with:
version: ${{ inputs.version || '' }}
+15 -18
View File
@@ -1,23 +1,20 @@
# Maven
target/
.env
.idea
*.iml
*.class
*.log
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
# IDE
.idea/
*.iml
.vscode/
.project
.classpath
.settings/
# OS
.DS_Store
*.sw*
.flattened-pom.xml
dependency-reduced-pom.xml
/.env.local
/.cache/
.claude/settings.local.json
__pycache__/
*.py[cod]
Thumbs.db
# Logs
*.log
-3
View File
@@ -1,3 +0,0 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+6 -7
View File
@@ -1,16 +1,15 @@
profile: qbit
orb_version: '0.8'
orb_version: 0.7.12
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
release_tag_policy: none
test_branch_policy: all
publish:
snapshot: true
release_candidate: true
release: true
snapshot: false
release_candidate: false
release: false
asciidoc: false
release_completion: true
-78
View File
@@ -1,78 +0,0 @@
# AGENTS.md / CLAUDE.md
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md in sync.
## Project Overview
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and writes through the Authentik API (never its DB). Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = the full user lifecycle and group/permission management (phases 0-3), read-only by default.
## Build Commands
```bash
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
./mvnw test -Dtest=ClassName
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
./mvnw test -Dtest=SchemaDriftTest -Dauthentik.schema.file=/path/to/schema.yml # drift vs another release
```
- **Contract ITs** (`*IT`, package `contract`) share one `AuthentikTestStack` per JVM; first boot takes ~1.5-3 minutes. `@ExtendWith(RequiresDockerCondition.class)`: skipped without Docker locally, failed with `CI=true`.
- The stack boots with `src/test/resources/blueprints/*.yaml` already in `/blueprints/custom`: that IS contract test (h) (bootstrap on an empty instance).
- `TestApi` uses HTTP/1.1 on purpose: Java's HttpClient tries an h2c upgrade on plain-http POSTs, which Authentik answers with "400 Invalid HTTP request received."
- Tests that grant extra permissions to the service-account role must remove them again (they are additive and shared).
## Architecture
- `AuthentikQBitProducer` validates the config, registers an `AuthentikRuntime` for the API backend, then runs the component producers in `metadata/`.
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `authentik.admin`.
## Invariants (do not break)
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
- Never filter client-side; reject what Authentik cannot evaluate.
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
## Code Style
3-space indentation, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters, wrapper types in config.
## Dependencies
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
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.
## 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.
-129
View File
@@ -1,129 +0,0 @@
# Changelog
All notable changes to this project are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document under the configured attribute, a differently-specced one included (skipping other specs is an explicit opt-in). Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). One shared rule (`SessionPermissionRules`) for sign-in, automated sessions and the refreshing mapper: a Deny subtracts policy grants, the mirror's stored permissions, static fallbacks, the host source and earlier session permissions, also while the cut-over guard blocks (only bootstrap identity permissions are exempt); an invalid policy or a missing group/parent leaves break-glass only even while blocked; an unreadable graph/mirror with no claim leaves only the configured outage fallbacks; a guard error keeps the last known real state (never OFF, original time, 15 minute bound); the refreshing mapper reads Deny rules from the mirrored group rows on the same connection and refuses an inconsistent or truncated snapshot; the mirror stores a valid policy document unredacted (an invalid one is redacted like any attribute); bootstrap operators keep their identity permissions even when their own group is void or denies them; a token with no groups claim and no policy claim is unavailable, an empty groups array is known-empty; a missing parent voids; documents with an unknown top-level key or no `Statement` are rejected; the group fingerprint covers the configured policy attribute; the refreshing mapper reads Deny rules from the mirror (no API call). Review fixes: operators are credited with a floor permission only if their own policies do not deny it; the configured policy attribute is reserved against Edit Group/User attributes; un-voiding an invalid policy is a release; a Deny a person keeps deliberately no longer trips the cut-over guard; a Deny of a prefix or named resource also covers its dotted host names; empty Resource/Action/Permission arrays and malformed host names are rejected; Save Policy requires `expectedFingerprint`; `PolicyResult` / `resultFromClaims` / `resolveResult` replace the deprecated plain-set APIs. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`.
- Fix: native **Create Group** now obeys the two-person gate. With `twoPersonApproval=true`, creating a group whose permissions expand to any `authentik.*` permission queues an `authentik_approval_request` for a second person instead of persisting the grant immediately; the same identity-permission expansion and second-person condition as Edit Group apply, nothing is written before approval, self-approval is refused and a decided request cannot be approved again. Previously a new group with `authentik.users.read` was created without approval.
- Fix (performance): one policy resolution builds the permission universe once, whatever the policy holds. `PolicyRules.allowed`, `resolve` and `withheld` read the universe they are given once per call (they read it once per Allow term), so `PolicyPermissions.resultFromClaims`, `fromClaims`, both claims mappers and the editor's save-time checks no longer pay one `PolicyCatalog` fingerprint, one walk of every resource and one call of every host-name function for each named permission. A host customizer runs on every request of a session, so a role of nine named permissions paid nine universes per request: on a 1,400-object instance one resolution went from 12 ms and 7.9 MB to 0.3 ms and 0.12 MB, and it no longer grows with the number of names. `PolicyCatalog.of` checks that its cached catalog is current without building the fingerprint text, and the catalog keeps its permission names. Grants are unchanged (the golden vectors pass as they were); the universe and the catalog still follow in-place instance changes on the next call; nothing is cached across resolutions. New: `PolicyUniverse.once(universe)`.
- `PolicyResult.replacement()`: the complete permission set of a session whose only source is the policies (Allow minus Deny, nothing carried over, nothing when void or unreadable). `apply(otherSources)` is unchanged and remains a union; a host that calls `apply(session.getPermissions())` keeps what the session already held, so a host whose permissions come only from the token calls `session.setPermissions(result.replacement())`.
- **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255).
- Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: <permission>`, `malformed <attribute>` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was.
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
- Replace expired token-delivery and credential-scope reflection bridges with the RC.7 APIs; retain conservative permission narrowing and explicit unavailable-delivery tests.
- Ship the expanded, host-neutral QBIT help bundle using QQQ 4.1.0-RC.7 registration and validation, with coverage for optional features and live references. Preserve inline app/table help for hosts without help storage; document HOST bundle overrides.
- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied inline by `AuthentikHelp` as well as registered with core, which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes.
- `AuthentikQBitConfig.fullAccessHostPermissions` lets a host declare permission names core cannot enumerate (custom checkers, named permissions); full access expands to them at use time, for the instance holding that runtime's backend only.
- `AuthentikMaintenance.applyPolicy` lets reviewed host run-once jobs apply a policy to an exact group name through the existing audited Save Policy path. Dedicated maintenance identities, full-access expansion, canonical no-ops, context restoration, stale checks and optional two-person approval are covered; system-session writes remain forbidden. No HTTP route or process is registered.
- Explicit full-access policy statement (`Action: ["*"], Resource: ["*"]`) resolves against all current core and qbit permissions, including features, READ_WRITE `write`, identity names and future resources. Added canonical round trips, complete-set save authorization, last-admin removal protection and the editor `fullAccess` payload contract; named-resource wildcards retain their five-action meaning.
- Edit Policy supplies per-member preview data for up to 200 active, in-scope direct and descendant members, with relevant direct group names, base permissions excluding only the edited policy, a truncation flag, and the configured mapper-enabled flag. The Next UI can compute effective permissions and additions/removals locally while preserving inherited, named, static and bootstrap grants.
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
### Added (central health)
- `authentik.api`, `authentik.api_token`, `authentik.mapper`, and `authentik.token_broker` dependency checks registered by the producer, with safe details and bounded requests through the existing client.
- Client-credentials broker probe using the existing token transport; its bearer is discarded.
### Removed (central health)
- The Identity home Connection health card, its direct version/token-expiry probes, and its inline mapper status. A single central component status links to System Health. The configuration drift-report widget is retained.
### Added (access tokens 6c)
- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153.
- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked.
- Daily expiry reminders with persistent per-window deduplication and optional Alerts delivery; high-severity guard-bypass alerts.
- A standard Access tokens TABLE widget with owner counts, age buckets and cleanup links, plus the existing record/list bulk revoke action.
### Added (access tokens, DESIGN.md section 13)
- Default-on `TOKENS` with personal create/rotate/revoke/extend, service-account creation and rotation, protected-owner guardrails and optional two-person approval.
- Action-scoped OAuth2 token exchange, response-only one-time key delivery (fail closed on older QQQ), generated usage instructions and non-secret token scope records.
- Token-manager read access, expanded token metadata, secret redaction and in-memory process/persistence tests.
### Changed (core baseline, #20 / F19)
- Build against the released QQQ BOM `4.1.0-RC.6` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. Adopt `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
- Core actions now enforce permissions for non-HTTP callers. Authentik-specific guards remain for direct util calls and domain rules. See [UPGRADE.md](UPGRADE.md) for host impact and feature registration requirements.
- Replace the expired feature-catalog stand-in with real `TableFeaturePermissionsMetaData` registrations and remove the test-only `PolicyCatalog` permission-source overload. Feature discovery uses the core catalog without static mocking.
### Added (automated sessions)
- `AuthentikGroupPermissionMapper` resolves tokenless automated sessions by a unique active Authentik email match, applying direct and inherited group permissions, static fallbacks and the sign-in cut-over guard while preserving existing session permissions.
### Added (table feature permissions)
- Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData
### Added (policy cut-over, DESIGN.md section 12.8)
- `AuthentikHostPermissionSource` SPI (today's access) and config `mapperEnabled`, `cutoverGuardGroups`, `mapperOverride`, `staticGroupPermissions`.
- **Enforcement status**: banner widget `authentikPolicyEnforcement` (first on group and policy records and on the Edit Policy step) and a "Policies at sign-in" row in the home's Connection card: OFF, ACTIVE, OVERRIDDEN or BLOCKED.
- **Compare Access** widget `authentikCompareAccess` on the app home (`authentik.admin`, read only): per active person, today vs the mapper, with added, removed and unchanged, and the guard result.
- **Cut-over guard**: while a member of a guarded group would lose a permission (and no override), the mapper grants today's access plus the static fallback and bootstrap subjects instead of the policies.
- **Generate Policy From Current Access** group action: the expressible part of today's access (joined with the current policy) becomes the policy, named permissions go to `qqq.io/permissions`, the rest is listed; Review shows the document and diff.
### Added (full user and group CRUD, phase 5c: DESIGN.md section 12.5)
- **Edit User** also changes the username (validated, unique) and the other attributes (a JSON object; reserved `qqq.io/` and `goauthentik.io/` keys are kept and cannot be set; blank leaves them as they are; editing attributes needs `authentik.audit.read`, which is also what shows them on the form). **Edit Group** edits other attributes the same way.
- **Delete User** and **Delete Group** record actions behind the new `DELETE` feature (off by default; needs Authentik's `delete_user` / `delete_group`). Typed confirmation; never yourself, a protected account, a superuser group or the last identity administrator; a group must have no members and no child groups first; the group's `authentik_policy` row goes with it. Deactivate stays the default for people.
### Added (policy editor, phase 5b: DESIGN.md section 12.4)
- **Edit Policy** record action on groups and on policies (feature `POLICIES`, permission `authentik.policies.manage`): a standard streamed-ETL action whose step shows the `AUTHENTIK_POLICY_EDITOR` widget (Next UI). Review parses the submitted document (fail closed), regenerates it in canonical form with the round-trip check, refuses new resources this app does not define, applies the subset rule to the permissions it adds, and lists what is added and removed. Result merge-patches `qqq.io/policy` into the group's current attributes (removed when empty). Optional second-person approval (`policyTwoPersonApproval`).
- **`PolicyCatalog`**: every app, table, process (hidden record actions included), report and protected widget a policy can grant, with the actions QQQ checks, grouped by app. Its permission names join the permission catalog.
- **`authentik_policy`** local table (one row per group; field-level audit is the change history; DDL in `docs/schema.sql`), in the nav under Access and as a child list on the group.
- Widget `authentikPolicyEditor` (type `AUTHENTIK_POLICY_EDITOR`): the catalog and the group's live document, with a flag when it was changed in Authentik since the last save in QQQ.
### Added (policies, phase 5a: DESIGN.md section 12, docs/POLICY-FORMAT.md)
- **Policy documents** (IAM-style statements) on Authentik groups: `PolicyParser` (fails closed: unknown spec or version, non-Allow effect, unknown action, bad resource or malformed JSON grant nothing), `PolicyGenerator` (canonical form with a round-trip check), `PolicyFormat` and the config `policySpec`, `policyVersion`, `policyAttribute`, `policyClaim`, `policyTwoPersonApproval`.
- **Runtime**: `AuthentikPolicyClaimMapper` (reads the `qqq_policy` claim from the ID token or access token; `applySecurityKeys` hook for record locks) and `PolicyPermissions.fromClaims` for a host's own customizer. `AuthentikGroupPermissionMapper` also adds the claim, and the group graph and `EffectivePermissionResolver` read each group's `qqq.io/policy`, so effective permissions, the subset rule and the last-admin check see policy grants. An invalid policy on a group is listed as malformed.
- **Blueprint example** for the scope mapping that puts the merged policies into the token, pinned by `PolicyClaimMappingIT` on Authentik 2026.8.3.
### Added (v1: full user lifecycle and group/permission management, phases 2-3)
- **Writes through the Authentik API**, behind the features `USERS`, `GROUPS`, `MEMBERSHIP`, `CREDENTIALS` and `SESSIONS` (read-only stays the default; `readOnly` is a hard kill switch). Every change goes through `AuthentikAdmin`: it plans against live Authentik data (the guardrails of DESIGN.md 5.4), audits first, calls Authentik, posts a `custom_qqq_admin_action` event naming the operator, and finishes the audit row. `AuthentikActionUtil.write` needs a `WriteAuthorization`, which only `AuthentikGuard.authorize` can mint.
- **Actions** (table and record actions with Review and Result steps; none in the nav): Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject.
- **Safety**: the subset (no escalation) rule evaluated from the operator's live groups; protected accounts and superuser groups; no self-edits; last-administrator protection; reasons; typed confirmations (and a typed count above 25 targets); a 100-target cap; stale checks by fingerprint (groups have no `last_updated`); attribute edits merge only the `qqq.io/*` keys into the current attributes; two-person approval for granting `authentik.*`.
- **Local tables** `authentik_admin_action` (append-only audit) and `authentik_approval_request` (DDL in `docs/schema.sql`), on the new `localBackendName`.
- **Read tables** `authentik_application`, `authentik_policy_binding`, `authentik_blueprint`, `authentik_role`, `authentik_token` (metadata only, never a key) and `authentik_invitation`. Sub-tables appear as child lists on their parents; the nav lists only the main tables.
- **Home**: the `authentikHome` widget (needs attention, people, groups and permissions, recent changes, connection).
- Config: `localBackendName` (replaces `mirrorBackendName`), `twoPersonApproval`, `schedulerName`, `linkPrefix`.
- Tests: an in-memory Authentik for the rules, process tests through `RunProcessAction`, and write contract tests on Authentik 2026.8.3 (`QbitWriteContractIT`, including the recovery email arriving in Mailpit).
### Security
- `bootstrapOperators` (emails) is replaced by `bootstrapOperatorSubjects` (env `AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS`): users can change their own email in Authentik, so an email must never confer identity permissions. A bootstrap operator is now matched on the token `sub` (email-looking subs ignored) or, with a verified email, on the single active Authentik user with that email whose pk or uuid is configured. Spoofed, duplicate or unverified emails are denied; entries containing `@` fail validation. The admin service uses the same rule.
### Fixed
- Count reported no distinct count, so a process Review showed no total; group lists now include member pks (member counts were 0).
- Spec: multi-value filters are repeated plain names (`type=a&type=b`), never `type[]=a` (which Authentik silently ignores); stale role-state, `check_access`, re-parenting-risk, private-repo and backend-name wording corrected (spec revision 4).
### Not in this release
- The Authentik webhook, the local mirror tables and reconcile, the access-matrix widget, snapshot export, RBAC role management (phase 4) and the Alerts producer integration. `MIRROR`, `WEBHOOK`, `ACCESS_MATRIX`, `SNAPSHOT_EXPORT` and `RBAC` are refused by config validation.
### Changed
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
- **Table names are lowercase snake_case with the `authentik_` prefix** (owner naming rule, 2026-10-12): `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event` (were `authentikUser`, ...). New `AuthentikQBitConfig.applyTablePrefix` and `toSnakeCase`; apps, widgets, the backend and processes keep camelCase names (`authentikAdmin`, `authentikApi`). The old names are kept as `@Deprecated` `AuthentikTables.LEGACY_*_TABLE_NAME` constants. See [UPGRADE.md](UPGRADE.md).
### Documentation
- README: "Adopting in another app" section (coordinates, configuration, secrets, permissions, limitations) linking the QQQ adoption checklist; examples use neutral names instead of one host's (#3).
### Added (phase 1, read-only MVP)
- Tables: `authentik_group_member` (virtual membership table: by group, by user, by key, or a bounded sweep), `authentik_session` and `authentik_event` (both need `authentik.audit.read`).
- `GroupGraph` and `EffectivePermissionResolver`: groups, including inherited parents, become QQQ permissions. `authentik.*` and catalog wildcards are expanded; unknown names are reported, never granted. Groups show inherited superuser status and effective permissions; users show effective permissions.
- `AuthentikGroupPermissionMapper`: an authentication customizer that turns the token's `groups` claim into QQQ session permissions through the resolver. It supports static fallbacks and `bootstrapOperators`; if Authentik cannot be read it grants only the static fallbacks, never everything.
- Review hardening:
- `search` accepts plain text only; Authentik's query language could reach sensitive fields.
- The group graph is thread-safe.
- A failed group-graph read shows "Unavailable", never empty permissions.
- Malformed `qqq.io/*` attributes are reported.
- Operator filters can't widen the user scope, so count and query agree.
- Membership rows are always scope-checked.
- Missing pagination is an error.
- Sorting by the key falls back to the default order.
- Booleans are strict.
- `..` keys are refused.
- https is required (except loopback or `allowInsecureHttp`).
- `groupScopeNamePrefix` is refused until implemented.
- Drift checks that cannot run report ERROR ("result unknown"); service-account tokens are filtered server-side; deleted targets are flagged; only human users count as identity admins.
- Identity drift report widget (`authentik.admin`). It checks the server version, blueprint status, the service account (type, groups, superuser, protection, extra self-minted tokens), break-glass presence, the last identity administrator, `qqq.io/*` attribute validity, and service-account changes to protected or superuser objects (lockout detection).
### Added (phase 0)
- Phase 0 foundations: `AuthentikQBitConfig`, `AuthentikQBitProducer`, `AuthentikActionUtil` (bearer auth, page/page_size paging, filter mapping that rejects unsupported filters, 10 rps token-bucket throttle, log redaction), read-only Users and Groups tables, Identity app.
- Pinned Authentik 2026.8.3 OpenAPI schema and schema drift tests.
- Testcontainers contract tests against Authentik 2026.8.3 (bootstrap on an empty instance, superuser floor, blueprint re-apply safety, access-check behaviour) and a fixture recorder.
+10 -71
View File
@@ -1,78 +1,17 @@
# AGENTS.md / CLAUDE.md
# qbit-template-extension
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md in sync.
GitHub template repository for scaffolding QQQ "Extension QBits" (infrastructure-level plugins: table customizers, auth, audit, action wrappers). Not a shipping library — consumers click "Use this template" and rename the `com.kingsrook.qbits.example` package.
## Project Overview
## QQQ 4.0 baseline — 2026-09-24
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and writes through the Authentik API (never its DB). Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = the full user lifecycle and group/permission management (phases 0-3), read-only by default.
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
## Build Commands
## Knowledge base
```bash
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
./mvnw test -Dtest=ClassName
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
./mvnw test -Dtest=SchemaDriftTest -Dauthentik.schema.file=/path/to/schema.yml # drift vs another release
```
Reviewed dossiers for this repo and the wider QQQ platform live in the second-brain vault:
- **Contract ITs** (`*IT`, package `contract`) share one `AuthentikTestStack` per JVM; first boot takes ~1.5-3 minutes. `@ExtendWith(RequiresDockerCondition.class)`: skipped without Docker locally, failed with `CI=true`.
- The stack boots with `src/test/resources/blueprints/*.yaml` already in `/blueprints/custom`: that IS contract test (h) (bootstrap on an empty instance).
- `TestApi` uses HTTP/1.1 on purpose: Java's HttpClient tries an h2c upgrade on plain-http POSTs, which Authentik answers with "400 Invalid HTTP request received."
- Tests that grant extra permissions to the service-account role must remove them again (they are additive and shared).
- Hub / entry point: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-extension.md`
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
## Architecture
- `AuthentikQBitProducer` validates the config, registers an `AuthentikRuntime` for the API backend, then runs the component producers in `metadata/`.
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `authentik.admin`.
## Invariants (do not break)
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
- Never filter client-side; reject what Authentik cannot evaluate.
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
## Code Style
3-space indentation, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters, wrapper types in config.
## Dependencies
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
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.
## 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.
Reviewed at commit `e7d4b4d672cc` (branch `main`, 2026-05-21). Known drift as of that review: no `qbit-build-parent` parent pom (org convention), qqq pinned at 0.35.0 on main (0.40.0-SNAPSHOT on develop), README still claims AGPL-3.0 while LICENSE/NOTICE are Apache-2.0, and README references `scripts/customize_template.py` / `actions/ExampleActionCustomizer.java` which do not exist. See the dossier's "Maturity & risks" and "v4.0 impact" sections before modifying the scaffold.
-3
View File
@@ -1,3 +0,0 @@
# Code of Conduct
See Project level [Code of Conduct](https://github.com/Kingsrook/qqq/blob/develop/CODE_OF_CONDUCT.md)
-3
View File
@@ -1,3 +0,0 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/Kingsrook/qqq/blob/develop/CODE_STYLE.md)
-3
View File
@@ -1,3 +0,0 @@
# Contributing to QBit Authentik
* See Project [Contributing](https://github.com/Kingsrook/qqq/blob/develop/CONTRIBUTING.md)
+1 -1
View File
@@ -176,7 +176,7 @@
END OF TERMS AND CONDITIONS
Copyright 2024 Kingsrook, LLC
Copyright 2022-2025 QRunIO, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+3 -3
View File
@@ -1,6 +1,6 @@
QBit Authentik
Copyright 2026 Kingsrook, LLC
qBit Template - Extension
Copyright 2022-2025 QRunIO, LLC
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
This product includes software developed at QRunIO, LLC (https://qrun.io/).
Licensed under the Apache License, Version 2.0.
+27 -170
View File
@@ -1,185 +1,42 @@
# QBit Authentik
# QBit Template: Extension
A QQQ QBit to manage [Authentik](https://goauthentik.io) identities from a QQQ app: **the full user lifecycle, and groups with the permissions they grant**. Authentik stays the source of truth. The qbit reads Authentik live and writes through the Authentik API, never its database. Changes made in the Authentik admin UI are equally valid, and the qbit never overwrites them blindly.
Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers.
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065). Access tokens (PATs): [`docs/ACCESS-TOKENS.md`](docs/ACCESS-TOKENS.md). Status: [`docs/STATUS.md`](docs/STATUS.md). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Table DDL: [`docs/schema.sql`](docs/schema.sql).
## What is an Extension QBit?
## What you get
Extension QBits extend or enhance how QQQ operates at the infrastructure/framework level. They provide:
- New backend implementations
- Authentication mechanisms
- Audit capabilities
- Table customizers
- Action handlers
| | |
|---|---|
| **Access tokens** | Create and rotate your personal access tokens, revoke (including bulk), extend expiry, and create service accounts. Enabled with any write feature; opt out with `tokensEnabled=false` or `AUTHENTIK_QBIT_TOKENS=false`. Keys are response-only and shown once; key-producing actions require QQQ 4.1.0-RC.7+ one-time output support. See [Access tokens](docs/ACCESS-TOKENS.md). |
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. Edit covers username, name, email, path and other attributes. Delete is a separate, explicit action behind the `DELETE` feature (off by default); deactivate stays the default. |
| **Groups and permissions** | Create, edit (parents, other attributes) and delete groups, add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, 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. |
## Quick Start
Everything is a standard QQQ table, action and widget, so it works in any QQQ UI and over the QQQ API.
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create your repository, customize its Maven coordinates and Java package/classes, implement the table customizer, then run `mvn clean verify`. See [Getting Started](docs/00-getting-started.md).
## Usage
## Structure
```java
AuthentikQBitConfig config = new AuthentikQBitConfig()
.withBaseUrl("https://auth.example.com") // host root; /api/v3 is appended
.withApiToken("${env.AUTHENTIK_API_TOKEN}") // service-account token, resolved by the qbit
.withAppKey("my-app-admin") // service account: qqq-my-app-admin-idadmin
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS, POLICIES)) // + DELETE for delete actions
.withLocalBackendName("rdbms") // audit trail and approval requests (see docs/schema.sql)
.withRecoveryEmailStage("my-recovery-email") // Authentik email stage (name or uuid)
.withInvitationFlow("my-enrollment"); // optional: enables invitations
new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
```
src/main/java/com/kingsrook/qbits/example/
├── ExampleExtensionQBitConfig.java # Configuration
├── ExampleExtensionQBitProducer.java # Entry point
└── customizers/
└── ExampleTableCustomizer.java # Table customization
```
Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill switch that refuses every write whatever the features say.
## Key Characteristics
### What it produces
- **No QAppSection** - Pure infrastructure
- **No user-facing tables** (or minimal operational)
- **Wraps existing tables/processes** with new behavior
- **Validates dependencies** on other QQQ components
| | |
|---|---|
| API backend | `authentikApi` |
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_policy` (with `POLICIES`), `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
| Child lists (on their parents, not in the nav) | `authentik_group_member`, `authentik_session`, `authentik_token` (metadata only, never a key), `authentik_event`, `authentik_policy_binding`, `authentik_role`, `authentik_blueprint` |
| App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report |
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group |
## Documentation
### 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`:
| Permission | Grants |
|---|---|
| `authentik.users.read` | the Identity app, home, users, groups, applications, bindings, roles, blueprints |
| `authentik.users.manage` | create, invite, edit, deactivate, reactivate users; cancel invitations |
| `authentik.groups.manage` | create and edit groups |
| `authentik.memberships.manage` | add and remove members (subset rule) |
| `authentik.permissions.manage` | edit a group's permissions and app access (subset rule) |
| `authentik.policies.manage` | edit a group's policy (subset rule) |
| `authentik.credentials.reset` | password reset email, reset MFA, revoke app passwords |
| `authentik.sessions.revoke` | revoke sessions |
| `authentik.audit.read` | sessions, tokens, events, the audit trail, sensitive fields |
| `authentik.approve` | approve or reject a second person's request |
| `authentik.admin` | the drift report |
| `authentik.roles.manage` | reserved (role management is phase 4) |
`authentik.*` grants all of them.
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
The editor discovers registered features and labels through core RC.6's `PermissionsHelper.getAllAvailablePermissions`. The build uses `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
### Authentik side
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, `view_token_key` or impersonate permissions, and holds user/group delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. Saving a policy uses `change_group`. Token management additionally needs `change_token`, `set_token_key` and `add_token` (Authentik requires `add_token` for `service_account` and `set_key`; for a non-superuser it can only create the caller's own token); never grant `view_token_key`. Personal token operations use an exchanged user bearer from the confidential token broker, never the qbit bearer. The `DELETE` feature also needs `delete_user` and `delete_group`. The contract fixtures include the token broker, claim mapping, detective rule and token write permissions.
## Adopting in another app
See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/docs/migration/adoption-checklist.adoc) (section 5 covers this QBit).
| 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.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)`. |
| Required config | `baseUrl`, `apiToken`, `appKey`. For writes: `features`, `localBackendName`, `recoveryEmailStage` (users and credentials). Optional: `invitationFlow`, `twoPersonApproval` (default on), `userScopePathStartsWith`, `protectedUsernames`, `applicationsInScope`, `linkPrefix` (default `/app`), `throttleRequestsPerSecond` (default 10), `bulkPacingMillis`. |
| Secrets / env | `apiToken` as a `${env.X}` reference; `bootstrapOperatorSubjects` (the lockout escape hatch) defaults to `${env.AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS}`: immutable ids only (the Authentik user pk or uuid, or the token `sub`), never emails, because users can change their own email. Empty it once real group grants exist. Keep secrets in the host's secret store. |
| Optional host wiring | ESB: publish the local tables with `EsbTableMetaData.ofOrWithNew(table).withPublication(...)`. Quick Search: register `authentik_user` and `authentik_group` with the Quick Search qbit. Alerts: see Known limitations. |
| 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
./mvnw test # unit tests (recorded 2026.8.3 fixtures and an in-memory Authentik, no network)
CI=true ./mvnw verify # + contract tests against Authentik 2026.8.3 in Docker (Testcontainers)
./mvnw verify -Dauthentik.test.image=ghcr.io/goauthentik/server:2026.10.0 # rehearse an upgrade
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures
```
Contract tests start Authentik (server and worker), Postgres and Mailpit, and take about 10 minutes, mostly first boot and blueprint applies. Without Docker they are skipped locally. With `CI=true` they fail instead.
- [Getting Started](docs/00-getting-started.md)
- [Extension Patterns](docs/01-extension-patterns.md)
## License
Apache 2.0
Access-token metadata is readable with `authentik.audit.read` or `authentik.tokens.manage`. Personal actions have no additional process permission; ownership is checked live. Managing service-account tokens needs `authentik.tokens.manage`, and creating a service account also needs `authentik.users.manage`. Configure `tokenBrokerClientId`, `tokenBrokerClientSecret` (environment references supported), `appClientId`, `appPublicUrl` and `authentikPublicUrl` for exchange and usage instructions. Scope fields remain unavailable until `tokenScopesEnabled=true`.
| Access-token setting | Default | Purpose |
|---|---|---|
| `tokenScopesEnabled` | `false` | Offer read-only, apps and tables fields after the signed claim mapping is installed. Names must exist and the operator must currently have read/hasAccess permission. |
| `tokenScopeClaim` | `qqq_token` | Signed JWT claim containing the Authentik token identifier. Claims are enforced even when offering new scopes is disabled. |
| `tokenUnusedDays` | `60` | Inactivity threshold for cleanup suggestions. With no login found, a known creation date and a threshold at most 90 days are required. |
| `tokenReminderSchedulerName` | First registered scheduler | Run `authentikTokenReminders` daily. Without a scheduler the hidden process is available for a host to invoke. Register the scheduler before producing the qbit. |
The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The qbit conservatively narrows session permissions for direct consumers and attaches core's `QCredentialScope` for framework enforcement. It does not add permissions.
The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`.
Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added.
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
## Health checks
The producer registers these central checks when Authentik is configured. All use
component `authentik`, category `IDENTITY`, and a 10-second timeout. They reuse the
API client and drift diagnostics, honor the runner deadline, and record only safe
version, expiry-day, and finding-code details.
| Name | Kind | Interval |
| --- | --- | --- |
| `authentik.api` | DEPENDENCY | 300 seconds |
| `authentik.api_token` | DEPENDENCY | 21600 seconds |
| `authentik.mapper` | DEPENDENCY | 21600 seconds |
| `authentik.token_broker` | DEPENDENCY | 300 seconds |
The broker check uses a client-credentials grant and discards the returned bearer;
it reports UNKNOWN when broker credentials are not configured. Token metadata must
identify one API token; ambiguous tokens or an unavailable expiry report UNKNOWN.
The Identity home links its central status to System Health. The configuration
`driftReport` widget remains available. Disable checks centrally with
`health_check.enabled`; no Authentik table fields are added.
These registrations use the core health SPI released in QQQ 4.1.0-RC.8; no
SNAPSHOT override or enforcer skip is needed.
## In-app help
The qbit ships `src/main/resources/help/qbit-authentik.yaml` and registers it with
QQQ's help bundle registry (QQQ 4.1.0-RC.7). Registration is metadata-only; core
skips startup synchronization when the host has no `helpContent` table. Inline
app and table help remains available without help storage.
To override wording, register a separate bundle with `layer: HOST`, its own bundle
ID and version, and entries with the same keys and roles. HOST entries take
precedence over QBIT entries; do not edit the dependency's resource. Keep deployment
names, navigation and authentication timing in the host bundle.
`AuthentikHelpBundleTest` enables every supported feature and uses
`HelpBundleAssertions` to require coverage and validate keys, summaries and live
references. Run it with `./mvnw test -Dtest=AuthentikHelpBundleTest`.
The bundle version is the literal project version; update it when releasing.
See [LICENSE](LICENSE) and [NOTICE](NOTICE).
-3
View File
@@ -1,3 +0,0 @@
# Security Policy
* See Project [Security Policy](https://github.com/Kingsrook/qqq/blob/develop/SECURITY.md)
-130
View File
@@ -1,130 +0,0 @@
# Upgrading qbit-authentik
## Policy format 1.1.0 (unreleased)
1. The default `PolicyVersion` is `1.1.0`. Documents stored as `1.0.0` stop parsing (they grant nothing, and one invalid document voids ALL of a user's policies). Either rewrite the stored documents and the claim expression to `1.1.0`, or keep `policyVersion("1.0.0")` in the config and the claim expression.
2. The example claim blueprint now emits a document the qbit rejects when any of a user's documents under your spec is unparsable, has another version or has a non-array `Statement`. Update the blueprint copy in your identity repository, and run `src/test/resources/policy-vectors-1.1.0.json` against any claim validator you own.
3. Code that read `PolicyParser.parse`/`parseStrict(...).permissions()` or `GroupGraph.Node.policyPermissions()` must move to `PolicyRules` (`resolve(universe)`, `hasFullAccess()`, `denyLabels()`); there is deliberately no plain-name view of a Deny.
4. Hosts that keep custom or service permission names (`email.send`, `echo.use`) declare them with `PolicyPermissions.registerHostPermissions(qInstance -> names)`; no Authentik runtime is needed. They are granted only through `Permission: [...]`.
5. The Next UI policy editor payloads gain `denied` (policy editor widget) and `policyFullAccess`, `policyDeny`, `policyInvalid` (access editor widget); `policyPermissions` is now the resolved list (Allow minus Deny), never the internal full-access marker.
## Table help bundle (unreleased)
Nothing to do. The qbit now applies `help/qbit-authentik.yaml` to the Identity app and its tables at the end of `produce()`, filling only help slots the host has not set. There is no schema change and nothing to seed or include in a Liquibase changelog: the API-backed tables are live Authentik data, and the four local tables fill as people use the actions.
## QQQ 4.1.0-RC.6 baseline (#20 / F19)
Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.6`
and use Java 21. The historical `qqq-snapshot` profile and `qqq.snapshot.version`
property names remain, but the imported core version is a release, not a SNAPSHOT.
Core actions now enforce permissions and field security locks for non-HTTP callers
(QRun-IO/qqq#1061, #1062); callers must supply an appropriately authorized session.
The qbit keeps its guards, scope checks and sensitive-field stripping because they
also protect direct backend calls and implement Authentik-specific rules.
No qbit configuration, stored policy format or schema changes are introduced.
The build adopts `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
The expired #1136 test stand-in is removed: tests now register features through
`TableFeaturePermissionsMetaData` and discover them through the real
`PermissionsHelper.getAllAvailablePermissions`, as production hosts do. The
unused explicit permission-source overload in `PolicyCatalog` is removed.
Duplicate shared-base entries, replacement registrations and malformed suffixes
remain covered. Hosts should register feature suffixes, labels and table
applicability through the core metadata API; no synthetic catalog is needed.
Client-channel attribution and identity/credential channel restrictions remain
separate follow-up work.
## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release)
The qbit's permissions follow the same owner naming rule (2026-10-12) and now
carry the qbit's name. The suffixes are unchanged:
| Before | After |
|---|---|
| `identity.*` | `authentik.*` |
| `identity.users.read` | `authentik.users.read` |
| `identity.users.manage` | `authentik.users.manage` |
| `identity.groups.manage` | `authentik.groups.manage` |
| `identity.memberships.manage` | `authentik.memberships.manage` |
| `identity.permissions.manage` | `authentik.permissions.manage` |
| `identity.roles.manage` | `authentik.roles.manage` |
| `identity.credentials.reset` | `authentik.credentials.reset` |
| `identity.sessions.revoke` | `authentik.sessions.revoke` |
| `identity.audit.read` | `authentik.audit.read` |
| `identity.approve` | `authentik.approve` |
| `identity.admin` | `authentik.admin` |
The QQQ session form is the new name plus `.hasAccess`
(`authentik.users.read.hasAccess`).
**Legacy names are not granted.** The resolver lists an old `identity.*` name
among a group's *unknown* permissions, so it grants nothing and fails closed.
Update every stored grant **before** you deploy, or the admins lose the
Identity app until you do:
- **Authentik group attributes:** edit `qqq.io/permissions` on each group, for
example `identity-admins`, in the Authentik UI (Directory → Groups →
Attributes) or in its blueprint. Replace `identity.` with `authentik.`:
```yaml
attributes:
qqq.io/permissions:
- authentik.* # was identity.*
```
To find the groups that still carry old names, run the drift report. Its
"group attributes" check warns about each `identity.*` entry ("grants
nothing in this app").
- **QQQ-side grants:** these are static fallbacks in
`AuthentikGroupPermissionMapper`, host permission lists and role tables. If
a host stores permission names in a table:
```sql
-- example only: use your host's table and column names
UPDATE role_permission
SET permission_name = REPLACE(permission_name, 'identity.', 'authentik.')
WHERE permission_name LIKE 'identity.%';
```
The qbit itself stores no grants in a database. `qqq_user_session` rows hold
resolved permissions, so sign users out (or let their sessions expire)
after the change.
- **Code** that names `identity.*` strings. Use `IdentityPermission`
instead. `IdentityPermission.LEGACY_PREFIX`, `LEGACY_WILDCARD` and
`getLegacyShortName()` (all deprecated) give the old names for upgrade
scripts.
The `identity-admins` group name and the "Identity" app label are unchanged.
## Snake_case table names (unreleased → first release)
Table names now follow the owner naming rule (2026-10-12): lowercase snake_case,
prefixed with the qbit's name and `_`.
| Before | After |
|---|---|
| `authentikUser` | `authentik_user` |
| `authentikGroup` | `authentik_group` |
| `authentikGroupMember` | `authentik_group_member` |
| `authentikSession` | `authentik_session` |
| `authentikEvent` | `authentik_event` |
With a custom `tableNamePrefix` the prefix is snake-cased too: `myAuth` gives
`my_auth_user`. App, widget, backend and process names keep camelCase
(`authentikAdmin`, `authentikDriftReport`, `authentikApi`).
The tables are API-backed, so there is no database DDL. Hosts that ran a
pre-release snapshot should update anything that stores a table name:
- **Permissions:** grants or role definitions that name a table (for example
table-level permission names derived from the table name) and any host code
calling `qInstance.getTable("authentikUser")`.
- **Saved views and reports:** rows whose table name is an old name, for
example `UPDATE saved_view SET table_name = 'authentik_user' WHERE table_name = 'authentikUser';`
(and the same for saved reports and any shared/favorite records).
- **Audit:** `audit_table.name` rows for the old names, if the host audits
these tables (rename them, or leave history under the old name).
- **Frontend links and bookmarks** of the form `/…/authentikUser/…`.
`AuthentikTables.LEGACY_*_TABLE_NAME` hold the old names (deprecated) so
upgrade scripts can find them.
-246
View File
@@ -1,246 +0,0 @@
<?xml version="1.0"?>
<!--
~ QQQ - Low-code Application Framework for Engineers.
~ Copyright (C) 2021-2022. Kingsrook, LLC
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
~ contact@kingsrook.com
~ https://github.com/Kingsrook/
~
~ 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
~
~ https://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.
-->
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
<!--
See reference at https://checkstyle.sourceforge.io/checks.html
-->
<module name = "Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java, properties, xml"/>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<!--
<module name="LineLength">
<property name="max" value="100"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
-->
<module name="TreeWalker">
<module name="SuppressWarningsHolder"/>
<module name="OuterTypeFilename"/>
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
</module>
<module name="AvoidEscapedUnicodeCharacters">
<property name="allowEscapesForControlCharacters" value="true"/>
<property name="allowByTailComment" value="true"/>
<property name="allowNonPrintableEscapes" value="true"/>
</module>
<module name="AvoidStarImport"/>
<module name="OneTopLevelClass"/>
<module name="NoLineWrap"/>
<module name="EmptyBlock">
<property name="option" value="TEXT"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
</module>
<module name="NeedBraces"/>
<module name="LeftCurly">
<property name="option" value="nl"/>
</module>
<module name="RightCurly">
<property name="option" value="alone"/>
</module>
<!--
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
-->
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="ArrayTypeStyle"/>
<module name="MissingSwitchDefault"/>
<module name="FallThrough"/>
<module name="UpperEll"/>
<module name="ModifierOrder"/>
<module name="EmptyLineSeparator">
<property name="allowNoEmptyLineBetweenFields" value="true"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapDot"/>
<property name="tokens" value="DOT"/>
<property name="option" value="nl"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapComma"/>
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
<property name="id" value="SeparatorWrapEllipsis"/>
<property name="tokens" value="ELLIPSIS"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
<property name="id" value="SeparatorWrapArrayDeclarator"/>
<property name="tokens" value="ARRAY_DECLARATOR"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapMethodRef"/>
<property name="tokens" value="METHOD_REF"/>
<property name="option" value="nl"/>
</module>
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="TypeName">
<message key="name.invalidPattern"
value="Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MemberName">
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="CatchParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ClassTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MethodTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="InterfaceTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="NoFinalizer"/>
<module name="GenericWhitespace">
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
</module>
<module name="Indentation">
<property name="basicOffset" value="3"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="3"/>
<property name="throwsIndent" value="6"/>
<property name="lineWrappingIndentation" value="3"/>
<property name="arrayInitIndent" value="6"/>
</module>
<!--
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="1"/>
</module>
<module name="OverloadMethodsDeclarationOrder"/>
<module name="VariableDeclarationUsageDistance"/>
<module name="CustomImportOrder">
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="true"/>
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
</module>
-->
<module name="CustomImportOrder">
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
<property name="specialImportsRegExp" value="^javax\."/>
<property name="standardPackageRegExp" value="^java\."/>
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="false"/>
</module>
<module name="MethodParamPad"/>
<module name="NoWhitespaceBefore">
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
<property name="allowLineBreaks" value="true"/>
</module>
<module name="ParenPad"/>
<module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationMostCases"/>
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationVariables"/>
<property name="tokens" value="VARIABLE_DEF"/>
<property name="allowSamelineMultipleAnnotations" value="true"/>
</module>
<module name="JavadocMethod">
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="allowedAnnotations" value="Override, Test"/>
</module>
<module name="MissingJavadocMethod">
<property name="scope" value="private"/>
</module>
<module name="MissingJavadocType">
<property name="scope" value="private"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="EmptyCatchBlock">
<property name="exceptionVariableName" value="expected"/>
</module>
<module name="CommentsIndentation"/>
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
<module name="MissingOverride"/>
</module>
<module name="Header">
<property name="headerFile" value="checkstyle/license.txt"/>
<property name="fileExtensions" value="java"/>
<property name="ignoreLines" value="3"/>
</module>
<module name="SuppressWarningsFilter"/>
</module>
-19
View File
@@ -1,19 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.
*/
+28
View File
@@ -0,0 +1,28 @@
# Getting Started
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
1. Choose **Use this template** on GitHub, then clone the repository you created.
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
5. Build the generated project:
```bash
mvn clean verify
```
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
## Register with a host application
The following uses the original example names; substitute the names chosen above:
```java
new ExampleExtensionQBitProducer()
.withConfig(new ExampleExtensionQBitConfig()
.withTargetTableName("order"))
.produce(qInstance, "my-extension");
```
Register the target table before producing the extension, then implement its customizer. See [Extension Patterns](01-extension-patterns.md).
+16
View File
@@ -0,0 +1,16 @@
# Extension Patterns
[ExampleExtensionQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java) registers the QBit identity and calls `applyTableCustomizations` for the configured target. That method is the extension point: look up the table, validate that it exists, and call your customized [ExampleTableCustomizer](../src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java).
```java
QTableMetaData table = qInstance.getTable(tableName);
if(table == null)
{
throw new QException("Target table not found: " + tableName);
}
new ExampleTableCustomizer().customize(table);
```
When implementing this method, add `throws QException` to its declaration. Run the config's `validate(QInstance, List<String>)` before registering metadata, and report validation errors rather than silently ignoring a missing target. The uncustomized template contains placeholders for this behavior.
For record hooks, use QQQ's existing table customizer interfaces or `AbstractPreInsertCustomizer`. Its `apply(List<QRecord>)` method returns the records for the next stage; register the customizer through the table's `TableCustomizers.PRE_INSERT_RECORD` role. Check the actual QQQ interface before adding another hook. Add tests proving that the intended table changes and invalid configurations are handled.
-71
View File
@@ -1,71 +0,0 @@
# Using access tokens
An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6).
## Create a token
1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**.
2. Choose **Create token** and fill in:
- a description, for example "nightly export script";
- an expiry. It's required: at most 90 days for people, and 365 for service accounts;
- optionally, a **scope**: read only, and/or only some apps and tables. A scoped token can never do more than you can, and loses access as soon as you do.
3. Copy the key from the dialog. **It is shown once.** The app does not keep it, and nobody can show it again. If you lose it, rotate the token.
The dialog also shows ready-to-run examples for this app, with its real URLs.
## Use a token
Tokens are exchanged for a short-lived access token (a JWT) at the Authentik token endpoint. You then call the app with that JWT.
```sh
# 1. Exchange the token for a JWT (valid for the app's access-token lifetime)
JWT=$(curl -s -X POST "https://auth.example.com/application/o/token/" \
-d grant_type=client_credentials \
-d client_id="$APP_CLIENT_ID" \
-d username="$YOUR_USERNAME" \
-d password="$QQQ_TOKEN" \
-d scope="openid profile email" | jq -r .access_token)
# 2. Call the app
curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData"
```
- Keep the token in a secret store or an environment variable. Never commit it.
- When the JWT expires (the API answers 401), repeat step 1.
- Every exchange is recorded as a sign-in in Authentik. The app shows the token's **last used** time from those records.
## Manage your tokens
From **Preferences → Access tokens**:
| Action | What happens |
|---|---|
| **Rotate** | A new key replaces the old one. The description, expiry and scope stay the same. The old key stops working at once. |
| **Extend** | Moves the expiry. The limit counts from today. |
| **Revoke** | Deletes the token in Authentik. Anything using it stops working at once. |
When the host has configured a scheduler and the optional Alerts delivery adapter, the daily job sends owner reminders in the 7-day and 1-day expiry windows. Delivery channels (bell/email) follow the host’s Alerts configuration. Each window sends once; if first seen within one day, only the urgent window sends. Extending a token through the qbit resets its reminder windows. Without delivery support, the job logs and leaves the reminder pending.
## For administrators
People with `authentik.tokens.manage` see the **Access tokens** TABLE widget on the Identity home page, with a link to **all access tokens**. From there they can:
- filter by person, intent, expiring status or token identifier; view computed creation time, scope and last use (these computed columns cannot be filtered or sorted by the Authentik API);
- revoke tokens one at a time or in bulk, for example the tokens the home page lists as unused for 60 days, or as belonging to deactivated people;
- create a **service account** for an integration (**Users → Create Service Account**). The account gets one app password, shown once, and the groups you choose. Rotate and extend it from its token record.
The widget shows the top ten owners plus a total, age buckets (`<30d`, `30–90d`, `90–365d`, `>365d`, and unknown), unused tokens and tokens with deactivated owners. Cleanup rows include owner and token links; age buckets link to the explicit token identifiers. Creation dates are known only for tokens created by the qbit. External tokens can receive reminders without acquiring a scope or an invented creation date.
Last use comes from app-password login events over the last 90 days, cached for five minutes. The pinned Authentik schema has no date-range parameter for this endpoint, so the qbit pages newest-first to the window boundary. API-intent tokens show **not tracked** and never appear as unused. A token with no recorded login is suggested as unused only if its known creation date is older than `tokenUnusedDays` and that threshold is at most 90 days.
Create Token offers read-only, apps and tables scopes only when `tokenScopesEnabled` is enabled after installing the signed claim mapping. Enter one metadata name per line. Review checks existence and the operator’s current read/hasAccess permission and shows the scope. The claims mappers intersect permissions after resolving them; scopes never grant access the owner lacks. A deleted token receives no permissions even if its local scope row remains, subject to the 60-second verification cache.
Administrators can never create, rotate or extend a token for **another person**. Each person manages their own.
Accounts that can administer Authentik, the break-glass account and the app's own service account can't get tokens from the app at all. Use the Authentik admin UI for them if you really need one.
## Security notes
- A token can do what its owner can do, or less if it's scoped. Prefer a scoped token and a short expiry.
- Tokens for people are created as that person, through Authentik. The app's own service account cannot create a token for a person.
- Every create, rotate, extend and revoke is audited in the app (`authentik_admin_action`, with the channel it came from) and in Authentik's event log.
- The key exists only in Authentik and in the one response that showed it to you.
-45
View File
@@ -1,45 +0,0 @@
# 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.
-1583
View File
File diff suppressed because it is too large. Load diff
-202
View File
@@ -1,202 +0,0 @@
# 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
@@ -1,142 +0,0 @@
# 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`.
-127
View File
@@ -1,127 +0,0 @@
# Phase 0 owner checklist (kof22)
These are the steps only James can take: items 6, 7, 8 and 10 of DESIGN.md section 11.0, plus a few decisions the phase 0 work surfaced. Agents prepared everything else. Nothing below has been run against production.
Links:
- cluster-gitops draft PR: KofTwentyTwo/cluster-gitops#72 (the identity bootstrap blueprint, the removal of the `present` entries, the attribute-driven sign-in policy, inert webhook plumbing, and the blueprint lint)
- Website-Backend and cluster-gitops wiring draft PRs: see `docs/STATUS.md`
- Spec: QRun-IO/qqq#1065 (revision 4: phase 0 findings, review fixes, owner decisions of 2026-10-12)
## 0. Decisions surfaced by phase 0 (read first)
| # | Decision | Recommendation |
|---|---|---|
| D1 | **Repo visibility.** Resolved: `QRun-IO/qbit-authentik` is public. | Check that CircleCI follows the repo (it does for PR CI); nothing else to do. |
| D2 | **Lockout risk.** Authentik stops the service account from *escalating*, but not from *locking out*. With the phase 2/3 permissions it can do all of the following (contract test `SuperuserFloorIT`): remove members from `authentik Admins`, deactivate `kof22-breakglass`, strip `qqq.io/protected`, and rename the superuser group. The qbit guard refuses all of these; Authentik does not. | Accept, with the mitigations in the spec (§5.1, revision 3). The real break-glass for a compromised QQQ is cluster access: `kubectl exec` into the Authentik worker and run `ak shell` to reactivate or re-add. Optionally, phase 2 grants `remove_user_from_group` per group (object permissions) instead of globally. |
| D3 | **Self-minted tokens.** Any authenticated user, including the service account, can create API tokens **for itself** without `add_token`, and read their keys. A compromised QQQ could therefore keep a second token alive after you rotate the first. | Accept. The phase 1 drift report flags service-account tokens beyond the expected one. Check that list after every rotation (item 8). |
| D4 | **Access matrix.** `check_access?for_user=` only works for superusers. For the service account it silently answers *for itself*. The policy-test endpoint returns HTTP 500 for non-superusers on 2026.8.3. | Accepted in the spec: the qbit computes app access itself from `qqq.io/app-access` and the app's bindings, and shows other expression policies as "not evaluated". |
## Item 6: staging rehearsal (needs your go-ahead)
The agent cannot read production data. The rehearsal runs entirely in local Docker, on a dump you take.
1. **Take a dump** of the Authentik database. This is a read-only `pg_dump` of the CNPG cluster that hosts it:
```bash
# CNPG cluster kof22-website-pg (namespace kof22-website-prod) hosts the database "authentik"
P=$(kubectl --context=k8s-galaxy -n kof22-website-prod get pod -l cnpg.io/cluster=kof22-website-pg,cnpg.io/instanceRole=primary -o name)
kubectl --context=k8s-galaxy -n kof22-website-prod exec "$P" -c postgres -- \
pg_dump -Fc -d authentik > authentik-$(date +%F).dump
```
Keep the dump on an encrypted disk. It contains every identity and password hash. Delete it after the rehearsal.
2. **Get both blueprint versions**:
```bash
git -C cluster-gitops show origin/main:tenants/kof22/website-authentik/blueprint-configmap.yaml > before.yaml
git -C cluster-gitops show origin/feat/kof22-identity-bootstrap:tenants/kof22/website-authentik/blueprint-configmap.yaml > after.yaml
```
3. **Run** `docs/cluster-gitops-phase0/rehearse-phase0.sh authentik-<date>.dump before.yaml after.yaml` from macOS, Linux or Git Bash. The script has already passed on a synthetic production-like dump: today's `main` blueprint with James re-pinned by the `present` entry, plus UI-made publishers.
- The script restores the dump into a throwaway Postgres and starts Authentik 2026.8.3 on the current blueprints. It then records every user's groups, switches to the phase 0 blueprints, re-applies both twice, and checks:
- nobody's memberships changed
- `james.maes` keeps `authentik Admins` and `kof22-admins`
- the service account, break-glass and seed groups exist
- It prints `REHEARSAL PASSED` or the differences.
- Containers and the work directory are removed on exit. The script mints a rehearsal-only API token **inside the throwaway copy**, which is deleted with the container.
4. Reply "rehearsal passed" (or paste the output) on cluster-gitops#72.
## Item 7: production apply
Do these in order, in one sitting.
1. **Freeze.** Record the current memberships: *Authentik admin → Directory → Users*, or export each admin group's member list. Note who is in `authentik Admins`, `kof22-admins` and `kof-website-publishers`.
2. **Merge** cluster-gitops#72 (convert it from draft first). ArgoCD syncs the ConfigMap, and the worker sees the file change and applies both blueprints within about a minute.
3. **Verify** in *Authentik admin → Customization → Blueprints*:
- [ ] `kof22-oauth2-apps` and `kof22-identity-bootstrap` both show **successful**
- [ ] `james.maes` still has `authentik Admins` and `kof22-admins`
- [ ] user `qqq-kof22-admin-idadmin` exists: type *Service account*, no groups, role `qqq-kof22-admin-idadmin`
- [ ] user `kof22-breakglass` exists and is a superuser
- [ ] groups `identity-admins`, `kof22-admins`, `kof-website-publishers`, `kof-website-drafters` exist; only `identity-admins` has attributes
- [ ] you can still sign in to `https://admin.kof22.com` (the name fallback in the sign-in policy keeps today's access)
- [ ] *Events → Notification rules*: `qqq-kof22-admin-sync` has **no destination** (inert)
4. **Prove it** (optional, recommended): add a test user to `kof-website-publishers` in the UI. Then *Blueprints → kof22-oauth2-apps → Apply* and the same for `kof22-identity-bootstrap`. Wait a minute and confirm the member is still there. Remove the test user afterwards.
5. **Rollback** if anything is wrong: revert the PR. This re-adds the old `present` entries, which re-pin James's groups. Logins keep working throughout because of the fallback.
## Item 8: service-account token and secrets
1. **Mint the API token**:
- In Authentik admin, go to *Directory → Tokens and App passwords → Create*.
- Identifier: `qqq-kof22-admin-idadmin-api`. User: `qqq-kof22-admin-idadmin`. Intent: **API**. Expiring: **on**, with an expiry **90 days** out.
- Copy the key once.
2. **Store it** in Infisical at `/kof22-website-authentik-api/token`. Paste it straight from the Authentik dialog into Infisical; it must never go in chat, git or a ticket.
3. **Webhook secret** (used in phase 2; create it now so the phase 2 PR can be merged directly): `openssl rand -base64 48` into Infisical at `/kof22-website-authentik/qqq-webhook-secret`.
4. **Check** that *Directory → Tokens* shows **exactly one** token for `qqq-kof22-admin-idadmin` (see D3).
5. **Calendar** a rotation reminder for day 76. The health widget will also warn 14 days before expiry.
## Item 10: seed data (Authentik UI)
1. Add **yourself** (`james.maes`) to **`identity-admins`**.
2. On group **`kof22-admins`** → *Attributes*, set the following. This is today's `ADMIN_PERMISSIONS` from Website-Backend `AuthentikAuthenticationCustomizer`, resolved to literal strings on 2026-10-11 against `origin/main` f936cbd. Re-check if that list changed before you seed, because the phase 1 mapper will grant exactly this:
```yaml
qqq.io/app-access: ["kof22-website-prod"]
qqq.io/permissions:
- authentik.users.read
- esbView.hasAccess
- esbOperate.hasAccess
- esbDelete.hasAccess
- analytics.hasAccess
- quickSearchAdmin.hasAccess
- quickSearchIndex.read
- quickSearchIndex.write
- quickSearchIndexRun.read
- quickSearchIndexRun.write
- quickSearchFailedEvent.read
- quickSearchFailedEvent.write
- dataHealth.hasAccess
```
Keep the existing attributes of the group. Add only these two keys. `authentik.users.read` lets `kof22-admins` open the Identity app; the seed group `identity-admins` already carries `authentik.*` (all identity permissions) from the blueprint, so you get everything through it once you join it (step 1).
3. On group **`kof-website-publishers`** → *Attributes*: `qqq.io/app-access: ["kof22-website-prod"]`. This keeps the `kof-publisher-prod` service account's client-credentials tokens working once the name fallback is removed.
4. **`kof-website-drafters`**: leave it without `qqq.io/app-access` until you decide that drafters should sign in to the admin. Granting it later is that one attribute.
5. **Break-glass password**:
- Set a long random password on `kof22-breakglass` (*Directory → Users → kof22-breakglass → Set password*) and store it in 1Password.
- Sign in with it once in a private window to prove it works, then sign out.
- Its recovery path is `ak create_recovery_key 10 kof22-breakglass` on the worker pod.
## After items 6-10
Tell the agent "phase 0 owner steps done". Next come item 11 (the qbit in read-only mode on staging, behind `AUTHENTIK_QBIT_ENABLED`), then the phase 1 mapper cut-over, then removal of the sign-in name fallback.
## Item 12: turn on the full lifecycle (v1)
The qbit can now create and change users and groups, not just read. Do this only after items 6-11 are done and the read-only screens look right on staging. Nothing here is applied by an agent.
1. **Add the write permissions to the service account's role.** In the identity bootstrap blueprint, append these to `qqq-kof22-admin-idadmin`'s `permissions` list (they are re-asserted additively on every apply; the PR is prepared as a separate commit on cluster-gitops#72):
```
authentik_core.add_user authentik_core.change_user
authentik_core.reset_user_password authentik_core.add_group
authentik_core.change_group authentik_core.add_user_to_group
authentik_core.remove_user_from_group
authentik_core.view_token authentik_core.delete_token
authentik_core.delete_authenticatedsession
authentik_stages_authenticator_{totp,static,webauthn,email,sms,duo}.view_<kind>device
authentik_stages_authenticator_{totp,static,webauthn,email,sms,duo}.delete_<kind>device
```
Never add `enable_group_superuser`, `view_token_key`, `set_token_key`, `add_token`, `impersonate` or `delete_user`. Invitations are not used at kof22 yet (there is no enrollment flow), so the invitation permissions and `view_flow` are left out; add them with the enrollment flow.
2. **Check the brand's recovery flow.** The password reset email (also used by Create User) fails with "No recovery flow set." unless the brand that serves `auth.kof22.com` has a recovery flow. In Authentik admin, *System → Brands*, edit the brand and set *Recovery flow* to `kof22-recovery`. This is a one-time setting.
3. **Create the two local tables** (`authentik_admin_action`, `authentik_approval_request`) with the Liquibase change in the Website-Backend PR (see `docs/schema.sql` for the DDL it follows).
4. **Turn the features on** in the Website-Backend deployment (the draft PR defines the flags; the default stays read-only). Start with `AUTHENTIK_QBIT_WRITES=false`, check the Identity home, then switch writes on.
5. **Try it on a test user**: create one, add them to `kof-website-publishers`, deactivate them. Check that Authentik's *Events* shows `custom_qqq_admin_action` events naming you, and that *Identity → Admin Actions* shows the rows.
6. **Two-person approval.** Granting `authentik.*` to a group needs a second person with `authentik.approve`. While you are the only identity administrator, make those grants in the Authentik UI (the break-glass in the spec), or turn `twoPersonApproval` off until a second administrator exists.
-214
View File
@@ -1,214 +0,0 @@
# QQQ policy format
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.
## Document
```json
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.1.0",
"Statement": [
{
"Effect": "Allow",
"Action": ["hasAccess"],
"Resource": ["contentApp", "publishPage"]
},
{
"Effect": "Allow",
"Action": ["read", "insert", "edit"],
"Resource": ["cms_page", "cms_media"]
}
]
}
```
| Field | Type | Rule |
|---|---|---|
| `PolicySpec` | string | Must equal the app's configured spec name (`policySpec`, default `qqq-iam`) |
| `PolicyVersion` | string, semver | Must equal the supported version (`policyVersion`, default `1.1.0`) |
| `Statement` | array of statements | May be empty (grants nothing) |
| `Statement[].Effect` | string | `Allow` or `Deny` |
| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`, or a table feature name registered for every listed resource (for a prefix resource: for at least one match) |
| `Statement[].Resource` | array of strings | `*` with exactly `Action: ["*"]`; a prefix such as `utils_*`; or a QQQ permission base name matching `[A-Za-z0-9_]+`: a table, process, app, report or widget name (or its custom `permissionBaseName`) |
| `Statement[].Permission` | array of strings | Instead of `Action` and `Resource`: exact permission names, dot-separated segments of `[A-Za-z0-9_-]` (hyphens allowed here, not in `Resource`; a bare name with no dot is allowed). Dotted names such as `authentik.users.manage.hasAccess`, `cdn.purge` or `document-generation.print` are granted only this way |
## Meaning
Each statement grants `{resource}.{action}` for every resource and action it lists, **that exists** (see "One universe" below): a standard action the resource does not have is silently dropped, so `Action ["hasAccess", "read"]` on a process grants only `hasAccess`. `Action: ["*"]` on a named resource stands for the five standard actions only; it never includes table features. The full-access statement below is the explicit exception. The result is the union over all statements, and over all of the user's groups.
| Action | QQQ permission | Applies to |
|---|---|---|
| `hasAccess` | `{name}.hasAccess` | apps, processes, reports, widgets, and tables with `HAS_ACCESS` permissions |
| `read` | `{name}.read` | tables (query, get) |
| `insert` | `{name}.insert` | tables |
| `edit` | `{name}.edit` | tables |
| `delete` | `{name}.delete` | tables |
| registered feature (e.g. `analyze`, `comment`) | `{name}.{feature}` | tables that register that feature, including `HAS_ACCESS` and `READ_WRITE` tables |
Examples:
- `{"Action": ["read"], "Resource": ["cms_page"]}` grants `cms_page.read`.
- `{"Action": ["*"], "Resource": ["cms_page"]}` grants `cms_page.hasAccess`, `cms_page.read`, `cms_page.insert`, `cms_page.edit` and `cms_page.delete`.
- `{"Action": ["analyze", "comment"], "Resource": ["person"]}` grants `person.analyze` and `person.comment` when both features are registered for `person`.
Features are generic strings, discovered by matching `PermissionsHelper.getAllAvailablePermissions(qInstance)` entries to the table's effective permission base name. Examples include `analyze` from qbit-data-analysis and `comment`, `attach`, `tag`, `share`, `task`, `assign` from qbit-collaboration. The feature label comes from `AvailablePermission.permissionType`. For named resources every feature grant is explicit, so registering a new feature never broadens an existing `Action: ["*"]` statement on a named resource. This matching rule also exposes the available `write` permission of `READ_WRITE` tables as an explicit named action; it is not part of `*`.
A grant that QQQ never checks (for example `hasAccess` on a table with insert/edit/delete permissions) is harmless.
**Not in 1.1.0:** conditions and field-level rules. Any statement key other than `Effect`, `Action`, `Resource` and `Permission` rejects the document, because a parser that silently dropped a `Condition` would turn a conditional statement into an unconditional one. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots; use the `Permission` key (next section). Explicit named grants may also stay in the group attribute `qqq.io/permissions`; full access includes them in session form.
## Deny, prefixes and named permissions (1.1.0)
**Evaluation.** Across all of a user's groups (direct and inherited), the result is the union of every `Allow` expansion minus the union of every `Deny` expansion. A `Deny` anywhere wins. There is no ordering, no specificity and no per-group precedence, so any result is explained by listing the statements that matched. Both sets are expanded against the live QQQ instance at sign-in, so a `Deny` on `billing_*` also covers a billing table added later.
**Prefix resources.** `utils_*` matches every resource whose base name starts with `utils_`: the part before the final `.action` of a permission name, containing no dot. It does not match `utilsboard`. An `Allow` prefix grants only names whose base has no dot. A `Deny` of a prefix (or of a named resource) is wider: it also covers the dotted host names that resource owns, those whose first dot-separated segment matches, so `Deny * billing_*` removes `billing_audit.export.run` that full access would otherwise grant. A prefix must have at least two characters before the `*`, end its literal part in `_`, and have the `*` as the last character; `u*`, `utils*` and `*_log` reject the document. `Action: ["*"]` on a prefix means the five standard actions for each match, exactly as on a named resource. A feature action grants only on matches that register it, and a statement whose feature action matches no resource is rejected at save.
**`Deny` and `*`.** For `Allow`, `Action: ["*"]` is the five standard actions only. For `Deny` it means **every action, including table features and `write`**, for each match. Otherwise denying `utils_log` under full access would leave `utils_log.analyze` granted. A `Deny` is therefore always at least as broad as the `Allow` it is written to cancel. `Resource: ["*"]` still requires `Action: ["*"]`, for `Deny` as for `Allow`.
**Named permissions.** A statement may carry `Permission: [...]` instead of `Action` and `Resource`, giving exact permission names. It works under `Allow` and `Deny`, and is the only way to deny a dotted permission such as `authentik.users.manage.hasAccess` or a host name such as `cdn.purge` that full access includes. Mixing `Permission` with `Action` or `Resource` in one statement rejects the document.
**One universe.** Every Allow expansion (an exact `Resource` and `Action`, a `Permission` name, a prefix, and full access) is computed against the same set of permission names: core `getAllAvailablePermissions`, plus the qbit catalog including hidden tables and processes, plus host-declared names, plus every `IdentityPermission` session name. A prefix matches the base name (the text before the final `.action`) of any entry in that set. An Allow of a name that is not in the set grants nothing at runtime. A `Deny` is a predicate over names and needs no universe entry to apply.
**Host-declared names are not features.** `Action` grants only the standard actions and the table features registered for that resource. Dotted service names (`alerts.admin`, `email.send`, `echo.use`) are never features; they are granted only through `Permission`. A host declares them with `PolicyPermissions.registerHostPermissions(qInstance -> names)`, which needs no Authentik runtime (so a host that runs only the policy mapper keeps its names), or with `AuthentikQBitConfig.withFullAccessHostPermissions`. The two are unioned and evaluated at every expansion.
**Claim shape.** The claim is ONE merged document, a JSON object or a JSON string, exactly what the claim expression emits. An array of documents is rejected.
**Zero matches.** At sign-in a prefix or named permission that matches nothing expands to nothing. When an operator saves a document, any prefix, feature or named permission that matches nothing in the current instance is rejected ("matches nothing"), for `Allow` and for `Deny`. A `Deny` of a named resource none of whose listed actions exists is rejected the same way (this is stricter than the golden vectors require). A mistyped `Deny` that silently does nothing is the dangerous case, so it is refused at the point where someone can fix it.
**Editing.** Adding a `Deny` is always allowed to an operator with policy management. Removing or narrowing a `Deny` is a grant: the operator must hold every permission it was withholding and must not be denied any of them themselves. Removing a person from a group, or a group from a parent, releases the `Deny` statements of that group and above it, so it needs the same check. Saving a policy that contains a `Deny` also runs the last-administrator check.
**What a Deny subtracts.** ONE rule, in `SessionPermissionRules.compute`, serves sign-in, automated sessions and the refreshing mapper, so they cannot disagree. By what is known about the user's policies:
- **Valid (known).** A `Deny` removes what the user's groups grant through policy documents and `qqq.io/permissions`, the mirror's stored effective permissions, the static group fallbacks, the host permission source, and the permissions an earlier customizer already put on the session. That holds whether the cut-over guard is enforcing or blocking: while it blocks, sign-in grants today's access (static floor plus host source) minus every valid `Deny`. A `Deny` only removes access, so the guard never switches it off. The ONLY exemption is the bootstrap-operator identity permissions (break-glass). A `Deny` that removes a permission from a guard-group member is a deliberate removal, so it never trips the guard by itself. If the guard cannot run (an Authentik error) its last known state stands (never OFF, with its original time, for at most 15 minutes); otherwise it blocks.
- **Void.** An invalid document, or a group or parent missing from the graph or mirror, leaves ONLY the bootstrap identity permissions: the floors, the host source, the mirror's stored grants and the session's earlier permissions are cleared, even while the guard blocks (the log carries an ERROR naming the groups).
- **Unavailable.** If the group graph (or mirror) cannot be read and the token carries no policy claim, no `Deny` can be known: the host source, the session's earlier permissions and any directory grants are withheld, and only the explicitly configured outage fallbacks (static group permissions) plus break-glass remain. If the claim is present it carries every group's merged policy and applies on its own.
A token with no `groups` claim at all and no policy claim says nothing about the user's groups, so it is treated as unavailable; an empty `groups` array is a real answer (no groups) and is known-empty.
**Operators and break-glass.** For the subset rule, the bootstrap identity permissions are added to an operator AFTER the `Deny` filter, so a void or denying group cannot lock a bootstrap operator out of repairing policies.
**`AuthentikPolicyClaimMapper` differs on purpose.** It is the claim-only mapper: it has no bootstrap, static or host floors, so it does not go through the shared rule. With no policy claim it leaves the session's permissions untouched; with a valid claim it adds the claim's grants and removes every `Deny` from them and from the session's earlier permissions; with an invalid claim it clears all of them. Hosts that need floors or the cut-over guard use `AuthentikGroupPermissionMapper`. The helpers `AuthentikGroupPermissionMapper.resolve`/`resolveResult` and `PolicyResult.apply` are for a host customizer's own assembly, not for sign-in assembly, which the mappers do themselves.
`PolicyResult` reports `isVoid()`, `isUnavailable()` and `isAbsent()` separately, and `apply()` returns nothing when void or unavailable.
**Freshness of the group graph.** Sign-in reads the group graph through a cache (`groupCacheTtlSeconds`): a `Deny` added in the Authentik admin UI is not seen at sign-in until the TTL elapses, unless the claim carries it. The refreshing mapper reads the mirrored group rows, so it sees a `Deny` as of the last mirror checkpoint (at most 15 minutes old), read on the same connection as the user row and refused unless every group row carries that checkpoint's refresh time and the table is under its bound.
**Void is not empty.** An invalid document voids the user: they get no permissions. Because a void group might have held a `Deny`, un-voiding is a release: removing a member from a void group, removing a parent link to it, or saving a valid document over an invalid stored one requires the operator to hold every permission that exists. Generate Policy refuses to run over an invalid stored document instead of discarding its `Deny`.
**Operators.** For every subset-rule check an operator is credited with a permission only if their own policies grant it, or a host floor (static fallback, host permission source, bootstrap) offers it AND their own policies do not deny it. An operator denied a permission can neither grant it nor release a `Deny` on it. Save Policy must carry the `expectedFingerprint` of the group as reviewed; the fingerprint covers `qqq.io/` attributes and the configured policy attribute even when it lives outside `qqq.io/`.
**Host customizers.** `PolicyPermissions.fromClaims` and `AuthentikGroupPermissionMapper.resolve` return plain sets and are deprecated: a set cannot tell void from absent or carry a `Deny` to the host's other sources. Use `PolicyPermissions.resultFromClaims(claims, format, catalog)` (or `AuthentikGroupPermissionMapper.resolveResult`), which returns a `PolicyResult` (`isVoid()`, `isAbsent()`, the typed rules and what they grant), and add the host's own service permissions through `result.apply(otherPermissions)`: it subtracts every `Deny` from them and returns nothing when the policy is void. `apply` is a union: `apply(session.getPermissions())` keeps whatever the session already held that no `Deny` covers. A host whose sessions take their permissions from the policies alone (QQQ runs the customizer again on every request of a session, and the session must end with exactly what the current token grants) calls `session.setPermissions(result.replacement())` instead: the grant alone, nothing carried over, nothing when the policy is void or unreadable.
The golden vectors in `src/test/resources/policy-vectors-1.1.0.json` define these rules over a small fixed permission universe. Every implementation of the grammar must pass them: this qbit's parser, the Authentik claim validator in the identity CD repository, and Forge's catalog tests.
## Full access
The exact full-access statement is:
```json
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
```
It grants **Full access (all current and future resources)**. A complete document is:
```json
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.1.0",
"Statement": [
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
]
}
```
Full access expands when permissions are computed (sign-in, effective-permission resolution, access comparison and member preview), using the current QQQ instance. The document and cached group graph retain a symbolic marker; no expanded permission list is stored in the document or cached for full access. Adding a table, process, app, report, widget or table feature therefore requires no policy edit. Existing signed-in sessions receive the new set when their permissions are computed again.
The expansion is the union of:
- **Every `AvailablePermission.name` returned by `PermissionsHelper.getAllAvailablePermissions(qInstance)`**, without limiting names to the editor's resource syntax. Core uses effective permission base names and permission levels: table `hasAccess`, `read`, `insert`, `edit`, `delete`, READ_WRITE `write`, registered table feature names, and process/app/report/protected-widget `hasAccess`. Names supplied by custom permission checkers are included when those checkers expose them through this API; arbitrary permissions that core does not enumerate are not inferred.
- **The qbit permission catalog**, including policy-catalog permissions for hidden tables and processes (core skips hidden tables/processes), plus any additional catalog names supplied to the resolver.
- **Host-declared permission names** from `AuthentikQBitConfig.withFullAccessHostPermissions(qInstance -> names)`, for each runtime whose API backend is in the instance. Use it for names that core cannot enumerate: custom permission checkers and named permissions such as `cdn.purge` or `email.send`. The function runs at every expansion, so names the host adds later are covered without a policy edit. Without it, full access does not grant those names.
- **Every `IdentityPermission` session name**, such as `authentik.users.manage.hasAccess`, `authentik.permissions.manage.hasAccess` and `authentik.policies.manage.hasAccess`. This covers the entire qbit identity catalog, even when a particular feature is not enabled.
This grants QQQ permissions, not Authentik superuser status. Existing scope, protected-account, audit, approval, last-admin and cut-over checks still apply.
Examples and boundaries:
- `{"Effect":"Allow","Action":["*"],"Resource":["*"]}` includes table features, READ_WRITE `write`, named identity permissions and future resources.
- `{"Effect":"Allow","Action":["*"],"Resource":["cms_page"]}` still grants only the five standard actions for `cms_page`; it does not grant features or `write`.
- `{"Effect":"Allow","Action":["read"],"Resource":["*"]}` is invalid. So is `Action: ["*", "read"]` with `Resource: ["*"]`. The error says that `Resource "*" requires Action ["*"]`.
- A full-access statement may coexist with other valid statements (or named resources in the same statement). Canonicalization drops the redundant grants. Every statement and resource is validated first: an invalid statement anywhere still rejects the entire document, even beside full access.
Saving a full-access document requires the operator to hold **every permission in its current expansion**, even when the target already has full access. An operator whose own effective policy is full access meets that check. Review and audit summaries use **Full access (all current and future resources)**. Removing full access cannot remove the last qualifying identity administrator.
## Parsing fails closed
A document grants **nothing**, and the parser logs a warning, when any of these is true:
- it is missing, blank, not JSON, or not a JSON object
- `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 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. A user's documents are evaluated together, and **one invalid document among a user's groups voids all of them: the user gets no permissions.** This is deliberate. With `Deny` in the format, dropping only the bad group could drop a `Deny` while other groups' `Allow` statements survive, which would silently escalate access. The same rule applies to the merged token claim: one bad statement voids the claim.
## Canonical form
Canonicalization never drops a `Deny`. A document containing full access canonicalizes to one full-access `Allow` statement followed by its `Deny` statements (and no other `Allow`); the redundant `Allow` grants are dropped, the `Deny` statements are kept. A generated permission set produces full access only when it explicitly contains the internal full-access marker; enumerating all current permissions never creates future grants.
For other documents the qbit writes the existing canonical form:
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`, then feature names sorted alphabetically (existing documents retain their byte-for-byte canonical form)
- each statement lists its resources sorted
- no `*`
- stored on the group as a compact JSON **string**
Before every save, the qbit checks the round trip: parsing the generated document must give back exactly the permissions it was generated from. A document that fails this check is never written.
## Where it lives
| What | Default | Config |
|---|---|---|
| Group attribute holding a group's document | `qqq.io/policy` | `policyAttribute` |
| Token claim holding the merged document | `qqq_policy` | `policyClaim` |
| Spec name | `qqq-iam` | `policySpec` |
| Version | `1.1.0` | `policyVersion` |
An app that already stores documents of this shape under its own spec name, attribute and claim sets those four values and keeps its data unchanged. The parser accepts the attribute or claim as a JSON string or as a JSON object.
## Getting policies into the token
Authentik puts the merged document into the token through a scope mapping. The example blueprint voids the whole claim (it emits a document the qbit rejects) when any document found under the configured attribute is unparsable, has a duplicate key, is not an object, carries a `PolicySpec` that is not exactly the configured one (missing, mistyped, another case, a trailing space), has another version or a non-array `Statement`. Skipping documents of another spec is an explicit opt-in (`skip_other_specs = True`, for an attribute shared with other applications) and is off by default. Known difference: the qbit reads JSON with org.json (which accepts some lenient syntax Python rejects, and rejects duplicate keys) and the blueprint with Python json (strict syntax, duplicate keys rejected, also nested); a document only one of them accepts voids the claim on the stricter side. `BlueprintClaimExpressionTest` runs the blueprint expression against these cases. Steps:
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.
3. Keep `include_claims_in_id_token: true` on the provider (the default).
4. Register the customizer in your app:
- `AuthentikPolicyClaimMapper` on its own, or
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
The expanded catalog is cached per `QInstance` (the last few instances, keyed by a digest of table permission rules, supplemental metadata and object names); `PolicyCatalog.clearCache()` forgets them. The cache lives in each JVM and is derived only from that JVM's own instance, so nodes of a cluster need no invalidation between them. Every `PolicyCatalog.of` call compares the instance with the digest, so an in-place metadata change is seen by the next call. The permission universe is never cached: it is built from the catalog's names, the host-declared names and the identity names at each resolution, once per resolution however many terms the policy has (`PolicyRules` reads the universe it is given once per call; `PolicyUniverse.once` does the same for a caller's own loop). No decision is cached: the same claim is resolved again on every request. Host-declared names are never cached, and a host-name function runs once per resolution, so it should be cheap. Host names must be dot-separated segments of `[A-Za-z0-9_-]`; a function that returns anything else fails the expansion loudly. The cache key also covers the permission rules of processes, apps, reports and widgets, hidden flags and the registered table-feature list; a feature's applies-to predicate is opaque, so a host that changes only that in place calls `clearCache()`.
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog accept standard actions and full access; `PolicyPermissions.fromClaims` uses the current `QContext` instance for full-access expansion when no catalog is supplied. Raw `PolicyParser` results are symbolic and must be resolved before entering a session. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the available-permission API from the pinned RC.6 core; registered `TableFeaturePermissionsMetaData` features appear in that API.
The mapping merges the statements of every group in `request.user.all_groups()` (direct groups and their ancestors) whose document has the right spec and version. It always emits a document, with an empty `Statement` array when the user has no policies.
The contract test `PolicyClaimMappingIT` runs this blueprint on Authentik 2026.8.3. It puts generated policies on a parent and a child group (one as a string, one as a JSON object), then runs the mapping through Authentik's property-mapping test endpoint for a member of the child group. It checks that the parser turns the claim into the union of the groups' permissions, that a document with another spec is skipped, and that a user without policies gets a valid empty document.
### Freshness
The claim is computed when the token is issued, so a policy change reaches a signed-in user at their next token refresh. In kof22 that is within 1 hour. To apply a change at once, revoke the user's sessions (*Revoke Sessions*).
## Record locks (not yet)
Row-level security keys are not part of the format. `AuthentikPolicyClaimMapper.applySecurityKeys(QSession, JSONObject)` is the extension point. A host overrides it to set security key values from its own explicit claims, and must never infer them from group names.
-128
View File
@@ -1,128 +0,0 @@
# 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
@@ -1,147 +0,0 @@
# 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
@@ -1,306 +0,0 @@
# 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.
-94
View File
@@ -1,94 +0,0 @@
# Status
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 |
|---|---|
| 0. Foundations and migration | agent items done; **owner items open** (checklist items 6-8, 10, then 12) |
| 1. Read-only plus the permissions model | done, except the mirror tables, reconcile and the access-matrix widget (see "Not built yet") |
| 2. Memberships and permission editing | done, except the webhook route, ESB trigger, inbox and session re-evaluation marking |
| 3. Full user lifecycle | done, except the optional snapshot export |
| 4. RBAC role management | post-v1, not started |
| 5. Policies and full CRUD (DESIGN.md section 12) | 5a policy model (#14), 5b policy editor (#15), 5c user and group CRUD additions; the Next UI widget is a separate qqq-frontend-next PR |
### Built in this line (0.1)
| Area | What |
|---|---|
| People | Create, invite, edit (username, name, email, path, other attributes), deactivate, reactivate, delete (feature `DELETE`), password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
| Groups | Create, edit (parents, other attributes), delete (feature `DELETE`), add and remove members (bulk), edit permissions and app access, edit the group policy (feature `POLICIES`) |
| Safety | Subset rule on live groups, protected accounts and superuser groups, no self-edits, last-admin protection, reasons, typed confirmations, bulk caps, stale checks, two-person approval for `authentik.*` |
| Audit | `authentik_admin_action` (before the call), Authentik `custom_qqq_admin_action` events, approval requests |
| Screens | Home dashboard, main tables in the nav, child lists on parents, every action as a table or record action |
| Tests | Unit tests on an in-memory Authentik (rules, processes, app, home), contract tests on Authentik 2026.8.3 |
### Not built yet (and why)
| Item | Note |
|---|---|
| Authentik webhook, ESB trigger, inbox, session re-evaluation marking | Needs the notification transport in cluster-gitops (inert today); reads are live (30 s cache) so screens are correct without it |
| Mirror tables and reconcile | For Web Users joins and widget counts; the home reads live |
| Access matrix widget, group tree widget | Needs Next UI custom widgets (separate frontend PRs) |
| Snapshot export | Optional in the spec |
| RBAC role management | Phase 4 |
| Alerts producer integration | Optional until the Alerts qbit SPI (QRun-IO/qqq#1090) lands |
| Quick Search for users and groups | They are API-backed (read-through), so they are searched with the table's own search box, which Authentik evaluates live |
| Invitations add groups | Groups for invited people are added after they sign up (needs the webhook) |
## Phase 0: foundations (agent items done; owner items open)
| # | Item | State | Where |
|---|---|---|---|
| 1 | Repo, DESIGN.md, CI, README, CLAUDE.md | done | this repo (`develop`); public |
| 2 | Config, producer, `AuthentikActionUtil`, pinned schema + drift test | done | phase 0 PR |
| 3 | Testcontainers harness, fixture recorder, contract tests (b), (c), (d), (h) | done | `src/test/java/.../contract` |
| 4 | Open points (i)-(iv) resolved | done | DESIGN.md section 0.2 |
| 5 | cluster-gitops PR (bootstrap, removals, sign-in policy, webhook, lint) | draft | KofTwentyTwo/cluster-gitops#72 |
| 6 | Staging rehearsal | **owner** | `docs/PHASE0-OWNER-CHECKLIST.md`, `docs/cluster-gitops-phase0/rehearse-phase0.sh` |
| 7 | Production apply | **owner** | checklist |
| 8 | Service-account token, Infisical, webhook secret | **owner** | checklist |
| 9 | Website-Backend wiring (ExternalSecret + env, qbit behind a disabled flag) | draft | KofTwentyTwo/cluster-gitops#73, Website-Backend#94 |
| 10 | Seed attributes, break-glass password | **owner** | checklist |
| 11 | Read-only on staging | after 6-10 | flip `AUTHENTIK_QBIT_ENABLED` |
| 12 | Turn on the full lifecycle (role permissions, brand recovery flow, tables, flags) | after 11 | checklist item 12 |
## 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
@@ -1,67 +0,0 @@
# 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
@@ -1,91 +0,0 @@
# 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.
-21
View File
@@ -1,21 +0,0 @@
# cluster-gitops phase 0 support files
The cluster-gitops changes themselves are in **KofTwentyTwo/cluster-gitops#72** (draft). That PR adds:
- the identity bootstrap blueprint
- the removal of the `present` `james.maes` and `kof22-admins` entries
- the attribute-driven sign-in policy
- the inert webhook plumbing
- the lint
The blueprint YAML in that PR is generated from this repo's tested fixtures, `src/test/resources/blueprints/*.yaml`. That way, what goes to cluster-gitops is what the contract tests applied to Authentik 2026.8.3.
| File | Purpose |
|---|---|
| `scripts/lint-authentik-blueprints.py` | The blueprint lint (also in cluster-gitops#72 under `scripts/`). It forbids `present` user/group entries, member lists, roles beyond `permissions`, tokens and literal secrets. `--self-test` runs the built-in cases. |
| `rehearse-phase0.sh` | Owner checklist item 6: replays the migration on a **local, disposable** Authentik restored from a dump you take, and checks that no membership moves. |
To re-check the fixtures with the lint:
```bash
python3 docs/cluster-gitops-phase0/scripts/lint-authentik-blueprints.py src/test/resources/blueprints
```
@@ -1,113 +0,0 @@
#!/usr/bin/env bash
# Rehearse the phase 0 blueprint migration (DESIGN.md section 4.4, owner
# checklist item 6) on a DISPOSABLE local Authentik restored from a dump of the
# production Authentik database. Nothing here talks to production: you supply
# the dump file; everything runs in local Docker and is removed at the end.
#
# Usage:
# rehearse-phase0.sh <authentik-db.dump> <configmap-before.yaml> <configmap-after.yaml>
#
# authentik-db.dump pg_dump -Fc of the production Authentik database
# configmap-before.yaml tenants/kof22/website-authentik/blueprint-configmap.yaml at main
# configmap-after.yaml the same file on branch feat/kof22-identity-bootstrap
#
# Needs: docker, jq, python3 with PyYAML. Takes ~5 minutes.
set -euo pipefail
export MSYS_NO_PATHCONV=1 # Git Bash on Windows: keep container paths as-is
DUMP=${1:?dump file}; BEFORE=${2:?configmap before}; AFTER=${3:?configmap after}
IMAGE=${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server:2026.8.3}
NET=ak-rehearsal; PORT=${PORT:-19900}
WORK=$(mktemp -d); BP="$WORK/blueprints"; mkdir -p "$BP"
BP_MOUNT="$BP"; command -v cygpath >/dev/null 2>&1 && BP_MOUNT=$(cygpath -w "$BP")
ADMIN_TOKEN="rehearsal-$(openssl rand -hex 16)"
API="http://localhost:$PORT/api/v3"
cleanup() { docker rm -f akr-server akr-worker akr-pg >/dev/null 2>&1 || true; docker network rm $NET >/dev/null 2>&1 || true; rm -rf "$WORK"; }
trap cleanup EXIT
extract() { # write every blueprint key of a ConfigMap into $BP
rm -f "$BP"/*.yaml
python3 - "$1" "$BP_MOUNT" <<'PY'
import sys, yaml
doc = yaml.safe_load(open(sys.argv[1], encoding="utf-8"))
for key, value in doc["data"].items():
open(f"{sys.argv[2]}/{key}", "w", encoding="utf-8").write(value)
print(" blueprint file:", key)
PY
}
api() { curl -sf -H "Authorization: Bearer $ADMIN_TOKEN" "$API$1"; }
snapshot() { # users -> sorted groups, as TSV
api "/core/users/?page_size=100&include_groups=true&type=internal&type=external&type=service_account" \
| jq -r '.results[] | [.username, ([.groups_obj[].name] | sort | join(","))] | @tsv' | tr -d '\r' | sort
}
wait_blueprints() { # $1 = how many kof22* blueprint instances must exist
local want=${1:-1}
for _ in $(seq 1 90); do
states=$(api "/managed/blueprints/?page_size=100" | jq -r '[.results[] | select(.name|startswith("kof22")) | .status] | join(" ")' || true)
count=$(wc -w <<<"$states")
if [ "$count" -ge "$want" ] && ! grep -qv successful <<<"$(tr ' ' '\n' <<<"$states")"; then echo " kof22 blueprints: $states"; return 0; fi
sleep 5
done
echo " blueprints did not all reach successful: $states"; api "/managed/blueprints/?page_size=100" | jq -r '.results[] | "\(.name) \(.status)"'; return 1
}
echo "== 1. restore the dump into a disposable Postgres"
docker network create $NET >/dev/null
docker run -d --name akr-pg --network $NET -e POSTGRES_USER=authentik -e POSTGRES_PASSWORD=rehearsal -e POSTGRES_DB=authentik postgres:16-alpine >/dev/null
until docker exec akr-pg pg_isready -U authentik >/dev/null 2>&1; do sleep 1; done
docker cp "$DUMP" akr-pg:/tmp/authentik.dump
docker exec akr-pg pg_restore -U authentik -d authentik --no-owner --no-privileges /tmp/authentik.dump || echo " (pg_restore reported warnings; usually role/ownership notices)"
docker exec akr-pg psql -U authentik -d authentik -tAc "select count(*) from authentik_core_user" | sed 's/^/ users restored: /'
echo "== 2. start Authentik with the CURRENT (main) blueprints"
extract "$BEFORE"
ENV=(-e AUTHENTIK_SECRET_KEY="$(openssl rand -hex 32)" -e AUTHENTIK_POSTGRESQL__HOST=akr-pg -e AUTHENTIK_POSTGRESQL__USER=authentik
-e AUTHENTIK_POSTGRESQL__PASSWORD=rehearsal -e AUTHENTIK_POSTGRESQL__NAME=authentik -e AUTHENTIK_BOOTSTRAP_TOKEN="$ADMIN_TOKEN"
-e KOF22_OAUTH2_CLIENT_SECRET=rehearsal-not-the-real-secret -e AUTHENTIK_ERROR_REPORTING__ENABLED=false
-e AUTHENTIK_DISABLE_UPDATE_CHECK=true -e AUTHENTIK_EMAIL__HOST=localhost -e AUTHENTIK_EMAIL__PORT=1)
docker run -d --name akr-worker --network $NET "${ENV[@]}" -v "$BP_MOUNT:/blueprints/custom" "$IMAGE" worker >/dev/null
docker run -d --name akr-server --network $NET -p "$PORT:9000" "${ENV[@]}" -v "$BP_MOUNT:/blueprints/custom" "$IMAGE" server >/dev/null
until curl -sf "http://localhost:$PORT/-/health/ready/" >/dev/null; do sleep 3; done
# The bootstrap token only applies to an EMPTY instance; on a restored database
# create a rehearsal token for akadmin-equivalent access through the shell.
docker exec akr-worker ak shell -c "
from authentik.core.models import Token, TokenIntents, User
u = next(x for x in User.objects.filter(is_active=True).exclude(username__startswith='ak-').order_by('pk') if x.is_superuser) # is_superuser is derived from groups
Token.objects.update_or_create(identifier='phase0-rehearsal', defaults={'user': u, 'intent': TokenIntents.INTENT_API, 'key': '$ADMIN_TOKEN', 'expiring': False})
print('rehearsal token for', u.username)" 2>&1 | grep -E 'rehearsal token|Error|error' | tail -3 | grep 'rehearsal token' || { echo "could not create the rehearsal API token in the restored copy"; exit 1; }
wait_blueprints 1
snapshot > "$WORK/before.tsv"; echo " $(wc -l < "$WORK/before.tsv") users recorded (step 1, freeze)"
echo "== 3. switch to the phase 0 blueprints (as an ArgoCD sync would)"
extract "$AFTER"
docker restart akr-worker >/dev/null # a restart triggers discovery like a ConfigMap change
wait_blueprints 2
for name in kof22-oauth2-apps kof22-identity-bootstrap; do
pk=$(api "/managed/blueprints/?name=$name" | jq -r '.results[0].pk')
curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$API/managed/blueprints/$pk/apply/" >/dev/null
done
sleep 30; wait_blueprints 2
echo "== 4. checks"
snapshot > "$WORK/after.tsv"
fail=0
if ! diff <(awk -F'\t' '$1 != "qqq-kof22-admin-idadmin" && $1 != "kof22-breakglass"' "$WORK/after.tsv") "$WORK/before.tsv" >/dev/null; then
echo " FAIL memberships changed:"; diff "$WORK/before.tsv" "$WORK/after.tsv" || true; fail=1
else echo " ok existing users' memberships unchanged"; fi
grep -q $'^james.maes\t.*authentik Admins.*kof22-admins' "$WORK/after.tsv" && echo " ok james.maes keeps authentik Admins + kof22-admins" || { echo " FAIL james.maes groups"; fail=1; }
api "/core/users/?username=qqq-kof22-admin-idadmin&include_roles=true" | jq -e '.results[0] | .type=="service_account" and (.groups|length)==0 and ([.roles_obj[].name]==["qqq-kof22-admin-idadmin"])' >/dev/null \
&& echo " ok service account: type, no groups, role" || { echo " FAIL service account"; fail=1; }
api "/core/users/?username=kof22-breakglass" | jq -e '.results[0].is_superuser' >/dev/null && echo " ok break-glass superuser" || { echo " FAIL break-glass"; fail=1; }
for g in identity-admins kof22-admins kof-website-publishers kof-website-drafters; do
api "/core/groups/?name=$g" | jq -e '.pagination.count==1' >/dev/null && echo " ok group $g" || { echo " FAIL group $g"; fail=1; }
done
echo "== 5. re-apply both again (an hourly run): memberships must not move"
for name in kof22-oauth2-apps kof22-identity-bootstrap; do
pk=$(api "/managed/blueprints/?name=$name" | jq -r '.results[0].pk')
curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$API/managed/blueprints/$pk/apply/" >/dev/null
done
sleep 30; wait_blueprints 2
snapshot | diff - "$WORK/after.tsv" >/dev/null && echo " ok stable across re-apply" || { echo " FAIL changed on re-apply"; fail=1; }
[ $fail = 0 ] && echo "REHEARSAL PASSED" || { echo "REHEARSAL FAILED"; exit 1; }
@@ -1,225 +0,0 @@
#!/usr/bin/env python3
"""Lint Authentik blueprints so a re-apply can never revert identity data.
Authentik's blueprint importer re-saves `state: present` entries on every apply
(hourly, and on every file change), and many-to-many attrs (users, groups,
parents, roles) are REPLACED, not merged. A present user or group entry, or a
member list, therefore silently undoes edits made in the Authentik UI or in QQQ
(qbit-authentik DESIGN.md sections 2.2, 4.2 and 4.5; contract test (d)).
Rules (each violation is printed as file:entry: message):
R1 authentik_core.user / authentik_core.group entries must be
`state: created`, `must_created` or `absent` (no state means present).
R2 no member lists: a group entry must not carry `users`.
R3 an `authentik_rbac.role` entry may be `present` only if its attrs are
limited to `permissions` (and `name`): the qbit role pattern, whose
global permissions are re-asserted additively.
R4 no literal secrets: password, password_hash, key, token, client_secret
must be a YAML tag (e.g. !Env) or absent; authentik_core.token entries are
not allowed at all.
Input: Kubernetes ConfigMap manifests (every data value that looks like a
blueprint is checked) and plain blueprint files. Usage:
lint-authentik-blueprints.py FILE_OR_DIR... # exit 1 on violations
lint-authentik-blueprints.py --self-test # run the built-in cases
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
import yaml
IDENTITY_MODELS = {"authentik_core.user", "authentik_core.group"}
SAFE_IDENTITY_STATES = {"created", "must_created", "absent"}
SECRET_KEYS = {"password", "password_hash", "key", "token", "client_secret"}
ROLE_PRESENT_ALLOWED_ATTRS = {"permissions", "name"}
class Tag:
"""Placeholder for an Authentik YAML tag such as !KeyOf, !Find or !Env."""
def __init__(self, tag: str, value):
self.tag = tag
self.value = value
def __repr__(self):
return f"{self.tag} {self.value!r}"
class BlueprintLoader(yaml.SafeLoader):
"""SafeLoader that accepts Authentik's custom tags."""
def _construct_tag(loader: yaml.SafeLoader, tag_suffix: str, node: yaml.Node):
if isinstance(node, yaml.ScalarNode):
value = loader.construct_scalar(node)
elif isinstance(node, yaml.SequenceNode):
value = loader.construct_sequence(node, deep=True)
else:
value = loader.construct_mapping(node, deep=True)
return Tag("!" + tag_suffix, value)
BlueprintLoader.add_multi_constructor("!", _construct_tag)
@dataclass
class Violation:
source: str
entry: str
message: str
def __str__(self):
return f"{self.source}: {self.entry}: {self.message}"
def entry_label(index: int, entry: dict) -> str:
identifiers = entry.get("identifiers") or {}
ident = ", ".join(f"{k}={v}" for k, v in identifiers.items()) if isinstance(identifiers, dict) else str(identifiers)
return f"entry {index} ({entry.get('model')} {ident})"
def lint_blueprint(source: str, document) -> list[Violation]:
violations: list[Violation] = []
if not isinstance(document, dict) or not isinstance(document.get("entries"), list):
return violations
for index, entry in enumerate(document["entries"]):
if not isinstance(entry, dict):
continue
model = entry.get("model")
if isinstance(model, Tag):
violations.append(Violation(source, entry_label(index, entry), "model must be a literal so it can be linted"))
continue
state = entry.get("state", "present")
if isinstance(state, Tag):
violations.append(Violation(source, entry_label(index, entry), "state must be a literal so it can be linted"))
continue
attrs = entry.get("attrs") or {}
label = entry_label(index, entry)
if model in IDENTITY_MODELS and state not in SAFE_IDENTITY_STATES:
violations.append(Violation(source, label, f"R1 {model} must be `state: created` (got `{state}`); present entries revert UI/QQQ edits on every apply"))
if model == "authentik_core.group" and state != "absent" and "users" in attrs:
violations.append(Violation(source, label, "R2 member list (`users:`) on a group; memberships are data, manage them in the Authentik UI or QQQ"))
if model == "authentik_rbac.role" and state not in SAFE_IDENTITY_STATES:
extra = set(attrs) - ROLE_PRESENT_ALLOWED_ATTRS
if extra:
violations.append(Violation(source, label, f"R3 present role may only declare `permissions` (found {sorted(extra)})"))
if model == "authentik_core.token":
violations.append(Violation(source, label, "R4 tokens must never be declared in blueprints"))
for key in SECRET_KEYS:
if key in attrs and attrs[key] is not None and not isinstance(attrs[key], Tag):
violations.append(Violation(source, label, f"R4 literal `{key}`; use !Env or set it outside git"))
return violations
def blueprints_in_manifest(source: str, text: str):
"""Yield (source, parsed blueprint) for a file: a ConfigMap or a blueprint."""
for document in yaml.load_all(text, Loader=BlueprintLoader):
if not isinstance(document, dict):
continue
if document.get("kind") == "ConfigMap" and isinstance(document.get("data"), dict):
for key, value in document["data"].items():
if isinstance(value, str) and "entries:" in value and "version:" in value:
yield f"{source}#{key}", yaml.load(value, Loader=BlueprintLoader)
elif "entries" in document:
yield source, document
def lint_paths(paths: list[str]) -> list[Violation]:
violations: list[Violation] = []
for path in paths:
files = []
if os.path.isdir(path):
for root, _, names in os.walk(path):
files.extend(os.path.join(root, n) for n in names if n.endswith((".yaml", ".yml")))
else:
files.append(path)
for file in sorted(files):
with open(file, encoding="utf-8") as handle:
text = handle.read()
if "entries:" not in text:
continue
try:
for source, blueprint in blueprints_in_manifest(file, text):
violations.extend(lint_blueprint(source, blueprint))
except yaml.YAMLError as error:
violations.append(Violation(file, "-", f"YAML error: {error}"))
return violations
SELF_TEST_GOOD = """
version: 1
entries:
- model: authentik_rbac.role
state: present
identifiers: {name: qqq-x}
attrs: {permissions: [authentik_core.view_user]}
- model: authentik_core.user
state: created
identifiers: {username: sa}
attrs: {type: service_account, roles: [!KeyOf r], groups: [!Find [authentik_core.group, [name, x]]]}
- model: authentik_core.group
state: created
identifiers: {name: g}
attrs: {attributes: {qqq.io/permissions: [a]}}
- model: authentik_providers_oauth2.oauth2provider
state: present
identifiers: {name: p}
attrs: {client_secret: !Env SECRET}
"""
SELF_TEST_BAD = {
"present group": ("model: authentik_core.group\n identifiers: {name: g}", "R1"),
"present user": ("model: authentik_core.user\n state: present\n identifiers: {username: u}\n attrs: {groups: [!KeyOf g]}", "R1"),
"member list": ("model: authentik_core.group\n state: created\n identifiers: {name: g}\n attrs: {users: [1]}", "R2"),
"role with users": ("model: authentik_rbac.role\n identifiers: {name: r}\n attrs: {permissions: [a], users: [1]}", "R3"),
"token": ("model: authentik_core.token\n state: created\n identifiers: {identifier: t}", "R4"),
"literal secret": ("model: authentik_providers_oauth2.oauth2provider\n identifiers: {name: p}\n attrs: {client_secret: hunter2}", "R4"),
"literal password": ("model: authentik_core.user\n state: created\n identifiers: {username: u}\n attrs: {password: hunter2}", "R4"),
}
def self_test() -> int:
failures = 0
good = lint_blueprint("good", yaml.load(SELF_TEST_GOOD, Loader=BlueprintLoader))
if good:
failures += 1
print("FAIL good blueprint flagged:", *good, sep="\n ")
for name, (entry, rule) in SELF_TEST_BAD.items():
found = lint_blueprint(name, yaml.load(f"version: 1\nentries:\n - {entry}\n", Loader=BlueprintLoader))
if not any(v.message.startswith(rule) for v in found):
failures += 1
print(f"FAIL {name}: expected {rule}, got {[str(v) for v in found]}")
configmap = "apiVersion: v1\nkind: ConfigMap\nmetadata: {name: x}\ndata:\n bad.yaml: |\n version: 1\n entries:\n - model: authentik_core.group\n identifiers: {name: g}\n"
if not any("#bad.yaml" in v.source for v in [v for s, b in blueprints_in_manifest("cm.yaml", configmap) for v in lint_blueprint(s, b)]):
failures += 1
print("FAIL ConfigMap data values are not linted")
print("self-test:", "OK" if failures == 0 else f"{failures} failure(s)")
return 1 if failures else 0
def main(argv: list[str]) -> int:
if argv == ["--self-test"]:
return self_test()
if not argv:
print(__doc__)
return 2
violations = lint_paths(argv)
for violation in violations:
print(violation)
print(f"{len(violations)} violation(s)")
return 1 if violations else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
-117
View File
@@ -1,117 +0,0 @@
-- 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);
-93
View File
@@ -1,93 +0,0 @@
-- The qbit's own tables (DESIGN.md section 6.2) for the host's local RDBMS backend.
-- Column names are the snake_case of the QQQ field names. PostgreSQL syntax; adapt the
-- types for another database. The tables are written only by the qbit (the generic
-- insert, update and delete capabilities are off); authentik_admin_action is append-only
-- in practice (rows move from RUNNING to a final status, and are never deleted).
-- Retention: the audit trail is kept 2 years (DESIGN.md section 9.4).
CREATE TABLE authentik_admin_action
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
correlation_id VARCHAR(64),
action_name VARCHAR(60) NOT NULL,
operator VARCHAR(255),
reason TEXT,
target_type VARCHAR(30),
target_key VARCHAR(100),
target_label VARCHAR(255),
before_text TEXT,
after_text TEXT,
status VARCHAR(30) NOT NULL,
error_message TEXT,
authentik_event_pk VARCHAR(64),
approver VARCHAR(255)
);
CREATE INDEX idx_authentik_admin_action_create_date ON authentik_admin_action (create_date);
CREATE INDEX idx_authentik_admin_action_target ON authentik_admin_action (target_type, target_key);
CREATE INDEX idx_authentik_admin_action_operator ON authentik_admin_action (operator);
CREATE TABLE authentik_approval_request
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
action_name VARCHAR(60) NOT NULL,
target_key VARCHAR(100),
target_label VARCHAR(255),
summary TEXT,
reason TEXT,
payload_json TEXT NOT NULL,
requester VARCHAR(255) NOT NULL,
approver VARCHAR(255),
status VARCHAR(30) NOT NULL,
expires_date TIMESTAMP WITH TIME ZONE,
decided_date TIMESTAMP WITH TIME ZONE,
error_message TEXT
);
CREATE INDEX idx_authentik_approval_request_status ON authentik_approval_request (status);
-- authentik_policy (DESIGN.md section 12): the QQQ record of each group's last policy save.
-- Authentik holds the live document (group attribute qqq.io/policy); field-level QQQ audit on
-- this table is the policy's change history.
CREATE TABLE authentik_policy
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
group_pk VARCHAR(64) NOT NULL,
group_name VARCHAR(255),
policy_json TEXT,
statement_count INTEGER,
permission_count INTEGER,
saved_by VARCHAR(255),
saved_date TIMESTAMP WITH TIME ZONE,
authentik_fingerprint VARCHAR(64),
CONSTRAINT uk_authentik_policy_group_pk UNIQUE (group_pk)
);
-- Token metadata only (DESIGN section 13.5). No key or key hash is stored.
CREATE TABLE authentik_token_scope
(
id SERIAL PRIMARY KEY,
token_identifier VARCHAR(255) NOT NULL,
owner_username VARCHAR(255),
owner_pk INTEGER,
read_only BOOLEAN,
apps TEXT,
tables TEXT,
processes TEXT,
reports TEXT,
widgets TEXT,
operation_ids TEXT,
created_by VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE,
rotated_at TIMESTAMP WITH TIME ZONE,
last_reminder_7d TIMESTAMP WITH TIME ZONE,
last_reminder_1d TIMESTAMP WITH TIME ZONE,
CONSTRAINT uk_authentik_token_scope_identifier UNIQUE (token_identifier)
);
Vendored
-295
View File
@@ -1,295 +0,0 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
-189
View File
@@ -1,189 +0,0 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+22 -266
View File
@@ -1,123 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<testcontainers.version>1.21.4</testcontainers.version>
<!-- Contract tests run against this exact Authentik release (DESIGN.md section 10). -->
<!-- Override with -Dauthentik.test.image=... to rehearse an upgrade. -->
<authentik.test.image>ghcr.io/goauthentik/server:2026.8.3</authentik.test.image>
<postgres.test.image>postgres:16-alpine</postgres.test.image>
<mailpit.test.image>axllent/mailpit:v1.27</mailpit.test.image>
<!-- -Dauthentik.fixtures.record=true refreshes src/test/resources/fixtures from the contract tests -->
<authentik.fixtures.record>false</authentik.fixtures.record>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-authentik</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<name>QBit Authentik</name>
<description>QBit for managing Authentik users, groups, memberships and QQQ permissions from a QQQ app, with Authentik as the source of truth</description>
<url>https://github.com/QRun-IO/qbit-authentik</url>
<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">
<modelVersion>4.0.0</modelVersion>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-example-extension</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<developers>
<developer>
<id>kingsrook</id>
<name>Kingsrook Team</name>
<email>contact@kingsrook.com</email>
<organization>Kingsrook, LLC</organization>
<organizationUrl>https://kingsrook.com</organizationUrl>
</developer>
</developers>
<name>QBit Example Extension</name>
<description>Example Extension QBit - rename this for your QBit</description>
<scm>
<connection>scm:git:git://github.com/QRun-IO/qbit-authentik.git</connection>
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-authentik.git</developerConnection>
<url>https://github.com/QRun-IO/qbit-authentik</url>
<tag>HEAD</tag>
</scm>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.0.0</qqq.version>
</properties>
<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>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-api</artifactId>
<version>${qqq.version}</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<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>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -126,177 +39,20 @@
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<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>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-authentik.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-authentik.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludes>
<exclude>**/*IntegrationTest.java</exclude>
<exclude>**/*IT.java</exclude>
</excludes>
</configuration>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<systemPropertyVariables>
<authentik.test.image>${authentik.test.image}</authentik.test.image>
<postgres.test.image>${postgres.test.image}</postgres.test.image>
<mailpit.test.image>${mailpit.test.image}</mailpit.test.image>
<authentik.fixtures.record>${authentik.fixtures.record}</authentik.fixtures.record>
</systemPropertyVariables>
<includes>
<include>**/*IntegrationTest.java</include>
<include>**/*IT.java</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<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.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>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</profile>
</profiles>
</project>
</project>
-60
View File
@@ -1,60 +0,0 @@
/* 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
@@ -1,27 +0,0 @@
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
@@ -1,48 +0,0 @@
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
@@ -1,12 +0,0 @@
$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
@@ -1,25 +0,0 @@
$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
@@ -1,104 +0,0 @@
/* 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
@@ -1,108 +0,0 @@
/* 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
@@ -1,94 +0,0 @@
/* 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
@@ -1,87 +0,0 @@
/* 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
@@ -1,42 +0,0 @@
/* 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; });
@@ -1,67 +0,0 @@
/*
* 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;
/*******************************************************************************
** Features a host can turn on (DESIGN.md section 3.2). A feature that is off
** produces no metadata, and the guard refuses its operations.
*******************************************************************************/
public enum AuthentikFeature
{
/** Read-only tables: users, groups, memberships, roles, applications, bindings, blueprints, sessions, events. */
READ,
/** Access tokens (DESIGN section 13). */
TOKENS,
/** Create, invite, edit, deactivate users (phase 3). */
USERS,
/** Create and edit groups, permissions and app-access attributes (phase 2/3). */
GROUPS,
/** Edit group policies (IAM-style statements) with the policy editor (DESIGN.md section 12). */
POLICIES,
/** Delete users and groups as an explicit action (DESIGN.md section 12.5). */
DELETE,
/** Add and remove members (phase 2). */
MEMBERSHIP,
/** Recovery email, MFA reset, app-password revoke (phase 3). */
CREDENTIALS,
/** Revoke sessions (phase 3). */
SESSIONS,
/** Authentik RBAC role management (phase 4). */
RBAC,
/** Access matrix widget. */
ACCESS_MATRIX,
/** Local mirror tables (phase 1+). */
MIRROR,
/** Inbound Authentik webhook (phase 2). */
WEBHOOK,
/** Nightly read-only snapshot export (phase 3). */
SNAPSHOT_EXPORT
}
File diff suppressed because it is too large. Load diff
@@ -1,289 +0,0 @@
/*
* 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;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
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;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Entry point of the Authentik QBit (QQQ 4.1 QBitMetaDataProducer).
**
** produce() validates the config, registers the AuthentikRuntime for the API
** backend, then lets QQQ run the component producers in the metadata package
** (API backend, read-only tables, the Identity app).
**
** Host usage:
** new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
*******************************************************************************/
public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBitConfig>
{
private static final QLogger LOG = QLogger.getLogger(AuthentikQBitProducer.class);
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "authentik";
private static final String VERSION = loadVersion();
private AuthentikQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
public AuthentikQBitProducer withQBitConfig(AuthentikQBitConfig config)
{
this.qBitConfig = config;
return (this);
}
/*******************************************************************************
** Setter for the config.
*******************************************************************************/
public void setQBitConfig(AuthentikQBitConfig config)
{
this.qBitConfig = config;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public AuthentikQBitConfig getQBitConfig()
{
return (this.qBitConfig);
}
/*******************************************************************************
** Namespace for this QBit instance; also the default tableNamePrefix.
*******************************************************************************/
public AuthentikQBitProducer withNamespace(String namespace)
{
this.namespace = namespace;
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getNamespace()
{
return (this.namespace);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QBitMetaData getQBitMetaData()
{
return (new QBitMetaData()
.withGroupId(GROUP_ID)
.withArtifactId(ARTIFACT_ID)
.withVersion(VERSION)
.withNamespace(getNamespace())
.withConfig(getQBitConfig()));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getPackageNameForFindingMetaDataProducers()
{
return (AuthentikAppMetaDataProducer.class.getPackageName());
}
/*******************************************************************************
** A producer found by package scanning has no config; skip it.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (qBitConfig != null);
}
/*******************************************************************************
** Validate, register the runtime, produce and add everything to the
** instance. Returns an empty output so a host that also calls
** addSelfToInstance on the result adds nothing twice.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
{
if(qBitConfig == null)
{
throw (new QException("AuthentikQBit configuration validation failed: no config was supplied (call withQBitConfig)"));
}
if(StringUtils.hasContent(namespace) && !StringUtils.hasContent(qBitConfig.getTableNamePrefix()))
{
qBitConfig.setTableNamePrefix(namespace);
}
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)));
}
AuthentikRuntime.register(new AuthentikRuntime(qBitConfig));
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()));
return (new MetaDataProducerMultiOutput());
}
/*******************************************************************************
** Possible-value sources: the user and group tables (for the group pickers
** on the actions) and the local tables' status enums.
*******************************************************************************/
private void addPossibleValueSources(QInstance qInstance)
{
for(String spec : List.of(AuthentikTables.USER, AuthentikTables.GROUP))
{
String name = qBitConfig.applyTablePrefix(spec);
if(qInstance.getPossibleValueSource(name) == null)
{
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(name)
.withSearchFields(List.of(AuthentikTables.USER.equals(spec) ? "username" : "name")));
}
}
if(qBitConfig.getLocalBackendName() != null)
{
for(QPossibleValueSource source : AuthentikLocalTables.possibleValueSources())
{
// These fixed status enums are shared by all named QBit instances.
if(qInstance.getPossibleValueSource(source.getName()) == null)
{
qInstance.addPossibleValueSource(source);
}
}
}
}
/*******************************************************************************
** The artifact version, from the Maven-filtered qbit-authentik.properties.
*******************************************************************************/
static String loadVersion()
{
try(InputStream inputStream = AuthentikQBitProducer.class.getClassLoader().getResourceAsStream("qbit-authentik.properties"))
{
if(inputStream != null)
{
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(StringUtils.hasContent(version) && !version.contains("${"))
{
return (version);
}
}
}
catch(Exception e)
{
LOG.debug("Could not read qbit-authentik.properties", e);
}
return ("unknown");
}
/*******************************************************************************
** Getter for the artifact version.
*******************************************************************************/
public static String getVersion()
{
return (VERSION);
}
}
@@ -1,339 +0,0 @@
/*
* 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;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
/*******************************************************************************
** Live state of one qbit instance: config, throttle, guard, filter mapper,
** response cache, and the table-name to spec map. Registered by API backend
** name, because AuthentikActionUtil is instantiated by QQQ (QCodeLoader) with
** no constructor arguments and finds its runtime from the backend it serves.
*******************************************************************************/
public class AuthentikRuntime
{
private static final Map<String, AuthentikRuntime> BY_BACKEND = new ConcurrentHashMap<>();
private final AuthentikQBitConfig config;
private final TokenBucketThrottle throttle;
private final AuthentikGuard guard;
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
private final Memoization<String, String> responseCache;
private final Memoization<String, GroupGraph> groupGraphCache;
private final Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> tokenScopeCache = new Memoization<>(Duration.ofSeconds(60));
private final Memoization<String, Map<String, java.time.Instant>> tokenActivityCache = new Memoization<>(Duration.ofMinutes(5));
private volatile java.util.Set<String> permissionCatalog;
private volatile PolicyCatalog policyCatalog;
private volatile AccessComparison.State mapperState;
private volatile boolean mapperInvoked;
private com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink tokenAlerts = new com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts()::send;
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AuthentikRuntime(AuthentikQBitConfig config)
{
this.config = config;
this.throttle = new TokenBucketThrottle(config.getThrottleRequestsPerSecond(), config.getThrottleBurst());
this.guard = new AuthentikGuard(config);
this.responseCache = new Memoization<String, String>(Duration.ofSeconds(config.getListCacheTtlSeconds())).withMaxSize(500);
this.groupGraphCache = new Memoization<String, GroupGraph>(Duration.ofSeconds(config.getGroupCacheTtlSeconds())).withMaxSize(1);
}
/*******************************************************************************
** Register a runtime for its config's API backend (replacing any previous).
*******************************************************************************/
public static AuthentikRuntime register(AuthentikRuntime runtime)
{
BY_BACKEND.put(runtime.config.getApiBackendName(), runtime);
return (runtime);
}
/*******************************************************************************
** Every registered runtime (a snapshot).
*******************************************************************************/
public static java.util.List<AuthentikRuntime> all()
{
return (java.util.List.copyOf(BY_BACKEND.values()));
}
/*******************************************************************************
** Find the runtime for an API backend name.
*******************************************************************************/
public static AuthentikRuntime forBackend(String backendName)
{
AuthentikRuntime runtime = BY_BACKEND.get(backendName);
if(runtime == null)
{
throw (new IllegalStateException("No Authentik qbit runtime is registered for backend " + backendName + " (was the qbit produced?)"));
}
return (runtime);
}
/*******************************************************************************
** Associate a produced (prefixed) table name with its spec.
*******************************************************************************/
public void registerTable(String tableName, AuthentikTableSpec spec)
{
specsByTableName.put(tableName, spec);
}
/*******************************************************************************
** The spec for a produced table name, or null.
*******************************************************************************/
public AuthentikTableSpec getSpec(String tableName)
{
return (specsByTableName.get(tableName));
}
/*******************************************************************************
** Whether responses should be cached at all.
*******************************************************************************/
public boolean isCacheEnabled()
{
return (config.getListCacheTtlSeconds() != null && config.getListCacheTtlSeconds() > 0);
}
/*******************************************************************************
** Drop every cached response (after writes, or when a webhook arrives).
*******************************************************************************/
public void invalidateCache()
{
responseCache.clear();
tokenScopeCache.clear();
tokenActivityCache.clear();
groupGraphCache.clear();
}
/*******************************************************************************
** Cache for the group graph (groupCacheTtlSeconds; 0 disables).
*******************************************************************************/
public Memoization<String, GroupGraph> getGroupGraphCache()
{
return (groupGraphCache);
}
/*******************************************************************************
** QQQ permission names that qqq.io/permissions may grant: every permission
** the instance's metadata defines (PermissionsHelper), computed per instance.
*******************************************************************************/
public java.util.Set<String> getPermissionCatalog()
{
PolicyCatalog currentPolicyCatalog = getPolicyCatalog();
if(permissionCatalog == null)
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(qInstance == null)
{
return (java.util.Set.of());
}
java.util.Set<String> catalog = new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
catalog.addAll(currentPolicyCatalog.permissionNames());
permissionCatalog = catalog;
}
return (permissionCatalog);
}
/*******************************************************************************
** What a policy can grant in the current instance (built lazily per instance: the
** instance is complete by the first request).
*******************************************************************************/
public PolicyCatalog getPolicyCatalog()
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(qInstance == null)
{
return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance()));
}
///////////////////////////////////////////////////////////////////////
// always the shared per-instance catalog (PolicyCatalog.of), so the //
// graph path and the claim path see the same one, rebuilt when the //
// metadata it is built from changes //
///////////////////////////////////////////////////////////////////////
PolicyCatalog current = PolicyCatalog.of(qInstance);
if(current != policyCatalog)
{
policyCatalog = current;
permissionCatalog = null;
}
return (policyCatalog);
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
public AuthentikQBitConfig getConfig()
{
return (config);
}
/*******************************************************************************
** Getter for throttle
*******************************************************************************/
public TokenBucketThrottle getThrottle()
{
return (throttle);
}
/*******************************************************************************
** Getter for guard
*******************************************************************************/
public AuthentikGuard getGuard()
{
return (guard);
}
/*******************************************************************************
** Getter for filterMapper
*******************************************************************************/
public AuthentikFilterMapper getFilterMapper()
{
return (filterMapper);
}
/*******************************************************************************
** Getter for responseCache (session-independent: the API always runs as
** the service account; permission filtering happens after the cache).
*******************************************************************************/
public Memoization<String, String> getResponseCache()
{
return (responseCache);
}
/*******************************************************************************
** The cached mapper state (DESIGN.md section 12.8), or null.
*******************************************************************************/
public AccessComparison.State getMapperState()
{
return (mapperState);
}
/*******************************************************************************
** Cache a mapper state.
*******************************************************************************/
public void setMapperState(AccessComparison.State mapperState)
{
this.mapperState = mapperState;
}
/*******************************************************************************
** Whether the host has run the qbit mapper at sign-in (counts as "enabled"
** for hosts that call the mapper without setting mapperEnabled).
*******************************************************************************/
public boolean isMapperInvoked()
{
return (mapperInvoked);
}
/*******************************************************************************
** Record that the mapper ran.
*******************************************************************************/
public void markMapperInvoked()
{
if(!mapperInvoked)
{
mapperInvoked = true;
mapperState = null;
}
}
/*******************************************************************************
** Token claims are cached independently of the general list TTL.
*******************************************************************************/
public Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> getTokenScopeCache()
{
return (tokenScopeCache);
}
/*******************************************************************************
** Login-event aggregation is shared by every token record in this runtime.
*******************************************************************************/
public Memoization<String, Map<String, java.time.Instant>> getTokenActivityCache()
{
return (tokenActivityCache);
}
/*******************************************************************************
** Optional Alerts delivery, replaceable by a host or a capturing test sink.
*******************************************************************************/
public com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink getTokenAlerts()
{
return (tokenAlerts);
}
/*******************************************************************************
** Install a sink for this runtime only.
*******************************************************************************/
public AuthentikRuntime withTokenAlerts(com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink sink)
{
tokenAlerts = java.util.Objects.requireNonNull(sink);
return (this);
}
}
@@ -1,136 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
/*******************************************************************************
** Every change the qbit can make to Authentik (DESIGN.md section 6.3), with
** the QQQ permission and feature it needs and how strongly it is confirmed.
*******************************************************************************/
public enum AdminAction
{
// Null permission means self-service: AuthentikTokens verifies the live owner.
// Processes add no permission beyond their table; cross-owner actions require TOKENS_MANAGE.
CREATE_TOKEN("Create token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
ROTATE_TOKEN("Rotate token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
REVOKE_TOKEN("Revoke token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
EXTEND_TOKEN("Extend expiry", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
CREATE_SERVICE_ACCOUNT("Create service account", "user", IdentityPermission.TOKENS_MANAGE, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
CREATE_USER("Create user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
INVITE_USER("Invite user", "invitation", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
EDIT_USER("Edit user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
DEACTIVATE_USER("Deactivate user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.TYPED),
REACTIVATE_USER("Reactivate user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
CANCEL_INVITATION("Cancel invitation", "invitation", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
ADD_MEMBER("Add to group", "membership", IdentityPermission.MEMBERSHIPS_MANAGE, AuthentikFeature.MEMBERSHIP, Confirmation.CONFIRM),
REMOVE_MEMBER("Remove from group", "membership", IdentityPermission.MEMBERSHIPS_MANAGE, AuthentikFeature.MEMBERSHIP, Confirmation.CONFIRM),
CREATE_GROUP("Create group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_GROUP("Edit group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_PERMISSIONS("Edit permissions and app access", "group", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_USER_PERMISSIONS("Edit user permissions", "user", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
SAVE_POLICY("Save policy", "group", IdentityPermission.POLICIES_MANAGE, AuthentikFeature.POLICIES, Confirmation.CONFIRM),
DELETE_USER("Delete user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
DELETE_GROUP("Delete group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
SEND_RECOVERY_EMAIL("Send password reset email", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.CONFIRM),
RESET_MFA("Reset MFA", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
REVOKE_APP_PASSWORDS("Revoke app passwords", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
REVOKE_SESSIONS("Revoke sessions", "user", IdentityPermission.SESSIONS_REVOKE, AuthentikFeature.SESSIONS, Confirmation.CONFIRM);
/** How strongly an action is confirmed (DESIGN.md section 6.3). */
public enum Confirmation
{
/** The Review screen is the confirmation. */
CONFIRM,
/** The operator also types the target's name (or the number of targets). */
TYPED
}
private final String label;
private final String targetType;
private final IdentityPermission permission;
private final AuthentikFeature feature;
private final Confirmation confirmation;
/*******************************************************************************
** Constructor.
*******************************************************************************/
AdminAction(String label, String targetType, IdentityPermission permission, AuthentikFeature feature, Confirmation confirmation)
{
this.label = label;
this.targetType = targetType;
this.permission = permission;
this.feature = feature;
this.confirmation = confirmation;
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Getter for targetType
*******************************************************************************/
public String getTargetType()
{
return (targetType);
}
/*******************************************************************************
** Getter for permission
*******************************************************************************/
public IdentityPermission getPermission()
{
return (permission);
}
/*******************************************************************************
** Getter for feature
*******************************************************************************/
public AuthentikFeature getFeature()
{
return (feature);
}
/*******************************************************************************
** Getter for confirmation
*******************************************************************************/
public Confirmation getConfirmation()
{
return (confirmation);
}
}
@@ -1,114 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Writes the local, append-only audit row (DESIGN.md sections 6.2 and 9.2).
** The row is inserted BEFORE the Authentik call; if that insert fails the call
** is not made. After the call the same row moves to its final status. Rows are
** never deleted.
*******************************************************************************/
public class AdminActionAuditor
{
private final AuthentikQBitConfig config;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AdminActionAuditor(AuthentikQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** Insert the row for a plan before it runs (or is refused / queued) and
** return its id. Throws when the audit trail cannot be written, so the
** caller must not proceed.
*******************************************************************************/
public Serializable begin(AdminPlan plan, String correlationId, String operator, AuthentikLocalTables.ActionStatus status, String approver) throws QException
{
QRecord record = new QRecord()
.withValue("correlationId", correlationId)
.withValue("actionName", plan.getRequest().action().name())
.withValue("operator", operator)
.withValue("reason", plan.getRequest().reason())
.withValue("targetType", plan.getTargetType())
.withValue("targetKey", plan.getRequest().targetKey())
.withValue("targetLabel", plan.getTargetLabel())
.withValue("beforeText", plan.getBefore())
.withValue("afterText", plan.getAfter())
.withValue("status", status.name())
.withValue("approver", approver)
.withValue("errorMessage", status == AuthentikLocalTables.ActionStatus.REFUSED ? plan.getMessage() : null)
.withValue("createDate", Instant.now())
.withValue("modifyDate", Instant.now());
InsertInput insertInput = new InsertInput();
insertInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
insertInput.setRecords(List.of(record));
InsertOutput output = new InsertAction().execute(insertInput);
QRecord saved = output.getRecords().isEmpty() ? null : output.getRecords().get(0);
if(saved == null || saved.getValue("id") == null || !saved.getErrors().isEmpty())
{
throw (new QException("The audit row could not be written, so nothing was changed in Authentik."));
}
return (saved.getValue("id"));
}
/*******************************************************************************
** Move a row to its final status. A failure here is logged by the caller;
** the Authentik change has already happened.
*******************************************************************************/
public void finish(Serializable id, AuthentikLocalTables.ActionStatus status, String errorMessage, String eventPk, String approver) throws QException
{
QRecord update = new QRecord()
.withValue("id", id)
.withValue("status", status.name())
.withValue("errorMessage", errorMessage)
.withValue("authentikEventPk", eventPk)
.withValue("modifyDate", Instant.now());
if(approver != null)
{
update.withValue("approver", approver);
}
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
updateInput.setRecords(List.of(update));
new UpdateAction().execute(updateInput);
}
}
@@ -1,235 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.util.ArrayList;
import java.util.List;
/*******************************************************************************
** What a request would do, computed by the guard rules before anything is
** sent: whether it may run, why not, the target, a before and after, and
** whether it needs a second person. The Review screen shows it; the Result
** step executes it (after computing it again against live data).
*******************************************************************************/
public class AdminPlan
{
/** The outcome of planning. */
public enum State
{
/** Will run. */
READY,
/** Already in the wanted state; nothing to do. */
NO_CHANGE,
/** Refused by a rule (the message says which). */
REFUSED,
/** Allowed, but needs a second person to approve first. */
NEEDS_APPROVAL
}
private final AdminRequest request;
private State state = State.READY;
private String message;
private String targetType;
private String targetLabel;
private String before;
private String after;
private final List<String> notes = new ArrayList<>();
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AdminPlan(AdminRequest request)
{
this.request = request;
this.targetType = request.action().getTargetType();
}
/*******************************************************************************
** Refuse, with the reason shown to the operator.
*******************************************************************************/
public AdminPlan refuse(String reason)
{
this.state = State.REFUSED;
this.message = reason;
return (this);
}
/*******************************************************************************
** Mark as already done.
*******************************************************************************/
public AdminPlan noChange(String why)
{
this.state = State.NO_CHANGE;
this.message = why;
return (this);
}
/*******************************************************************************
** Whether the plan will make a change when executed.
*******************************************************************************/
public boolean isRunnable()
{
return (state == State.READY);
}
/*******************************************************************************
** Getter for request
*******************************************************************************/
public AdminRequest getRequest()
{
return (request);
}
/*******************************************************************************
** Getter for state
*******************************************************************************/
public State getState()
{
return (state);
}
/*******************************************************************************
** Setter for state
*******************************************************************************/
public AdminPlan withState(State state)
{
this.state = state;
return (this);
}
/*******************************************************************************
** Getter for message
*******************************************************************************/
public String getMessage()
{
return (message);
}
/*******************************************************************************
** Fluent setter for message
*******************************************************************************/
public AdminPlan withMessage(String message)
{
this.message = message;
return (this);
}
/*******************************************************************************
** Getter for targetType
*******************************************************************************/
public String getTargetType()
{
return (targetType);
}
/*******************************************************************************
** Getter for targetLabel
*******************************************************************************/
public String getTargetLabel()
{
return (targetLabel);
}
/*******************************************************************************
** Fluent setter for targetLabel
*******************************************************************************/
public AdminPlan withTargetLabel(String targetLabel)
{
this.targetLabel = targetLabel;
return (this);
}
/*******************************************************************************
** Getter for before
*******************************************************************************/
public String getBefore()
{
return (before);
}
/*******************************************************************************
** Getter for after
*******************************************************************************/
public String getAfter()
{
return (after);
}
/*******************************************************************************
** Set the before and after text.
*******************************************************************************/
public AdminPlan withChange(String before, String after)
{
this.before = before;
this.after = after;
return (this);
}
/*******************************************************************************
** Notes for the operator (warnings and consequences).
*******************************************************************************/
public List<String> getNotes()
{
return (notes);
}
/*******************************************************************************
** A one-line description for review lines and audit.
*******************************************************************************/
public String describe()
{
return (request.action().getLabel() + ": " + (targetLabel == null ? request.targetKey() : targetLabel)
+ (before != null || after != null ? " (" + (before == null ? "" : before) + " -> " + (after == null ? "" : after) + ")" : ""));
}
}
@@ -1,116 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import org.json.JSONObject;
/*******************************************************************************
** One requested change: the action, its target key (a user pk, a group uuid,
** "groupPk:userPk" for memberships, an invitation uuid, or blank when
** creating), its parameters, and the operator's reason. Serializable to JSON
** so a two-person request can be stored and applied later.
**
** @param action what to do
** @param targetKey the target (see above)
** @param params action parameters (strings and booleans)
** @param reason why (required for every write)
*******************************************************************************/
public record AdminRequest(AdminAction action, String targetKey, Map<String, Serializable> params, String reason)
{
/*******************************************************************************
** A request with no parameters.
*******************************************************************************/
public static AdminRequest of(AdminAction action, String targetKey, String reason)
{
return (new AdminRequest(action, targetKey, new LinkedHashMap<>(), reason));
}
/*******************************************************************************
** Fluent parameter setter (returns this request).
*******************************************************************************/
public AdminRequest with(String name, Serializable value)
{
params.put(name, value);
return (this);
}
/*******************************************************************************
** A parameter as trimmed text, or "" when absent.
*******************************************************************************/
public String str(String name)
{
Serializable value = params.get(name);
return (value == null ? "" : String.valueOf(value).trim());
}
/*******************************************************************************
** A parameter as a boolean (true for true or "true").
*******************************************************************************/
public boolean bool(String name)
{
Serializable value = params.get(name);
return (value instanceof Boolean b ? b : value != null && "true".equalsIgnoreCase(String.valueOf(value).trim()));
}
/*******************************************************************************
** To JSON (for a stored approval request).
*******************************************************************************/
public String toJson()
{
return (new JSONObject()
.put("action", action.name())
.put("targetKey", targetKey)
.put("reason", reason)
.put("params", new JSONObject(params)).toString());
}
/*******************************************************************************
** From JSON written by toJson.
*******************************************************************************/
public static AdminRequest fromJson(String json)
{
JSONObject object = new JSONObject(json);
Map<String, Serializable> params = new LinkedHashMap<>();
JSONObject stored = object.optJSONObject("params");
if(stored != null)
{
for(String key : stored.keySet())
{
Object value = stored.get(key);
params.put(key, value instanceof Boolean || value instanceof Number ? (Serializable) value : String.valueOf(value));
}
}
return (new AdminRequest(AdminAction.valueOf(object.getString("action")), object.optString("targetKey", ""), params, object.optString("reason", "")));
}
}
File diff suppressed because it is too large. Load diff
@@ -1,150 +0,0 @@
/*
* 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());
}
}
@@ -1,126 +0,0 @@
/*
* 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());
}
}
}
@@ -1,309 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** The qbit's own tables (DESIGN.md section 6.2), standard visible QQQ tables on
** the host's local RDBMS backend, named authentik_* (lowercase snake_case).
** Both are written only by the qbit: the generic insert, update and delete
** capabilities are off for everyone, and the audit table is append-only.
**
** The host creates the SQL tables (see docs/schema.sql).
*******************************************************************************/
public final class AuthentikLocalTables
{
public static final String TOKEN_SCOPE = "token_scope";
public static final String ADMIN_ACTION = "admin_action";
public static final String APPROVAL_REQUEST = "approval_request";
public static final String POLICY = "policy";
public static final String PVS_ACTION_STATUS = "authentik_action_status";
public static final String PVS_APPROVAL_STATUS = "authentik_approval_status";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikLocalTables()
{
}
/*******************************************************************************
** Status of an audited action.
*******************************************************************************/
public enum ActionStatus implements PossibleValueEnum<String>
{
PENDING_APPROVAL("Waiting for approval"),
RUNNING("Running"),
SUCCEEDED("Succeeded"),
FAILED("Failed"),
REFUSED("Refused");
private final String label;
/*******************************************************************************
** Constructor.
*******************************************************************************/
ActionStatus(String label)
{
this.label = label;
}
/*******************************************************************************
** Getter for possibleValueId.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Getter for possibleValueLabel.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (label);
}
}
/*******************************************************************************
** Status of a two-person approval request.
*******************************************************************************/
public enum ApprovalStatus implements PossibleValueEnum<String>
{
PENDING("Waiting for approval"),
APPROVED("Approved and applied"),
REJECTED("Rejected"),
EXPIRED("Expired"),
FAILED("Approved but failed");
private final String label;
/*******************************************************************************
** Constructor.
*******************************************************************************/
ApprovalStatus(String label)
{
this.label = label;
}
/*******************************************************************************
** Getter for possibleValueId.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Getter for possibleValueLabel.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (label);
}
}
/*******************************************************************************
** The possible-value sources the local tables use.
*******************************************************************************/
public static List<QPossibleValueSource> possibleValueSources()
{
return (List.of(
QPossibleValueSource.newForEnum(PVS_ACTION_STATUS, ActionStatus.values()),
QPossibleValueSource.newForEnum(PVS_APPROVAL_STATUS, ApprovalStatus.values())));
}
/*******************************************************************************
** authentik_admin_action: append-only audit and outbox of every write the
** qbit attempts. The row is inserted BEFORE the API call.
*******************************************************************************/
public static QTableMetaData adminAction(AuthentikQBitConfig config)
{
return (base(config, ADMIN_ACTION, "Admin Actions", "history", IdentityPermission.AUDIT_READ)
.withRecordLabelFormatAndFields("%s %s", "actionName", "targetLabel")
.withField(ro("correlationId", QFieldType.STRING).withMaxLength(64))
.withField(ro("actionName", QFieldType.STRING).withMaxLength(60))
.withField(ro("operator", QFieldType.STRING).withMaxLength(255))
.withField(ro("reason", QFieldType.TEXT))
.withField(ro("targetType", QFieldType.STRING).withMaxLength(30))
.withField(ro("targetKey", QFieldType.STRING).withMaxLength(100))
.withField(ro("targetLabel", QFieldType.STRING).withMaxLength(255))
.withField(ro("beforeText", QFieldType.TEXT))
.withField(ro("afterText", QFieldType.TEXT))
.withField(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_ACTION_STATUS))
.withField(ro("errorMessage", QFieldType.TEXT))
.withField(ro("authentikEventPk", QFieldType.STRING).withMaxLength(64))
.withField(ro("approver", QFieldType.STRING).withMaxLength(255)));
}
/*******************************************************************************
** authentik_approval_request: two-person requests (a second person with
** authentik.approve applies or rejects the stored change).
*******************************************************************************/
public static QTableMetaData approvalRequest(AuthentikQBitConfig config)
{
return (base(config, APPROVAL_REQUEST, "Approval Requests", "how_to_reg", IdentityPermission.APPROVE)
.withRecordLabelFormatAndFields("%s %s", "actionName", "targetLabel")
.withField(ro("actionName", QFieldType.STRING).withMaxLength(60))
.withField(ro("targetKey", QFieldType.STRING).withMaxLength(100))
.withField(ro("targetLabel", QFieldType.STRING).withMaxLength(255))
.withField(ro("summary", QFieldType.TEXT))
.withField(ro("reason", QFieldType.TEXT))
.withField(ro("payloadJson", QFieldType.TEXT))
.withField(ro("requester", QFieldType.STRING).withMaxLength(255))
.withField(ro("approver", QFieldType.STRING).withMaxLength(255))
.withField(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_APPROVAL_STATUS))
.withField(ro("expiresDate", QFieldType.DATE_TIME))
.withField(ro("decidedDate", QFieldType.DATE_TIME))
.withField(ro("errorMessage", QFieldType.TEXT)));
}
/*******************************************************************************
** authentik_policy: one row per group with a policy (DESIGN.md section 12).
** Authentik holds the live document; this row is the QQQ record of the last
** save, and field-level audit is its change history. Written only by the
** Edit Policy action.
*******************************************************************************/
public static QTableMetaData policy(AuthentikQBitConfig config)
{
return (base(config, POLICY, "Policies", "policy", IdentityPermission.USERS_READ)
.withRecordLabelFormatAndFields("%s", "groupName")
.withUniqueKey(new UniqueKey("groupPk"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withField(ro("groupPk", QFieldType.STRING).withLabel("Group Id").withMaxLength(64))
.withField(ro("groupName", QFieldType.STRING).withLabel("Group").withMaxLength(255))
.withField(ro("policyJson", QFieldType.TEXT).withLabel("Policy Document"))
.withField(ro("statementCount", QFieldType.INTEGER).withLabel("Statements"))
.withField(ro("permissionCount", QFieldType.INTEGER).withLabel("Permissions"))
.withField(ro("savedBy", QFieldType.STRING).withLabel("Saved By").withMaxLength(255))
.withField(ro("savedDate", QFieldType.DATE_TIME).withLabel("Saved"))
.withField(ro("authentikFingerprint", QFieldType.STRING).withLabel("Authentik Fingerprint").withMaxLength(64)));
}
/*******************************************************************************
** Common shape: id, create and modify dates, no generic writes.
*******************************************************************************/
private static QTableMetaData base(AuthentikQBitConfig config, String name, String label, String icon, IdentityPermission permission)
{
return (new QTableMetaData()
.withName(config.applyTablePrefix(name))
.withLabel(label)
.withIcon(new QIcon(icon))
.withBackendName(config.getLocalBackendName())
.withPrimaryKeyField("id")
.withPermissionRules(permission.rules())
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_EXPORT)
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE)
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)));
}
/*******************************************************************************
** A read-only field.
*******************************************************************************/
private static QFieldMetaData ro(String name, QFieldType type)
{
return (new QFieldMetaData(name, type).withIsEditable(false));
}
/*******************************************************************************
** Non-secret scope metadata only (DESIGN section 13.5).
*******************************************************************************/
public static QTableMetaData tokenScope(AuthentikQBitConfig config)
{
QTableMetaData table = base(config, TOKEN_SCOPE, "Token Scopes", "key", IdentityPermission.TOKENS_MANAGE)
.withPermissionRules(com.kingsrook.qbits.authentik.security.TokenReadPermission.rules())
.withRecordLabelFormatAndFields("%s", "tokenIdentifier")
.withUniqueKey(new UniqueKey("tokenIdentifier"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
.withField(ro("tokenIdentifier", QFieldType.STRING).withMaxLength(255))
.withField(ro("ownerUsername", QFieldType.STRING).withMaxLength(255))
.withField(ro("ownerPk", QFieldType.INTEGER))
.withField(ro("readOnly", QFieldType.BOOLEAN))
.withField(ro("createdBy", QFieldType.STRING).withMaxLength(255));
table.getFields().remove("createDate");
table.getFields().remove("modifyDate");
for(String name : List.of("apps", "tables", "processes", "reports", "widgets", "operationIds"))
{
table.withField(ro(name, QFieldType.TEXT));
}
for(String name : List.of("createdAt", "rotatedAt"))
{
table.withField(ro(name, QFieldType.DATE_TIME));
}
////////////////////////////////////////////////////////////////////////
// explicit column names: a camel-to-snake mapping would give //
// last_reminder7d, while docs/schema.sql (and hosts) use _7d / _1d //
////////////////////////////////////////////////////////////////////////
table.withField(ro("lastReminder7d", QFieldType.DATE_TIME).withBackendName("last_reminder_7d"));
table.withField(ro("lastReminder1d", QFieldType.DATE_TIME).withBackendName("last_reminder_1d"));
return (table);
}
}
@@ -1,173 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONObject;
/*******************************************************************************
** Audited policy maintenance for reviewed host Java code, typically a run-once
** job. This API deliberately creates its own authority; never expose it through
** an HTTP route or a user-invokable process. No route or process is registered.
*******************************************************************************/
public final class AuthentikMaintenance
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikMaintenance()
{
}
/*******************************************************************************
** Apply a policy to exactly one group by name through the normal Save Policy
** plan and execute path. Requires an initialized QContext with the host's
** QInstance. The caller's context is restored on success, refusal or failure.
**
** Runs as the host's maintenance service principal (config
** maintenancePrincipal, default "maintenance"; docs/SERVICE-IDENTITY.md in
** qqq): under OAuth2 a real token for its Authentik service account, so its
** authority is exactly what the owner granted that account (policies.manage
** and every grant the document applies - the subset rule still holds). No
** authority is created here. The audit operator is service:{principal};
** the job name (maintenance: or run-once:, nonblank suffix) prefixes the
** required reason. Existing scope, protection, readOnly, feature and stale
** checks still apply. Equivalent policies return NO_CHANGE without a write
** or an audit row. With policyTwoPersonApproval enabled, a change queues an
** approval instead of writing to Authentik; this API never bypasses
** second-person approval.
*******************************************************************************/
public static AuthentikAdmin.AdminResult applyPolicy(String apiBackendName, String groupName, String policyJson, String operatorName, String reason) throws QException
{
if(operatorName == null || !(operatorName.startsWith("maintenance:") || operatorName.startsWith("run-once:"))
|| operatorName.substring(operatorName.indexOf(':') + 1).isBlank())
{
throw (new QUserFacingException("The operator name must start with maintenance: or run-once: and name the job."));
}
if(!StringUtils.hasContent(reason))
{
throw (new QUserFacingException("A reason is required for every change."));
}
if(!StringUtils.hasContent(groupName))
{
throw (new QUserFacingException("A group name is required."));
}
CapturedContext caller = QContext.capture();
try
{
String principal = AuthentikRuntime.forBackend(apiBackendName).getConfig().getMaintenancePrincipal();
QContext.init(caller.qInstance(), ServiceIdentities.session(caller.qInstance(), principal));
AuthentikAdmin admin = AuthentikAdmin.forBackend(apiBackendName);
AuthentikRuntime runtime = admin.getUtil().getRuntime();
runtime.getGuard().authorize(AdminAction.SAVE_POLICY);
if(StringUtils.hasContent(policyJson))
{
PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
}
runtime.invalidateCache();
List<JSONObject> groups = admin.getUtil().getAllPages("/core/groups/?name=" + URLEncoder.encode(groupName, StandardCharsets.UTF_8)
+ "&include_users=true&include_parents=true&include_children=true", 2);
if(groups.size() != 1 || !groupName.equals(groups.get(0).optString("name")))
{
throw (new QUserFacingException("Expected exactly one Authentik group named " + groupName + "."));
}
JSONObject group = groups.get(0);
if(isServicePrincipalGroup(group))
{
////////////////////////////////////////////////////////////////////
// a principal never changes a service principal's grants (its own //
// or another's): only the owner does, in Identity //
////////////////////////////////////////////////////////////////////
throw (new QUserFacingException("Service principal groups are changed only by the owner in Identity."));
}
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), operatorName + ": " + reason)
.with(AuthentikAdmin.POLICY_DOCUMENT, policyJson)
.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group, runtime.getConfig().getPolicyFormat().attribute()));
return (admin.execute(request));
}
catch(PolicyParseException e)
{
throw (new QUserFacingException("The policy is not valid: " + e.getMessage()));
}
finally
{
QContext.init(caller);
}
}
/*******************************************************************************
** A service principal role group: named service:..., or a child of
** qqq-service-accounts.
*******************************************************************************/
static boolean isServicePrincipalGroup(JSONObject group)
{
if(group.optString("name", "").startsWith("service:"))
{
return (true);
}
org.json.JSONArray parents = group.optJSONArray("parents_obj");
for(int i = 0; parents != null && i < parents.length(); i++)
{
if("qqq-service-accounts".equals(parents.optJSONObject(i) == null ? null : parents.getJSONObject(i).optString("name")))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** Canonical compact full-access document using the host's spec and version.
*******************************************************************************/
public static String fullAccessDocument(AuthentikQBitConfig config) throws QException
{
try
{
return (PolicyGenerator.toCompact(PolicyGenerator.generate(Set.of(PolicyPermissions.FULL_ACCESS), config.getPolicyFormat())));
}
catch(PolicyParseException e)
{
throw (new QException("Could not generate the full-access policy.", e));
}
}
}
@@ -1,300 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** The pure rules of DESIGN.md section 5.4, separated from I/O so they can be
** tested exhaustively: the subset (no escalation) rule, last-admin
** protection, descendant checks and group fingerprints.
*******************************************************************************/
public final class AuthentikSafety
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikSafety()
{
}
/*******************************************************************************
** What a user is, for last-admin protection.
**
** @param pk Authentik user pk
** @param active whether the account is active
** @param human internal or external (not a service account)
** @param protectedAccount break-glass, superuser, service account
*******************************************************************************/
public record UserFacts(int pk, boolean active, boolean human, boolean protectedAccount)
{
}
/*******************************************************************************
** The permissions in needed that held does not contain (the subset rule:
** an operator may only grant what they hold).
*******************************************************************************/
public static Set<String> missing(Collection<String> needed, Collection<String> held)
{
Set<String> rs = new TreeSet<>(needed);
rs.removeAll(held);
return (rs);
}
/*******************************************************************************
** pks of the users who qualify as identity administrators in a graph: active,
** human, not protected, and holding both authentik.memberships.manage and
** authentik.permissions.manage through their groups (inherited included).
*******************************************************************************/
public static Set<Integer> qualifyingAdmins(GroupGraph graph, EffectivePermissionResolver resolver, Collection<UserFacts> users)
{
Set<Integer> rs = new LinkedHashSet<>();
for(UserFacts user : users)
{
if(!user.active() || !user.human() || user.protectedAccount())
{
continue;
}
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(graph.groupsOfUser(user.pk()));
if(EffectivePermissionResolver.holdsAll(resolution, List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE)))
{
rs.add(user.pk());
}
}
return (rs);
}
/*******************************************************************************
** Whether a change leaves the qbit without an administrator that it had
** before: some admin existed, and none does after. A state that already had
** none (for example a bootstrap window) is not made worse, so it never
** blocks.
*******************************************************************************/
public static boolean removesLastAdmin(Set<Integer> before, Set<Integer> after)
{
return (!before.isEmpty() && after.isEmpty());
}
/*******************************************************************************
** A copy of a graph with some nodes replaced (matched by pk), for
** simulating a change.
*******************************************************************************/
public static GroupGraph withNodes(GroupGraph graph, GroupGraph.Node... replacements)
{
GroupGraph copy = new GroupGraph();
graph.nodes().forEach(copy::add);
for(GroupGraph.Node node : replacements)
{
copy.add(node);
}
return (copy);
}
/*******************************************************************************
** A node without a member.
*******************************************************************************/
public static GroupGraph.Node withoutMember(GroupGraph.Node node, int userPk)
{
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.policy()));
}
/*******************************************************************************
** A node with other permissions and app access.
*******************************************************************************/
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.policy()));
}
/*******************************************************************************
** A node with other parents.
*******************************************************************************/
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.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));
}
/*******************************************************************************
** Every group below a group (children, their children, ...), cycle-safe.
*******************************************************************************/
public static Set<String> descendantPks(GroupGraph graph, String pk)
{
Set<String> rs = new HashSet<>();
for(GroupGraph.Node node : graph.nodes())
{
if(!node.pk().equals(pk) && graph.ancestorPks(node.pk()).contains(pk))
{
rs.add(node.pk());
}
}
return (rs);
}
/*******************************************************************************
** Split free text (new lines or commas) into distinct trimmed entries.
*******************************************************************************/
public static List<String> parseList(String text)
{
List<String> rs = new ArrayList<>();
if(text == null)
{
return (rs);
}
for(String part : text.split("[\\r\\n,;]+"))
{
String trimmed = part.trim();
if(!trimmed.isEmpty() && !rs.contains(trimmed))
{
rs.add(trimmed);
}
}
return (rs);
}
/*******************************************************************************
** A short fingerprint of a group's editable state: name, parents,
** qqq.io/* attributes and member count. Authentik groups have no
** last_updated (DESIGN.md section 0.2 vi), so this is how a change made in
** 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<>();
if(attributes != null)
{
for(String key : attributes.keySet())
{
if(key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX) || key.equals(policyAttribute))
{
qqq.put(key, String.valueOf(attributes.get(key)));
}
}
}
List<String> parents = new ArrayList<>();
JSONArray array = group.optJSONArray("parents");
for(int i = 0; array != null && i < array.length(); i++)
{
Object value = array.get(i);
parents.add(value instanceof JSONObject object ? object.optString("pk") : String.valueOf(value));
}
java.util.Collections.sort(parents);
int members = group.optJSONArray("users") == null ? -1 : group.getJSONArray("users").length();
return (sha(group.optString("name") + "|" + parents + "|" + qqq + "|" + members));
}
/*******************************************************************************
** A short fingerprint of a user's editable state (name, email, path, type,
** active, last_updated).
*******************************************************************************/
public static String userFingerprint(JSONObject user)
{
return (sha(user.optString("username") + "|" + user.optString("name") + "|" + user.optString("email") + "|" + user.optString("path") + "|"
+ user.optString("type") + "|" + user.optBoolean("is_active", true) + "|" + user.optString("last_updated")));
}
/*******************************************************************************
**
*******************************************************************************/
private static String sha(String text)
{
try
{
byte[] digest = MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for(int i = 0; i < 8; i++)
{
hex.append(String.format("%02x", digest[i]));
}
return (hex.toString());
}
catch(NoSuchAlgorithmException e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -1,106 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.logging.QLogger;
/*******************************************************************************
** Optional Alerts bridge. The qbit compiles on RC.2 without either Alerts API.
** No credentials enter this interface. Hosts and tests can supply a sink.
*******************************************************************************/
public class AuthentikTokenAlerts
{
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenAlerts.class);
/*******************************************************************************
** Safe notification envelope. A null recipient denotes the admin audience.
*******************************************************************************/
public record Notice(String key, String severity, String recipient, String message)
{
}
/*******************************************************************************
** Return true only when delivery was accepted, so absence never dedupes.
*******************************************************************************/
@FunctionalInterface
public interface Sink
{
/*******************************************************************************
** Accept a safe notification, returning false when delivery is unavailable.
*******************************************************************************/
boolean send(Notice notice);
}
/*******************************************************************************
** Constructor for the reflection adapter.
*******************************************************************************/
public AuthentikTokenAlerts()
{
}
/*******************************************************************************
** Guard incidents are raised through core QAlerts; owner reminders through
** core QAlerts.notify (qqq#1125, QQQ 4.1.0-RC.6+). Never another qbit's API.
*******************************************************************************/
public boolean send(Notice notice)
{
try
{
if("high".equals(notice.severity()))
{
QAlerts.raise(new AlertSignal("authentik.token.guard-bypass")
.withDedupKey(notice.key())
.withTitle("Access token guard bypass")
.withSummary(notice.message())
.withSeverity(AlertSeverity.CRITICAL));
return (true);
}
///////////////////////////////////////////////////////////////////
// owner reminders: core's notification SPI (bell and email via //
// whichever alerts producer the host installed; qqq#1125) //
///////////////////////////////////////////////////////////////////
QAlerts.notify(new NotificationSignal("Access token expiry reminder")
.withBody(notice.message())
.withSourceName("authentik")
.withSourceKey(notice.key())
.withAudience(NotificationAudience.user(notice.recipient())));
return (true);
}
catch(RuntimeException e)
{
///////////////////////////////////////////////////////////
// never fail the caller; never log the notice payload //
///////////////////////////////////////////////////////////
LOG.info("Access-token notification delivery failed; notification skipped.");
return (false);
}
}
}
@@ -1,703 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker;
import com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Access-token actions (DESIGN section 13). All target checks run at Review and
** again at execution; credentials exist only in the action and one-time result.
*******************************************************************************/
public class AuthentikTokens
{
public static final String DELIVERY_UNAVAILABLE = "This QQQ version cannot show a key once; upgrade QQQ (4.1.0-RC.7+)";
public static final String TOKEN_MOVED = "Token moved: owner or intent changed after the update; investigate in Authentik.";
private static final QLogger LOG = QLogger.getLogger(AuthentikTokens.class);
private static final SecureRandom RANDOM = new SecureRandom();
private final AuthentikAdmin admin;
private final AuthentikActionUtil util;
private final AuthentikQBitConfig config;
private final TokenScopeStore scopes;
/*******************************************************************************
** Delegate with the existing admin's live operator and subset checks.
*******************************************************************************/
public AuthentikTokens(AuthentikAdmin admin)
{
this.admin = admin;
util = admin.getUtil();
config = util.getRuntime().getConfig();
scopes = new TokenScopeStore(config);
}
/*******************************************************************************
** Review all constraints, without exchanging or creating a credential.
*******************************************************************************/
public AdminPlan plan(AdminPlan plan) throws QException
{
AdminRequest request = plan.getRequest();
AdminAction action = request.action();
util.getRuntime().invalidateCache();
try
{
if(action == AdminAction.CREATE_SERVICE_ACCOUNT)
{
if(!request.str("name").matches("[A-Za-z0-9][A-Za-z0-9._-]{0,99}"))
{
return (plan.refuse("A service-account name (letters, numbers, dot, dash or underscore) is required."));
}
if(admin.userPkByUsername(request.str("name")) != null)
{
return (plan.refuse("That username already exists."));
}
JSONObject candidate = new JSONObject().put("username", request.str("name")).put("type", "service_account");
checkProtected(candidate);
expiry(request, true);
if(!TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
for(GroupGraph.Node group : serviceGroups(request))
{
String problem = admin.checkGroupGrantable(group);
if(problem != null)
{
return (plan.refuse(problem));
}
checkProtectedGroup(group);
}
plan.withTargetLabel(request.str("name"));
return (approval(plan));
}
JSONObject token = action == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
Integer operatorPk = admin.operator().pk();
int ownerPk = token == null ? (operatorPk == null ? -1 : operatorPk) : ownerPk(token);
if(action == AdminAction.CREATE_TOKEN && (!request.targetKey().isBlank() && !request.targetKey().equals(String.valueOf(operatorPk))))
{
return (plan.refuse("Create Token only creates a token for you."));
}
if(ownerPk < 0)
{
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
}
JSONObject owner = owner(ownerPk);
boolean own = Objects.equals(operatorPk, ownerPk);
boolean service = "service_account".equals(owner.optString("type"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if(service && action == AdminAction.CREATE_TOKEN)
{
return (plan.refuse("Service accounts have one app password. Use Rotate Token or Create Service Account."));
}
if(!own && !manager || service && !manager)
{
return (plan.refuse("You may only manage your own tokens; service accounts need authentik.tokens.manage."));
}
if(!own && !service && action != AdminAction.REVOKE_TOKEN)
{
return (plan.refuse("You cannot create, rotate or extend another person's token."));
}
checkProtected(owner);
plan.withTargetLabel(token == null ? owner.getString("username") : request.targetKey());
if(action != AdminAction.REVOKE_TOKEN && token != null && !"app_password".equals(token.optString("intent")))
{
return (plan.refuse("Only app-password tokens can be rotated or extended here. API tokens can be revoked."));
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.EXTEND_TOKEN)
{
expiry(request, service);
}
if(action == AdminAction.ROTATE_TOKEN)
{
checkExpiry(token, service);
}
if(action == AdminAction.CREATE_TOKEN)
{
if(request.str("description").isBlank())
{
return (plan.refuse("A description is required."));
}
if(!Boolean.TRUE.equals(config.getTokenScopesEnabled()) && TokenScopeStore.SCOPE_FIELDS.stream().anyMatch(request.params()::containsKey))
{
return (plan.refuse("Scopes are not enabled on this app yet."));
}
}
if(action == AdminAction.CREATE_TOKEN && Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.validate(request);
QRecord scope = new QRecord();
request.params().forEach(scope::setValue);
plan.withChange(null, TokenScopeStore.summary(scope));
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
{
if(!TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
}
if(own && !service)
{
if(blank(config.getTokenBrokerClientId()) || blank(config.getTokenBrokerClientSecret()))
{
return (plan.refuse("Self-service tokens need a configured Authentik token broker."));
}
if(config.getSubjectTokenSource() == null || blank(config.getSubjectTokenSource().subjectToken(QContext.getQSession())))
{
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
}
}
return (service && action == AdminAction.ROTATE_TOKEN ? approval(plan) : plan);
}
catch(QUserFacingException e)
{
return (plan.refuse(e.getMessage()));
}
}
/*******************************************************************************
** Optional approval for new service accounts and service-account rotation.
*******************************************************************************/
private AdminPlan approval(AdminPlan plan)
{
if(Boolean.TRUE.equals(config.getTokenTwoPersonApproval()) && !plan.getRequest().bool("approved"))
{
plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("Service-account tokens require a second person's approval.");
}
return (plan);
}
/*******************************************************************************
** Resolve requested groups with the same subset rules used for membership.
*******************************************************************************/
private List<GroupGraph.Node> serviceGroups(AdminRequest request) throws QException
{
Set<String> names = new LinkedHashSet<>(admin.requestedGroupNames(request, "groups"));
GroupGraph graph = util.groupGraph();
List<GroupGraph.Node> groups = new ArrayList<>();
for(String name : names)
{
GroupGraph.Node node = graph.byName(name);
if(node == null)
{
throw (new QUserFacingException("Group not found: " + name));
}
groups.add(node);
}
return (groups);
}
/*******************************************************************************
** Required expiry measured from now, with separate human and service caps.
*******************************************************************************/
private Instant expiry(AdminRequest request, boolean service) throws QUserFacingException
{
int maximum = service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser();
try
{
int days = Integer.parseInt(request.str("expiresInDays"));
if(days >= 1 && days <= maximum)
{
return (Instant.now().plus(days, ChronoUnit.DAYS));
}
}
catch(NumberFormatException ignored)
{
// Missing, fractional and malformed values are all refused.
}
throw (new QUserFacingException("Expiry is required: expiresInDays must be between 1 and " + maximum + "."));
}
/*******************************************************************************
** Rotation preserves a valid existing expiry, never a non-expiring token.
*******************************************************************************/
private Instant checkExpiry(JSONObject token, boolean service) throws QUserFacingException
{
try
{
Instant expires = Instant.parse(token.getString("expires"));
if(token.optBoolean("expiring") && expires.isAfter(Instant.now())
&& !expires.isAfter(Instant.now().plus(service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser(), ChronoUnit.DAYS)))
{
return (expires);
}
}
catch(RuntimeException ignored)
{
// No expiry is never acceptable for a new credential.
}
throw (new QUserFacingException("Token expiry is missing, expired or exceeds the maximum lifetime."));
}
/*******************************************************************************
** Built-in protected groups cannot be removed by configuration.
*******************************************************************************/
private void checkProtectedGroup(GroupGraph.Node group) throws QException
{
Set<String> protectedNames = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
protectedNames.addAll(List.of("authentik Admins", "identity-admins"));
if(config.getTokenProtectedGroups() != null)
{
protectedNames.addAll(config.getTokenProtectedGroups());
}
for(GroupGraph.Node ancestor : util.groupGraph().selfAndAncestors(group.pk()))
{
if(ancestor.ownSuperuser() || protectedNames.contains(ancestor.name()))
{
throw (protectedAccount("member of " + ancestor.name()));
}
}
}
/*******************************************************************************
** Reuse account protection, adding inherited protected-group membership.
*******************************************************************************/
private void checkProtected(JSONObject owner) throws QException
{
AuthentikGuard.ProtectionResult protection = util.getRuntime().getGuard().protection(owner);
if(protection.isProtected())
{
throw (protectedAccount(protection.reason()));
}
JSONArray groups = owner.optJSONArray("groups_obj");
for(int i = 0; groups != null && i < groups.length(); i++)
{
GroupGraph.Node node = util.groupGraph().byPk(groups.getJSONObject(i).getString("pk"));
if(node == null)
{
throw (protectedAccount("group protection could not be verified"));
}
checkProtectedGroup(node);
}
}
/*******************************************************************************
** Consistent protected-owner refusal, including service-account set_key.
*******************************************************************************/
private static QUserFacingException protectedAccount(String reason)
{
return (new QUserFacingException("Protected account (" + reason + "): tokens for it are managed in the Authentik admin UI."));
}
/*******************************************************************************
** Fetch token metadata without retaining a bearer or key.
*******************************************************************************/
private JSONObject token(String identifier) throws QException
{
util.getRuntime().invalidateCache();
JSONObject token = util.getApiJsonOrNull(path(identifier));
if(token == null)
{
throw (new QUserFacingException("That token no longer exists."));
}
return (token);
}
/*******************************************************************************
** Token owner pk from the pinned schema.
*******************************************************************************/
private static int ownerPk(JSONObject token)
{
return (token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk"));
}
/*******************************************************************************
** Fetch live owner facts, refusing missing owners.
*******************************************************************************/
private JSONObject owner(int pk) throws QException
{
util.getRuntime().invalidateCache();
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
if(owner == null)
{
throw (new QUserFacingException("The token's owner no longer exists."));
}
return (owner);
}
/*******************************************************************************
** Validated token URL component.
*******************************************************************************/
private static String path(String identifier) throws QException
{
return ("/core/tokens/" + AuthentikAdmin.safeKey(identifier) + "/");
}
/*******************************************************************************
** Whether a resolved config string is absent.
*******************************************************************************/
private static boolean blank(String value)
{
return (value == null || value.isBlank());
}
/*******************************************************************************
** Execute only after auditing has begun; return the key outside audit data.
*******************************************************************************/
public AuthentikAdmin.AdminResult perform(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
if((request.action() == AdminAction.CREATE_TOKEN || request.action() == AdminAction.ROTATE_TOKEN || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
&& !TokenKeyDelivery.get().available())
{
throw (new QUserFacingException(DELIVERY_UNAVAILABLE));
}
if(request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
{
return (createServiceAccount(plan, auth));
}
JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before));
checkProtectedAfterRead(owner);
boolean service = "service_account".equals(owner.optString("type"));
boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if((!own || service) && !manager || !own && !service && request.action() != AdminAction.REVOKE_TOKEN)
{
throw (new QUserFacingException("Token ownership changed; review again."));
}
String bearer = own && !service ? new AuthentikTokenBroker().exchange(util, auth) : config.getResolvedApiToken();
switch(request.action())
{
case CREATE_TOKEN:
return (createPersonal(request, owner, expiry(request, false), null, bearer, auth));
case REVOKE_TOKEN:
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
scopes.delete(request.targetKey());
return (success("Token revoked."));
case EXTEND_TOKEN:
util.writeAs(bearer, auth, "PATCH", path(request.targetKey()), new JSONObject().put("intent", before.getString("intent"))
.put("user", owner.getInt("pk")).put("expires", expiry(request, service).toString()).put("expiring", true));
raceGuard(before, token(request.targetKey()));
scopes.resetReminders(request.targetKey());
return (success("Token expiry updated."));
case ROTATE_TOKEN:
if(!service)
{
return (createPersonal(request, owner, checkExpiry(before, false), before, bearer, auth));
}
String scope = scopeSummary(request.targetKey());
Instant expires = checkExpiry(before, true);
byte[] random = new byte[48];
RANDOM.nextBytes(random);
String key = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
util.writeAs(bearer, auth, "POST", path(request.targetKey()) + "set_key/", new JSONObject().put("key", key));
JSONObject after = token(request.targetKey());
raceGuard(before, after);
return (success("Service-account token rotated. The old key no longer works.")
.withSecret(oneTime(request.targetKey(), key, owner, expires, scope), () ->
{
throw (new QUserFacingException("The old key no longer works and the new key is lost. Rotate it in the Authentik admin UI."));
}));
default:
throw (new QException("Unsupported token action"));
}
}
/*******************************************************************************
** Guard owner and intent after every PATCH or set_key, with a safe warning.
*******************************************************************************/
private void raceGuard(JSONObject before, JSONObject after) throws QException
{
if(ownerPk(before) != ownerPk(after) || !before.getString("intent").equals(after.getString("intent")))
{
alertGuardBypass();
LOG.warn(TOKEN_MOVED);
throw (new QUserFacingException(TOKEN_MOVED));
}
checkProtectedAfterRead(owner(ownerPk(after)));
}
/*******************************************************************************
** Create as the operator, fetch their key, and optionally replace the old id.
** On partial failure discard the new credential; the old one stays usable.
*******************************************************************************/
private AuthentikAdmin.AdminResult createPersonal(AdminRequest request, JSONObject owner, Instant expires, JSONObject old,
String bearer, WriteAuthorization auth) throws QException
{
String identifier = identifier(owner.getString("username"));
String description = old == null ? request.str("description") : old.optString("description");
util.writeAs(bearer, auth, "POST", "/core/tokens/", new JSONObject().put("identifier", identifier).put("intent", "app_password")
.put("description", description).put("expiring", true).put("expires", expires.toString()));
try
{
JSONObject created = util.getAs(bearer, auth, path(identifier));
checkProtectedAfterRead(owner(ownerPk(created)));
if(ownerPk(created) != owner.getInt("pk") || !"app_password".equals(created.optString("intent")))
{
throw (new QUserFacingException(TOKEN_MOVED));
}
String key = util.getAs(bearer, auth, path(identifier) + "view_key/").getString("key");
Instant actualExpiry = checkExpiry(created, false);
if(old == null)
{
scopes.create(identifier, owner, request, admin.operator().email());
}
else
{
QRecord previous = scopes.find(request.targetKey());
if(previous == null)
{
scopes.create(identifier, owner, AdminRequest.of(request.action(), identifier, request.reason()), admin.operator().email());
}
else
{
scopes.move(request.targetKey(), identifier);
}
}
OneTimeKey secret = oneTime(identifier, key, owner, actualExpiry, scopeSummary(identifier));
if(old != null)
{
try
{
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
}
catch(QException e)
{
scopes.move(identifier, request.targetKey());
throw (new QUserFacingException("Rotation failed: the old token could not be deleted and remains listed."));
}
}
return (success(old == null ? "Token created. The key is shown once." : "Token rotated. The old token was deleted.")
.withSecret(secret, () -> revokeIssued(identifier, bearer, auth)));
}
catch(Exception e)
{
revokeIssued(identifier, bearer, auth);
if(e instanceof QUserFacingException facing)
{
throw (facing);
}
throw (new QUserFacingException("The new token could not be completed and was revoked."));
}
}
/*******************************************************************************
** Create the single service-account app password and grant checked groups.
*******************************************************************************/
private AuthentikAdmin.AdminResult createServiceAccount(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
String identifier = "service-account-" + request.str("name") + "-password";
JSONObject created = util.write(auth, "POST", "/core/users/service_account/", new JSONObject().put("name", request.str("name"))
.put("create_group", false).put("expiring", true).put("expires", expiry(request, true).toString()));
try
{
Integer pk = created.has("user_pk") ? created.getInt("user_pk") : admin.userPkByUsername(created.optString("username", request.str("name")));
if(pk == null)
{
throw (new QException("The service account could not be found."));
}
JSONObject current = owner(pk);
JSONObject attributes = new JSONObject(current.optJSONObject("attributes") == null ? "{}" : current.getJSONObject("attributes").toString());
attributes.put("goauthentik.io/user/token-maximum-lifetime", config.getServiceAccountTokenLifetime());
util.write(auth, "PATCH", "/core/users/" + pk + "/", new JSONObject().put("attributes", attributes));
for(GroupGraph.Node group : serviceGroups(request))
{
checkProtectedGroup(group);
String problem = admin.checkGroupGrantable(group);
if(problem != null)
{
throw (new QUserFacingException(problem));
}
util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk));
}
JSONObject owner = owner(pk);
checkProtectedAfterRead(owner);
JSONObject after = token(identifier);
checkProtectedAfterRead(owner(ownerPk(after)));
scopes.create(identifier, owner, request, admin.operator().email());
return (success("Service account created. " + String.join(" ", plan.getNotes()))
.withSecret(oneTime(identifier, created.getString("token"), owner, checkExpiry(after, true), "Owner permissions"),
() -> revokeIssued(identifier, config.getResolvedApiToken(), auth)));
}
catch(Exception e)
{
revokeIssued(identifier, config.getResolvedApiToken(), auth);
throw (new QUserFacingException("The service account was created, but setup failed. Its token was revoked; finish setup in Authentik."));
}
}
/*******************************************************************************
** Emergency revocation for a newly created token whose key cannot be shown.
*******************************************************************************/
private void revokeIssued(String identifier, String bearer, WriteAuthorization auth) throws QException
{
try
{
util.writeAs(bearer, auth, "DELETE", path(identifier), null);
scopes.delete(identifier);
}
catch(QException e)
{
throw (new QUserFacingException("The new key could not be delivered and token revocation failed. Revoke it in the Authentik admin UI."));
}
}
/*******************************************************************************
** Scope summary contains metadata only.
*******************************************************************************/
private String scopeSummary(String identifier) throws QException
{
QRecord row = scopes.find(identifier);
List<String> parts = new ArrayList<>();
for(String field : TokenScopeStore.SCOPE_FIELDS)
{
if(row != null && row.getValue(field) != null)
{
parts.add(field + ": " + row.getValueString(field));
}
}
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
}
/*******************************************************************************
** Build the isolated one-time object, including its response-only usage text.
*******************************************************************************/
private OneTimeKey oneTime(String identifier, String key, JSONObject owner, Instant expires, String scope)
{
return (new OneTimeKey(identifier, key, TokenUsageText.build(config, owner.getString("username"), key, expires, scope)));
}
/*******************************************************************************
** Non-secret result text.
*******************************************************************************/
private static AuthentikAdmin.AdminResult success(String message)
{
return (new AuthentikAdmin.AdminResult(true, false, false, message, null));
}
/*******************************************************************************
** Collision-resistant identifier, independent of the secret value.
*******************************************************************************/
private static String identifier(String username)
{
String alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder suffix = new StringBuilder();
for(int i = 0; i < 6; i++)
{
suffix.append(alphabet.charAt(RANDOM.nextInt(alphabet.length())));
}
String slug = username.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-]+", "-");
return ("qqq-pat-" + slug + "-" + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC).format(Instant.now()) + "-" + suffix);
}
/*******************************************************************************
** A protected owner observed after Review or a write is a guard incident.
*******************************************************************************/
private void checkProtectedAfterRead(JSONObject owner) throws QException
{
try
{
checkProtected(owner);
}
catch(QUserFacingException e)
{
alertGuardBypass();
throw (e);
}
}
/*******************************************************************************
** Safe incident payload: no response bodies, credentials or owner details.
*******************************************************************************/
private void alertGuardBypass()
{
try
{
util.getRuntime().getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null,
"An access token changed owner or intent, or a protected owner was observed after Review. Investigate in Authentik."));
}
catch(RuntimeException e)
{
LOG.warn("Access-token guard alert delivery failed.");
}
}
}
@@ -1,88 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
/*******************************************************************************
** A response-only value. Deliberately not Serializable; never a process value.
*******************************************************************************/
public final class OneTimeKey
{
private final String label;
private final String value;
private final String usageText;
/*******************************************************************************
** Construct the one-time response.
*******************************************************************************/
public OneTimeKey(String label, String value, String usageText)
{
this.label = label;
this.value = value;
this.usageText = usageText;
}
/*******************************************************************************
** Getter for label.
*******************************************************************************/
public String label()
{
return (label);
}
/*******************************************************************************
** Getter for value.
*******************************************************************************/
public String value()
{
return (value);
}
/*******************************************************************************
** Getter for masked.
*******************************************************************************/
public String masked()
{
return ("\u2022\u2022\u2022\u2022" + value.substring(Math.max(0, value.length() - 4)));
}
/*******************************************************************************
** Getter for usageText.
*******************************************************************************/
public String usageText()
{
return (usageText);
}
/*******************************************************************************
** Never render the key or its usage text.
*******************************************************************************/
@Override
public String toString()
{
return ("OneTimeKey{label=" + label + ", value=[one-time]}" );
}
}
@@ -1,192 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Non-secret bookkeeping. No method accepts a key or a bearer.
*******************************************************************************/
public class TokenScopeStore
{
public static final List<String> SCOPE_FIELDS = List.of("readOnly", "apps", "tables", "processes", "reports", "widgets", "operationIds");
private final AuthentikQBitConfig config;
/*******************************************************************************
** Bind to one qbit's local backend.
*******************************************************************************/
public TokenScopeStore(AuthentikQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** Local table name.
*******************************************************************************/
private String table()
{
return (config.applyTablePrefix(AuthentikLocalTables.TOKEN_SCOPE));
}
/*******************************************************************************
** Fetch the unique scope row as a trusted SYSTEM read (QueryInput default).
*******************************************************************************/
public QRecord find(String identifier) throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput().withTableName(table())
.withFilter(new QQueryFilter(new QFilterCriteria("tokenIdentifier", QCriteriaOperator.EQUALS, identifier)))).getRecords();
return (rows.isEmpty() ? null : rows.get(0));
}
/*******************************************************************************
** Record a new identifier, even when scopes are disabled.
*******************************************************************************/
public void create(String identifier, JSONObject owner, AdminRequest request, String operator) throws QException
{
QRecord row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
.withValue("ownerUsername", owner.getString("username")).withValue("createdBy", operator).withValue("createdAt", Instant.now());
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
for(String field : SCOPE_FIELDS)
{
if(request.params().containsKey(field))
{
row.setValue(field, request.params().get(field));
}
}
}
List<QRecord> rows = new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords();
check(rows);
}
/*******************************************************************************
** Keep all scope and reminder fields when rotating a personal credential.
*******************************************************************************/
public void move(String oldIdentifier, String newIdentifier) throws QException
{
QRecord row = find(oldIdentifier);
if(row != null)
{
row.setValue("tokenIdentifier", newIdentifier);
row.setValue("rotatedAt", Instant.now());
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
/*******************************************************************************
** Revoke removes only the corresponding metadata row.
*******************************************************************************/
public void delete(String identifier) throws QException
{
QRecord row = find(identifier);
if(row != null)
{
new DeleteAction().execute(new DeleteInput().withTableName(table()).withPrimaryKeys(List.of(row.getValue("id"))));
}
}
/*******************************************************************************
** Fail closed if local storage rejects the write.
*******************************************************************************/
private static void check(List<QRecord> rows) throws QException
{
if(rows.isEmpty() || !rows.get(0).getErrors().isEmpty())
{
throw (new QException("Token scope metadata could not be saved."));
}
}
/*******************************************************************************
** Human-readable, non-secret scope for lists and Review.
*******************************************************************************/
public static String summary(QRecord row)
{
java.util.ArrayList<String> parts = new java.util.ArrayList<>();
for(String field : SCOPE_FIELDS)
{
if(row != null && row.getValue(field) != null && !row.getValueString(field).isBlank())
{
parts.add(field + ": " + row.getValueString(field));
}
}
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
}
/*******************************************************************************
** Persist dedupe metadata. External tokens get no invented creation date or
** restrictions; their token row remains unscoped.
*******************************************************************************/
public void reminded(String identifier, JSONObject owner, String field, Instant at) throws QException
{
QRecord row = find(identifier);
if(row == null)
{
row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
.withValue("ownerUsername", owner.getString("username")).withValue(field, at);
check(new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
else
{
row.setValue(field, at);
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
/*******************************************************************************
** A new expiry starts a new set of reminder windows.
*******************************************************************************/
public void resetReminders(String identifier) throws QException
{
QRecord row = find(identifier);
if(row != null)
{
row.setValue("lastReminder7d", null);
row.setValue("lastReminder1d", null);
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
}
@@ -1,69 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
/*******************************************************************************
** Usage instructions held exclusively inside the one-time response.
*******************************************************************************/
public final class TokenUsageText
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TokenUsageText()
{
}
/*******************************************************************************
** Generate shell-quoted exchange and application requests from public URLs.
*******************************************************************************/
public static String build(AuthentikQBitConfig config, String username, String key, Instant expires, String scope)
{
String clientId = config.getAppClientId();
if(clientId == null && QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth)
{
clientId = oauth.getClientId();
}
String endpoint = config.getAuthentikPublicUrl().replaceAll("/+$", "") + "/application/o/token/";
String app = config.getAppPublicUrl() == null ? "{appPublicUrl}" : config.getAppPublicUrl().replaceAll("/+$", "");
return ("Expires: " + expires + "\nScope: " + scope + "\n\n"
+ "curl -X POST " + quote(endpoint) + " --data-urlencode 'grant_type=client_credentials'"
+ " --data-urlencode " + quote("client_id=" + (clientId == null ? "{appClientId}" : clientId))
+ " --data-urlencode " + quote("username=" + username) + " --data-urlencode " + quote("password=" + key)
+ " --data-urlencode 'scope=openid profile email'\n\n"
+ "curl -H 'Authorization: Bearer {jwt}' " + quote(app + "/qqq/v1/metaData"));
}
/*******************************************************************************
** Quote literal shell arguments, including usernames containing apostrophes.
*******************************************************************************/
private static String quote(String value)
{
return ("'" + value.replace("'", "'\"'\"'") + "'");
}
}
@@ -1,557 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import java.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.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;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
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 org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Everything the Identity home shows (DESIGN.md section 8.3), read live from
** Authentik and the qbit's own tables. Each section is loaded on its own, so a
** failure shows as "unavailable" for that card instead of hiding the page.
** Sections that expose sensitive data need authentik.audit.read.
*******************************************************************************/
public class AuthentikHomeData
{
private static final QLogger LOG = QLogger.getLogger(AuthentikHomeData.class);
private boolean peopleAvailable;
private int activePeople;
private int inactivePeople;
private int serviceAccounts;
private int neverSignedIn;
private int protectedAccounts;
private boolean groupsAvailable;
private int groups;
private int groupsWithPermissions;
private int groupsWithAppAccess;
private int superuserGroups;
private int emptyGroups;
private boolean approvalsAvailable;
private int pendingApprovals;
private boolean driftAvailable;
private int driftErrors;
private int driftWarnings;
private boolean mayAudit;
private boolean actionsAvailable;
private List<QRecord> recentActions = new ArrayList<>();
private List<String> attention = new ArrayList<>();
private String healthStatus = "UNKNOWN";
private Integer healthComponentId;
private AuthentikQBitConfig config;
/*******************************************************************************
** Load everything for the current session.
*******************************************************************************/
public static AuthentikHomeData load(AuthentikQBitConfig config) throws Exception
{
AuthentikHomeData data = new AuthentikHomeData();
data.config = config;
data.mayAudit = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.AUDIT_READ);
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName());
data.loadPeople(util);
data.loadGroups(util);
data.loadApprovals();
data.loadActions();
data.loadAttention(util);
data.loadHealth();
return (data);
}
/*******************************************************************************
**
*******************************************************************************/
private void loadPeople(AuthentikActionUtil util)
{
try
{
for(JSONObject user : util.allUsers())
{
String type = user.optString("type");
if("service_account".equals(type))
{
serviceAccounts++;
}
else if(user.optBoolean("is_active", true))
{
activePeople++;
}
else
{
inactivePeople++;
}
if(user.isNull("last_login") && !"service_account".equals(type) && user.optBoolean("is_active", true))
{
neverSignedIn++;
}
if(util.getRuntime().getGuard().protection(user).isProtected())
{
protectedAccounts++;
}
}
peopleAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: people unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
**
*******************************************************************************/
private void loadGroups(AuthentikActionUtil util)
{
try
{
GroupGraph graph = util.groupGraphWithMembers();
for(GroupGraph.Node node : graph.nodes())
{
groups++;
if(!node.permissions().isEmpty())
{
groupsWithPermissions++;
}
if(!node.appAccess().isEmpty())
{
groupsWithAppAccess++;
}
if(graph.isSuperuserInherited(node.pk()))
{
superuserGroups++;
}
if(node.memberPks().isEmpty())
{
emptyGroups++;
}
}
groupsAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: groups unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
**
*******************************************************************************/
private void loadApprovals()
{
if(!hasLocal() || !config.getTwoPersonApproval() || !config.hasFeature(AuthentikFeature.GROUPS))
{
return;
}
try
{
CountInput countInput = new CountInput();
countInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.APPROVAL_REQUEST));
countInput.setFilter(new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, AuthentikLocalTables.ApprovalStatus.PENDING.name())));
pendingApprovals = new CountAction().execute(countInput).getCount();
approvalsAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: approvals unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
** The latest admin actions (audit.read only).
*******************************************************************************/
private void loadActions()
{
if(!hasLocal() || !mayAudit)
{
return;
}
try
{
QueryInput queryInput = new QueryInput();
queryInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
queryInput.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false)).withLimit(8));
recentActions = new QueryAction().execute(queryInput).getRecords();
actionsAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: admin actions unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
** Configuration review and bootstrap operators still set. Health is central.
*******************************************************************************/
private void loadAttention(AuthentikActionUtil util)
{
if(!config.getResolvedBootstrapSubjects().isEmpty())
{
attention.add("bootstrapOperatorSubjects is still set: remove it once an identity administrator is set up in Authentik.");
}
if(Boolean.TRUE.equals(config.getReadOnly()))
{
attention.add("The Identity screens are in read-only mode: no change can be made from here.");
}
if(AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN))
{
try
{
for(DriftFinding finding : new AuthentikDriftReport(util, config).run())
{
if(finding.severity() == DriftFinding.Severity.ERROR)
{
driftErrors++;
}
else if(finding.severity() == DriftFinding.Severity.WARNING)
{
driftWarnings++;
}
}
driftAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: drift report unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private boolean hasLocal()
{
return (config.getLocalBackendName() != null && QContext.getQInstance().getTable(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION)) != null);
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
public AuthentikQBitConfig getConfig()
{
return (config);
}
/*******************************************************************************
** Read the central rollup using the current session's table permissions.
*******************************************************************************/
private void loadHealth()
{
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);
}
/*******************************************************************************
** Getter for peopleAvailable
*******************************************************************************/
public boolean isPeopleAvailable()
{
return (peopleAvailable);
}
/*******************************************************************************
** Getter for activePeople
*******************************************************************************/
public int getActivePeople()
{
return (activePeople);
}
/*******************************************************************************
** Getter for inactivePeople
*******************************************************************************/
public int getInactivePeople()
{
return (inactivePeople);
}
/*******************************************************************************
** Getter for serviceAccounts
*******************************************************************************/
public int getServiceAccounts()
{
return (serviceAccounts);
}
/*******************************************************************************
** Getter for neverSignedIn
*******************************************************************************/
public int getNeverSignedIn()
{
return (neverSignedIn);
}
/*******************************************************************************
** Getter for protectedAccounts
*******************************************************************************/
public int getProtectedAccounts()
{
return (protectedAccounts);
}
/*******************************************************************************
** Getter for groupsAvailable
*******************************************************************************/
public boolean isGroupsAvailable()
{
return (groupsAvailable);
}
/*******************************************************************************
** Getter for groups
*******************************************************************************/
public int getGroups()
{
return (groups);
}
/*******************************************************************************
** Getter for groupsWithPermissions
*******************************************************************************/
public int getGroupsWithPermissions()
{
return (groupsWithPermissions);
}
/*******************************************************************************
** Getter for groupsWithAppAccess
*******************************************************************************/
public int getGroupsWithAppAccess()
{
return (groupsWithAppAccess);
}
/*******************************************************************************
** Getter for superuserGroups
*******************************************************************************/
public int getSuperuserGroups()
{
return (superuserGroups);
}
/*******************************************************************************
** Getter for emptyGroups
*******************************************************************************/
public int getEmptyGroups()
{
return (emptyGroups);
}
/*******************************************************************************
** Getter for approvalsAvailable
*******************************************************************************/
public boolean isApprovalsAvailable()
{
return (approvalsAvailable);
}
/*******************************************************************************
** Getter for pendingApprovals
*******************************************************************************/
public int getPendingApprovals()
{
return (pendingApprovals);
}
/*******************************************************************************
** Getter for driftAvailable
*******************************************************************************/
public boolean isDriftAvailable()
{
return (driftAvailable);
}
/*******************************************************************************
** Getter for driftErrors
*******************************************************************************/
public int getDriftErrors()
{
return (driftErrors);
}
/*******************************************************************************
** Getter for driftWarnings
*******************************************************************************/
public int getDriftWarnings()
{
return (driftWarnings);
}
/*******************************************************************************
** Getter for mayAudit
*******************************************************************************/
public boolean isMayAudit()
{
return (mayAudit);
}
/*******************************************************************************
** Getter for actionsAvailable
*******************************************************************************/
public boolean isActionsAvailable()
{
return (actionsAvailable);
}
/*******************************************************************************
** Getter for recentActions
*******************************************************************************/
public List<QRecord> getRecentActions()
{
return (recentActions);
}
/*******************************************************************************
** Getter for attention
*******************************************************************************/
public List<String> getAttention()
{
return (attention);
}
}
@@ -1,292 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Renders the Identity home as one responsive HTML block (no tables, so it
** stays readable at every width): cards stack to one column on narrow
** screens, long values wrap, every card has an honest empty or unavailable
** state, and it follows the app's light and dark theme (inherited colors).
*******************************************************************************/
public final class AuthentikHomeView
{
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr));gap:1rem;";
private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;";
private static final String LIST = "display:grid;grid-template-columns:minmax(8rem,auto) 1fr;gap:.25rem .75rem;margin:0 0 .75rem;font-size:.9rem;";
private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;font-size:.9rem;";
private static final String MUTED = "opacity:.75;";
private static final String WARN = "font-weight:600;";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHomeView()
{
}
/*******************************************************************************
** The whole home.
*******************************************************************************/
public static String render(AuthentikHomeData data)
{
AuthentikQBitConfig config = data.getConfig();
StringBuilder html = new StringBuilder("<div class=\"authentik-home\" style=\"" + GRID + "\">");
attention(html, data, config);
people(html, data, config);
groups(html, data, config);
activity(html, data, config);
health(html, data);
return (html.append("</div>").toString());
}
/*******************************************************************************
** Needs attention: the first thing a person should see.
*******************************************************************************/
private static void attention(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "Needs attention");
boolean any = false;
html.append("<ul style=\"").append(ITEMS).append("\">");
if(data.isApprovalsAvailable() && data.getPendingApprovals() > 0)
{
any = true;
html.append("<li style=\"").append(WARN).append("\">").append(data.getPendingApprovals()).append(" change(s) waiting for a second person to approve. ")
.append(link(config, AuthentikLocalTables.APPROVAL_REQUEST, "Open approval requests")).append("</li>");
}
if(data.isDriftAvailable() && data.getDriftErrors() + data.getDriftWarnings() > 0)
{
any = true;
html.append("<li>").append(data.getDriftErrors()).append(" drift error(s) and ").append(data.getDriftWarnings()).append(" warning(s) in the identity drift report below.</li>");
}
for(String line : data.getAttention())
{
any = true;
html.append("<li>").append(esc(line)).append("</li>");
}
if(data.isPeopleAvailable() && data.getNeverSignedIn() > 0)
{
any = true;
html.append("<li>").append(data.getNeverSignedIn()).append(" active person(s) have never signed in.</li>");
}
html.append("</ul>");
if(!any)
{
empty(html, "Nothing needs attention.");
}
close(html);
}
/*******************************************************************************
**
*******************************************************************************/
private static void people(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "People");
if(!data.isPeopleAvailable())
{
empty(html, "People are unavailable right now (Authentik could not be reached).");
}
else
{
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Active", String.valueOf(data.getActivePeople()));
row(html, "Inactive", String.valueOf(data.getInactivePeople()));
row(html, "Service accounts", String.valueOf(data.getServiceAccounts()));
row(html, "Protected", data.getProtectedAccounts() + " (cannot be changed from here)");
html.append("</dl>");
}
html.append(link(config, AuthentikTables.USER, "Users"));
close(html);
}
/*******************************************************************************
**
*******************************************************************************/
private static void groups(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "Groups and permissions");
if(!data.isGroupsAvailable())
{
empty(html, "Groups are unavailable right now (Authentik could not be reached).");
}
else
{
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Groups", String.valueOf(data.getGroups()));
row(html, "Grant QQQ permissions", String.valueOf(data.getGroupsWithPermissions()));
row(html, "Grant app sign-in", String.valueOf(data.getGroupsWithAppAccess()));
row(html, "Superuser (locked)", String.valueOf(data.getSuperuserGroups()));
row(html, "Empty", String.valueOf(data.getEmptyGroups()));
html.append("</dl>");
}
html.append(link(config, AuthentikTables.GROUP, "Groups"));
close(html);
}
/*******************************************************************************
** Recent changes made from here (audit.read only).
*******************************************************************************/
private static void activity(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "Recent changes");
if(!data.isMayAudit())
{
empty(html, "The change history needs the authentik.audit.read permission.");
}
else if(!data.isActionsAvailable())
{
empty(html, "The change history is unavailable right now.");
}
else if(data.getRecentActions().isEmpty())
{
empty(html, "No changes have been made from here yet.");
}
else
{
html.append("<ul style=\"").append(ITEMS).append("\">");
for(QRecord row : data.getRecentActions())
{
Instant created = ValueUtils.getValueAsInstant(row.getValue("createDate"));
html.append("<li><a href=\"").append(esc(prefix(config) + "/" + config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION) + "/" + row.getValue("id"))).append("\">")
.append(esc(row.getValueString("actionName"))).append("</a> ").append(esc(row.getValueString("targetLabel"))).append(", ")
.append(esc(row.getValueString("status"))).append(created == null ? "" : ", " + WHEN.format(created)).append("</li>");
}
html.append("</ul>");
}
if(data.isMayAudit())
{
html.append(link(config, AuthentikLocalTables.ADMIN_ACTION, "All changes"));
}
close(html);
}
/*******************************************************************************
**
*******************************************************************************/
private static void health(StringBuilder html, AuthentikHomeData data)
{
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);
}
/*******************************************************************************
**
*******************************************************************************/
private static String prefix(AuthentikQBitConfig config)
{
return (config.getLinkPrefix() == null ? "" : config.getLinkPrefix());
}
/*******************************************************************************
**
*******************************************************************************/
private static String link(AuthentikQBitConfig config, String table, String text)
{
String name = AuthentikLocalTables.APPROVAL_REQUEST.equals(table) || AuthentikLocalTables.ADMIN_ACTION.equals(table)
? config.applyTablePrefix(table) : config.applyTablePrefix(table);
return ("<a href=\"" + esc(prefix(config) + "/" + name) + "\">" + esc(text) + "</a>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void open(StringBuilder html, String title)
{
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .5rem;font-size:1rem;\">").append(esc(title)).append("</h3>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void close(StringBuilder html)
{
html.append("</section>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void empty(StringBuilder html, String text)
{
html.append("<p style=\"").append(MUTED).append("\">").append(esc(text)).append("</p>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void row(StringBuilder html, String label, String value)
{
html.append("<dt style=\"").append(MUTED).append("\">").append(esc(label)).append("</dt><dd style=\"margin:0;\">").append(esc(value)).append("</dd>");
}
/*******************************************************************************
** HTML-escape.
*******************************************************************************/
static String esc(String text)
{
if(text == null)
{
return ("");
}
return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"));
}
}
@@ -1,74 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.metadata.AuthentikHomeWidgetProducer;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Holder of the Identity home widget renderer.
*******************************************************************************/
public final class AuthentikHomeWidget
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHomeWidget()
{
}
/*******************************************************************************
** Renders the home. QQQ checks the widget's permission rules (authentik.users.read);
** each card checks the finer permissions it needs itself.
*******************************************************************************/
public static class Renderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(AuthentikHomeWidgetProducer.BACKEND_NAME_KEY));
AuthentikQBitConfig config = AuthentikRuntime.forBackend(backendName).getConfig();
try
{
return (new RenderWidgetOutput(new RawHTML(null, AuthentikHomeView.render(AuthentikHomeData.load(config)))));
}
catch(QException e)
{
throw (e);
}
catch(Exception e)
{
throw (new QException("The Identity home could not be built: " + e.getMessage(), e));
}
}
}
}
@@ -1,168 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
/*******************************************************************************
** Sub-tables appear on their parents' record screens as child lists, not in
** the nav (owner standard): a user shows its memberships, sessions, tokens and
** events; a group shows its members; an application shows its policy bindings;
** an approval request needs none. The joins are metadata only (the lists
** query the child table filtered by the parent's key), so they work on the
** API backend.
*******************************************************************************/
public final class AuthentikRelations
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikRelations()
{
}
/*******************************************************************************
** Add the joins, child-list widgets and record sections for the tables the
** config produced. Safe to call once per instance.
*******************************************************************************/
public static void apply(QInstance qInstance, AuthentikQBitConfig config)
{
String user = config.applyTablePrefix(AuthentikTables.USER);
String group = config.applyTablePrefix(AuthentikTables.GROUP);
String membership = config.applyTablePrefix(AuthentikTables.GROUP_MEMBER);
String session = config.applyTablePrefix(AuthentikTables.SESSION);
String token = config.applyTablePrefix(AuthentikTables.TOKEN);
String event = config.applyTablePrefix(AuthentikTables.EVENT);
String application = config.applyTablePrefix(AuthentikTables.APPLICATION);
String binding = config.applyTablePrefix(AuthentikTables.POLICY_BINDING);
List<QFieldSection> userSections = new ArrayList<>();
userSections.add(new QFieldSection("identity", "Identity", new QIcon("badge"), Tier.T1,
List.of("username", "name", "email", "isActive", "type", "isProtected", "isSuperuser", "path", "lastLogin", "dateJoined", "lastUpdated", "pk")));
userSections.add(new QFieldSection("access", "Access", new QIcon("admin_panel_settings"), Tier.T2,
List.of("groupNames", "roleNames", "effectivePermissions")));
relate(qInstance, userSections, "memberships", "Memberships", "group_add", user, membership, "pk", "userPk");
relate(qInstance, userSections, "sessions", "Sessions", "devices", user, session, "username", "username");
relate(qInstance, userSections, "tokens", "Tokens and app passwords", "key", user, token, "username", "username");
relate(qInstance, userSections, "events", "History", "history", user, event, "username", "username");
userSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("qqqAttributes", "passwordChangeDate", "attributes")));
applySections(qInstance, user, userSections);
List<QFieldSection> groupSections = new ArrayList<>();
groupSections.add(new QFieldSection("details", "Details", new QIcon("groups"), Tier.T1,
List.of("name", "accessType", "isSuperuser", "isSuperuserInherited", "parentNames", "childNames", "memberCount", "roleNames", "pk")));
groupSections.add(new QFieldSection("permissions", "Permissions and app access", new QIcon("admin_panel_settings"), Tier.T2,
List.of("permissions", "effectivePermissions", "appAccess")));
relate(qInstance, groupSections, "members", "Members", "group", group, membership, "pk", "groupPk");
enforcement(qInstance, config, groupSections);
relate(qInstance, groupSections, "policy", "Policy", "policy", group, config.applyTablePrefix(AuthentikLocalTables.POLICY), "pk", "groupPk");
groupSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("attributes")));
applySections(qInstance, group, groupSections);
String policyTable = config.applyTablePrefix(AuthentikLocalTables.POLICY);
if(qInstance.getTable(policyTable) != null)
{
List<QFieldSection> policySections = new ArrayList<>();
enforcement(qInstance, config, policySections);
policySections.add(new QFieldSection("policy", "Policy", new QIcon("policy"), Tier.T1,
List.of("groupName", "statementCount", "permissionCount", "savedBy", "savedDate", "policyJson")));
policySections.add(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3, List.of("id", "groupPk", "authentikFingerprint", "createDate", "modifyDate")));
applySections(qInstance, policyTable, policySections);
}
List<QFieldSection> appSections = new ArrayList<>();
appSections.add(new QFieldSection("details", "Details", new QIcon("apps"), Tier.T1,
List.of("name", "slug", "group", "providerName", "policyEngineMode", "launchUrl", "publisher", "hiddenFromLibrary")));
relate(qInstance, appSections, "bindings", "Who can open it (policy bindings)", "rule", application, binding, "bindingTarget", "target");
applySections(qInstance, application, appSections);
}
/*******************************************************************************
** The policy enforcement banner as the first section (when produced).
*******************************************************************************/
private static void enforcement(QInstance qInstance, AuthentikQBitConfig config, List<QFieldSection> sections)
{
String widget = config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.ENFORCEMENT);
if(qInstance.getWidget(widget) != null)
{
sections.add(0, new QFieldSection("enforcement", new QIcon("verified_user"), Tier.T2).withLabel("Policy enforcement").withWidgetName(widget));
}
}
/*******************************************************************************
** One join plus its child-list widget and the section that shows it.
*******************************************************************************/
private static void relate(QInstance qInstance, List<QFieldSection> sections, String key, String label, String icon,
String parentTable, String childTable, String parentField, String childField)
{
if(qInstance.getTable(parentTable) == null || qInstance.getTable(childTable) == null)
{
return;
}
String joinName = parentTable + "_" + key;
QJoinMetaData join = new QJoinMetaData()
.withName(joinName)
.withLeftTable(parentTable)
.withRightTable(childTable)
.withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn(parentField, childField));
qInstance.addJoin(join);
QWidgetMetaData widget = ChildRecordListRenderer.widgetMetaDataBuilder(join).withLabel(label).withMaxRows(20).getWidgetMetaData();
qInstance.addWidget(widget);
sections.add(new QFieldSection(key, new QIcon(icon), Tier.T2).withLabel(label).withWidgetName(widget.getName()));
}
/*******************************************************************************
** Replace a table's sections (the enricher would otherwise skip the field
** sections once a widget section exists).
*******************************************************************************/
private static void applySections(QInstance qInstance, String tableName, List<QFieldSection> sections)
{
QTableMetaData table = qInstance.getTable(tableName);
if(table != null)
{
table.setSections(sections);
}
}
}
@@ -1,180 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Standard TABLE widget: inventory, age and actionable cleanup suggestions.
** Computed fields never become API filters; links use owner or token keys.
*******************************************************************************/
public class AuthentikTokenWidget extends AbstractWidgetRenderer
{
/*******************************************************************************
** Check the permission here as well as in metadata (non-HTTP callers).
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backend = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get("backendName"));
return (new RenderWidgetOutput(data(AuthentikActionUtil.forBackend(backend), Instant.now())));
}
/*******************************************************************************
** Report complete inventory and explicit unknown ages. An API token's
** activity is untracked and never qualifies as unused.
*******************************************************************************/
public static TableData data(AuthentikActionUtil util, Instant now) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
{
throw (new QPermissionDeniedException("Access token widgets require authentik.tokens.manage."));
}
AuthentikQBitConfig config = util.getRuntime().getConfig();
List<JSONObject> tokens = util.getAllPages("/core/tokens/", Integer.MAX_VALUE);
Map<String, Instant> activity = AuthentikTokenActivity.lastUsed(util);
Map<String, List<String>> owners = new LinkedHashMap<>();
Map<String, List<String>> ages = new LinkedHashMap<>();
for(String bucket : List.of("<30d", "30-90d", "90-365d", ">365d", "unknown"))
{
ages.put(bucket, new ArrayList<>());
}
Map<Integer, JSONObject> users = new LinkedHashMap<>();
List<Map<String, Object>> cleanup = new ArrayList<>();
TokenScopeStore scopes = new TokenScopeStore(config);
for(JSONObject token : tokens)
{
String id = token.getString("identifier");
JSONObject owner = token.getJSONObject("user_obj");
String username = owner.getString("username");
owners.computeIfAbsent(username, k -> new ArrayList<>()).add(id);
QRecord scope = scopes.find(id);
Instant created = scope == null ? null : scope.getValueInstant("createdAt");
long days = created == null ? -1 : ChronoUnit.DAYS.between(created, now);
ages.get(days < 0 ? "unknown" : days < 30 ? "<30d" : days < 90 ? "30-90d" : days <= 365 ? "90-365d" : ">365d").add(id);
int pk = owner.getInt("pk");
if(!users.containsKey(pk))
{
users.put(pk, util.getApiJsonOrNull("/core/users/" + pk + "/"));
}
JSONObject live = users.get(pk);
String links = link(list(config, "username", List.of(username)), username) + " / " + link(list(config, "identifier", List.of(id)), id);
if(live != null && !live.optBoolean("is_active", true))
{
cleanup.add(row("Owner deactivated", 1, links));
}
Instant last = activity.get(id);
Instant cutoff = now.minus(config.getTokenUnusedDays(), ChronoUnit.DAYS);
// Without a creation time or an event we cannot prove age. A 90-day
// event window cannot prove inactivity beyond 90 days either.
boolean unused = last != null ? last.isBefore(cutoff)
: config.getTokenUnusedDays() <= 90 && created != null && created.isBefore(cutoff);
if("app_password".equals(token.optString("intent")) && unused)
{
cleanup.add(row("Unused for " + config.getTokenUnusedDays() + " days", 1, links));
}
}
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(row("Total", tokens.size(), link(list(config, null, List.of()), "All access tokens")));
owners.entrySet().stream().sorted(Comparator.<Map.Entry<String, List<String>>>comparingInt(e -> e.getValue().size()).reversed().thenComparing(Map.Entry::getKey))
.limit(10).forEach(e -> rows.add(row("Owner: " + e.getKey(), e.getValue().size(), link(list(config, "username", List.of(e.getKey())), "Tokens"))));
ages.forEach((bucket, ids) -> rows.add(row("Age: " + bucket, ids.size(), ids.isEmpty() ? "" : link(list(config, "identifier", ids), "Tokens"))));
rows.addAll(cleanup);
return (new TableData("Access tokens", List.of(
new TableData.Column("default", "Category", "category", "35%", "left"),
new TableData.Column("default", "Count", "count", "10%", "right"),
new TableData.Column("html", "Access tokens", "tokens", "55%", "left")), rows)
.withRowsPerPage(25).withLinkText("All access tokens / bulk revoke").withLinkURL(list(config, null, List.of())));
}
/*******************************************************************************
** A standard table row; only the link cell contains escaped anchor markup.
*******************************************************************************/
private static Map<String, Object> row(String category, int count, String tokens)
{
return (Map.of("category", category, "count", count, "tokens", tokens));
}
/*******************************************************************************
** Native list filter format shared by Material and Next. Primary-key IN is
** supported by the API adapter as individual GETs; no computed filtering.
*******************************************************************************/
private static String list(AuthentikQBitConfig config, String field, List<String> values)
{
String url = (config.getLinkPrefix() == null ? "" : config.getLinkPrefix()) + "/" + config.applyTablePrefix("token");
if(field != null)
{
JSONObject criteria = new JSONObject().put("fieldName", field).put("operator", values.size() == 1 ? "EQUALS" : "IN").put("values", values);
String filter = new JSONObject().put("booleanOperator", "AND").put("criteria", new JSONArray().put(criteria)).toString();
url += "?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8);
}
return (url);
}
/*******************************************************************************
** Escape both the link target and text; descriptions are never HTML.
*******************************************************************************/
private static String link(String url, String text)
{
return ("<a href=\"" + escape(url) + "\">" + escape(text) + "</a>");
}
/*******************************************************************************
** Minimal HTML escaping for a standard table link cell.
*******************************************************************************/
private static String escape(String value)
{
return (value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
}
File diff suppressed because it is too large. Load diff
@@ -1,126 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.apache.http.NameValuePair;
/*******************************************************************************
** Every Authentik operation the qbit uses, with the query parameters it
** sends. The schema drift tests check this surface against the pinned
** schema (and, in CI, against new Authentik releases). Table operations are
** derived from AuthentikTables; others are listed here.
*******************************************************************************/
public final class AuthentikApiSurface
{
/*******************************************************************************
** One operation: method, schema path template, and parameters used.
*******************************************************************************/
public record Operation(String method, String path, Set<String> params)
{
}
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private AuthentikApiSurface()
{
}
/*******************************************************************************
** Operations outside the table specs.
*******************************************************************************/
static List<Operation> extraOperations()
{
List<Operation> rs = new ArrayList<>();
rs.add(new Operation("get", "/admin/version/", Set.of()));
rs.add(new Operation("get", "/flows/instances/{slug}/", Set.of()));
rs.add(new Operation("get", "/flows/instances/", Set.of("flow_uuid", "page", "page_size")));
rs.add(new Operation("get", "/flows/bindings/", Set.of("target", "page", "page_size")));
rs.add(new Operation("get", "/policies/bindings/", Set.of("target", "page", "page_size")));
rs.add(new Operation("get", "/policies/expression/{policy_uuid}/", Set.of()));
rs.add(new Operation("post", "/core/tokens/", Set.of()));
rs.add(new Operation("patch", "/core/tokens/{key}/", Set.of()));
rs.add(new Operation("delete", "/core/tokens/{key}/", Set.of()));
rs.add(new Operation("get", "/core/tokens/{key}/view_key/", Set.of()));
rs.add(new Operation("post", "/core/tokens/{key}/set_key/", Set.of()));
rs.add(new Operation("post", "/core/users/service_account/", Set.of()));
///////////////////////////////////////////////////////////
// virtual membership table, group graph, drift report //
///////////////////////////////////////////////////////////
rs.add(new Operation("get", "/core/groups/", Set.of("include_users", "include_parents", "include_children", "name", "ordering", "page", "page_size")));
rs.add(new Operation("get", "/core/groups/{key}/", Set.of("include_users")));
rs.add(new Operation("get", "/core/users/", Set.of("username", "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);
}
/*******************************************************************************
** All operations. Detail paths use "{key}" for the id segment.
*******************************************************************************/
public static List<Operation> operations()
{
List<Operation> rs = new ArrayList<>();
for(AuthentikTableSpec spec : AuthentikTables.all().values())
{
if(spec.isVirtual())
{
continue;
}
Set<String> listParams = new LinkedHashSet<>(List.of("page", "page_size", "ordering"));
for(AuthentikFieldSpec field : spec.getFields())
{
listParams.addAll(field.getFilterParams().values());
if(field.isSearchable())
{
listParams.add("search");
}
}
spec.getDefaultListParams().stream().map(NameValuePair::getName).forEach(listParams::add);
if(AuthentikTables.USER.equals(spec.getName()))
{
listParams.addAll(List.of("type", "path_startswith", "groups_by_name"));
}
rs.add(new Operation("get", spec.getPath(), listParams));
Set<String> getParams = new LinkedHashSet<>();
spec.getDefaultGetParams().stream().map(NameValuePair::getName).forEach(getParams::add);
rs.add(new Operation("get", spec.getPath() + "{key}/", getParams));
}
rs.addAll(extraOperations());
return (rs);
}
}
@@ -1,204 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.io.Serializable;
import java.util.EnumMap;
import java.util.Map;
import java.util.function.Function;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import org.json.JSONObject;
/*******************************************************************************
** One field of an Authentik-backed table: how to read it from the Authentik
** JSON, which QQQ criteria map to which Authentik query parameters, and
** whether it is sensitive (stripped without authentik.audit.read).
*******************************************************************************/
public class AuthentikFieldSpec
{
private final String name;
private final String label;
private final QFieldType type;
private final Function<JSONObject, Serializable> extractor;
private final Map<QCriteriaOperator, String> filterParams = new EnumMap<>(QCriteriaOperator.class);
private boolean sensitive;
private boolean searchable;
private String orderingName;
private boolean hidden;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AuthentikFieldSpec(String name, String label, QFieldType type, Function<JSONObject, Serializable> extractor)
{
this.name = name;
this.label = label;
this.type = type;
this.extractor = extractor;
}
/*******************************************************************************
** Map a QQQ operator to an Authentik query parameter for this field.
*******************************************************************************/
public AuthentikFieldSpec withFilter(QCriteriaOperator operator, String param)
{
filterParams.put(operator, param);
return (this);
}
/*******************************************************************************
** Mark as sensitive (needs authentik.audit.read to see or filter on).
*******************************************************************************/
public AuthentikFieldSpec withSensitive()
{
this.sensitive = true;
return (this);
}
/*******************************************************************************
** CONTAINS / STARTS_WITH on this field map to Authentik's search param.
*******************************************************************************/
public AuthentikFieldSpec withSearchable()
{
this.searchable = true;
return (this);
}
/*******************************************************************************
** Allow ordering on this field, by the given Authentik ordering name.
*******************************************************************************/
public AuthentikFieldSpec withOrdering(String orderingName)
{
this.orderingName = orderingName;
return (this);
}
/*******************************************************************************
** Hide the field in the UI (it still exists on records).
*******************************************************************************/
public AuthentikFieldSpec withHidden()
{
this.hidden = true;
return (this);
}
/*******************************************************************************
** Read this field's value from an Authentik object.
*******************************************************************************/
public Serializable extract(JSONObject object)
{
return (extractor.apply(object));
}
/*******************************************************************************
** Getter for name
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Getter for type
*******************************************************************************/
public QFieldType getType()
{
return (type);
}
/*******************************************************************************
** Getter for filterParams
*******************************************************************************/
public Map<QCriteriaOperator, String> getFilterParams()
{
return (filterParams);
}
/*******************************************************************************
** Getter for sensitive
*******************************************************************************/
public boolean isSensitive()
{
return (sensitive);
}
/*******************************************************************************
** Getter for searchable
*******************************************************************************/
public boolean isSearchable()
{
return (searchable);
}
/*******************************************************************************
** Getter for orderingName
*******************************************************************************/
public String getOrderingName()
{
return (orderingName);
}
/*******************************************************************************
** Getter for hidden
*******************************************************************************/
public boolean isHidden()
{
return (hidden);
}
}
@@ -1,481 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.AbstractFilterExpression;
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.utils.CollectionUtils;
import org.apache.http.NameValuePair;
import org.apache.http.message.BasicNameValuePair;
/*******************************************************************************
** Maps a QQQ filter to Authentik query parameters (DESIGN.md section 6.1).
**
** Exactly what Authentik can evaluate server-side is accepted. Everything
** else (OR, sub-filters, NOT_* operators, unmapped fields or operators,
** field-to-field criteria) is REJECTED with a user-facing message: filtering
** client-side over 100-row pages would silently truncate results.
**
** Notes:
** - Authentik date filters are strict (__gt / __lt). GREATER_THAN_OR_EQUALS
** and LESS_THAN_OR_EQUALS are widened by one millisecond; BETWEEN uses both.
** - CONTAINS / STARTS_WITH on a searchable field use Authentik's "search"
** parameter, which matches the term across several fields (a superset of
** the field criterion). Only one search term per query.
** - A filter made only of key EQUALS / IN criteria is returned as a key
** lookup (the caller fetches each record by id), since Authentik has no
** list filter on every table's key.
*******************************************************************************/
public class AuthentikFilterMapper
{
/*******************************************************************************
** Result of mapping a filter.
*******************************************************************************/
public record MappedFilter(List<NameValuePair> params, List<Serializable> keyLookup, Set<String> filteredFields)
{
/*******************************************************************************
** Whether this mapping is a key lookup (fetch-by-id) rather than a list query.
*******************************************************************************/
public boolean isKeyLookup()
{
return (keyLookup != null);
}
}
/*******************************************************************************
** Map a filter for a table.
**
** @param spec the table
** @param filter the QQQ filter (may be null)
** @param mayUseSensitive whether the session may filter on sensitive fields
*******************************************************************************/
public MappedFilter map(AuthentikTableSpec spec, QQueryFilter filter, boolean mayUseSensitive) throws QException
{
List<NameValuePair> params = new ArrayList<>();
Set<String> usedParams = new HashSet<>();
Set<String> filteredFields = new HashSet<>();
if(filter == null)
{
addOrdering(spec, null, params);
return (new MappedFilter(params, null, filteredFields));
}
if(CollectionUtils.nullSafeHasContents(filter.getSubFilters()))
{
throw (new QUserFacingException("Nested filters (sub-filters) are not supported for " + spec.getLabel() + "."));
}
List<QFilterCriteria> criteria = CollectionUtils.nonNullList(filter.getCriteria());
if(criteria.size() > 1 && filter.getBooleanOperator() == QQueryFilter.BooleanOperator.OR)
{
throw (new QUserFacingException("\"Any of\" (OR) filters are not supported for " + spec.getLabel() + "; use \"all of\" (AND)."));
}
List<Serializable> keyLookup = keyLookup(spec, criteria);
if(keyLookup != null)
{
filteredFields.add(spec.getKeyFieldName());
return (new MappedFilter(params, keyLookup, filteredFields));
}
boolean usedSearch = false;
for(QFilterCriteria criterion : criteria)
{
if(criterion == null)
{
continue;
}
if(criterion.getFieldName() == null)
{
throw (new QUserFacingException("A filter criterion without a field is not supported for " + spec.getLabel() + "."));
}
if(criterion.getOtherFieldName() != null || criterion.getFieldFunction() != null)
{
throw (new QUserFacingException("Field-to-field and function criteria are not supported for " + spec.getLabel() + "."));
}
AuthentikFieldSpec field = spec.getField(criterion.getFieldName());
if(field == null)
{
throw (new QUserFacingException("Unknown field for " + spec.getLabel() + ": " + criterion.getFieldName()));
}
if(field.isSensitive() && !mayUseSensitive)
{
throw (new QUserFacingException("You do not have permission to filter " + spec.getLabel() + " by " + field.getLabel() + "."));
}
QCriteriaOperator operator = criterion.getOperator();
List<Serializable> values = evaluateValues(field, criterion.getValues());
filteredFields.add(field.getName());
if((operator == QCriteriaOperator.CONTAINS || operator == QCriteriaOperator.STARTS_WITH) && !field.getFilterParams().containsKey(operator) && field.isSearchable())
{
if(usedSearch)
{
throw (new QUserFacingException("Only one text-search criterion is supported for " + spec.getLabel() + "."));
}
usedSearch = true;
add(params, usedParams, "search", plainSearchTerm(field, singleString(field, operator, values)));
continue;
}
switch(operator)
{
case EQUALS, STARTS_WITH -> add(params, usedParams, param(field, operator), formatValue(field, singleValue(field, operator, values)));
case IN ->
{
String param = param(field, operator);
if(values.isEmpty())
{
throw (new QUserFacingException("An empty \"is any of\" list is not supported for " + field.getLabel() + "."));
}
if(!usedParams.add(param))
{
throw (duplicate(field));
}
for(Serializable value : values)
{
params.add(new BasicNameValuePair(param, formatValue(field, value)));
}
}
case GREATER_THAN, LESS_THAN -> add(params, usedParams, param(field, operator), formatValue(field, singleValue(field, operator, values)));
case GREATER_THAN_OR_EQUALS -> add(params, usedParams, param(field, QCriteriaOperator.GREATER_THAN), formatInstant(shift(field, singleValue(field, operator, values), -1)));
case LESS_THAN_OR_EQUALS -> add(params, usedParams, param(field, QCriteriaOperator.LESS_THAN), formatInstant(shift(field, singleValue(field, operator, values), 1)));
case BETWEEN ->
{
if(values.size() != 2)
{
throw (new QUserFacingException("\"Between\" needs two values for " + field.getLabel() + "."));
}
add(params, usedParams, param(field, QCriteriaOperator.GREATER_THAN), formatInstant(shift(field, values.get(0), -1)));
add(params, usedParams, param(field, QCriteriaOperator.LESS_THAN), formatInstant(shift(field, values.get(1), 1)));
}
case IS_BLANK -> add(params, usedParams, param(field, operator), "true");
case IS_NOT_BLANK -> add(params, usedParams, param(field, operator), "false");
default -> throw (unsupported(field, operator));
}
}
addOrdering(spec, filter.getOrderBys(), params);
return (new MappedFilter(params, null, filteredFields));
}
/*******************************************************************************
** If every criterion is key EQUALS / IN, return the keys; else null.
*******************************************************************************/
private List<Serializable> keyLookup(AuthentikTableSpec spec, List<QFilterCriteria> criteria)
{
if(criteria.isEmpty())
{
return (null);
}
List<Serializable> keys = null;
for(QFilterCriteria criterion : criteria)
{
if(criterion == null || !spec.getKeyFieldName().equals(criterion.getFieldName()) || criterion.getOtherFieldName() != null)
{
return (null);
}
if(criterion.getOperator() != QCriteriaOperator.EQUALS && criterion.getOperator() != QCriteriaOperator.IN)
{
return (null);
}
List<Serializable> these = new ArrayList<>(CollectionUtils.nonNullList(criterion.getValues()));
if(keys == null)
{
keys = these;
}
else
{
keys.retainAll(these);
}
}
return (keys);
}
/*******************************************************************************
**
*******************************************************************************/
private void addOrdering(AuthentikTableSpec spec, List<QFilterOrderBy> orderBys, List<NameValuePair> params) throws QUserFacingException
{
List<String> orderings = new ArrayList<>();
for(QFilterOrderBy orderBy : CollectionUtils.nonNullList(orderBys))
{
AuthentikFieldSpec field = spec.getField(orderBy.getFieldName());
if(field != null && field.getOrderingName() == null && spec.getKeyFieldName().equals(field.getName()))
{
//////////////////////////////////////////////////////////////////
// UIs often sort by the key by default: use the table's default //
//////////////////////////////////////////////////////////////////
continue;
}
if(field == null || field.getOrderingName() == null)
{
throw (new QUserFacingException(spec.getLabel() + " cannot be sorted by " + (field == null ? orderBy.getFieldName() : field.getLabel()) + "."));
}
orderings.add((orderBy.getIsAscending() ? "" : "-") + field.getOrderingName());
}
if(orderings.isEmpty() && spec.getDefaultOrdering() != null)
{
orderings.add(spec.getDefaultOrdering());
}
if(!orderings.isEmpty())
{
params.add(new BasicNameValuePair("ordering", String.join(",", orderings)));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void add(List<NameValuePair> params, Set<String> usedParams, String param, String value) throws QUserFacingException
{
if(!usedParams.add(param))
{
throw (new QUserFacingException("Two criteria map to the same Authentik filter (" + param + "); combine them into one."));
}
params.add(new BasicNameValuePair(param, value));
}
/*******************************************************************************
**
*******************************************************************************/
private static String param(AuthentikFieldSpec field, QCriteriaOperator operator) throws QUserFacingException
{
String param = field.getFilterParams().get(operator);
if(param == null)
{
throw (unsupported(field, operator));
}
return (param);
}
/*******************************************************************************
**
*******************************************************************************/
private static QUserFacingException unsupported(AuthentikFieldSpec field, QCriteriaOperator operator)
{
return (new QUserFacingException("Filtering " + field.getLabel() + " with \"" + operator.name().toLowerCase().replace('_', ' ') + "\" is not supported by Authentik."));
}
/*******************************************************************************
**
*******************************************************************************/
private static QUserFacingException duplicate(AuthentikFieldSpec field)
{
return (new QUserFacingException("Only one criterion per field is supported (" + field.getLabel() + ")."));
}
/*******************************************************************************
**
*******************************************************************************/
private static Serializable singleValue(AuthentikFieldSpec field, QCriteriaOperator operator, List<Serializable> values) throws QUserFacingException
{
if(values.size() != 1 || values.get(0) == null)
{
throw (new QUserFacingException("\"" + operator.name().toLowerCase().replace('_', ' ') + "\" needs exactly one value for " + field.getLabel() + "."));
}
return (values.get(0));
}
/*******************************************************************************
**
*******************************************************************************/
private static String singleString(AuthentikFieldSpec field, QCriteriaOperator operator, List<Serializable> values) throws QUserFacingException
{
String value = String.valueOf(singleValue(field, operator, values)).trim();
if(value.isEmpty())
{
throw (new QUserFacingException("A search term is required for " + field.getLabel() + "."));
}
return (value);
}
/*******************************************************************************
** Authentik's search parameter also accepts a query language (QLSearch),
** which could reach fields the session may not filter on (attributes).
** Only plain text is passed: QL operators, quotes and keywords are refused.
*******************************************************************************/
static String plainSearchTerm(AuthentikFieldSpec field, String term) throws QUserFacingException
{
if(term.matches(".*[=~\"'()<>!\\\\].*") || term.matches("(?i).*\\s(and|or|not|in)\\s.*"))
{
throw (new QUserFacingException("Search " + field.getLabel() + " with plain text only (no operators or quotes)."));
}
return (term);
}
/*******************************************************************************
** Evaluate filter expressions (e.g. "now minus 90 days") into plain values.
*******************************************************************************/
private static List<Serializable> evaluateValues(AuthentikFieldSpec field, List<Serializable> values) throws QException
{
List<Serializable> rs = new ArrayList<>();
for(Serializable value : CollectionUtils.nonNullList(values))
{
if(value instanceof AbstractFilterExpression<?> expression)
{
rs.add(expression.evaluate(new QFieldMetaData(field.getName(), field.getType())));
}
else
{
rs.add(value);
}
}
return (rs);
}
/*******************************************************************************
** Format a value for a query string.
*******************************************************************************/
static String formatValue(AuthentikFieldSpec field, Serializable value) throws QUserFacingException
{
if(field.getType() == QFieldType.DATE_TIME || field.getType() == QFieldType.DATE)
{
return (formatInstant(toInstant(field, value)));
}
if(field.getType() == QFieldType.BOOLEAN)
{
String text = String.valueOf(value).trim().toLowerCase();
if(text.equals("true") || text.equals("1") || text.equals("yes"))
{
return ("true");
}
if(text.equals("false") || text.equals("0") || text.equals("no"))
{
return ("false");
}
throw (new QUserFacingException("Not a valid yes/no value for " + field.getLabel() + ": " + value));
}
return (String.valueOf(value));
}
/*******************************************************************************
**
*******************************************************************************/
private static Instant shift(AuthentikFieldSpec field, Serializable value, long millis) throws QUserFacingException
{
return (toInstant(field, value).plusMillis(millis));
}
/*******************************************************************************
** Convert a date-ish value to an Instant (a date means its start, UTC).
*******************************************************************************/
static Instant toInstant(AuthentikFieldSpec field, Serializable value) throws QUserFacingException
{
if(value instanceof Instant instant)
{
return (instant);
}
if(value instanceof LocalDate localDate)
{
return (localDate.atStartOfDay(ZoneOffset.UTC).toInstant());
}
if(value instanceof java.util.Date date)
{
return (date.toInstant());
}
String text = value == null ? "" : String.valueOf(value).trim();
try
{
return (Instant.parse(text));
}
catch(DateTimeParseException e)
{
try
{
return (AuthentikJson.parseInstant(text));
}
catch(DateTimeParseException e2)
{
try
{
return (LocalDate.parse(text).atStartOfDay(ZoneOffset.UTC).toInstant());
}
catch(DateTimeParseException e3)
{
throw (new QUserFacingException("Not a valid date/time for " + field.getLabel() + ": " + text));
}
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private static String formatInstant(Instant instant)
{
return (instant.toString());
}
}
@@ -1,311 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.io.Serializable;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.function.Function;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Null-safe readers for Authentik JSON objects, used as field extractors.
*******************************************************************************/
public final class AuthentikJson
{
/** Attribute-key prefix the qbit owns (DESIGN.md section 5.2). */
public static final String QQQ_ATTRIBUTE_PREFIX = "qqq.io/";
public static final String ATTR_PERMISSIONS = "qqq.io/permissions";
public static final String ATTR_APP_ACCESS = "qqq.io/app-access";
public static final String ATTR_PROTECTED = "qqq.io/protected";
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private AuthentikJson()
{
}
/*******************************************************************************
** String value, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> string(String key)
{
return (o -> o.has(key) && !o.isNull(key) ? String.valueOf(o.get(key)) : null);
}
/*******************************************************************************
** Integer value, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> integer(String key)
{
return (o -> o.has(key) && !o.isNull(key) ? o.getInt(key) : null);
}
/*******************************************************************************
** Boolean value, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> bool(String key)
{
return (o -> o.has(key) && !o.isNull(key) ? o.getBoolean(key) : null);
}
/*******************************************************************************
** ISO-8601 date-time as an Instant, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> instant(String key)
{
return (o -> parseInstant(o.has(key) && !o.isNull(key) ? o.getString(key) : null));
}
/*******************************************************************************
** A nested object (or array) rendered as JSON text, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> json(String key)
{
return (o -> o.has(key) && !o.isNull(key) ? o.get(key).toString() : null);
}
/*******************************************************************************
** A value at a nested path (e.g. "user", "username"), as a string, or null.
** Objects and arrays are rendered as JSON text.
*******************************************************************************/
public static Function<JSONObject, Serializable> at(String... path)
{
return (o ->
{
Object current = o;
for(String key : path)
{
if(!(current instanceof JSONObject object) || !object.has(key) || object.isNull(key))
{
return (null);
}
current = object.get(key);
}
return (String.valueOf(current));
});
}
/*******************************************************************************
** An integer at a nested path, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> intAt(String... path)
{
Function<JSONObject, Serializable> text = at(path);
return (o ->
{
Serializable value = text.apply(o);
try
{
return (value == null ? null : Integer.valueOf(String.valueOf(value)));
}
catch(NumberFormatException e)
{
return (null);
}
});
}
/*******************************************************************************
** Length of an array value, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> arrayLength(String key)
{
return (o -> o.optJSONArray(key) == null ? null : o.getJSONArray(key).length());
}
/*******************************************************************************
** Comma-joined values of a property across an array of objects, e.g. the
** names in groups_obj. Null when the array is absent.
*******************************************************************************/
public static Function<JSONObject, Serializable> joined(String arrayKey, String property)
{
return (o ->
{
JSONArray array = o.optJSONArray(arrayKey);
if(array == null)
{
return (null);
}
List<String> values = new ArrayList<>();
for(int i = 0; i < array.length(); i++)
{
Object element = array.get(i);
if(element instanceof JSONObject object && object.has(property) && !object.isNull(property))
{
values.add(String.valueOf(object.get(property)));
}
else if(!(element instanceof JSONObject) && property == null)
{
values.add(String.valueOf(element));
}
}
return (String.join(", ", values));
});
}
/*******************************************************************************
** Comma-joined values of a string array inside "attributes", e.g.
** qqq.io/permissions. Null when absent.
*******************************************************************************/
public static Function<JSONObject, Serializable> attributeList(String attributeKey)
{
return (o ->
{
List<String> values = stringList(o.optJSONObject("attributes"), attributeKey);
return (values == null ? null : String.join(", ", values));
});
}
/*******************************************************************************
** Only the qqq.io/* attribute keys, as JSON text (non-sensitive view).
*******************************************************************************/
public static Function<JSONObject, Serializable> qqqAttributes()
{
return (o ->
{
JSONObject attributes = o.optJSONObject("attributes");
if(attributes == null)
{
return (null);
}
JSONObject rs = new JSONObject();
Iterator<String> keys = attributes.keys();
while(keys.hasNext())
{
String key = keys.next();
if(key.startsWith(QQQ_ATTRIBUTE_PREFIX))
{
rs.put(key, attributes.get(key));
}
}
return (rs.isEmpty() ? null : rs.toString());
});
}
/*******************************************************************************
** Read a list of strings from an object property; null when absent, and
** non-string elements are skipped. A single string is treated as a list of
** one (Authentik attributes are free-form JSON).
*******************************************************************************/
public static List<String> stringList(JSONObject object, String key)
{
if(object == null || !object.has(key) || object.isNull(key))
{
return (null);
}
Object value = object.get(key);
List<String> values = new ArrayList<>();
if(value instanceof JSONArray array)
{
for(int i = 0; i < array.length(); i++)
{
if(array.get(i) instanceof String s && !s.isBlank())
{
values.add(s.trim());
}
}
}
else if(value instanceof String s && !s.isBlank())
{
values.add(s.trim());
}
return (values);
}
/*******************************************************************************
** Entries of a list attribute that stringList() drops: a value that is not a
** string or array, or non-string / blank array elements. Empty when fine.
*******************************************************************************/
public static List<String> malformedEntries(JSONObject object, String key)
{
List<String> rs = new ArrayList<>();
if(object == null || !object.has(key) || object.isNull(key))
{
return (rs);
}
Object value = object.get(key);
if(value instanceof JSONArray array)
{
for(int i = 0; i < array.length(); i++)
{
Object element = array.get(i);
if(!(element instanceof String s) || s.isBlank())
{
rs.add(String.valueOf(element));
}
}
}
else if(!(value instanceof String))
{
rs.add(String.valueOf(value));
}
return (rs);
}
/*******************************************************************************
** Parse an Authentik timestamp (ISO-8601 with offset or Z).
*******************************************************************************/
public static Instant parseInstant(String value)
{
if(value == null || value.isBlank())
{
return (null);
}
return (OffsetDateTime.parse(value).toInstant());
}
}
@@ -1,129 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/*******************************************************************************
** Redaction for anything the qbit logs or stores about Authentik calls
** (DESIGN.md section 9.1):
** - the bearer token is never logged (it is replaced wherever it appears);
** - bodies of sensitive endpoints (recovery, tokens, passwords, service-account
** creation, user create) are dropped entirely;
** - in any other body, values of JSON keys that look like secrets (key, token,
** link, password, secret, client_secret) are masked.
*******************************************************************************/
public final class AuthentikRedactor
{
public static final String MASK = "******";
/** Path fragments whose request and response bodies are never logged. */
private static final List<String> SENSITIVE_PATH_FRAGMENTS = List.of(
"/application/o/token/", "/recovery", "/recovery_email", "/set_password", "/set_password_hash", "/core/tokens/", "/service_account", "/impersonate", "/view_key", "/set_key");
private static final Pattern SECRET_JSON_VALUE = Pattern.compile(
"(\"(?:key|token|access_token|subject_token|link|password|secret|client_secret|password_hash|recovery_link)\"\\s*:\\s*)\"((?:[^\"\\\\]|\\\\.)*)\"", Pattern.CASE_INSENSITIVE);
private static final Pattern BEARER = Pattern.compile("(?i)(bearer\\s+)[A-Za-z0-9._~+/=-]+");
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private AuthentikRedactor()
{
}
/*******************************************************************************
** Whether bodies for this path must never be logged.
*******************************************************************************/
public static boolean isSensitivePath(String path)
{
if(path == null)
{
return (false);
}
for(String fragment : SENSITIVE_PATH_FRAGMENTS)
{
if(path.contains(fragment))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** Redact a request or response body for logging.
**
** @param path the request path (decides whether to drop the body)
** @param body the body (may be null)
** @param token the bearer token in use (may be null)
*******************************************************************************/
public static String redactBody(String path, String body, String token)
{
if(body == null)
{
return (null);
}
if(isSensitivePath(path))
{
return ("[body omitted: sensitive endpoint]");
}
return (redactText(body, token));
}
/*******************************************************************************
** Redact free text (exception messages, log lines): the token, bearer
** values and secret-looking JSON values.
*******************************************************************************/
public static String redactText(String text, String token)
{
if(text == null)
{
return (null);
}
String safe = text;
if(token != null && !token.isBlank())
{
safe = safe.replace(token, MASK);
}
safe = BEARER.matcher(safe).replaceAll("$1" + MASK);
Matcher matcher = SECRET_JSON_VALUE.matcher(safe);
StringBuilder rs = new StringBuilder();
while(matcher.find())
{
matcher.appendReplacement(rs, Matcher.quoteReplacement(matcher.group(1) + "\"" + MASK + "\""));
}
matcher.appendTail(rs);
return (rs.toString());
}
}
@@ -1,34 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Supplies the current operator's subject JWT for one token action.
*******************************************************************************/
@FunctionalInterface
public interface AuthentikSubjectTokenSource
{
/*******************************************************************************
** Return a subject JWT without retaining or logging it.
*******************************************************************************/
String subjectToken(QSession session) throws QException;
}
@@ -1,332 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import org.apache.http.NameValuePair;
import org.apache.http.message.BasicNameValuePair;
/*******************************************************************************
** Definition of one Authentik-backed QQQ table: its endpoint, key, fields,
** default query parameters and the permission needed to read it.
*******************************************************************************/
public class AuthentikTableSpec
{
private final String name;
private final String label;
private final String icon;
private final String path;
private final String keyFieldName;
private final Map<String, AuthentikFieldSpec> fields = new LinkedHashMap<>();
private final List<NameValuePair> defaultListParams = new ArrayList<>();
private final List<NameValuePair> defaultGetParams = new ArrayList<>();
private IdentityPermission alternativeReadPermission;
private IdentityPermission readPermission = IdentityPermission.USERS_READ;
private String defaultOrdering;
private List<String> recordLabelFields = new ArrayList<>();
private String requiredFilterField;
private boolean virtual;
/*******************************************************************************
** Constructor.
**
** @param name unprefixed table name, e.g. "user"
** @param label UI label
** @param icon material icon name
** @param path API path below /api/v3, with leading and trailing slash
** @param keyFieldName name of the primary-key field
*******************************************************************************/
public AuthentikTableSpec(String name, String label, String icon, String path, String keyFieldName)
{
this.name = name;
this.label = label;
this.icon = icon;
this.path = path;
this.keyFieldName = keyFieldName;
}
/*******************************************************************************
** Add a field.
*******************************************************************************/
public AuthentikTableSpec withField(AuthentikFieldSpec field)
{
fields.put(field.getName(), field);
return (this);
}
/*******************************************************************************
** Add a query parameter sent with every list request.
*******************************************************************************/
public AuthentikTableSpec withDefaultListParam(String param, String value)
{
defaultListParams.add(new BasicNameValuePair(param, value));
return (this);
}
/*******************************************************************************
** Add a query parameter sent with every single-record GET.
*******************************************************************************/
public AuthentikTableSpec withDefaultGetParam(String param, String value)
{
defaultGetParams.add(new BasicNameValuePair(param, value));
return (this);
}
/*******************************************************************************
** Set the permission needed to read the table.
*******************************************************************************/
public AuthentikTableSpec withReadPermission(IdentityPermission readPermission)
{
this.readPermission = readPermission;
return (this);
}
/*******************************************************************************
** Set the Authentik ordering used when the query has no orderBy.
*******************************************************************************/
public AuthentikTableSpec withDefaultOrdering(String defaultOrdering)
{
this.defaultOrdering = defaultOrdering;
return (this);
}
/*******************************************************************************
** Set the record label fields.
*******************************************************************************/
public AuthentikTableSpec withRecordLabelFields(String... recordLabelFields)
{
this.recordLabelFields = new ArrayList<>(List.of(recordLabelFields));
return (this);
}
/*******************************************************************************
** Require a criterion on this field for every query (e.g. MFA devices are
** only listed per user).
*******************************************************************************/
public AuthentikTableSpec withRequiredFilterField(String requiredFilterField)
{
this.requiredFilterField = requiredFilterField;
return (this);
}
/*******************************************************************************
** Mark as virtual: served by custom logic in AuthentikActionUtil rather than
** by one list endpoint (its filter "params" are field names, not Authentik
** query parameters).
*******************************************************************************/
public AuthentikTableSpec withVirtual()
{
this.virtual = true;
return (this);
}
/*******************************************************************************
** Getter for virtual
*******************************************************************************/
public boolean isVirtual()
{
return (virtual);
}
/*******************************************************************************
** Get a field spec, or null.
*******************************************************************************/
public AuthentikFieldSpec getField(String fieldName)
{
return (fields.get(fieldName));
}
/*******************************************************************************
** All field specs, in display order.
*******************************************************************************/
public Collection<AuthentikFieldSpec> getFields()
{
return (fields.values());
}
/*******************************************************************************
** Getter for name
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Getter for icon
*******************************************************************************/
public String getIcon()
{
return (icon);
}
/*******************************************************************************
** Getter for path
*******************************************************************************/
public String getPath()
{
return (path);
}
/*******************************************************************************
** Getter for keyFieldName
*******************************************************************************/
public String getKeyFieldName()
{
return (keyFieldName);
}
/*******************************************************************************
** Getter for defaultListParams
*******************************************************************************/
public List<NameValuePair> getDefaultListParams()
{
return (defaultListParams);
}
/*******************************************************************************
** Getter for defaultGetParams
*******************************************************************************/
public List<NameValuePair> getDefaultGetParams()
{
return (defaultGetParams);
}
/*******************************************************************************
** Getter for readPermission
*******************************************************************************/
public IdentityPermission getReadPermission()
{
return (readPermission);
}
/*******************************************************************************
** Getter for defaultOrdering
*******************************************************************************/
public String getDefaultOrdering()
{
return (defaultOrdering);
}
/*******************************************************************************
** Getter for recordLabelFields
*******************************************************************************/
public List<String> getRecordLabelFields()
{
return (recordLabelFields);
}
/*******************************************************************************
** Getter for requiredFilterField
*******************************************************************************/
public String getRequiredFilterField()
{
return (requiredFilterField);
}
/*******************************************************************************
** Add a second permission that also permits reads.
*******************************************************************************/
public AuthentikTableSpec withAlternativeReadPermission(IdentityPermission permission)
{
alternativeReadPermission = permission;
return (this);
}
/*******************************************************************************
** Getter for the optional alternative permission.
*******************************************************************************/
public IdentityPermission getAlternativeReadPermission()
{
return (alternativeReadPermission);
}
/*******************************************************************************
** Token tables need OR rules in core as well as in the API guard.
*******************************************************************************/
public com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules readRules()
{
return (alternativeReadPermission == null ? readPermission.rules() : com.kingsrook.qbits.authentik.security.TokenReadPermission.rules());
}
}
@@ -1,452 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.at;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.attributeList;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.bool;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.instant;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.intAt;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.integer;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.joined;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.json;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.qqqAttributes;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.string;
/*******************************************************************************
** The Authentik-backed tables (DESIGN.md section 6.1), mapped against the
** pinned 2026.8.3 schema (src/test/resources/authentik/schema-2026.8.3.yml).
**
** Multi-value query parameters are sent as repeated plain names
** (type=internal&type=external). Authentik silently IGNORES the bracketed
** form (type[]=internal), returning unfiltered results (phase 0 finding).
*******************************************************************************/
public final class AuthentikTables
{
public static final String USER = "user";
public static final String GROUP = "group";
public static final String GROUP_MEMBER = "groupMember";
public static final String SESSION = "session";
public static final String EVENT = "event";
public static final String APPLICATION = "application";
public static final String POLICY_BINDING = "policyBinding";
public static final String BLUEPRINT = "blueprint";
public static final String ROLE = "role";
public static final String TOKEN = "token";
public static final String INVITATION = "invitation";
/*******************************************************************************
** Table names produced before the owner naming rule (2026-10-12), with the
** default prefix. Kept only to find stored references (permissions, saved
** views, audit table names) when upgrading; see UPGRADE.md.
*******************************************************************************/
@Deprecated
public static final String LEGACY_USER_TABLE_NAME = "authentikUser";
@Deprecated
public static final String LEGACY_GROUP_TABLE_NAME = "authentikGroup";
@Deprecated
public static final String LEGACY_GROUP_MEMBER_TABLE_NAME = "authentikGroupMember";
@Deprecated
public static final String LEGACY_SESSION_TABLE_NAME = "authentikSession";
@Deprecated
public static final String LEGACY_EVENT_TABLE_NAME = "authentikEvent";
/** User types the qbit shows by default: everything but outposts' internal service accounts. */
public static final String[] DEFAULT_USER_TYPES = { "internal", "external", "service_account" };
private static final Map<String, AuthentikTableSpec> SPECS = new LinkedHashMap<>();
static
{
register(users());
register(groups());
register(groupMembers());
register(sessions());
register(events());
register(applications());
register(policyBindings());
register(blueprints());
register(roles());
register(tokens());
register(invitations());
}
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private AuthentikTables()
{
}
/*******************************************************************************
** Look up a spec by unprefixed table name, or null.
*******************************************************************************/
public static AuthentikTableSpec get(String name)
{
return (SPECS.get(name));
}
/*******************************************************************************
** All specs.
*******************************************************************************/
public static Map<String, AuthentikTableSpec> all()
{
return (SPECS);
}
/*******************************************************************************
**
*******************************************************************************/
private static void register(AuthentikTableSpec spec)
{
SPECS.put(spec.getName(), spec);
}
/*******************************************************************************
** /core/users/ (int pk). The detail GET takes no include_* parameters and
** always returns groups_obj and roles_obj.
*******************************************************************************/
static AuthentikTableSpec users()
{
AuthentikTableSpec spec = new AuthentikTableSpec(USER, "Users", "person", "/core/users/", "pk")
.withDefaultListParam("include_groups", "true")
.withDefaultListParam("include_roles", "true")
.withDefaultOrdering("username")
.withRecordLabelFields("username");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.INTEGER, integer("pk")));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, string("username"))
.withFilter(QCriteriaOperator.EQUALS, "username").withSearchable().withOrdering("username"));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name"))
.withFilter(QCriteriaOperator.EQUALS, "name").withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, string("email"))
.withFilter(QCriteriaOperator.EQUALS, "email").withSearchable().withOrdering("email"));
spec.withField(new AuthentikFieldSpec("isActive", "Active", QFieldType.BOOLEAN, bool("is_active"))
.withFilter(QCriteriaOperator.EQUALS, "is_active").withOrdering("is_active"));
spec.withField(new AuthentikFieldSpec("type", "Type", QFieldType.STRING, string("type"))
.withFilter(QCriteriaOperator.EQUALS, "type").withFilter(QCriteriaOperator.IN, "type"));
spec.withField(new AuthentikFieldSpec("isSuperuser", "Superuser", QFieldType.BOOLEAN, bool("is_superuser"))
.withFilter(QCriteriaOperator.EQUALS, "is_superuser"));
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", "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")
.withFilter(QCriteriaOperator.IS_BLANK, "last_login__isnull").withFilter(QCriteriaOperator.IS_NOT_BLANK, "last_login__isnull")
.withOrdering("last_login"));
spec.withField(new AuthentikFieldSpec("dateJoined", "Date Joined", QFieldType.DATE_TIME, instant("date_joined"))
.withFilter(QCriteriaOperator.GREATER_THAN, "date_joined__gt").withFilter(QCriteriaOperator.LESS_THAN, "date_joined__lt")
.withOrdering("date_joined"));
spec.withField(new AuthentikFieldSpec("lastUpdated", "Last Updated", QFieldType.DATE_TIME, instant("last_updated"))
.withFilter(QCriteriaOperator.GREATER_THAN, "last_updated__gt").withFilter(QCriteriaOperator.LESS_THAN, "last_updated__lt"));
spec.withField(new AuthentikFieldSpec("path", "Path", QFieldType.STRING, string("path"))
.withFilter(QCriteriaOperator.EQUALS, "path").withFilter(QCriteriaOperator.STARTS_WITH, "path_startswith"));
spec.withField(new AuthentikFieldSpec("uuid", "UUID", QFieldType.STRING, string("uuid"))
.withFilter(QCriteriaOperator.EQUALS, "uuid").withHidden());
spec.withField(new AuthentikFieldSpec("qqqAttributes", "QQQ Attributes", QFieldType.TEXT, qqqAttributes()));
spec.withField(new AuthentikFieldSpec("effectivePermissions", "Effective QQQ Permissions", QFieldType.TEXT, o -> null));
spec.withField(new AuthentikFieldSpec("passwordChangeDate", "Password Changed", QFieldType.DATE_TIME, instant("password_change_date"))
.withSensitive());
spec.withField(new AuthentikFieldSpec("attributes", "Attributes", QFieldType.TEXT, json("attributes"))
.withSensitive());
return (spec);
}
/*******************************************************************************
** /core/groups/ (uuid pk). Note: Group has no last_updated in 2026.8.3.
*******************************************************************************/
static AuthentikTableSpec groups()
{
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")
.withDefaultGetParam("include_users", "true")
.withDefaultGetParam("include_parents", "true")
.withDefaultGetParam("include_children", "true")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
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));
spec.withField(new AuthentikFieldSpec("parentNames", "Parents", QFieldType.STRING, joined("parents_obj", "name")));
spec.withField(new AuthentikFieldSpec("childNames", "Children", QFieldType.STRING, joined("children_obj", "name")));
spec.withField(new AuthentikFieldSpec("memberCount", "Members", QFieldType.INTEGER, AuthentikJson.arrayLength("users")));
spec.withField(new AuthentikFieldSpec("memberUsername", "Member Username", QFieldType.STRING, o -> null)
.withFilter(QCriteriaOperator.EQUALS, "members_by_username").withFilter(QCriteriaOperator.IN, "members_by_username").withHidden());
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", "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());
return (spec);
}
/*******************************************************************************
** Virtual membership table (group x user). Served by AuthentikActionUtil
** from GET /core/groups/{pk}/?include_users=true, GET /core/users/{pk}/
** (groups_obj) or a group sweep; rows are synthetic objects keyed by these
** field names. Key: "groupPk:userPk".
*******************************************************************************/
static AuthentikTableSpec groupMembers()
{
AuthentikTableSpec spec = new AuthentikTableSpec(GROUP_MEMBER, "Group Memberships", "group_add", "/core/groups/", "id")
.withVirtual()
.withDefaultOrdering("groupName")
.withRecordLabelFields("groupName", "username");
spec.withField(new AuthentikFieldSpec("id", "Id", QFieldType.STRING, string("id")));
spec.withField(new AuthentikFieldSpec("groupName", "Group", QFieldType.STRING, string("groupName"))
.withFilter(QCriteriaOperator.EQUALS, "groupName").withOrdering("groupName"));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, string("username"))
.withFilter(QCriteriaOperator.EQUALS, "username").withOrdering("username"));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")));
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, string("email")));
spec.withField(new AuthentikFieldSpec("isActive", "Active", QFieldType.BOOLEAN, bool("isActive")));
spec.withField(new AuthentikFieldSpec("groupPk", "Group Id", QFieldType.STRING, string("groupPk"))
.withFilter(QCriteriaOperator.EQUALS, "groupPk").withFilter(QCriteriaOperator.IN, "groupPk"));
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, integer("userPk"))
.withFilter(QCriteriaOperator.EQUALS, "userPk"));
return (spec);
}
/*******************************************************************************
** /core/authenticated_sessions/ (uuid). IPs and user agents need audit.read.
*******************************************************************************/
static AuthentikTableSpec sessions()
{
AuthentikTableSpec spec = new AuthentikTableSpec(SESSION, "Sessions", "devices", "/core/authenticated_sessions/", "uuid")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withRecordLabelFields("uuid");
spec.withField(new AuthentikFieldSpec("uuid", "Id", QFieldType.STRING, string("uuid")));
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, integer("user")));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, o -> null)
.withFilter(QCriteriaOperator.EQUALS, "user__username").withHidden());
spec.withField(new AuthentikFieldSpec("lastIp", "Last IP", QFieldType.STRING, string("last_ip"))
.withFilter(QCriteriaOperator.EQUALS, "session__last_ip"));
spec.withField(new AuthentikFieldSpec("lastUserAgent", "Last User Agent", QFieldType.TEXT, string("last_user_agent")));
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, instant("last_used")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("current", "Current", QFieldType.BOOLEAN, bool("current")));
spec.withField(new AuthentikFieldSpec("geoIp", "Geo IP", QFieldType.TEXT, json("geo_ip")));
spec.withField(new AuthentikFieldSpec("asn", "ASN", QFieldType.TEXT, json("asn")));
return (spec);
}
/*******************************************************************************
** /events/events/ (uuid). Event contexts and client IPs need audit.read.
*******************************************************************************/
static AuthentikTableSpec events()
{
AuthentikTableSpec spec = new AuthentikTableSpec(EVENT, "Events", "receipt_long", "/events/events/", "pk")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withDefaultOrdering("-created")
.withRecordLabelFields("action", "created");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("created", "Created", QFieldType.DATE_TIME, instant("created")).withOrdering("created"));
spec.withField(new AuthentikFieldSpec("action", "Action", QFieldType.STRING, string("action"))
.withFilter(QCriteriaOperator.EQUALS, "action").withFilter(QCriteriaOperator.IN, "actions"));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, at("user", "username"))
.withFilter(QCriteriaOperator.EQUALS, "username"));
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, intAt("user", "pk")));
spec.withField(new AuthentikFieldSpec("app", "App", QFieldType.STRING, string("app")));
spec.withField(new AuthentikFieldSpec("clientIp", "Client IP", QFieldType.STRING, string("client_ip"))
.withFilter(QCriteriaOperator.EQUALS, "client_ip"));
spec.withField(new AuthentikFieldSpec("modelApp", "Model App", QFieldType.STRING, at("context", "model", "app"))
.withFilter(QCriteriaOperator.EQUALS, "context_model_app"));
spec.withField(new AuthentikFieldSpec("modelName", "Model", QFieldType.STRING, at("context", "model", "model_name"))
.withFilter(QCriteriaOperator.EQUALS, "context_model_name"));
spec.withField(new AuthentikFieldSpec("modelPk", "Model Id", QFieldType.STRING, at("context", "model", "pk"))
.withFilter(QCriteriaOperator.EQUALS, "context_model_pk"));
spec.withField(new AuthentikFieldSpec("modelLabel", "Object", QFieldType.STRING, at("context", "model", "name")));
spec.withField(new AuthentikFieldSpec("authorizedApp", "Authorized App", QFieldType.STRING, at("context", "authorized_application", "slug"))
.withFilter(QCriteriaOperator.EQUALS, "context_authorized_app"));
spec.withField(new AuthentikFieldSpec("context", "Context", QFieldType.TEXT, json("context")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
return (spec);
}
/*******************************************************************************
** /core/applications/ (slug). Read only. A non-superuser caller (the service
** account) sees only applications it can itself access, so the qbit never
** relies on this list for access decisions (DESIGN.md 0.2 iii).
*******************************************************************************/
static AuthentikTableSpec applications()
{
AuthentikTableSpec spec = new AuthentikTableSpec(APPLICATION, "Applications", "apps", "/core/applications/", "slug")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("slug", "Slug", QFieldType.STRING, string("slug")).withFilter(QCriteriaOperator.EQUALS, "slug"));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("group", "Group", QFieldType.STRING, string("group")).withFilter(QCriteriaOperator.EQUALS, "group"));
spec.withField(new AuthentikFieldSpec("providerName", "Provider", QFieldType.STRING, at("provider_obj", "name")));
spec.withField(new AuthentikFieldSpec("policyEngineMode", "Policy Engine Mode", QFieldType.STRING, string("policy_engine_mode")));
spec.withField(new AuthentikFieldSpec("launchUrl", "Launch URL", QFieldType.STRING, string("launch_url")));
spec.withField(new AuthentikFieldSpec("publisher", "Publisher", QFieldType.STRING, string("meta_publisher")).withFilter(QCriteriaOperator.EQUALS, "meta_publisher"));
spec.withField(new AuthentikFieldSpec("hiddenFromLibrary", "Hidden", QFieldType.BOOLEAN, bool("meta_hide")));
spec.withField(new AuthentikFieldSpec("bindingTarget", "Binding Target", QFieldType.STRING, string("pbm_uuid")).withHidden());
return (spec);
}
/*******************************************************************************
** /policies/bindings/ (uuid): who or what an application's access policy
** binds. Read only.
*******************************************************************************/
static AuthentikTableSpec policyBindings()
{
AuthentikTableSpec spec = new AuthentikTableSpec(POLICY_BINDING, "Policy Bindings", "rule", "/policies/bindings/", "pk")
.withDefaultOrdering("order")
.withRecordLabelFields("pk");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("target", "Target Id", QFieldType.STRING, string("target")).withFilter(QCriteriaOperator.EQUALS, "target").withHidden());
spec.withField(new AuthentikFieldSpec("policyName", "Policy", QFieldType.STRING, at("policy_obj", "name")));
spec.withField(new AuthentikFieldSpec("groupName", "Group", QFieldType.STRING, at("group_obj", "name")));
spec.withField(new AuthentikFieldSpec("username", "User", QFieldType.STRING, at("user_obj", "username")));
spec.withField(new AuthentikFieldSpec("negate", "Negate", QFieldType.BOOLEAN, bool("negate")));
spec.withField(new AuthentikFieldSpec("enabled", "Enabled", QFieldType.BOOLEAN, bool("enabled")).withFilter(QCriteriaOperator.EQUALS, "enabled"));
spec.withField(new AuthentikFieldSpec("order", "Order", QFieldType.INTEGER, integer("order")).withFilter(QCriteriaOperator.EQUALS, "order").withOrdering("order"));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
return (spec);
}
/*******************************************************************************
** /managed/blueprints/ (uuid). Read only; the drift report uses it too.
*******************************************************************************/
static AuthentikTableSpec blueprints()
{
AuthentikTableSpec spec = new AuthentikTableSpec(BLUEPRINT, "Blueprints", "description", "/managed/blueprints/", "pk")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("path", "Path", QFieldType.STRING, string("path")).withFilter(QCriteriaOperator.EQUALS, "path"));
spec.withField(new AuthentikFieldSpec("status", "Status", QFieldType.STRING, string("status")));
spec.withField(new AuthentikFieldSpec("enabled", "Enabled", QFieldType.BOOLEAN, bool("enabled")));
spec.withField(new AuthentikFieldSpec("lastApplied", "Last Applied", QFieldType.DATE_TIME, instant("last_applied")));
return (spec);
}
/*******************************************************************************
** /rbac/roles/ (uuid). Read only in v1 (role management is phase 4);
** system-managed roles are hidden.
*******************************************************************************/
static AuthentikTableSpec roles()
{
AuthentikTableSpec spec = new AuthentikTableSpec(ROLE, "Roles", "admin_panel_settings", "/rbac/roles/", "pk")
.withDefaultListParam("managed__isnull", "true")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
return (spec);
}
/*******************************************************************************
** /core/tokens/ (identifier). Metadata only: there is NO key field, and the
** service account never reads token keys. Auditors and token managers may read.
*******************************************************************************/
static AuthentikTableSpec tokens()
{
AuthentikTableSpec spec = new AuthentikTableSpec(TOKEN, "Tokens", "key", "/core/tokens/", "identifier")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withAlternativeReadPermission(IdentityPermission.TOKENS_MANAGE)
.withDefaultOrdering("identifier")
.withRecordLabelFields("identifier");
spec.withField(new AuthentikFieldSpec("identifier", "Identifier", QFieldType.STRING, string("identifier")).withSearchable().withOrdering("identifier"));
spec.withField(new AuthentikFieldSpec("intent", "Intent", QFieldType.STRING, string("intent")).withFilter(QCriteriaOperator.EQUALS, "intent"));
spec.withField(new AuthentikFieldSpec("username", "User", QFieldType.STRING, at("user_obj", "username")).withFilter(QCriteriaOperator.EQUALS, "user__username"));
spec.withField(new AuthentikFieldSpec("description", "Description", QFieldType.STRING, string("description")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("expiring", "Expiring", QFieldType.BOOLEAN, bool("expiring")).withFilter(QCriteriaOperator.EQUALS, "expiring"));
spec.withField(new AuthentikFieldSpec("ownerPk", "Owner Id", QFieldType.INTEGER, intAt("user_obj", "pk")));
spec.withField(new AuthentikFieldSpec("ownerType", "Owner Type", QFieldType.STRING, at("user_obj", "type")));
spec.withField(new AuthentikFieldSpec("ownerName", "Owner", QFieldType.STRING, at("user_obj", "name")));
spec.withField(new AuthentikFieldSpec("managed", "Managed", QFieldType.STRING, string("managed")));
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, o -> null));
spec.withField(new AuthentikFieldSpec("lastUsedNote", "Last Used Note", QFieldType.STRING, o -> null));
spec.withField(new AuthentikFieldSpec("createdAt", "Created", QFieldType.DATE_TIME, o -> null));
spec.withField(new AuthentikFieldSpec("scopeSummary", "Scope", QFieldType.STRING, o -> null));
return (spec);
}
/*******************************************************************************
** /stages/invitation/invitations/ (uuid). Created by the Invite User action;
** cancelled by Cancel Invitation.
*******************************************************************************/
static AuthentikTableSpec invitations()
{
AuthentikTableSpec spec = new AuthentikTableSpec(INVITATION, "Invitations", "mail", "/stages/invitation/invitations/", "pk")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, at("fixed_data", "email")));
spec.withField(new AuthentikFieldSpec("singleUse", "Single Use", QFieldType.BOOLEAN, bool("single_use")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("flowSlug", "Flow", QFieldType.STRING, at("flow_obj", "slug")));
spec.withField(new AuthentikFieldSpec("createdBy", "Created By", QFieldType.STRING, at("created_by", "username")));
return (spec);
}
}
@@ -1,112 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Non-secret local metadata and the most recent app-password login per token.
*******************************************************************************/
public final class AuthentikTokenActivity
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikTokenActivity()
{
}
/*******************************************************************************
** The pinned schema has no created__gt. Walk descending pages until the
** 90-day boundary instead of sending a silently ignored query parameter.
*******************************************************************************/
public static Map<String, Instant> lastUsed(AuthentikActionUtil util) throws QException
{
return (util.getRuntime().getTokenActivityCache().getResultThrowing("login", key ->
{
Map<String, Instant> latest = new LinkedHashMap<>();
Instant cutoff = Instant.now().minus(90, ChronoUnit.DAYS);
for(int page = 1; ; page++)
{
String path = "/events/events/?action=login&ordering=-created&page=" + page + "&page_size=100";
util.getRuntime().getResponseCache().clearKey(util.getRuntime().getConfig().getApiRoot() + path);
JSONObject response = util.getApiJson(path);
JSONArray events = response.getJSONArray("results");
for(int i = 0; i < events.length(); i++)
{
JSONObject event = events.getJSONObject(i);
Instant at = Instant.parse(event.getString("created"));
if(at.isBefore(cutoff))
{
return (Map.copyOf(latest));
}
JSONObject context = event.optJSONObject("context");
if("login".equals(event.optString("action")) && context != null && "token".equals(context.optString("method")))
{
JSONObject args = context.optJSONObject("method_args");
String identifier = args == null ? "" : args.optString("identifier");
if(!identifier.isBlank())
{
latest.merge(identifier, at, (a, b) -> a.isAfter(b) ? a : b);
}
}
}
if(events.isEmpty() || page >= response.getJSONObject("pagination").getInt("total_pages"))
{
return (Map.copyOf(latest));
}
}
}).orElseThrow());
}
/*******************************************************************************
** Enrich only token records, never leaking token keys into the table.
*******************************************************************************/
public static void enrich(AuthentikActionUtil util, QRecord record) throws QException
{
var config = util.getRuntime().getConfig();
QRecord scope = QContext.getQInstance().getTable(config.applyTablePrefix("token_scope")) == null ? null
: new TokenScopeStore(config).find(record.getValueString("identifier"));
record.setValue("createdAt", scope == null ? null : scope.getValue("createdAt"));
record.setValue("scopeSummary", TokenScopeStore.summary(scope));
if("api".equals(record.getValueString("intent")))
{
record.setValue("lastUsedNote", "not tracked");
record.setDisplayValue("lastUsed", "not tracked");
}
else
{
record.setValue("lastUsed", lastUsed(util).get(record.getValueString("identifier")));
record.setValue("lastUsedNote", record.getValue("lastUsed") == null ? "No login in the last 90 days" : null);
}
}
}
@@ -1,101 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.nio.charset.StandardCharsets;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.json.JSONObject;
/*******************************************************************************
** RFC 8693 exchange for a two-minute API bearer, never cached or logged.
*******************************************************************************/
public class AuthentikTokenBroker
{
/*******************************************************************************
** Exchange the operator's JWT through the configured protected-user broker.
*******************************************************************************/
public String exchange(AuthentikActionUtil util, WriteAuthorization auth) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
String subject = config.getSubjectTokenSource().subjectToken(QContext.getQSession());
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
request.setEntity(new UrlEncodedFormEntity(List.of(
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret()),
new BasicNameValuePair("subject_token", subject),
new BasicNameValuePair("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"),
new BasicNameValuePair("scope", "goauthentik.io/api")), StandardCharsets.UTF_8));
JSONObject result = util.exchangeToken(auth, request);
String bearer = result.optString("access_token", "");
if(bearer.isBlank())
{
throw (new QUserFacingException("Authentik refused to act as you (invalid_response)"));
}
return (bearer);
}
/*******************************************************************************
** Prove the broker's client credentials with the grant it actually uses
** (token exchange), without a subject token, so nothing is issued and no
** user data is sent. Authentik checks the grant type, then the client
** secret, then the subject: invalid_request (missing subject) means both
** passed; invalid_client means a bad secret; invalid_grant means the
** provider does not allow token exchange.
*******************************************************************************/
public void probeCredentials(AuthentikActionUtil util) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
request.setEntity(new UrlEncodedFormEntity(List.of(
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret())), StandardCharsets.UTF_8));
String code;
try
{
util.requestBrokerCredentials(request);
code = "issued";
}
catch(BrokerRefusedException e)
{
code = e.getErrorCode();
}
switch(code)
{
case "invalid_request" ->
{
return;
}
case "invalid_client" -> throw (new QUserFacingException("Authentik rejected the token broker's client credentials"));
case "invalid_grant", "unsupported_grant_type" -> throw (new QUserFacingException("The token broker's Authentik provider does not allow token exchange"));
default -> throw (new QUserFacingException("Authentik answered the token broker probe unexpectedly (" + code + ")"));
}
}
}
@@ -1,51 +0,0 @@
/*
* 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);
}
}
@@ -1,75 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Reads the OAuth2 module's user-session row (uuid and accessToken in RC.2).
** No memoization: the credential lives only for the current action.
*******************************************************************************/
public class OAuth2SubjectTokenSource implements AuthentikSubjectTokenSource
{
public static final String UNAVAILABLE = "Self-service tokens need a signed-in Authentik session.";
/*******************************************************************************
** Look up the exact current session, including its hidden access token.
*******************************************************************************/
@Override
public String subjectToken(QSession session) throws QException
{
if(session != null && session == QContext.getQSession() && !(session instanceof QSystemUserSession) && session.getUser() != null && session.getUuid() != null && QContext.getQInstance() != null
&& QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth
&& oauth.getUserSessionTableName() != null)
{
QRecord row;
QSession previous = QContext.getQSession();
try
{
// Core uses a system read here too, avoiding recursive OAuth validation.
// The UUID is taken only from the already authorized current session.
QContext.setQSession(new QSystemUserSession());
row = new GetAction().executeForRecord(new GetInput(oauth.getUserSessionTableName())
.withUniqueKey(Map.of("uuid", session.getUuid())).withShouldMaskPasswords(false).withShouldOmitHiddenFields(false));
}
catch(QException e)
{
throw (new QUserFacingException(UNAVAILABLE));
}
finally
{
QContext.setQSession(previous);
}
if(row != null && row.getValueString("accessToken") != null && !row.getValueString("accessToken").isBlank())
{
return (row.getValueString("accessToken"));
}
}
throw (new QUserFacingException(UNAVAILABLE));
}
}
@@ -1,159 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.util.function.LongConsumer;
import java.util.function.LongSupplier;
/*******************************************************************************
** Self-imposed client-side rate limit (DESIGN.md section 9.3). Authentik does
** not throttle authenticated API calls at all, and an Authentik may share the
** Postgres of other applications, so the qbit limits itself: a token bucket refilled at
** requestsPerSecond, holding at most burst tokens. acquire() blocks until a
** token is available. Shared by all requests of one qbit instance.
*******************************************************************************/
public class TokenBucketThrottle
{
private final double tokensPerNano;
private final double burst;
private final LongSupplier nanoClock;
private final LongConsumer nanoSleeper;
private double tokens;
private long lastRefillNanos;
/*******************************************************************************
** Constructor using the system clock.
*******************************************************************************/
public TokenBucketThrottle(int requestsPerSecond, int burst)
{
this(requestsPerSecond, burst, System::nanoTime, TokenBucketThrottle::sleepNanos);
}
/*******************************************************************************
** Constructor with an injectable clock and sleeper (for tests).
*******************************************************************************/
public TokenBucketThrottle(int requestsPerSecond, int burst, LongSupplier nanoClock, LongConsumer nanoSleeper)
{
if(requestsPerSecond < 1)
{
throw (new IllegalArgumentException("requestsPerSecond must be at least 1"));
}
this.tokensPerNano = requestsPerSecond / 1_000_000_000.0;
this.burst = Math.max(1, burst);
this.nanoClock = nanoClock;
this.nanoSleeper = nanoSleeper;
this.tokens = this.burst;
this.lastRefillNanos = nanoClock.getAsLong();
}
/*******************************************************************************
** Take one token, waiting as long as needed.
*******************************************************************************/
public void acquire()
{
long waitNanos;
synchronized(this)
{
refill();
tokens -= 1;
waitNanos = tokens >= 0 ? 0 : (long) Math.ceil(-tokens / tokensPerNano);
}
if(waitNanos > 0)
{
nanoSleeper.accept(waitNanos);
}
}
/*******************************************************************************
** 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).
*******************************************************************************/
public synchronized double getAvailableTokens()
{
refill();
return (tokens);
}
/*******************************************************************************
**
*******************************************************************************/
private void refill()
{
long now = nanoClock.getAsLong();
long elapsed = now - lastRefillNanos;
if(elapsed > 0)
{
tokens = Math.min(burst, tokens + elapsed * tokensPerNano);
lastRefillNanos = now;
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void sleepNanos(long nanos)
{
try
{
Thread.sleep(nanos / 1_000_000, (int) (nanos % 1_000_000));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
}
@@ -1,509 +0,0 @@
/*
* 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.drift;
import java.util.ArrayList;
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;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONArray;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The identity drift report (DESIGN.md sections 4.5, 5.1 and 8.2): checks
** that Authentik is in the shape the qbit relies on, using only the service
** account's read permissions. Each check is independent; a check that cannot
** run reports why and the rest still run.
**
** Checks:
** 1. version: server major.minor = expectedServerVersion
** 2. blueprints: every blueprint instance applied successfully
** 3. service account: exists, is a service account, has no groups, is not a
** superuser, is protected; exactly one API token (it can mint its own)
** 4. break-glass: at least one active, protected superuser exists
** 5. identity admins: at least one active, unprotected human holds both
** authentik.memberships.manage and authentik.permissions.manage (last-admin)
** 6. group attributes: qqq.io/permissions entries that grant nothing known;
** qqq.io/app-access entries outside applicationsInScope; superuser groups
** (or their descendants) carrying qqq.io/permissions
** 7. service-account activity: events by the service account touching a
** superuser or protected user, or a superuser group (lockout detection)
*******************************************************************************/
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;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AuthentikDriftReport(AuthentikActionUtil client, AuthentikQBitConfig config)
{
this.client = client;
this.config = config;
this.guard = new AuthentikGuard(config);
}
/*******************************************************************************
** Run every check; findings sorted by severity (errors first).
*******************************************************************************/
public List<DriftFinding> run()
{
List<DriftFinding> findings = new ArrayList<>();
runCheck(findings, "version", this::checkVersion);
runCheck(findings, "blueprints", this::checkBlueprints);
runCheck(findings, "service account", this::checkServiceAccount);
runCheck(findings, "break-glass", this::checkBreakGlass);
runCheck(findings, "identity admins", this::checkIdentityAdmins);
runCheck(findings, "group attributes", this::checkGroupAttributes);
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
findings.sort(Comparator.comparing(DriftFinding::severity));
return (findings);
}
/*******************************************************************************
** 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.
*******************************************************************************/
@FunctionalInterface
interface Check
{
/*******************************************************************************
**
*******************************************************************************/
void run(List<DriftFinding> findings) throws QException;
}
/*******************************************************************************
**
*******************************************************************************/
private void runCheck(List<DriftFinding> findings, String name, Check check)
{
try
{
check.run(findings);
}
catch(Exception e)
{
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
LOG.warn("Drift check could not run", logPair("check", name), logPair("error", AuthentikRedactor.redactText(message, config.getResolvedApiToken())));
findings.add(DriftFinding.error(name, "-", "Check could not run, so its result is UNKNOWN: " + AuthentikRedactor.redactText(message, config.getResolvedApiToken())));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static String encode(String value)
{
return (java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8));
}
/*******************************************************************************
** 1
*******************************************************************************/
void checkVersion(List<DriftFinding> findings)
{
String expected = config.getExpectedServerVersion();
String actual = client.fetchServerVersion().orElse(null);
if(expected == null || expected.isBlank())
{
findings.add(DriftFinding.info("version", actual == null ? "Authentik" : "Authentik " + actual, "No expectedServerVersion configured."));
}
else if(actual == null)
{
findings.add(DriftFinding.warning("version", "Authentik", "Server version unavailable (GET /admin/version/ failed)."));
}
else if(!(actual.equals(expected) || actual.startsWith(expected + ".")))
{
findings.add(DriftFinding.warning("version", "Authentik " + actual, "qbit-authentik was tested against " + expected + ". Run its contract tests against " + actual + " before relying on it."));
}
else
{
findings.add(DriftFinding.ok("version", "Authentik " + actual, "Matches the tested version " + expected + "."));
}
}
/*******************************************************************************
** 2
*******************************************************************************/
void checkBlueprints(List<DriftFinding> findings) throws QException
{
int failing = 0;
for(JSONObject blueprint : client.getAllPages("/managed/blueprints/", 1000))
{
String status = blueprint.optString("status", "unknown");
if(!blueprint.optBoolean("enabled", true))
{
continue;
}
if(!"successful".equals(status))
{
failing++;
findings.add(DriftFinding.error("blueprints", blueprint.optString("name"),
"Blueprint status is \"" + status + "\" (last applied " + blueprint.optString("last_applied", "never") + "). A failed blueprint rolls back entirely; check the worker logs."));
}
}
if(failing == 0)
{
findings.add(DriftFinding.ok("blueprints", "all", "Every enabled blueprint applied successfully."));
}
}
/*******************************************************************************
** 3
*******************************************************************************/
void checkServiceAccount(List<DriftFinding> findings) throws QException
{
String username = config.getEffectiveServiceAccountUsername();
JSONArray results = client.getApiJson("/core/users/?include_groups=true&username=" + encode(username)).optJSONArray("results");
JSONObject user = results == null || results.isEmpty() ? null : results.getJSONObject(0);
if(user == null)
{
findings.add(DriftFinding.error("service account", username, "Not found, or not visible to the API token's user. Is the token minted for this account?"));
return;
}
List<String> problems = new ArrayList<>();
if(!"service_account".equals(user.optString("type")))
{
problems.add("type is " + user.optString("type") + ", not service_account");
}
if(user.optBoolean("is_superuser", false))
{
problems.add("it is a SUPERUSER: remove it from every superuser group now");
}
JSONArray groups = user.optJSONArray("groups_obj");
if(groups != null && !groups.isEmpty())
{
problems.add("it is in " + groups.length() + " group(s); it should be in none");
}
if(!user.optBoolean("is_active", true))
{
problems.add("it is inactive");
}
JSONObject attributes = user.optJSONObject("attributes");
if(!AuthentikGuard.isMarkedProtected(user))
{
problems.add("qqq.io/protected is not true");
}
for(String problem : problems)
{
findings.add((problem.contains("SUPERUSER") ? DriftFinding.error("service account", username, problem) : DriftFinding.warning("service account", username, problem)));
}
////////////////////////////////////////////////////////////////////////////
// tokens: listing without view_token returns the caller's own tokens //
////////////////////////////////////////////////////////////////////////////
List<JSONObject> tokens = client.getAllPages("/core/tokens/?intent=api&user__username=" + encode(username), 200);
int apiOwned = 0;
if(tokens.size() >= 200)
{
findings.add(DriftFinding.error("service account", username, "200 or more API tokens for the service account; something is minting tokens."));
}
for(JSONObject token : tokens)
{
JSONObject owner = token.optJSONObject("user_obj");
if(owner != null && username.equals(owner.optString("username")) && "api".equals(token.optString("intent")))
{
apiOwned++;
}
}
if(apiOwned == 0)
{
findings.add(DriftFinding.warning("service account", username, "The service account's own API token is not visible, so extra tokens cannot be detected."));
}
else if(apiOwned > 1)
{
findings.add(DriftFinding.warning("service account", username, apiOwned + " API tokens exist for the service account; expected 1. It can mint its own tokens, so an extra one may be a leftover rotation or persistence after a compromise. Delete the ones not in Infisical."));
}
if(problems.isEmpty() && apiOwned == 1)
{
findings.add(DriftFinding.ok("service account", username, "Service account, no groups, not a superuser, protected, one API token."));
}
}
/*******************************************************************************
** 4
*******************************************************************************/
void checkBreakGlass(List<DriftFinding> findings) throws QException
{
List<JSONObject> protectedUsers = client.getAllPages("/core/users/?attributes=" + java.net.URLEncoder.encode("{\"" + AuthentikJson.ATTR_PROTECTED + "\": true}", java.nio.charset.StandardCharsets.UTF_8), 200);
List<String> breakGlass = new ArrayList<>();
for(JSONObject user : protectedUsers)
{
if(user.optBoolean("is_superuser", false) && user.optBoolean("is_active", true) && AuthentikGuard.isMarkedProtected(user))
{
breakGlass.add(user.optString("username"));
}
}
if(breakGlass.isEmpty())
{
findings.add(DriftFinding.error("break-glass", "-", "No active superuser marked qqq.io/protected. Without one, a lockout can only be undone from the cluster (ak shell)."));
}
else
{
findings.add(DriftFinding.ok("break-glass", String.join(", ", breakGlass), "Active, protected superuser present."));
}
}
/*******************************************************************************
** 5
*******************************************************************************/
void checkIdentityAdmins(List<DriftFinding> findings) throws QException
{
List<JSONObject> groups = client.getAllPages("/core/groups/?include_users=true&ordering=name", AuthentikActionUtil.MAX_SWEEP_OBJECTS);
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat(), client.getRuntime().getPolicyCatalog());
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
Set<Integer> humans = new HashSet<>();
for(JSONObject human : client.getAllPages("/core/users/?type=internal&type=external", AuthentikActionUtil.MAX_SWEEP_OBJECTS))
{
humans.add(human.getInt("pk"));
}
Set<Integer> checked = new HashSet<>();
List<String> admins = new ArrayList<>();
for(JSONObject group : groups)
{
JSONArray users = group.optJSONArray("users_obj");
for(int i = 0; users != null && i < users.length(); i++)
{
JSONObject user = users.getJSONObject(i);
if(!checked.add(user.getInt("pk")) || !user.optBoolean("is_active", true) || !humans.contains(user.getInt("pk")))
{
continue;
}
//////////////////////////////////////////////////////////////////////
// members are partial users (no is_superuser): derive it from the //
// groups, so superusers (who cannot be managed from QQQ) and other //
// protected accounts are not counted //
//////////////////////////////////////////////////////////////////////
boolean superuser = graph.groupsOfUser(user.getInt("pk")).stream().anyMatch(graph::isSuperuserInherited);
if(superuser || guard.protection(user).isProtected())
{
continue;
}
if(EffectivePermissionResolver.holdsAll(resolver.resolveUser(user.getInt("pk")), required))
{
admins.add(user.optString("username"));
}
}
}
if(admins.isEmpty())
{
String bootstrap = config.getResolvedBootstrapSubjects().isEmpty() ? "" : " bootstrapOperatorSubjects is set, which covers sign-in for now.";
findings.add(DriftFinding.error("identity admins", "-", "Nobody can administer identities from QQQ: no active, unprotected human user holds authentik.memberships.manage and authentik.permissions.manage. Add an administrator to a group carrying authentik.* (e.g. identity-admins) in the Authentik UI." + bootstrap));
}
else if(admins.size() == 1)
{
findings.add(DriftFinding.info("identity admins", admins.get(0), "Only one identity administrator. Two-person approval of authentik.* grants needs a second one (or the Authentik UI)."));
}
else
{
findings.add(DriftFinding.ok("identity admins", String.join(", ", admins), admins.size() + " identity administrators."));
}
}
/*******************************************************************************
** 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
*******************************************************************************/
void checkGroupAttributes(List<DriftFinding> findings) throws QException
{
GroupGraph graph = client.groupGraph();
EffectivePermissionResolver resolver = client.resolver();
Set<String> inScope = new HashSet<>(config.getApplicationsInScope() == null ? List.of() : config.getApplicationsInScope());
int issues = 0;
for(GroupGraph.Node node : graph.nodes())
{
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.")
.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; 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())
{
if(!inScope.isEmpty() && !inScope.contains(app))
{
issues++;
findings.add(DriftFinding.info("group attributes", node.name(), "qqq.io/app-access lists \"" + app + "\", which is not in applicationsInScope."));
}
}
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.")
.withReason("superuser group carries qqq.io/permissions"));
}
}
if(issues == 0)
{
findings.add(DriftFinding.ok("group attributes", graph.nodes().size() + " groups", "Every qqq.io/* attribute is valid."));
}
}
/*******************************************************************************
** 7
*******************************************************************************/
void checkServiceAccountActivity(List<DriftFinding> findings) throws QException
{
String username = config.getEffectiveServiceAccountUsername();
List<JSONObject> events = client.getAllPages("/events/events/?ordering=-created&username=" + encode(username), 200);
GroupGraph graph = client.groupGraph();
int flagged = 0;
for(JSONObject event : events)
{
JSONObject context = event.optJSONObject("context");
JSONObject model = context == null ? null : context.optJSONObject("model");
if(model == null)
{
continue;
}
String modelName = model.optString("model_name");
String pk = dashed(model.optString("pk"));
boolean sensitive = false;
if(!"user".equals(modelName) && !"group".equals(modelName))
{
continue;
}
if("model_deleted".equals(event.optString("action")))
{
sensitive = true;
}
else if("group".equals(modelName))
{
sensitive = graph.byPk(pk) == null || graph.isSuperuserInherited(pk);
}
else if(pk.matches("[0-9]+"))
{
JSONObject user = client.getApiJsonOrNull("/core/users/" + pk + "/");
sensitive = user == null || (guard.protection(user).isProtected() && !username.equals(user.optString("username")));
}
if(sensitive)
{
flagged++;
findings.add(DriftFinding.error("service-account activity", model.optString("name", pk),
"The qbit's service account changed a protected or superuser object, deleted one, or changed one that no longer exists (" + event.optString("action") + " at " + event.optString("created") + "). QQQ never does this; investigate as a possible compromise."));
}
}
if(flagged == 0)
{
findings.add(DriftFinding.ok("service-account activity", username, "No changes to protected or superuser objects in the last " + events.size() + " service-account events."));
}
}
/*******************************************************************************
** Authentik events carry group pks as 32 hex characters without dashes;
** the graph is keyed by the dashed uuid.
*******************************************************************************/
static String dashed(String pk)
{
if(pk != null && pk.matches("[0-9a-fA-F]{32}"))
{
return (pk.substring(0, 8) + "-" + pk.substring(8, 12) + "-" + pk.substring(12, 16) + "-" + pk.substring(16, 20) + "-" + pk.substring(20));
}
return (pk);
}
}
@@ -1,103 +0,0 @@
/*
* 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.drift;
/*******************************************************************************
** One line of the drift report.
**
** @param severity ERROR (identity management is broken or unsafe), WARNING
** (needs attention), INFO (worth knowing), OK (a check passed)
** @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, 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.
*******************************************************************************/
public enum Severity
{
ERROR, WARNING, INFO, OK
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding error(String check, String subject, String message)
{
return (new DriftFinding(Severity.ERROR, check, subject, message));
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding warning(String check, String subject, String message)
{
return (new DriftFinding(Severity.WARNING, check, subject, message));
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding info(String check, String subject, String message)
{
return (new DriftFinding(Severity.INFO, check, subject, message));
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding ok(String check, String subject, String message)
{
return (new DriftFinding(Severity.OK, check, subject, message));
}
}
@@ -1,78 +0,0 @@
/*
* 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."));
}
}
}
@@ -1,266 +0,0 @@
/*
* 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"));
}
}
@@ -1,121 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikFieldSpec;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
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.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
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.module.api.model.metadata.APITableBackendDetails;
/*******************************************************************************
** Builds a read-only QQQ table from an AuthentikTableSpec and registers it
** with the runtime. Subclasses name the spec.
*******************************************************************************/
public abstract class AbstractAuthentikTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** The unprefixed spec name, e.g. AuthentikTables.USER.
*******************************************************************************/
protected abstract String getSpecName();
/*******************************************************************************
** Tables need READ; subclasses may add conditions.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.READ));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (buildTable(getQBitConfig(), AuthentikTables.get(getSpecName())));
}
/*******************************************************************************
** Build the table and register it with the config's runtime.
*******************************************************************************/
public static QTableMetaData buildTable(AuthentikQBitConfig config, AuthentikTableSpec spec)
{
String tableName = config.applyTablePrefix(spec.getName());
QTableMetaData table = new QTableMetaData()
.withName(tableName)
.withLabel(spec.getLabel())
.withIcon(new QIcon(spec.getIcon()))
.withBackendName(config.getApiBackendName())
.withPrimaryKeyField(spec.getKeyFieldName())
.withBackendDetails(new APITableBackendDetails().withTablePath(spec.getPath()))
.withPermissionRules(spec.readRules())
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_EXPORT)
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE, Capability.QUERY_STATS);
for(AuthentikFieldSpec field : spec.getFields())
{
QFieldMetaData fieldMetaData = new QFieldMetaData(field.getName(), field.getType()).withLabel(field.getLabel());
if(field.isHidden())
{
fieldMetaData.withIsHidden(true);
}
table.withField(fieldMetaData);
}
if(!spec.getRecordLabelFields().isEmpty())
{
table.withRecordLabelFormat(String.join(" ", spec.getRecordLabelFields().stream().map(f -> "%s").toList()));
table.withRecordLabelFields(spec.getRecordLabelFields());
}
AuthentikRuntime.forBackend(config.getApiBackendName()).registerTable(tableName, spec);
return (table);
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (200);
}
}
@@ -1,65 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** authentik_admin_action: the append-only audit trail of every change made from QQQ.
*******************************************************************************/
public class AuthentikAdminActionTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.adminAction(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff. Show more