Compare commits

...
Author SHA1 Message Date
james.maesandCodex c677055200 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:24:31 -05:00
james.maesandCodex 17486b88d2 ci: adopt central orb 0.7.12 and stable gates (#45)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 09:03:12 -05:00
james.maesandCodex 7d1227adf5 ci: centralize rc release completion in qqq orb (#44)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:35:07 -05:00
james.maesandCodex 7df05f2324 ci: align canonical shared orb workers (#43)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:39:22 -05:00
james.maesandCodex 28cb654d13 ci: adopt shared orb 0.7.11 (#42)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:10:02 -05:00
james.maes 33cdaa1141 Merge pull request #41 from QRun-IO/feature/central-ci
ci: adopt verified central QQQ orb 0.7.1
2026-10-20 18:56:04 -05:00
james.maesandCodex 44a46a3cfc ci: adopt verified central QQQ orb 0.7.2
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:42:36 -05:00
james.maes dbae77da5b Merge pull request #38 from QRun-IO/feature/maintenance-service-principal
feat(maintenance): applyPolicy runs as the maintenance service principal
2026-10-16 13:53:54 -05:00
james.maesandClaude Opus 5.5 1d72ce3cec Merge origin/develop into feature/maintenance-service-principal
Keeps both CHANGELOG entries (#38 maintenance principal; #37/#39 health fixes).

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:44:21 -05:00
james.maes a5db01a071 Merge pull request #32 from QRun-IO/feature/policy-full-access
feat(policy): full access ("*") statement and maintenance policy API
2026-10-14 15:42:07 -05:00
james.maesandClaude Opus 5.5 24a97823a9 docs: Authentik health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:18 -05:00
james.maesandClaude Opus 5.5 fee7e2ad20 refactor(authentik): Identity home shows central health instead of direct probes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:17 -05:00
james.maesandClaude Opus 5.5 3b117cfff4 feat(health): register Authentik API, token, mapper and broker checks with core central health
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:16 -05:00
james.maesandClaude Opus 5.5 35d9886bbf feat(health): deadline-bounded Authentik client calls for health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:15 -05:00
36 changed files with 3000 additions and 336 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.7.12
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+6 -32
View File
@@ -1,42 +1,16 @@
# Weekly: does the newest Authentik release still have every operation and
# query parameter qbit-authentik uses? Downloads the release's schema.yml from
# goauthentik/authentik and runs SchemaDriftTest against it (no Docker, no
# credentials). A failure means: review the release notes before upgrading.
name: authentik-schema-drift
on:
'on':
schedule:
- cron: "17 6 * * 1"
- cron: 17 6 * * 1
workflow_dispatch:
inputs:
version:
description: "Authentik version (e.g. 2026.10.0); empty = latest release"
description: Authentik version (e.g. 2026.10.0); empty = latest release
required: false
permissions:
contents: read
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
cache: maven
- name: Resolve version and fetch its schema
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ inputs.version }}
run: |
version="${INPUT_VERSION}"
if [ -z "$version" ]; then
version=$(gh release view --repo goauthentik/authentik --json tagName -q .tagName | sed 's#^version/##')
fi
echo "Authentik $version"
curl -fsSL "https://raw.githubusercontent.com/goauthentik/authentik/version/${version}/schema.yml" -o "$RUNNER_TEMP/schema.yml"
echo "SCHEMA=$RUNNER_TEMP/schema.yml" >> "$GITHUB_ENV"
- name: Drift test
run: ./mvnw -B -q test -Dtest=SchemaDriftTest -Dsurefire.failIfNoSpecifiedTests=false -Dauthentik.schema.file="$SCHEMA"
uses: QRun-IO/qqq-orb/.github/workflows/qbit-schema-drift.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
with:
version: ${{ inputs.version || '' }}
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.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
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+14
View File
@@ -4,6 +4,13 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255).
- Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: <permission>`, `malformed <attribute>` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was.
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
- Replace expired token-delivery and credential-scope reflection bridges with the RC.7 APIs; retain conservative permission narrowing and explicit unavailable-delivery tests.
- Ship the expanded, host-neutral QBIT help bundle using QQQ 4.1.0-RC.7 registration and validation, with coverage for optional features and live references. Preserve inline app/table help for hosts without help storage; document HOST bundle overrides.
- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied inline by `AuthentikHelp` as well as registered with core, which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes.
- `AuthentikQBitConfig.fullAccessHostPermissions` lets a host declare permission names core cannot enumerate (custom checkers, named permissions); full access expands to them at use time, for the instance holding that runtime's backend only.
- `AuthentikMaintenance.applyPolicy` lets reviewed host run-once jobs apply a policy to an exact group name through the existing audited Save Policy path. Dedicated maintenance identities, full-access expansion, canonical no-ops, context restoration, stale checks and optional two-person approval are covered; system-session writes remain forbidden. No HTTP route or process is registered.
@@ -13,6 +20,13 @@ All notable changes to this project are documented here. Format: [Keep a Changel
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
### Added (central health)
- `authentik.api`, `authentik.api_token`, `authentik.mapper`, and `authentik.token_broker` dependency checks registered by the producer, with safe details and bounded requests through the existing client.
- Client-credentials broker probe using the existing token transport; its bearer is discarded.
### Removed (central health)
- The Identity home Connection health card, its direct version/token-expiry probes, and its inline mapper status. A single central component status links to System Health. The configuration drift-report widget is retained.
### Added (access tokens 6c)
- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153.
- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked.
+53 -3
View File
@@ -14,7 +14,7 @@ Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065)
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, connection), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, central health), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
Everything is a standard QQQ table, action and widget, so it works in any QQQ UI and over the QQQ API.
@@ -45,6 +45,15 @@ Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill s
| App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report |
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group |
### Tables, starter data and empty screens
Nothing is seeded: there are no starter defaults to include in a changelog. Setup comes from `AuthentikQBitConfig` (URL, API token, features, read-only mode), and the Identity home's "Needs attention" card lists what is left. The tables are of two kinds:
- **API-backed (live):** `authentik_user`, `_group`, `_group_member` (virtual), `_session`, `_event`, `_application`, `_policy_binding`, `_blueprint`, `_role`, `_token`, `_invitation`. Every row is read from Authentik on each view and nothing is stored locally. An empty list means Authentik returned nothing to you: your role lacks the permission, the qbit's feature or invitation flow is not enabled, or Authentik has no such records. Read-only mode only blocks changes.
- **Local (the qbit's own records):** `authentik_admin_action` (audit trail), `authentik_approval_request`, `authentik_policy` and `authentik_token_scope`. They fill as people run actions, and are written only by the qbit.
Every table, and the app, ships help (`help/qbit-authentik.yaml`, the QQQ help-bundle format) that says what the table is for, how its rows get created and, for the live tables, what an empty list means. The Next UI shows it as the empty state. A host's own help on a table is never replaced, and a host's `tableNamePrefix` is applied to the bundle's table names. `AuthentikTablesGuardrailTest` fails when a table is added without runtime use, a documented reason it starts empty, and this help.
### Permissions
QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute `qqq.io/permissions` and read by `AuthentikGroupPermissionMapper`:
@@ -83,7 +92,7 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
| Item | What to do |
|---|---|
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
| QQQ version | QQQ 4.1.0-RC.6 (released BOM, no core SNAPSHOT). Java 21. |
| QQQ version | QQQ 4.1.0-RC.8 (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)`. |
@@ -119,7 +128,7 @@ Access-token metadata is readable with `authentik.audit.read` or `authentik.toke
The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The temporary session-permission intersection is marked `STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7)`; a reflection bridge also attaches `QCredentialScope` when available. It does not add permissions.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The qbit conservatively narrows session permissions for direct consumers and attaches core's `QCredentialScope` for framework enforcement. It does not add permissions.
The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`.
@@ -127,3 +136,44 @@ The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at
Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added.
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
## Health checks
The producer registers these central checks when Authentik is configured. All use
component `authentik`, category `IDENTITY`, and a 10-second timeout. They reuse the
API client and drift diagnostics, honor the runner deadline, and record only safe
version, expiry-day, and finding-code details.
| Name | Kind | Interval |
| --- | --- | --- |
| `authentik.api` | DEPENDENCY | 300 seconds |
| `authentik.api_token` | DEPENDENCY | 21600 seconds |
| `authentik.mapper` | DEPENDENCY | 21600 seconds |
| `authentik.token_broker` | DEPENDENCY | 300 seconds |
The broker check uses a client-credentials grant and discards the returned bearer;
it reports UNKNOWN when broker credentials are not configured. Token metadata must
identify one API token; ambiguous tokens or an unavailable expiry report UNKNOWN.
The Identity home links its central status to System Health. The configuration
`driftReport` widget remains available. Disable checks centrally with
`health_check.enabled`; no Authentik table fields are added.
These registrations use the core health SPI released in QQQ 4.1.0-RC.8; no
SNAPSHOT override or enforcer skip is needed.
## In-app help
The qbit ships `src/main/resources/help/qbit-authentik.yaml` and registers it with
QQQ's help bundle registry (QQQ 4.1.0-RC.7). Registration is metadata-only; core
skips startup synchronization when the host has no `helpContent` table. Inline
app and table help remains available without help storage.
To override wording, register a separate bundle with `layer: HOST`, its own bundle
ID and version, and entries with the same keys and roles. HOST entries take
precedence over QBIT entries; do not edit the dependency's resource. Keep deployment
names, navigation and authentication timing in the host bundle.
`AuthentikHelpBundleTest` enables every supported feature and uses
`HelpBundleAssertions` to require coverage and validate keys, summaries and live
references. Run it with `./mvnw test -Dtest=AuthentikHelpBundleTest`.
The bundle version is the literal project version; update it when releasing.
+4
View File
@@ -1,5 +1,9 @@
# Upgrading qbit-authentik
## 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`
+4 -2
View File
@@ -141,6 +141,7 @@
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-authentik.properties</include>
</includes>
</resource>
@@ -148,6 +149,7 @@
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-authentik.properties</exclude>
</excludes>
</resource>
@@ -239,7 +241,7 @@
<profiles>
<!-- Needs qqq 4.1 (QBit producer API). Until qbit-build-parent imports the -->
<!-- released 4.1 BOM, this profile is active by default and imports -->
<!-- qqq-bom-pom 4.1.0-RC.5 ahead of the parent 4.0.0 BOM, as in -->
<!-- qqq-bom-pom 4.1.0-RC.7 ahead of the parent 4.0.0 BOM, as in -->
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
@@ -247,7 +249,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -66,6 +66,7 @@ public class AuthentikQBitConfig implements QBitConfig
private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}";
private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}";
private String tokenEndpointUrl = null;
private String maintenancePrincipal = "maintenance";
private String appPublicUrl = null;
private String authentikPublicUrl = null;
private String appClientId = null;
@@ -1737,6 +1738,29 @@ public class AuthentikQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for maintenancePrincipal: the service principal (declared by the
** host, docs/SERVICE-IDENTITY.md in qqq) that AuthentikMaintenance runs as.
** Its Authentik policy must hold policies.manage and every grant it applies.
*******************************************************************************/
public String getMaintenancePrincipal()
{
return (this.maintenancePrincipal);
}
/*******************************************************************************
** Fluent setter for maintenancePrincipal (default maintenance).
*******************************************************************************/
public AuthentikQBitConfig withMaintenancePrincipal(String maintenancePrincipal)
{
this.maintenancePrincipal = maintenancePrincipal;
return (this);
}
/*******************************************************************************
** Getter for appPublicUrl.
*******************************************************************************/
@@ -24,12 +24,15 @@ import java.util.Properties;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.app.AuthentikRelations;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.health.AuthentikHealthChecks;
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
import com.kingsrook.qbits.authentik.processes.AuthentikProcesses;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
@@ -184,12 +187,16 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
output.addSelfToInstance(qInstance);
AuthentikHealthChecks.register(qInstance, qBitConfig);
addPossibleValueSources(qInstance);
AuthentikRelations.apply(qInstance, qBitConfig);
for(QProcessMetaData process : AuthentikProcesses.processes(qBitConfig))
{
qInstance.addProcess(process);
}
// 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()));
@@ -63,6 +63,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -329,8 +330,8 @@ public class AuthentikAdmin
/*******************************************************************************
** The operator, resolved once per instance from live Authentik data. When
** the operator is not an Authentik user (static or development sessions),
** the session's own permissions stand in. Maintenance sessions are local job
** identities and never resolved as Authentik users. bootstrapOperatorSubjects additionally
** the session's own permissions stand in. Service principal sessions
** (maintenance) are never resolved as Authentik users by email. bootstrapOperatorSubjects additionally
** hold every authentik.* permission.
*******************************************************************************/
public Operator operator() throws QException
@@ -341,7 +342,7 @@ public class AuthentikAdmin
}
String email = guard.operatorEmail();
boolean maintenance = AuthentikMaintenance.isMaintenanceSession(QContext.getQSession());
boolean maintenance = ServiceIdentities.isMintedServiceSession(QContext.getQSession());
boolean bootstrap = !maintenance && email != null && com.kingsrook.qbits.authentik.security.BootstrapOperators.matchesUniqueUser(config.getResolvedBootstrapSubjects(), util, email);
Integer pk = null;
Set<String> permissions = new TreeSet<>();
@@ -21,20 +21,17 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONObject;
@@ -60,13 +57,18 @@ public final class AuthentikMaintenance
** plan and execute path. Requires an initialized QContext with the host's
** QInstance. The caller's context is restored on success, refusal or failure.
**
** The operator must start with maintenance: or run-once: and have a nonblank
** suffix. Its name and the required reason are recorded verbatim. A dedicated
** non-system session holds policies.manage and the document's current grants.
** Existing scope, protection, readOnly, feature and stale checks still apply.
** Equivalent policies return NO_CHANGE without a write or an audit row.
** With policyTwoPersonApproval enabled, a change queues an approval instead
** of writing to Authentik; this API never bypasses second-person approval.
** Runs as the host's maintenance service principal (config
** maintenancePrincipal, default "maintenance"; docs/SERVICE-IDENTITY.md in
** qqq): under OAuth2 a real token for its Authentik service account, so its
** authority is exactly what the owner granted that account (policies.manage
** and every grant the document applies - the subset rule still holds). No
** authority is created here. The audit operator is service:{principal};
** the job name (maintenance: or run-once:, nonblank suffix) prefixes the
** required reason. Existing scope, protection, readOnly, feature and stale
** checks still apply. Equivalent policies return NO_CHANGE without a write
** or an audit row. With policyTwoPersonApproval enabled, a change queues an
** approval instead of writing to Authentik; this API never bypasses
** second-person approval.
*******************************************************************************/
public static AuthentikAdmin.AdminResult applyPolicy(String apiBackendName, String groupName, String policyJson, String operatorName, String reason) throws QException
{
@@ -87,18 +89,16 @@ public final class AuthentikMaintenance
CapturedContext caller = QContext.capture();
try
{
MaintenanceSession session = new MaintenanceSession();
session.setUser(new QUser().withIdReference(operatorName).withFullName("Maintenance (" + operatorName + ")"));
session.setPermissions(new TreeSet<>(Set.of(IdentityPermission.POLICIES_MANAGE.getSessionPermission())));
QContext.init(caller.qInstance(), session);
String principal = AuthentikRuntime.forBackend(apiBackendName).getConfig().getMaintenancePrincipal();
QContext.init(caller.qInstance(), ServiceIdentities.session(caller.qInstance(), principal));
AuthentikAdmin admin = AuthentikAdmin.forBackend(apiBackendName);
AuthentikRuntime runtime = admin.getUtil().getRuntime();
runtime.getGuard().authorize(AdminAction.SAVE_POLICY);
Set<String> grants = StringUtils.hasContent(policyJson)
? PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog()).permissions() : Set.of();
session.getPermissions().addAll(grants.contains(PolicyPermissions.FULL_ACCESS)
? PolicyPermissions.allPermissions(caller.qInstance(), runtime.getPermissionCatalog()) : grants);
if(StringUtils.hasContent(policyJson))
{
PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
}
runtime.invalidateCache();
List<JSONObject> groups = admin.getUtil().getAllPages("/core/groups/?name=" + URLEncoder.encode(groupName, StandardCharsets.UTF_8)
@@ -108,7 +108,15 @@ public final class AuthentikMaintenance
throw (new QUserFacingException("Expected exactly one Authentik group named " + groupName + "."));
}
JSONObject group = groups.get(0);
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), reason)
if(isServicePrincipalGroup(group))
{
////////////////////////////////////////////////////////////////////
// a principal never changes a service principal's grants (its own //
// or another's): only the owner does, in Identity //
////////////////////////////////////////////////////////////////////
throw (new QUserFacingException("Service principal groups are changed only by the owner in Identity."));
}
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), operatorName + ": " + reason)
.with(AuthentikAdmin.POLICY_DOCUMENT, policyJson)
.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
return (admin.execute(request));
@@ -125,6 +133,29 @@ public final class AuthentikMaintenance
/*******************************************************************************
** A service principal role group: named service:..., or a child of
** qqq-service-accounts.
*******************************************************************************/
static boolean isServicePrincipalGroup(JSONObject group)
{
if(group.optString("name", "").startsWith("service:"))
{
return (true);
}
org.json.JSONArray parents = group.optJSONArray("parents_obj");
for(int i = 0; parents != null && i < parents.length(); i++)
{
if("qqq-service-accounts".equals(parents.optJSONObject(i) == null ? null : parents.getJSONObject(i).optString("name")))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** Canonical compact full-access document using the host's spec and version.
*******************************************************************************/
@@ -139,23 +170,4 @@ public final class AuthentikMaintenance
throw (new QException("Could not generate the full-access policy.", e));
}
}
/*******************************************************************************
** Recognize only sessions created here, never an ordinary user's name prefix.
*******************************************************************************/
static boolean isMaintenanceSession(QSession session)
{
return (session instanceof MaintenanceSession);
}
/*******************************************************************************
** A local job identity, never an Authentik user or a QSystemUserSession.
*******************************************************************************/
private static final class MaintenanceSession extends QSession
{
}
}
@@ -17,20 +17,18 @@
package com.kingsrook.qbits.authentik.app;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -56,8 +54,6 @@ public class AuthentikHomeData
{
private static final QLogger LOG = QLogger.getLogger(AuthentikHomeData.class);
public static final int TOKEN_WARNING_DAYS = 14;
private boolean peopleAvailable;
private int activePeople;
private int inactivePeople;
@@ -84,8 +80,10 @@ public class AuthentikHomeData
private List<QRecord> recentActions = new ArrayList<>();
private List<String> attention = new ArrayList<>();
private String healthStatus = "UNKNOWN";
private Integer healthComponentId;
private AuthentikQBitConfig config;
private String version;
@@ -104,7 +102,7 @@ public class AuthentikHomeData
data.loadApprovals();
data.loadActions();
data.loadAttention(util);
data.version = util.fetchServerVersion().orElse(null);
data.loadHealth();
return (data);
}
@@ -240,8 +238,7 @@ public class AuthentikHomeData
/*******************************************************************************
** Things that need a person: drift findings, blueprint state, tokens about
** to expire, bootstrap operators still set.
** Configuration review and bootstrap operators still set. Health is central.
*******************************************************************************/
private void loadAttention(AuthentikActionUtil util)
{
@@ -275,26 +272,6 @@ public class AuthentikHomeData
LOG.info("Identity home: drift report unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
if(mayAudit)
{
try
{
Instant soon = Instant.now().plus(TOKEN_WARNING_DAYS, ChronoUnit.DAYS);
for(JSONObject token : util.getAllPages("/core/tokens/?user__username=" + java.net.URLEncoder.encode(config.getEffectiveServiceAccountUsername(), java.nio.charset.StandardCharsets.UTF_8), 50))
{
Instant expires = AuthentikJson.parseInstant(token.optString("expires", null));
if(token.optBoolean("expiring", true) && expires != null && expires.isBefore(soon))
{
attention.add("The qbit's API token (" + token.optString("identifier") + ") expires on "
+ java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(java.time.ZoneOffset.UTC).format(expires) + ": rotate it (owner checklist).");
}
}
}
catch(Exception e)
{
LOG.debug("Identity home: token expiry not checked", e);
}
}
}
@@ -320,11 +297,51 @@ public class AuthentikHomeData
/*******************************************************************************
** Getter for version
** Read the central rollup using the current session's table permissions.
*******************************************************************************/
public String getVersion()
private void loadHealth()
{
return (version);
if(QContext.getQInstance().getTable("health_component") == null)
{
return;
}
try
{
QueryInput query = new QueryInput();
query.setTableName("health_component");
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
query.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "authentik")).withLimit(1));
List<QRecord> records = new QueryAction().execute(query).getRecords();
if(!records.isEmpty())
{
healthStatus = com.kingsrook.qqq.backend.core.health.HealthStatus.valueOf(records.getFirst().getValueString("status")).name();
healthComponentId = records.getFirst().getValueInteger("id");
}
}
catch(Exception e)
{
LOG.debug("Identity home: central health unavailable");
}
}
/*******************************************************************************
** Getter for the central component status.
*******************************************************************************/
public String getHealthStatus()
{
return (healthStatus);
}
/*******************************************************************************
** Getter for the central component record link.
*******************************************************************************/
public Integer getHealthComponentId()
{
return (healthComponentId);
}
@@ -66,7 +66,7 @@ public final class AuthentikHomeView
people(html, data, config);
groups(html, data, config);
activity(html, data, config);
connection(html, data);
health(html, data);
return (html.append("</div>").toString());
}
@@ -204,31 +204,12 @@ public final class AuthentikHomeView
/*******************************************************************************
**
*******************************************************************************/
private static void connection(StringBuilder html, AuthentikHomeData data)
private static void health(StringBuilder html, AuthentikHomeData data)
{
open(html, "Connection");
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Authentik", data.getConfig().getBaseUrl());
row(html, "Version", data.getVersion() == null ? "Not reachable" : data.getVersion());
row(html, "Service account", data.getConfig().getEffectiveServiceAccountUsername());
row(html, "Changes", Boolean.TRUE.equals(data.getConfig().getReadOnly()) ? "Off (read-only)" : "On, every change is audited here and in Authentik");
if(data.getConfig().hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.POLICIES))
{
String state;
try
{
com.kingsrook.qbits.authentik.permissions.AccessComparison.State mapper = com.kingsrook.qbits.authentik.permissions.AccessComparison.state(
com.kingsrook.qbits.authentik.AuthentikRuntime.forBackend(data.getConfig().getApiBackendName()),
com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(data.getConfig().getApiBackendName()));
state = (mapper.enforced() ? "Enforced. " : "Not enforced. ") + mapper.message();
}
catch(Exception e)
{
state = "Unknown (" + e.getMessage() + ")";
}
row(html, "Policies at sign-in", state);
}
html.append("</dl>");
open(html, "Health");
String target = prefix(data.getConfig()) + "/health_component" + (data.getHealthComponentId() == null ? "" : "/" + data.getHealthComponentId());
html.append("<p>Authentik: ").append(esc(data.getHealthStatus())).append(" - <a href=\"")
.append(esc(target)).append("\">System Health</a></p>");
close(html);
}
@@ -64,6 +64,7 @@ import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.apache.http.Header;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.NameValuePair;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
@@ -75,7 +76,6 @@ import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.util.EntityUtils;
import org.json.JSONArray;
import org.json.JSONException;
@@ -116,6 +116,8 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/** Display value for derived fields when the group graph cannot be read. */
public static final String UNAVAILABLE = "Unavailable (Authentik groups could not be read)";
private java.time.Instant deadline;
private EffectivePermissionResolver resolverForCall;
@@ -138,6 +140,94 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** Bound this dedicated health client; ordinary action clients are unchanged.
*******************************************************************************/
public AuthentikActionUtil withHealthDeadline(java.time.Instant deadline) throws QException
{
runtime().getGuard().checkHealth();
this.deadline = java.util.Objects.requireNonNull(deadline);
remainingMillis();
return (this);
}
/*******************************************************************************
** Remaining health budget; zero must never become an unlimited HTTP timeout.
*******************************************************************************/
private int remainingMillis() throws QException
{
long remaining = deadline == null ? Integer.MAX_VALUE : java.time.Duration.between(java.time.Instant.now(), deadline).toMillis();
if(remaining <= 0 || (deadline != null && Thread.currentThread().isInterrupted()))
{
throw (new QException("Authentik health deadline exceeded"));
}
return ((int) Math.min(Integer.MAX_VALUE, remaining));
}
/*******************************************************************************
** Configured zero means unlimited normally, but health always has a deadline.
*******************************************************************************/
private static int healthTimeout(int remaining, int configured)
{
return (configured <= 0 ? remaining : Math.min(remaining, configured));
}
/*******************************************************************************
** Keep retry delays inside the health budget and preserve cancellation.
*******************************************************************************/
private void retrySleep(long millis) throws QException
{
if(deadline == null)
{
SleepUtils.sleep(millis, java.util.concurrent.TimeUnit.MILLISECONDS);
return;
}
if(millis >= remainingMillis())
{
throw (new QException("Authentik health deadline exceeded"));
}
try
{
Thread.sleep(millis);
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Authentik health check interrupted"));
}
}
/*******************************************************************************
** Read the service account API token metadata through the existing paged path.
*******************************************************************************/
public List<JSONObject> getServiceAccountApiTokens() throws QException
{
runtime().getGuard().checkHealth();
return (getAllPages("/core/tokens/?intent=api&user__username=" + java.net.URLEncoder.encode(
runtime().getConfig().getEffectiveServiceAccountUsername(), StandardCharsets.UTF_8), 50));
}
/*******************************************************************************
** Broker credentials use the same secret-safe request loop as token exchange.
*******************************************************************************/
JSONObject requestBrokerCredentials(HttpPost request) throws QException
{
runtime().getGuard().checkHealth();
return (brokerResponse(request));
}
/*******************************************************************************
** The runtime for the backend this util serves.
*******************************************************************************/
@@ -463,6 +553,16 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public JSONObject exchangeToken(WriteAuthorization auth, HttpPost request) throws QException
{
checkAuthorization(auth);
return (brokerResponse(request));
}
/*******************************************************************************
** Parse a broker response without exposing its body.
*******************************************************************************/
private JSONObject brokerResponse(HttpPost request) throws QException
{
if(!request.getURI().toString().equals(runtime().getConfig().getTokenEndpointUrl()))
{
throw (new QException("Bad token endpoint"));
@@ -826,7 +926,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public GroupGraph groupGraph() throws QException
{
AuthentikRuntime runtime = runtime();
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
if(cache)
{
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("all").map(m -> m.getResult());
@@ -880,7 +980,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public GroupGraph groupGraphWithMembers() throws QException
{
AuthentikRuntime runtime = runtime();
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
if(cache)
{
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("members").map(m -> m.getResult());
@@ -1302,7 +1402,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
private String getBody(String url, boolean nullOn404) throws QException
{
AuthentikRuntime runtime = runtime();
if(runtime.isCacheEnabled())
if(deadline == null && runtime.isCacheEnabled())
{
Optional<String> cached = runtime.getResponseCache().getMemoizedResult(url).map(m -> m.getResult());
if(cached.isPresent())
@@ -1322,7 +1422,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
String body = response.getContent();
if(runtime.isCacheEnabled() && body != null)
if(deadline == null && runtime.isCacheEnabled() && body != null)
{
runtime.getResponseCache().storeResult(url, body);
}
@@ -1376,7 +1476,20 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
while(true)
{
attempts++;
runtime().getThrottle().acquire();
remainingMillis();
if(deadline == null)
{
runtime().getThrottle().acquire();
}
else
{
runtime().getThrottle().acquire(deadline);
int remaining = remainingMillis();
request.setConfig(RequestConfig.custom()
.setConnectTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
.setConnectionRequestTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
.setSocketTimeout(healthTimeout(remaining, runtime().getConfig().getSocketTimeoutMillis())).build());
}
if(!exchange)
{
request.setHeader("Authorization", "Bearer " + token);
@@ -1392,6 +1505,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
try
{
response = send(request);
remainingMillis();
}
catch(Exception e)
{
@@ -1399,7 +1513,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
LOG.warn("Authentik API call failed", logPair("method", request.getMethod()), logPair("path", path), logPair("error", message));
if(attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(backoffMs);
backoffMs *= 2;
continue;
}
@@ -1412,17 +1526,20 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
logOutboundCall(request, path, response, token);
}
checkVersionOnce(status);
if(deadline == null)
{
checkVersionOnce(status);
}
if(status == 429 && attempts <= MAX_RETRIES)
{
SleepUtils.sleep(retryAfterMillis(response, backoffMs), java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(retryAfterMillis(response, backoffMs));
backoffMs *= 2;
continue;
}
if(status >= 500 && attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(backoffMs);
backoffMs *= 2;
continue;
}
@@ -1446,7 +1563,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
// Never echo an untrusted broker error body.
}
throw (new QUserFacingException("Authentik refused to act as you (" + error + ")"));
throw (new BrokerRefusedException(error));
}
throw (mapError(request, path, status, response.getContent(), token));
}
@@ -58,4 +58,44 @@ public class AuthentikTokenBroker
}
return (bearer);
}
/*******************************************************************************
** Prove the broker's client credentials with the grant it actually uses
** (token exchange), without a subject token, so nothing is issued and no
** user data is sent. Authentik checks the grant type, then the client
** secret, then the subject: invalid_request (missing subject) means both
** passed; invalid_client means a bad secret; invalid_grant means the
** provider does not allow token exchange.
*******************************************************************************/
public void probeCredentials(AuthentikActionUtil util) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
request.setEntity(new UrlEncodedFormEntity(List.of(
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret())), StandardCharsets.UTF_8));
String code;
try
{
util.requestBrokerCredentials(request);
code = "issued";
}
catch(BrokerRefusedException e)
{
code = e.getErrorCode();
}
switch(code)
{
case "invalid_request" ->
{
return;
}
case "invalid_client" -> throw (new QUserFacingException("Authentik rejected the token broker's client credentials"));
case "invalid_grant", "unsupported_grant_type" -> throw (new QUserFacingException("The token broker's Authentik provider does not allow token exchange"));
default -> throw (new QUserFacingException("Authentik answered the token broker probe unexpectedly (" + code + ")"));
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
/*******************************************************************************
** Authentik's token endpoint refused a broker request. Carries only the
** standard OAuth2 error code (never the untrusted response body).
*******************************************************************************/
public class BrokerRefusedException extends QUserFacingException
{
private final String errorCode;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public BrokerRefusedException(String errorCode)
{
super("Authentik refused to act as you (" + errorCode + ")");
this.errorCode = errorCode;
}
/*******************************************************************************
** The OAuth2 error code, or exchange_failed when it was not a standard one.
*******************************************************************************/
public String getErrorCode()
{
return (errorCode);
}
}
@@ -90,6 +90,30 @@ public class TokenBucketThrottle
/*******************************************************************************
** Reserve a token only when the health deadline permits the throttle delay.
*******************************************************************************/
public void acquire(java.time.Instant deadline) throws com.kingsrook.qqq.backend.core.exceptions.QException
{
long waitNanos;
synchronized(this)
{
refill();
waitNanos = (long) Math.ceil(Math.max(0, 1 - tokens) / tokensPerNano);
if(Thread.currentThread().isInterrupted() || waitNanos >= java.time.Duration.between(java.time.Instant.now(), deadline).toNanos())
{
throw (new com.kingsrook.qqq.backend.core.exceptions.QException("Authentik health deadline exceeded"));
}
tokens -= 1;
}
if(waitNanos > 0)
{
nanoSleeper.accept(waitNanos);
}
}
/*******************************************************************************
** Tokens currently available (may be negative while callers are waiting).
*******************************************************************************/
@@ -22,6 +22,7 @@ import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
@@ -32,9 +33,9 @@ import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
import org.json.JSONArray;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
@@ -61,6 +62,9 @@ public class AuthentikDriftReport
{
private static final QLogger LOG = QLogger.getLogger(AuthentikDriftReport.class);
private static final Pattern SAFE_PERMISSION = Pattern.compile("[A-Za-z0-9_.*-]{1,120}");
private static final Pattern SAFE_ATTRIBUTE = Pattern.compile("[A-Za-z0-9_./-]{1,60}");
private final AuthentikActionUtil client;
private final AuthentikQBitConfig config;
private final AuthentikGuard guard;
@@ -98,6 +102,19 @@ public class AuthentikDriftReport
/*******************************************************************************
** Reuse only mapper attributes and service-account lockout diagnostics.
*******************************************************************************/
public List<DriftFinding> runMapperChecks()
{
List<DriftFinding> findings = new ArrayList<>();
runCheck(findings, "group attributes", this::checkGroupAttributes);
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
return (findings);
}
/*******************************************************************************
** A check.
*******************************************************************************/
@@ -360,6 +377,19 @@ public class AuthentikDriftReport
/*******************************************************************************
** The attribute name of a GroupGraph malformed entry ("key: detail"), never
** the detail, which can echo whatever a person typed.
*******************************************************************************/
static String attributeOf(String malformedEntry)
{
int colon = malformedEntry == null ? -1 : malformedEntry.indexOf(": ");
String key = colon < 0 ? "" : malformedEntry.substring(0, colon);
return (SAFE_ATTRIBUTE.matcher(key).matches() ? key : "attribute");
}
/*******************************************************************************
** 6
*******************************************************************************/
@@ -375,14 +405,16 @@ public class AuthentikDriftReport
for(String bad : node.malformed())
{
issues++;
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings."));
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings.")
.withReason("malformed " + attributeOf(bad)));
}
for(String permission : node.permissions())
{
if(resolver.expand(permission).isEmpty())
{
issues++;
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard)."));
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard; a name the app checks only in code, not in its metadata, is not in the catalog).")
.withReason("grants nothing: " + (SAFE_PERMISSION.matcher(permission).matches() ? permission : "(unprintable name)")));
}
}
for(String app : node.appAccess())
@@ -396,7 +428,8 @@ public class AuthentikDriftReport
if(graph.isSuperuserInherited(node.pk()) && !node.permissions().isEmpty())
{
issues++;
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups."));
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups.")
.withReason("superuser group carries qqq.io/permissions"));
}
}
if(issues == 0)
@@ -25,10 +25,33 @@ package com.kingsrook.qbits.authentik.drift;
** @param check the check's short name, e.g. "blueprints"
** @param subject what the finding is about, e.g. a group name
** @param message plain-language explanation and what to do
** @param reason a short, safe summary for health outcomes (no attribute
** values a person typed beyond validated permission names);
** null when the finding has none
*******************************************************************************/
public record DriftFinding(Severity severity, String check, String subject, String message)
public record DriftFinding(Severity severity, String check, String subject, String message, String reason)
{
/*******************************************************************************
** A finding without a health reason.
*******************************************************************************/
public DriftFinding(Severity severity, String check, String subject, String message)
{
this(severity, check, subject, message, null);
}
/*******************************************************************************
** This finding with a short, safe reason for health outcomes.
*******************************************************************************/
public DriftFinding withReason(String reason)
{
return (new DriftFinding(severity, check, subject, message, reason));
}
/*******************************************************************************
** Severity, most severe first.
*******************************************************************************/
@@ -0,0 +1,260 @@
/*
* 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);
for(String name : List.of("api", "api_token", "mapper", "token_broker"))
{
registry.withCheck(new HealthCheckMetaData().withName("authentik." + name).withLabel("Authentik " + name)
.withComponent("authentik").withCategory(HealthCategory.IDENTITY).withKind(HealthCheckKind.DEPENDENCY)
.withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : 21600)
.withTimeoutMs(10000).withCheckInstance(input ->
{
try
{
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName()).withHealthDeadline(input.getDeadline());
HealthCheckOutcome outcome;
switch(name)
{
case "api" -> outcome = api(util, config);
case "api_token" -> outcome = apiToken(util, Instant.now());
case "mapper" -> outcome = mapper(util, config);
default -> outcome = tokenBroker(util, config);
}
return (Instant.now().isBefore(input.getDeadline()) && !Thread.currentThread().isInterrupted()
? outcome : HealthCheckOutcome.down("Authentik health deadline exceeded"));
}
catch(Exception e)
{
// Never copy exception messages, URLs or credential values into outcomes.
return (HealthCheckOutcome.down("Authentik health check failed"));
}
}));
}
}
/*******************************************************************************
** Version compatibility follows the existing drift report's prefix rule.
*******************************************************************************/
private static HealthCheckOutcome api(AuthentikActionUtil util, AuthentikQBitConfig config)
{
String version = util.fetchServerVersion().orElse("");
if(!version.matches("[0-9]{1,4}(\\.[0-9]{1,4}){1,3}"))
{
return (HealthCheckOutcome.down("Authentik server version unavailable"));
}
String expected = config.getExpectedServerVersion();
boolean matches = !StringUtils.hasContent(expected) || version.equals(expected) || version.startsWith(expected + ".");
return ((matches ? HealthCheckOutcome.up("Authentik API reachable") : HealthCheckOutcome.degraded("Authentik server version differs"))
.withDetails(new HealthDetails().withValue("serverVersion", version)));
}
/*******************************************************************************
** The service account should have one API token; never read its key.
*******************************************************************************/
private static HealthCheckOutcome apiToken(AuthentikActionUtil util, Instant now) throws QException
{
List<JSONObject> tokens = util.getServiceAccountApiTokens();
if(tokens.isEmpty())
{
return (HealthCheckOutcome.down("API token missing"));
}
if(tokens.size() != 1)
{
return (HealthCheckOutcome.unknown("API token cannot be identified uniquely"));
}
JSONObject token = tokens.getFirst();
if(!token.optBoolean("expiring", true))
{
return (HealthCheckOutcome.up("API token present without expiry"));
}
Instant expires;
try
{
expires = AuthentikJson.parseInstant(token.optString("expires", null));
}
catch(java.time.format.DateTimeParseException e)
{
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
}
if(expires == null)
{
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
}
HealthCheckOutcome outcome = !expires.isAfter(now) ? HealthCheckOutcome.down("API token expired")
: expires.isBefore(now.plus(14, ChronoUnit.DAYS)) ? HealthCheckOutcome.degraded("API token expires within 14 days")
: HealthCheckOutcome.up("API token present");
return (outcome.withDetails(new HealthDetails().withValue("daysToExpiry", ChronoUnit.DAYS.between(now, expires))));
}
/*******************************************************************************
** Findings carry fixed codes, plus the flagged group names and each group
** finding's safe reason (DriftFinding.reason), so a degraded mapper says
** what to fix. Service-account activity subjects (people) stay private.
*******************************************************************************/
static HealthCheckOutcome mapper(AuthentikActionUtil util, AuthentikQBitConfig config)
{
return (mapperOutcome(new AuthentikDriftReport(util, config).runMapperChecks()));
}
/*******************************************************************************
** The mapper outcome for a set of drift findings.
*******************************************************************************/
static HealthCheckOutcome mapperOutcome(List<DriftFinding> findings)
{
HealthStatus status = HealthStatus.UP;
TreeSet<String> codes = new TreeSet<>();
TreeMap<String, TreeMap<String, TreeSet<String>>> byGroup = new TreeMap<>();
TreeSet<String> other = new TreeSet<>();
for(DriftFinding finding : findings)
{
if(finding.severity() != DriftFinding.Severity.ERROR && finding.severity() != DriftFinding.Severity.WARNING)
{
continue;
}
boolean groupCheck = GROUP_ATTRIBUTES.equals(finding.check());
codes.add((groupCheck ? "group_attributes" : "service_account_activity") + "." + finding.severity().name().toLowerCase(Locale.ROOT));
status = finding.severity() == DriftFinding.Severity.ERROR || status == HealthStatus.DOWN ? HealthStatus.DOWN : HealthStatus.DEGRADED;
if(groupCheck && finding.reason() != null)
{
String reason = finding.reason();
int colon = reason.indexOf(": ");
TreeSet<String> values = byGroup.computeIfAbsent(safeName(finding.subject()), k -> new TreeMap<>())
.computeIfAbsent(colon < 0 ? reason : reason.substring(0, colon), k -> new TreeSet<>());
if(colon >= 0)
{
values.add(reason.substring(colon + 2));
}
}
else
{
other.add(groupCheck ? "group attributes check could not run" : "service-account activity (see the identity drift report)");
}
}
if(status == HealthStatus.UP)
{
return (HealthCheckOutcome.up("Mapper checks passed").withDetails(new HealthDetails().withValue("findingCodes", "")));
}
List<String> parts = new ArrayList<>();
byGroup.forEach((group, reasons) ->
{
List<String> texts = new ArrayList<>();
reasons.forEach((label, values) -> texts.add(values.isEmpty() ? label : label + ": " + String.join(", ", values)));
parts.add(group + " (" + String.join("; ", texts) + ")");
});
parts.addAll(other);
String summary = String.join("; ", parts);
return (new HealthCheckOutcome().withStatus(status).withMessage(truncate("Mapper drift: " + summary, 500))
.withDetails(new HealthDetails()
.withValue("findingCodes", String.join(",", codes))
.withValue("flaggedGroups", truncate(String.join(", ", byGroup.keySet()), 500))
.withValue("summary", truncate(summary, 500))));
}
/*******************************************************************************
** A group name fit for a health message: printable and bounded.
*******************************************************************************/
private static String safeName(String name)
{
String printable = name == null ? "" : name.replaceAll("\\p{Cntrl}", "").trim();
return (printable.isEmpty() ? "(unnamed group)" : truncate(printable, 100));
}
/*******************************************************************************
** Cut a text to a length, marking the cut.
*******************************************************************************/
private static String truncate(String text, int max)
{
return (text.length() <= max ? text : text.substring(0, max - 3) + "...");
}
/*******************************************************************************
** Authenticate through the existing broker transport, retaining no bearer.
*******************************************************************************/
private static HealthCheckOutcome tokenBroker(AuthentikActionUtil util, AuthentikQBitConfig config) throws QException
{
if(!StringUtils.hasContent(config.getTokenBrokerClientId()) || !StringUtils.hasContent(config.getTokenBrokerClientSecret()))
{
return (HealthCheckOutcome.unknown("not configured"));
}
new AuthentikTokenBroker().probeCredentials(util);
return (HealthCheckOutcome.up("Token broker authenticated"));
}
}
@@ -0,0 +1,159 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-authentik.yaml (the QQQ help-bundle format) to the Identity
** app and its authentik_* tables: what each table is for and how its rows get
** created (for the API-backed tables: that they are read live, and what an
** empty list means), shown on the table screen and when it is empty.
**
** The bundle's keys use the default table prefix; a host's own tableNamePrefix
** is applied to them, so renamed tables get the same text.
** Inline fallback for hosts without help storage; core owns bundle validation
** and the producer also registers the bundle for startup synchronization.
*******************************************************************************/
public final class AuthentikHelp
{
public static final String RESOURCE = "/help/qbit-authentik.yaml";
public static final String SLOT = "help";
private static final String DEFAULT_TABLE_PREFIX = "authentik_";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
Map<String, JsonNode> entries = new LinkedHashMap<>();
var bundle = HelpBundles.load(RESOURCE.substring(1), AuthentikHelp.class.getClassLoader());
ObjectMapper mapper = new ObjectMapper();
for(var entry : bundle.getEntries())
{
entries.put(entry.getKey(), mapper.valueToTree(entry));
}
return (entries);
}
/*******************************************************************************
** Apply the help to the app and every table the qbit produced. A host's own
** help on a table or the app is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance qInstance, AuthentikQBitConfig config) throws QException
{
for(var bundle : HelpBundles.of(qInstance).getBundles())
{
if("qbit-authentik".equals(bundle.getId()))
{
for(var entry : bundle.getEntries())
{
entry.setKey(configuredNames(entry.getKey(), config));
entry.setSummary(configuredNames(entry.getSummary(), config));
entry.setContent(configuredNames(entry.getContent(), config));
}
}
}
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
{
String key = entry.getKey();
if(key.startsWith("table:") && key.endsWith(";slot:help"))
{
String tableKey = key.substring("table:".length(), key.indexOf(';'));
String unprefixed = tableKey.substring(DEFAULT_TABLE_PREFIX.length());
QTableMetaData table = qInstance.getTable(config.applyTablePrefix(unprefixed));
if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
else if(key.startsWith("app:") && key.endsWith(";slot:help"))
{
QAppMetaData app = qInstance.getApp(config.applyPrefix(AuthentikAppMetaDataProducer.APP_NAME));
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry, AuthentikQBitConfig config)
{
return (new QHelpContent().withContentAsMarkdown(configuredNames(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip(), config)));
}
/*******************************************************************************
** Follow the host prefix in keys and live references; status enums are fixed.
*******************************************************************************/
private static String configuredNames(String text, AuthentikQBitConfig config)
{
if(text == null)
{
return (null);
}
return (Pattern.compile("authentik_[a-zA-Z0-9_]+|authentik[A-Z][a-zA-Z0-9]*").matcher(text).replaceAll(match ->
{
String name = match.group();
if(name.equals("authentik_action_status") || name.equals("authentik_approval_status"))
{
return (name);
}
if(name.startsWith(DEFAULT_TABLE_PREFIX))
{
return (config.applyTablePrefix(name.substring(DEFAULT_TABLE_PREFIX.length())));
}
return (config.applyPrefix(name.substring("authentik".length())));
}));
}
}
@@ -19,6 +19,7 @@ package com.kingsrook.qbits.authentik.processes;
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
/*******************************************************************************
@@ -43,11 +44,11 @@ public interface TokenKeyDelivery
/*******************************************************************************
** Current adapter; null restores the safe reflection adapter.
** Current adapter; null restores the core response-only adapter.
*******************************************************************************/
static void set(TokenKeyDelivery delivery)
{
Holder.delivery = delivery == null ? new ReflectiveDelivery() : delivery;
Holder.delivery = delivery == null ? new CoreDelivery() : delivery;
}
@@ -90,7 +91,7 @@ public interface TokenKeyDelivery
*******************************************************************************/
final class Holder
{
private static volatile TokenKeyDelivery delivery = new ReflectiveDelivery();
private static volatile TokenKeyDelivery delivery = new CoreDelivery();
/*******************************************************************************
** Utility holder.
@@ -103,56 +104,18 @@ public interface TokenKeyDelivery
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7)
** Reflection keeps RC.2 binary-compatible and never falls back to values.
** Core response-only delivery; credentials never enter process values.
*******************************************************************************/
final class ReflectiveDelivery implements TokenKeyDelivery
final class CoreDelivery implements TokenKeyDelivery
{
/*******************************************************************************
** Check the complete core contract before any irreversible key mutation.
*******************************************************************************/
@Override
public boolean available()
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
type.getConstructor();
for(String field : new String[] { "Label", "Value", "Masked", "UsageText" })
{
type.getMethod("with" + field, String.class);
}
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type);
return (true);
}
catch(ReflectiveOperationException e)
{
return (false);
}
}
/*******************************************************************************
** Add to core's response-only channel; never include failures' contents.
** Add to core's response-only channel.
*******************************************************************************/
@Override
public void deliver(RunBackendStepOutput output, OneTimeKey key)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
Object secret = type.getConstructor().newInstance();
type.getMethod("withLabel", String.class).invoke(secret, key.label());
type.getMethod("withValue", String.class).invoke(secret, key.value());
type.getMethod("withMasked", String.class).invoke(secret, key.masked());
type.getMethod("withUsageText", String.class).invoke(secret, key.usageText());
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type).invoke(output, secret);
}
catch(ReflectiveOperationException e)
{
throw (new IllegalStateException("One-time output unavailable"));
}
output.addOneTimeSecret(new OneTimeSecret().withLabel(key.label()).withValue(key.value())
.withMasked(key.masked()).withUsageText(key.usageText()));
}
}
}
@@ -20,8 +20,8 @@ package com.kingsrook.qbits.authentik.security;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
@@ -65,6 +65,21 @@ public class AuthentikGuard
/*******************************************************************************
** Health probes expose only safe outcomes, including to the central runner.
*******************************************************************************/
public void checkHealth() throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
requireUser(session);
if(!has(session, IdentityPermission.ADMIN) && (session.getPermissions() == null || !session.getPermissions().contains("health.read")))
{
throw (new QPermissionDeniedException("Health read permission is required."));
}
}
/*******************************************************************************
** Check that the current session may read a table.
*******************************************************************************/
@@ -17,11 +17,9 @@
package com.kingsrook.qbits.authentik.security;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
@@ -39,6 +37,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
@@ -262,7 +261,7 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7): intersect, never grant.
** Intersect permissions for direct session consumers and attach core scope.
*******************************************************************************/
public static void narrow(QSession session, TokenScope scope)
{
@@ -272,8 +271,8 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** Keep only permitted bases and read operations, then attach core's scope
** when available. Management hasAccess permissions are not read operations.
** Keep conservative permission narrowing for direct session consumers, then
** attach core scope for framework enforcement. Management is not read-only.
*******************************************************************************/
private static void narrow(QInstance instance, QSession session, TokenScope scope)
{
@@ -410,10 +409,6 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** Optional core bridge. An incompatible installed contract fails closed;
** an absent class uses the permission intersection above.
*******************************************************************************/
/*******************************************************************************
** An empty scope dimension means "not restricted" (null for core).
*******************************************************************************/
@@ -425,51 +420,18 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1168, removeBy=4.1.0-RC.7): until the qbit's QQQ
** version has QCredentialScope (qqq#1153), narrow() above enforces the scope
** by intersecting the session's permissions; this attaches the core scope by
** reflection when it exists. Replace both with a direct withCredentialScope.
** Attach the released core scope contract; a revoked token allows nothing.
*******************************************************************************/
private static void attachCoreScope(QSession session, TokenScope scope)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.session.QCredentialScope");
Object value = type.getConstructor().newInstance();
///////////////////////////////////////////////////////////////////////
// core treats a null set as unrestricted and an empty set as //
// "nothing": an unlisted dimension must therefore be passed as null //
///////////////////////////////////////////////////////////////////////
Map<String, Object> fields = new java.util.LinkedHashMap<>();
fields.put("CredentialId", scope.identifier());
fields.put("ReadOnly", scope.readOnly());
fields.put("Apps", nullIfEmpty(scope.apps()));
fields.put("Tables", nullIfEmpty(scope.tables()));
fields.put("Processes", nullIfEmpty(scope.processes()));
fields.put("Reports", nullIfEmpty(scope.reports()));
fields.put("Widgets", nullIfEmpty(scope.widgets()));
fields.put("OperationIds", nullIfEmpty(scope.operationIds()));
for(var field : fields.entrySet())
{
Method setter = Arrays.stream(type.getMethods()).filter(m -> m.getName().equals("with" + field.getKey()) && m.getParameterCount() == 1)
.findFirst().orElseThrow(NoSuchMethodException::new);
Object arg = field.getValue();
if(arg instanceof Set<?> set && List.class.isAssignableFrom(setter.getParameterTypes()[0]))
{
arg = List.copyOf(set);
}
setter.invoke(value, arg);
}
session.getClass().getMethod("withCredentialScope", type).invoke(session, value);
}
catch(ClassNotFoundException ignored)
{
// RC.2 has no credential scope; the stand-in above is active.
}
catch(ReflectiveOperationException | IllegalArgumentException e)
{
session.setPermissions(new TreeSet<>());
LOG.warn("The installed credential-scope contract is incompatible; denying credential permissions.");
}
boolean denied = scope.mode() == Mode.DENY_ALL;
session.withCredentialScope(new QCredentialScope().withCredentialId(scope.identifier())
.withReadOnly(denied || scope.readOnly())
.withApps(denied ? Set.of() : nullIfEmpty(scope.apps()))
.withTables(denied ? Set.of() : nullIfEmpty(scope.tables()))
.withProcesses(denied ? Set.of() : nullIfEmpty(scope.processes()))
.withReports(denied ? Set.of() : nullIfEmpty(scope.reports()))
.withWidgets(denied ? Set.of() : nullIfEmpty(scope.widgets()))
.withOperationIds(denied ? Set.of() : nullIfEmpty(scope.operationIds())));
}
}
+957
View File
@@ -0,0 +1,957 @@
bundle: qbit-authentik
version: ${project.version}
layer: QBIT
entries:
- key: app:authentikAdmin;slot:help
summary: Review Authentik accounts, groups, application access and identity administration.
content: |
Identity shows the accounts, groups and applications held in Authentik, the service that signs people in to this application. The directory data is read from Authentik with the host's configured caching, and Authentik stays the source of truth. Changes made in Authentik itself are equally valid, and nothing here overwrites them blindly.
**Read only by default.** Out of the box you can look but not change anything. The change actions (creating and editing users and groups, resetting passwords, revoking sessions, editing policies) appear only when writes are switched on for this site. When they are off, those buttons are absent, not just disabled. Deleting a user or group needs a second switch and is off unless it was turned on deliberately.
Some details, such as sessions, tokens, events and raw attributes, need the audit permission. You need the permission to read users to open this app.
- key: table:authentik_application;slot:help
summary: The apps that people sign in to through Authentik, and who is allowed to open them.
content: |
Each row is an application set up in Authentik. Open one to see its provider, how it is launched and, under **Who can open it (policy bindings)**, the rules that decide which groups or users may use it.
This list is read only. Applications are managed in Authentik, not here.
**How rows get created:** Define applications in Authentik; you can inspect their live state here. An empty list means no applications match your query and visible scope.
- key: table:authentik_application;section:details
summary: The application's name, address, provider and library settings.
- key: table:authentik_application;section:bindings
summary: The rules that allow or deny groups and users from opening this application.
- key: table:authentik_application;field:slug
summary: The application's short address name inside Authentik.
- key: table:authentik_application;field:name
summary: The application's display name in Authentik.
- key: table:authentik_application;field:group
summary: The heading Authentik lists this application under in its library; often empty.
- key: table:authentik_application;field:providerName
summary: The sign-in provider connected to this application, such as an OAuth2 or proxy provider.
- key: table:authentik_application;field:policyEngineMode
summary: How the application's access rules combine.
content: |
Authentik uses **any** (one passing rule is enough) or **all** (every rule must pass). See the rules under **Who can open it (policy bindings)**.
- key: table:authentik_application;field:launchUrl
summary: The address people are sent to when they open the application from Authentik.
- key: table:authentik_application;field:publisher
summary: The publisher name shown for the application in Authentik; often empty.
- key: table:authentik_application;field:hiddenFromLibrary
summary: Yes when the application is hidden from users' Authentik app library.
content: |
Hidden applications can still be opened directly.
- key: table:authentik_application;field:bindingTarget
summary: Internal link used to list this application's access rules.
- key: table:authentik_blueprint;slot:help
summary: The configuration files Authentik applies to set itself up, and whether each applied cleanly.
content: |
Blueprints are files that describe parts of Authentik, such as its groups and service accounts. Authentik applies them itself. This list shows each one's **{{label:authentik_blueprint.status}}**, whether it is **{{label:authentik_blueprint.enabled}}**, and when it was **{{label:authentik_blueprint.lastApplied}}**.
It is read only and for checking. A status that is not successful means something in Authentik's own setup needs attention.
**How rows get created:** Authentik discovers and applies blueprints; you can inspect their live status here. An empty list means no blueprint instances match your query.
- key: table:authentik_blueprint;field:pk
summary: Authentik's internal id for the blueprint.
- key: table:authentik_blueprint;field:name
summary: The blueprint's name.
- key: table:authentik_blueprint;field:path
summary: Where Authentik finds the blueprint file.
- key: table:authentik_blueprint;field:status
summary: Whether Authentik applied the blueprint successfully, as reported by Authentik.
- key: table:authentik_blueprint;field:enabled
summary: Whether Authentik applies this blueprint automatically.
- key: table:authentik_blueprint;field:lastApplied
summary: When Authentik last applied this blueprint.
- key: table:authentik_event;slot:help
summary: 'Authentik''s activity log: sign-ins, failed logins, changes and other security events, newest first.'
content: |
Use this to answer who did what and when: a login, a password reset, a group change, or something an admin did from this app. Each row names the **{{label:authentik_event.action}}**, who did it, from which **{{label:authentik_event.clientIp}}**, and what it touched. **{{label:authentik_event.context}}** holds the raw detail.
The list is read only and needs the audit permission. On a user, the **History** section shows just that person's events. Authentik deletes old events on its own schedule, shown in **{{label:authentik_event.expires}}**.
**How rows get created:** Authentik records security events; you can inspect the live event log here. An empty list means no events match your query.
- key: table:authentik_event;field:pk
summary: Authentik's internal id for the event.
- key: table:authentik_event;field:created
summary: When the event happened.
- key: table:authentik_event;field:action
summary: What happened, in Authentik's own terms, such as login, login failed, model created or model updated.
- key: table:authentik_event;field:username
summary: The account that did it, where known.
- key: table:authentik_event;field:userPk
summary: The Authentik number of the account that did it.
- key: table:authentik_event;field:app
summary: The part of Authentik that recorded the event.
- key: table:authentik_event;field:clientIp
summary: The network address the request came from.
- key: table:authentik_event;field:modelApp
summary: For changes, which part of Authentik owns the thing that changed.
- key: table:authentik_event;field:modelName
summary: For changes, the kind of thing that changed, such as user or group.
- key: table:authentik_event;field:modelPk
summary: For changes, the id of the thing that changed.
- key: table:authentik_event;field:modelLabel
summary: For changes, the name of the thing that changed.
- key: table:authentik_event;field:authorizedApp
summary: For sign-ins, the application the person was signing in to.
- key: table:authentik_event;field:context
summary: The raw details Authentik recorded with the event, as JSON text.
- key: table:authentik_event;field:expires
summary: When Authentik will delete this event from its log.
- key: table:authentik_group_member;slot:help
summary: Which people belong to which Authentik groups, one row per person and group.
content: |
This is the list behind the **Members** section on a group and the **Memberships** section on a user. It is read only. To change who is in a group you use the **{{label:authentikAddMembers}}**, **{{label:authentikRemoveMembers}}**, **{{label:authentikAddToGroup}}** or **{{label:authentikRemoveFromGroup}}** actions, which are present only when writes are enabled.
**How rows get created:** Membership rows are assembled live from Authentik. Use the membership actions to add or remove people. An empty list means no memberships match your query.
- key: table:authentik_group_member;field:groupName
summary: The group the person belongs to.
- key: table:authentik_group_member;field:username
summary: The member's sign-in username.
- key: table:authentik_group_member;field:name
summary: The member's full name.
- key: table:authentik_group_member;field:email
summary: The member's email address.
- key: table:authentik_group_member;field:isActive
summary: Whether the member's account can currently sign in.
- key: table:authentik_group_member;field:groupPk
summary: Authentik's internal id of the group.
- key: table:authentik_group_member;field:userPk
summary: Authentik's number for the member's account.
- key: table:authentik_group;slot:help
summary: The groups that decide what each person can do in this application and which applications they can open.
content: |
Access in this application comes from group membership. A group can grant specific application permissions and app sign-in, can sit under parent groups whose access it inherits, and can carry a written policy.
Open a group to see its members, the permissions it grants directly and the full set including inherited ones, and its policy.
You can inspect the details your permissions allow. These actions appear when their features and writes are enabled: **{{label:authentikCreateGroup}}**, **{{label:authentikEditGroup}}**, **{{label:authentikEditPermissions}}**, **Edit Policy**, **{{label:authentikGeneratePolicy}}**, **{{label:authentikAddMembers}}** and **{{label:authentikRemoveMembers}}**. Safeguards apply: you can only grant permissions you hold yourself, you cannot edit your own access, superuser groups cannot be managed, and granting identity administration rights needs a second person when two-person approval is enabled.
**How rows get created:** Create groups in Authentik or use Create Group when enabled. You read their live state from Authentik. An empty list means no groups match your query.
- key: table:authentik_group;section:enforcement
summary: Whether saved policies currently take effect when people sign in.
- key: table:authentik_group;section:details
summary: The group's name, parents, children, member count and superuser status.
- key: table:authentik_group;section:permissions
summary: The application permissions and app sign-in this group grants, directly and by inheritance.
- key: table:authentik_group;section:members
summary: The people in this group.
- key: table:authentik_group;section:policy
summary: The written access policy saved on this group, if any.
- key: table:authentik_group;section:attributes
summary: The raw extra settings stored on the group in Authentik; needs the audit permission.
- key: table:authentik_group;field:pk
summary: Authentik's internal id for the group.
- key: table:authentik_group;field:name
summary: The group's name.
- key: table:authentik_group;field:isSuperuser
summary: Yes when this group itself gives its members full superuser rights in Authentik.
content: |
Superuser groups cannot be managed from here.
- key: table:authentik_group;field:isSuperuserInherited
summary: Yes when this group, or any parent it inherits from, gives members superuser rights.
- key: table:authentik_group;field:parentNames
summary: The groups this group inherits access from.
- key: table:authentik_group;field:childNames
summary: The groups that inherit access from this one.
- key: table:authentik_group;field:memberCount
summary: How many people are directly in this group.
- key: table:authentik_group;field:memberUsername
summary: Filter for groups containing a given username; not shown.
- key: table:authentik_group;field:permissions
summary: The application permissions this group grants directly, one per line.
content: |
These come from the group's permissions setting in Authentik. A permission looks like a screen or action name followed by what is allowed, for example read or has access.
- key: table:authentik_group;field:effectivePermissions
summary: Everything this group grants once parent groups and the group's policy are included.
content: |
Use it to answer why a person has a permission. The permissions here are the full set for the group's members.
- key: table:authentik_group;field:appAccess
summary: The applications the group's members may sign in to, one per line.
- key: table:authentik_group;field:roleNames
summary: The Authentik roles attached to this group.
- key: table:authentik_group;field:numPk
summary: Authentik's numeric id for the group; used internally.
- key: table:authentik_group;field:parentPks
summary: The internal ids of the parent groups; used internally.
- key: table:authentik_group;field:attributes
summary: The raw attributes stored on the group in Authentik, as JSON text.
content: |
Needs the audit permission. Permissions, app access and policy are kept here, so edit them with the actions rather than by hand.
- key: table:authentik_policy_binding;slot:help
summary: The rules attached to an application that allow or deny specific groups or users from opening it.
content: |
Each binding points at a group or user (or a named policy) and says whether it grants or blocks access. They are evaluated in **{{label:authentik_policy_binding.order}}**, and the application's **Policy Engine Mode** decides how the results combine.
This list is read only and appears on an application under **Who can open it (policy bindings)**. Bindings are changed in Authentik.
**How rows get created:** Define application bindings in Authentik; you can inspect the live bindings here. An empty list means no bindings match your query.
- key: table:authentik_policy_binding;field:pk
summary: Authentik's internal id for the binding.
- key: table:authentik_policy_binding;field:target
summary: Internal id of the application the binding is attached to.
- key: table:authentik_policy_binding;field:policyName
summary: The named policy the binding uses, when it is a policy rather than a group or user.
- key: table:authentik_policy_binding;field:groupName
summary: The group this binding applies to, if it targets a group.
- key: table:authentik_policy_binding;field:username
summary: The user this binding applies to, if it targets a person.
- key: table:authentik_policy_binding;field:negate
summary: Yes when the rule is reversed, so it applies to everyone except the group or user named.
- key: table:authentik_policy_binding;field:enabled
summary: Whether the rule is currently in force.
- key: table:authentik_policy_binding;field:order
summary: The position of the rule; lower numbers are checked first.
- key: table:authentik_policy_binding;field:expires
summary: When the rule stops applying, if it has an end date.
- key: table:authentik_role;slot:help
summary: Authentik roles, which bundle Authentik's own admin rights and can be attached to groups.
content: |
A role controls what someone may do inside Authentik itself, not inside this application. Only roles created by people are listed; ones Authentik manages itself are left out.
This list is read only. Managing roles from here is not available.
**How rows get created:** Define roles in Authentik; you can inspect its live, unmanaged roles here. An empty list means no unmanaged roles match your query.
- key: table:authentik_role;field:pk
summary: Authentik's internal id for the role.
- key: table:authentik_role;field:name
summary: The role's name.
- key: table:authentik_session;slot:help
summary: The sign-in sessions currently open in Authentik, with where and when each was last used.
content: |
A session is a signed-in browser or device. Use this to see who is signed in, from which address, and whether a session looks unfamiliar. A person's sessions also appear under **{{label:authentik_session}}** on their user record.
The list is read only and needs the audit permission. The **{{label:authentikRevokeSessions}}** action, which signs a person out everywhere, appears only when writes are switched on.
**How rows get created:** Authentik creates sessions when people sign in; you can inspect them live here. An empty list means no sessions match your query.
- key: table:authentik_session;field:uuid
summary: Authentik's id for the session.
- key: table:authentik_session;field:userPk
summary: The Authentik id of the person this sign-in session belongs to.
- key: table:authentik_session;field:username
summary: The username of the person who owns this session.
- key: table:authentik_session;field:lastIp
summary: The network address this session was last seen from.
content: |
Only people with the audit permission can open the Sessions list.
- key: table:authentik_session;field:lastUserAgent
summary: The browser or device description sent with the session's most recent request.
- key: table:authentik_session;field:lastUsed
summary: When the session was last used.
- key: table:authentik_session;field:expires
summary: When the session ends by itself if it is not revoked first.
- key: table:authentik_session;field:current
summary: Yes when this is the session you are using right now.
- key: table:authentik_session;field:geoIp
summary: The approximate location Authentik worked out from the last IP address, when it has one.
- key: table:authentik_session;field:asn
summary: The network provider Authentik matched to the last IP address, when it has one.
- key: table:authentik_token;slot:help
summary: Details of Authentik tokens and app passwords. The secret key is never shown.
content: |
This list holds the details of access tokens and app passwords: who owns each one, what it is for and when it expires. The secret key is never stored or shown here.
It is read from Authentik, subject to the host's configured cache lifetime.
You need the audit permission (or the permission to manage tokens) to see it. You normally reach it from the **Tokens and app passwords** section on a person's record.
Token actions are available when the host enables token management and writes, and you have the required permissions.
**How rows get created:** Create tokens in Authentik or use Create Token when enabled. This list reads live token metadata, never keys. An empty list means no tokens match your query and visible scope.
- key: table:authentik_token;field:identifier
summary: The token's unique name in Authentik, not its secret key.
- key: table:authentik_token;field:intent
summary: What the token is for in Authentik, for example an API token or an app password.
- key: table:authentik_token;field:username
summary: The username of the person who owns the token.
- key: table:authentik_token;field:description
summary: The note whoever created the token gave it, to remember what it is used for.
- key: table:authentik_token;field:expires
summary: When the token stops working.
- key: table:authentik_token;field:expiring
summary: Yes when the token has an expiry date; No when it never expires.
- key: table:authentik_user;slot:help
summary: Review people and service accounts within your configured Authentik user scope.
content: |
**{{label:authentik_user}}** lists the accounts in Authentik. It is read live (with caching configured by your host), so changes made in the Authentik admin screens show up here too. Authentik stays the source of truth.
Open a person to see their identity, groups and roles, sessions, tokens and recent history.
**Changing people** (create, edit, deactivate and reactivate, add to group, send a password reset, reset MFA, revoke app passwords, revoke sessions) only appears when changes are switched on for this site. Otherwise the Identity screens are read-only and every change must be made in Authentik itself.
Some accounts are protected and cannot be changed from here: Authentik superusers, the service account that connects this site to Authentik, internal service accounts, and any account marked protected. You also cannot change your own account.
Every change needs a reason and is recorded under **{{label:authentik_admin_action}}**.
**How rows get created:** Create accounts in Authentik or use the enabled user actions. You read their live state from Authentik. An empty list means no accounts match your query and configured user scope.
- key: table:authentik_user;section:identity
summary: 'Who the person is: username, name, email, status, type and sign-in dates.'
- key: table:authentik_user;section:access
summary: The groups and roles the person belongs to and the permissions they add up to in this app.
- key: table:authentik_user;section:memberships
summary: The groups this person is a member of.
- key: table:authentik_user;section:sessions
summary: The person's current sign-in sessions. Needs the audit permission.
- key: table:authentik_user;section:tokens
summary: Details of the person's access tokens and app passwords, never the secret keys. Needs the audit permission.
- key: table:authentik_user;section:events
summary: Recent things Authentik recorded for this person, such as sign-ins and changes. Needs the audit permission.
- key: table:authentik_user;section:attributes
summary: Extra data stored on the person in Authentik, plus when their password last changed. Needs the audit permission.
- key: table:authentik_user;field:pk
summary: The number Authentik uses to identify this person.
- key: table:authentik_user;field:username
summary: "The name the person signs in with."
content: |
The name the person signs in with. Letters, digits and . _ @ + - only.
- key: table:authentik_user;field:name
summary: The person's full name as shown in Authentik.
- key: table:authentik_user;field:email
summary: The person's email address. Password reset emails are sent here.
- key: table:authentik_user;field:isActive
summary: Yes if the person can sign in; deactivated accounts keep their history but cannot sign in.
- key: table:authentik_user;field:type
summary: The kind of account in Authentik.
content: |
Values you can see here:
- **internal**: a normal member account.
- **external**: an account for someone outside the organisation.
- **service_account**: an account for a script or service rather than a person.
- key: table:authentik_user;field:isSuperuser
summary: Yes if the account is an Authentik superuser, directly or through a parent group. Not editable here.
- key: table:authentik_user;field:isProtected
summary: Yes if this account cannot be changed from this screen.
content: |
An account is protected when it is an Authentik superuser, the service account that connects this site to Authentik, an internal Authentik service account, listed as protected in the site's setup, or marked as protected in its attributes. Use the Authentik admin screens for these.
- key: table:authentik_user;field:groupNames
summary: The groups the person is directly a member of.
- key: table:authentik_user;field:roleNames
summary: The Authentik roles the person has through their groups.
- key: table:authentik_user;field:lastLogin
summary: When the person last signed in. Blank if they never have.
- key: table:authentik_user;field:dateJoined
summary: When the account was created in Authentik.
- key: table:authentik_user;field:lastUpdated
summary: When the account was last changed in Authentik.
- key: table:authentik_user;field:path
summary: The folder-like location of the account in Authentik, used to organise users.
- key: table:authentik_user;field:uuid
summary: The unique identifier Authentik uses for this account.
- key: table:authentik_user;field:qqqAttributes
summary: The Authentik attributes that start with qqq.io/, which this application reads for permissions and protection.
- key: table:authentik_user;field:effectivePermissions
summary: The permissions this person ends up with in this app, added up from all their groups and parent groups.
content: |
This list is worked out when you open the record. Policies on groups only count here when policy enforcement is on (see **Policy Enforcement** on a group).
- key: table:authentik_user;field:passwordChangeDate
summary: When the person last changed their password. Needs the audit permission.
- key: table:authentik_user;field:attributes
summary: All extra data stored on the person in Authentik, as JSON. Needs the audit permission.
content: |
**{{label:authentikEditUser}}** can change attributes that do not start with qqq.io/ or goauthentik.io/. Those two prefixes are managed elsewhere.
- key: table:authentik_admin_action;slot:help
summary: The audit trail of every change attempted from the Identity screens, successful or not.
content: |
**{{label:authentik_admin_action}}** records each attempt to change something in Authentik: who did it, what, to whom, why, and how it ended. A row is written before the call to Authentik is made, so failures and refusals are recorded too. Authentik's own event log also gets a matching entry naming the person who did it.
Use it to answer who changed someone's access and when, and to see the state before and after.
You need the audit permission to read it. Rows are written by the system only; you cannot add or edit them by hand.
**How rows get created:** Your administrative actions write audit records before making changes in Authentik. An empty list means no audit records match your query.
- key: table:authentik_admin_action;field:correlationId
summary: An id shared by every record of the same attempt, to match it with Authentik's own log.
- key: table:authentik_admin_action;field:actionName
summary: Which change was attempted, for example Edit User or Add To Group.
- key: table:authentik_admin_action;field:operator
summary: The person who did it, shown by their sign-in email.
- key: table:authentik_admin_action;field:reason
summary: The reason the person typed when they made the change.
- key: table:authentik_admin_action;field:targetType
summary: 'What kind of thing was changed: a user, group, membership or similar.'
- key: table:authentik_admin_action;field:targetKey
summary: The Authentik id of the thing that was changed.
- key: table:authentik_admin_action;field:targetLabel
summary: The name of the thing that was changed, such as a username or group name.
- key: table:authentik_admin_action;field:beforeText
summary: How the thing looked before the change.
- key: table:authentik_admin_action;field:afterText
summary: How the thing looked after the change.
- key: table:authentik_admin_action;field:status
summary: 'How the attempt ended: {{values:authentik_action_status}}.'
content: |
Values:
- **Waiting for approval**: needs a second person to approve it; nothing has been sent to Authentik.
- **Running**: in progress.
- **Succeeded**: done in Authentik.
- **Failed**: sent to Authentik but it did not work; see the error message.
- **Refused**: a safety rule or missing permission stopped it before anything was sent.
- key: table:authentik_admin_action;field:errorMessage
summary: Why the attempt failed or was refused. Blank when it succeeded.
- key: table:authentik_admin_action;field:authentikEventPk
summary: The id of the matching entry in Authentik's own event log, when one was written.
- key: table:authentik_admin_action;field:approver
summary: The second person who approved the change, when approval was needed.
- key: table:authentik_approval_request;slot:help
summary: Changes waiting for a second person to approve before they are sent to Authentik.
content: |
Some changes need two people, so one person cannot grant themselves power. When two-person approval is enabled, adding identity administration permissions requires approval. Your host can also require approval for policy changes. The first person's request waits here. Nothing has changed in Authentik yet.
A different person with the approve permission opens the request and runs **{{label:authentikApprove}}**, or **{{label:authentikReject}}**. You cannot approve your own request.
Requests expire after 24 hours; ask again if one has expired. When approved, the change is checked again against live data, so it can still fail if something changed in the meantime.
Waiting requests also show on the Identity home under **Needs attention**.
**How rows get created:** Changes requiring a second person create approval requests. An empty list means no requests match your query; completed requests remain recorded.
- key: table:authentik_approval_request;field:actionName
summary: Which change is waiting for approval.
- key: table:authentik_approval_request;field:targetKey
summary: The Authentik id of the thing the change applies to.
- key: table:authentik_approval_request;field:targetLabel
summary: The name of the person or group the change applies to.
- key: table:authentik_approval_request;field:summary
summary: A plain description of what will change if the request is approved.
- key: table:authentik_approval_request;field:reason
summary: The reason the requester gave for the change.
- key: table:authentik_approval_request;field:payloadJson
summary: The exact change that will be applied, saved so the approver applies what was requested.
- key: table:authentik_approval_request;field:requester
summary: The person who asked for the change, shown by their sign-in email.
- key: table:authentik_approval_request;field:approver
summary: The person who approved or rejected the request. Blank while it is still waiting.
- key: table:authentik_approval_request;field:status
summary: 'Where the request stands: waiting, approved, rejected, expired or failed.'
content: |
Values:
- **Waiting for approval**: nobody has decided yet.
- **Approved and applied**: a second person approved it and the change was made.
- **Rejected**: a second person said no; nothing changed.
- **Expired**: nobody decided within 24 hours.
- **Approved but failed**: it was approved but the change could not be made; see the error message.
- key: table:authentik_approval_request;field:expiresDate
summary: When the request lapses if nobody decides it. It is 24 hours after the request was made.
- key: table:authentik_approval_request;field:decidedDate
summary: When the request was approved, rejected or marked expired.
- key: table:authentik_approval_request;field:errorMessage
summary: Why an approved change failed, or the word expired. Blank otherwise.
- key: table:authentik_policy;slot:help
summary: 'The saved policy of each group: a list of what its members may do in this app.'
content: |
A **policy** is a document saved on a group in Authentik. It lists the apps, tables, actions and reports the group's members may use, and what they may do (open, read, add, edit, delete). Members of the group, and of groups below it, receive those permissions when they sign in.
This list shows the last policy saved from this application for each group, with who saved it and when. Authentik holds the live document.
Saved policies only take effect at sign-in when policy enforcement is on. The banner on this screen says whether it is. When signed-in users receive changed permissions depends on the host's authentication and session configuration.
Use **{{label:authentikEditPolicyRecord}}** to change one. It needs the policies permission, and you can only grant permissions you hold yourself.
**How rows get created:** Saving a group policy records its document and author here. Authentik holds the live policy. An empty list means no saved policy records match your query.
- key: table:authentik_policy;section:enforcement
summary: Whether saved policies are actually applied when people sign in.
- key: table:authentik_policy;section:policy
summary: The group, how big its policy is, who saved it and the policy document itself.
- key: table:authentik_policy;section:system
summary: Technical details kept by the system, including the check used to spot changes made in Authentik.
- key: table:authentik_policy;field:groupPk
summary: The Authentik id of the group this policy belongs to.
- key: table:authentik_policy;field:groupName
summary: The name of the group the policy is saved on.
- key: table:authentik_policy;field:policyJson
summary: The policy document as it was last saved, in JSON.
content: |
The document lists statements. Each statement allows some actions (open, read, insert, edit, delete) on named apps, tables, actions, reports or widgets. Use **{{label:authentikEditPolicyRecord}}** to change it with tick boxes and a live preview instead of editing the JSON by hand. A document that is not valid grants nothing.
- key: table:authentik_policy;field:statementCount
summary: How many statements the saved policy has.
- key: table:authentik_policy;field:permissionCount
summary: How many separate permissions the saved policy grants in total.
- key: table:authentik_policy;field:savedBy
summary: Who saved this policy last, shown by their sign-in email.
- key: table:authentik_policy;field:savedDate
summary: When this policy was last saved from this application.
- key: table:authentik_policy;field:authentikFingerprint
summary: A short code of the policy as saved, used to detect that someone changed it directly in Authentik.
content: |
When you open **{{label:authentikEditPolicyRecord}}**, the editor compares this code to what Authentik holds now. If they differ, someone changed the policy in Authentik, and you are asked to review it again instead of overwriting their change.
- key: process:authentikCreateUser;slot:help
summary: Creates a new person in Authentik and, by default, emails them a link to set their own password.
content: |
Only available when changes are switched on for this site. Needs the permission to manage users.
You never set or see a password. If **Email them a link to set their own password** is ticked (the default), Authentik sends the person a link to choose one.
- The username may use letters, digits and . _ @ + - (up to 150 characters), and must not already exist.
- The email must be valid and not already used by another person.
- **{{label:authentik_group}}** adds them to existing groups straight away. You can only pick groups whose permissions you hold yourself.
Review shows what will be created before anything is sent to Authentik. Every run needs a reason and is recorded under **{{label:authentik_admin_action}}**.
- key: process:authentikCreateUser;field:username
summary: "The unique sign-in name for the new person."
content: |
The sign-in name: letters, digits and . _ @ + - only, up to 150 characters, and not already taken.
- key: process:authentikCreateUser;field:name
summary: The person's full name.
- key: process:authentikCreateUser;field:email
summary: Their email address. The link to set a password is sent here, and it must not belong to another person.
- key: process:authentikCreateUser;field:groups
summary: Groups to add them to, one group name per line. Optional.
- key: process:authentikCreateUser;field:sendRecovery
summary: Tick to have Authentik email the person a link so they can set their own password. On by default.
- key: process:authentikCreateUser;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikEditUser;slot:help
summary: Changes a person's username, name, email, path or other attributes in Authentik.
content: |
Only available when changes are switched on for this site. Needs the permission to manage users.
You can edit one person at a time. The form opens filled in with their current details; change what you need. Review shows exactly what will change.
- Attributes starting with qqq.io/ or goauthentik.io/ are managed elsewhere and cannot be changed here. Editing the other attributes also needs the audit permission.
- If someone changed this person in Authentik after you opened the form, the change is stopped and you are asked to review it again, rather than overwriting their work.
- Protected accounts, and your own account, cannot be edited here.
- key: process:authentikEditUser;field:username
summary: The sign-in name. It must not already belong to another person.
- key: process:authentikEditUser;field:name
summary: The person's full name. It cannot be empty.
- key: process:authentikEditUser;field:email
summary: The person's email address. It must be valid and not used by another person.
- key: process:authentikEditUser;field:path
summary: The folder-like location of the account in Authentik. It cannot be empty.
- key: process:authentikEditUser;field:otherAttributes
summary: 'Extra attributes as a JSON object, for example {"department": "sales"}. Leave blank to keep them as they are.'
- key: process:authentikEditUser;field:expectedFingerprint
summary: Filled in automatically. Used to detect that the person was changed in Authentik while you had this form open.
- key: process:authentikEditUser;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikDeactivateUser;slot:help
summary: Stops selected people from signing in and ends their sessions. Nothing is deleted.
content: |
Only available when changes are switched on for this site. Needs the permission to manage users.
You can select up to 100 people. Deactivating signs them out immediately and keeps the account and its history. Use **{{label:authentikReactivateUser}}** to undo it.
To confirm, type the username. When you select several people, type the username of the first one. Tick **Also revoke their app passwords** to remove those too.
Protected accounts, service accounts that connect this site, superusers and your own account cannot be deactivated here. The last person able to manage memberships and permissions cannot be deactivated either: add another administrator first.
- key: process:authentikDeactivateUser;field:revokeAppPasswords
summary: Also deletes the person's app passwords. Off by default.
- key: process:authentikDeactivateUser;field:confirmText
summary: Type the person's username to confirm. With several people selected, type the first one's username.
- key: process:authentikDeactivateUser;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikReactivateUser;slot:help
summary: Lets selected deactivated people sign in again.
content: |
Only available when changes are switched on for this site. Needs the permission to manage users.
You can select up to 100 people. Their groups and history are as they were. Protected accounts and your own account cannot be changed from here.
- key: process:authentikReactivateUser;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikAddToGroup;slot:help
summary: Adds the selected people to one group.
content: |
Only available when changes are switched on for this site. Needs the permission to manage memberships.
Pick the group and give a reason. You can select up to 100 people. Above 25 people you must also type how many you selected.
You can only add people to a group whose permissions and app sign-in access you hold yourself. Superuser groups and the groups below them cannot be managed here, and protected accounts and your own account cannot be changed.
Review shows who will be added before anything is sent to Authentik. To add several people to a group from the group's own screen, use **{{label:authentikAddMembers}}**.
- key: process:authentikAddToGroup;field:group
summary: The group to add the selected people to.
- key: process:authentikAddToGroup;field:confirmText
summary: Type the number of people you selected. Only needed when you selected more than 25.
- key: process:authentikAddToGroup;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikRemoveFromGroup;slot:help
summary: Removes the selected people from one group.
content: |
Only available when changes are switched on for this site. Needs the permission to manage memberships.
Pick the group and give a reason. You can select up to 100 people. Above 25 people you must also type how many you selected.
The last person able to manage memberships and permissions cannot be removed: add another administrator first. Superuser groups cannot be managed here, and protected accounts and your own account cannot be changed.
Use **{{label:authentikRevokeSessions}}** to end Authentik sessions. Permission refresh timing depends on your host.
- key: process:authentikRemoveFromGroup;field:group
summary: The group to remove the selected people from.
- key: process:authentikRemoveFromGroup;field:confirmText
summary: Type the number of people you selected. Only needed when you selected more than 25.
- key: process:authentikRemoveFromGroup;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikSendPasswordReset;slot:help
summary: Has Authentik email each selected person a link to choose a new password.
content: |
Only available when changes are switched on for this site. Needs the permission to reset credentials.
You never see the link. The person must be active and have an email address, and the password email must be set up for this site, otherwise the run is refused for that person. You can select up to 100 people.
- key: process:authentikSendPasswordReset;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikResetMfa;slot:help
summary: Removes every multi-factor sign-in device for the selected people.
content: |
Only available when changes are switched on for this site. Needs the permission to reset credentials.
Use it when someone has lost their phone or authenticator. Whether they must enroll another device depends on your Authentik sign-in flow. Type the username to confirm. It is recorded under **{{label:authentik_admin_action}}**.
- key: process:authentikResetMfa;field:confirmText
summary: Type the person's username to confirm.
- key: process:authentikResetMfa;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikRevokeAppPasswords;slot:help
summary: Deletes the selected people's app passwords. API tokens are not touched.
content: |
Only available when changes are switched on for this site. Needs the permission to reset credentials.
App passwords are long-lived passwords people create for scripts and other apps. Use this when one may have leaked. Type the username to confirm. It does not sign anyone out; use **{{label:authentikRevokeSessions}}** for that.
- key: process:authentikRevokeAppPasswords;field:confirmText
summary: Type the person's username to confirm.
- key: process:authentikRevokeAppPasswords;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikRevokeSessions;slot:help
summary: Signs the selected people out everywhere.
content: |
Only available when changes are switched on for this site. Needs the permission to revoke sessions.
Use it if an account may be compromised or its Authentik sessions should end. The person can sign in again unless deactivated. Existing host sessions and permission refresh timing depend on your host.
- key: process:authentikRevokeSessions;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikCreateGroup;slot:help
summary: Creates a group in Authentik, with its parents, permissions and app sign-in access.
content: |
Only available when changes are switched on for this site. Needs the permission to manage groups.
A group's permissions apply to everyone in it and to the groups below it.
- **Parent groups** and group names must exist; a group name must not already exist.
- **QQQ permissions** and **App access** are optional and can be set later with **{{label:authentikEditPermissions}}**.
- You can only grant permissions you hold yourself. Granting identity administration permissions needs a second person when two-person approval is enabled.
- A group cannot be placed under a superuser group.
- key: process:authentikCreateGroup;field:name
summary: The new group's name. It must not already exist.
- key: process:authentikCreateGroup;field:parents
summary: Parent groups, one group name per line. Optional. Members of this group also get the parents' access.
- key: process:authentikCreateGroup;field:permissions
summary: Named permissions the group grants, one per line. You can only list permissions you hold yourself.
- key: process:authentikCreateGroup;field:appAccess
summary: Apps the group may sign in to, one app key per line. You can only list apps you can sign in to yourself.
- key: process:authentikCreateGroup;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikEditGroup;slot:help
summary: Renames a group, changes its parent groups or its other attributes.
content: |
Only available when changes are switched on for this site. Needs the permission to manage groups.
You can edit one group at a time. The form opens filled in with the current values.
- Parent groups: one name per line. Write none to clear them. A group cannot be put below itself or its own descendants, and not under a superuser group.
- Changes that would leave nobody able to manage memberships and permissions are refused.
- If someone changed the group in Authentik after you opened the form, you are asked to review it again instead of overwriting their change.
To change what the group grants, use **{{label:authentikEditPermissions}}** or **Edit Policy**.
- key: process:authentikEditGroup;field:name
summary: The group's name. It must not already belong to another group.
- key: process:authentikEditGroup;field:parents
summary: Parent groups, one group name per line. Write none to clear them.
- key: process:authentikEditGroup;field:otherAttributes
summary: Extra attributes as a JSON object. Leave blank to keep them as they are.
- key: process:authentikEditGroup;field:expectedFingerprint
summary: Filled in automatically. Used to detect that the group was changed in Authentik while you had this form open.
- key: process:authentikEditGroup;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikEditPermissions;slot:help
summary: Changes the named permissions and app sign-in access a group grants.
content: |
Only available when changes are switched on for this site. Needs the permission to manage permissions.
Both lists are one entry per line. They replace what the group has now, so Review shows what is added and removed.
- You can only add permissions you hold yourself, and give sign-in access only to apps you can sign in to yourself.
- When two-person approval is enabled, adding identity administration permissions requires approval, so the change waits under **{{label:authentik_approval_request}}**.
- Removing permissions is refused if nobody would be left able to manage memberships and permissions.
- Superuser groups cannot be managed here.
Permission refresh timing depends on your host. Use **{{label:authentikRevokeSessions}}** to end Authentik sessions.
- key: process:authentikEditPermissions;field:permissions
summary: Named permissions the group grants, one per line. Replaces the current list.
- key: process:authentikEditPermissions;field:appAccess
summary: Apps the group may sign in to, one app key per line. Replaces the current list.
- key: process:authentikEditPermissions;field:expectedFingerprint
summary: Filled in automatically. Used to detect that the group was changed in Authentik while you had this form open.
- key: process:authentikEditPermissions;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikEditPolicy;slot:help
summary: Opens the policy editor to choose what a group's members may do, then saves it to Authentik.
content: |
Only available when changes are switched on for this site. Needs the permission to manage policies.
Tick the apps, tables, actions and reports the group's members may use, and what they may do with each (open, read, insert, edit, delete). A live preview shows the policy document that will be saved. Members of the group, and of groups below it, receive these permissions when they sign in.
- You can only grant permissions you hold yourself.
- A document that is not valid grants nothing, and one that cannot be read back exactly is never saved.
- If someone changed the policy in Authentik after you opened it, you are asked to review it again instead of overwriting their change.
- The banner at the top says whether saved policies are enforced at sign-in. If not, saving still works but changes nothing yet.
Permission refresh timing depends on your host. Use **{{label:authentikRevokeSessions}}** to end Authentik sessions.
- key: process:authentikEditPolicy;field:groupPk
summary: Filled in automatically with the group you opened.
- key: process:authentikEditPolicy;field:expectedFingerprint
summary: Filled in automatically. Used to detect that the policy was changed in Authentik while you had this form open.
- key: process:authentikEditPolicy;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikGeneratePolicy;slot:help
summary: Builds a group's policy from the access its members get today, so nothing is lost when policies are switched on.
content: |
Only available when changes are switched on for this site. Needs the permission to manage policies.
The generated policy is joined with the group's current policy, so nothing is removed. Named permissions this app has that a policy cannot express stay on the group's permissions list, and anything that cannot be written is listed as skipped.
Review shows the generated document and what changes before anything is saved to Authentik. Use it once per group when moving from fixed access to policy-based access. See **Compare Access** to check the result.
- key: process:authentikGeneratePolicy;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikEditPolicyRecord;slot:help
summary: Opens the policy editor for this group's saved policy, then saves it to Authentik.
content: |
Works the same as **{{label:authentikEditPolicy}}** on a group. Only available when changes are switched on for this site. Needs the permission to manage policies.
Tick what members of the group, and of the groups below it, may do. A live preview shows the document that will be saved. You can only grant permissions you hold yourself, and you are asked to review again if someone changed the policy in Authentik meanwhile.
Saved policies only take effect at sign-in when enforcement is on; the banner says whether it is.
- key: process:authentikEditPolicyRecord;field:groupPk
summary: Filled in automatically with the group this policy belongs to.
- key: process:authentikEditPolicyRecord;field:expectedFingerprint
summary: Filled in automatically. Used to detect that the policy was changed in Authentik while you had this form open.
- key: process:authentikEditPolicyRecord;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikAddMembers;slot:help
summary: Adds people to the selected group by typing their usernames.
content: |
Only available when changes are switched on for this site. Needs the permission to manage memberships.
Enter one username per line. You can add up to 100 people at a time; above 25 you must also type how many. The group must grant only permissions and app access you hold yourself, and superuser groups cannot be managed here. Protected accounts and your own account cannot be changed.
Review shows who will be added before anything is sent to Authentik. See also **{{label:authentikAddToGroup}}**, which starts from selected people.
- key: process:authentikAddMembers;field:usernames
summary: The usernames to add, one per line.
- key: process:authentikAddMembers;field:confirmText
summary: Type the number of people listed. Only needed when there are more than 25.
- key: process:authentikAddMembers;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikRemoveMembers;slot:help
summary: Removes people from the selected group by typing their usernames.
content: |
Only available when changes are switched on for this site. Needs the permission to manage memberships.
Enter one username per line. You can remove up to 100 people at a time; above 25 you must also type how many. The last person able to manage memberships and permissions cannot be removed: add another administrator first. Superuser groups cannot be managed here.
Review shows who will be removed before anything is sent to Authentik. See also **{{label:authentikRemoveFromGroup}}**, which starts from selected people.
- key: process:authentikRemoveMembers;field:usernames
summary: The usernames to remove, one per line.
- key: process:authentikRemoveMembers;field:confirmText
summary: Type the number of people listed. Only needed when there are more than 25.
- key: process:authentikRemoveMembers;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikApprove;slot:help
summary: Applies a change a colleague asked for, after you check it.
content: |
Needs the approve permission. It only exists while two-person approval is on.
You must be a different person from the one who asked, and the request must still be waiting and under 24 hours old. The change is checked again against live data, under your own permissions, then sent to Authentik. If it can no longer be applied the request is marked as failed and nothing is changed.
The result is recorded under **{{label:authentik_admin_action}}** with both names.
- key: process:authentikReject;slot:help
summary: Turns down a colleague's request. Nothing is changed in Authentik.
content: |
Needs the approve permission, and you must be a different person from the one who asked. The request is marked as rejected and stays in the list as a record.
- key: widget:authentikHome;slot:label
summary: 'The Identity home: what needs attention, people and group counts, recent changes and the connection to Authentik.'
content: |
Needs attention shows waiting approval requests, drift report problems, a read-only notice, and an expiring Authentik connection token. Recent changes lists the latest 8 entries from Admin Actions and needs the audit permission.
- key: widget:authentikPolicyEditor;slot:label
summary: 'The editor for a group''s policy: tick boxes for apps, tables, actions and reports, with a live document preview.'
- key: widget:authentikPolicyEnforcement;slot:label
summary: A banner saying whether saved policies are enforced when people sign in.
content: |
Green and "Enforced" means saved policies decide people's permissions at sign-in. "Not enforced" means they are saved but ignored for now. The text says why, for example that enforcement is switched off, or that switching it on would take permissions away from current administrators.
- key: widget:authentikCompareAccess;slot:label
summary: Shows, for every active person, the permissions they have today next to what saved policies would give them.
content: |
Needs the admin permission. Added shows what policies would give them that they lack today, and Removed shows what they would lose. Use it before switching policy enforcement on. Read only.
- key: widget:authentikDriftReport;slot:label
summary: Health checks on the Authentik setup, with errors listed first.
content: |
Needs the admin permission. It checks the Authentik version, blueprints, the service account, a protected break-glass superuser, that at least one person can administer identities, group attributes, and the service account's activity. Each finding is OK, info, warning or error. A check that cannot run is reported as an error with an unknown result.
- key: widget:authentik_user_memberships;slot:label
summary: The groups this person belongs to.
- key: widget:authentik_user_sessions;slot:label
summary: The person's current sign-in sessions. Needs the audit permission.
- key: widget:authentik_user_tokens;slot:label
summary: Details of the person's access tokens and app passwords, never the keys. Needs the audit permission.
- key: widget:authentik_user_events;slot:label
summary: The latest events Authentik recorded for this person. Needs the audit permission.
- key: widget:authentik_group_members;slot:label
summary: The people who are members of this group.
- key: widget:authentik_group_policy;slot:label
summary: The saved policy for this group, with who saved it and when.
- key: widget:authentik_application_bindings;slot:label
summary: The groups and people Authentik allows to open this application.
- key: possibleValueSource:authentik_action_status;slot:help
summary: The outcomes an attempted change can have in the audit trail.
content: |
Values:
- **Waiting for approval**: a second person must approve it; nothing has been sent to Authentik.
- **Running**: in progress.
- **Succeeded**: the change was made.
- **Failed**: it was sent to Authentik but did not work.
- **Refused**: a safety rule or missing permission stopped it before anything was sent.
- key: possibleValueSource:authentik_approval_status;slot:help
summary: The states an approval request can be in.
content: |
Values:
- **Waiting for approval**: nobody has decided yet.
- **Approved and applied**: a second person approved it and the change was made.
- **Rejected**: a second person said no; nothing changed.
- **Expired**: nobody decided within 24 hours.
- **Approved but failed**: it was approved but the change could not be made.
- key: table:authentik_token;field:ownerPk
summary: 'The Authentik identifier of the token’s owner.'
- key: table:authentik_token;field:ownerType
summary: 'Whether the token belongs to a person or a service account.'
- key: table:authentik_token;field:ownerName
summary: 'The displayed name of the token’s owner.'
- key: table:authentik_token;field:managed
summary: 'The management marker reported for this token.'
- key: table:authentik_token;field:lastUsed
summary: 'The last usage time available for this token.'
- key: table:authentik_token;field:lastUsedNote
summary: 'An explanation of the available token usage information.'
- key: table:authentik_token;field:createdAt
summary: 'The creation time available for this token.'
- key: table:authentik_token;field:scopeSummary
summary: 'A readable summary of the restrictions associated with this token.'
- key: table:authentik_token_scope;slot:help
summary: 'Locally stored access restrictions and reminder history for an Authentik token.'
content: |
Use **{{label:authentik_token_scope}}** to inspect the restrictions associated with an access token. These records contain identifiers and scope metadata, not the credential itself. Read-only mode and selected resources can narrow the access already held by the token’s owner; they cannot grant additional permissions.
Scope records also track creation, rotation and expiry reminders. Review them alongside **Tokens** when investigating access. Token creation and rotation actions maintain this metadata, and an empty scope dimension means that dimension adds no restriction.
**How rows get created:** Token actions maintain local scope and reminder metadata here. An empty list means no token scope records match your query.
- key: table:authentik_token_scope;field:tokenIdentifier
summary: 'The identifier of the Authentik token these restrictions belong to.'
- key: table:authentik_token_scope;field:ownerUsername
summary: 'The username of the token’s owner.'
- key: table:authentik_token_scope;field:ownerPk
summary: 'The Authentik identifier of the token’s owner.'
- key: table:authentik_token_scope;field:readOnly
summary: 'Whether the token is restricted to read operations.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:createdBy
summary: 'Who created this token scope record.'
- key: table:authentik_token_scope;field:apps
summary: 'The applications included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:tables
summary: 'The tables included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:processes
summary: 'The processes included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:reports
summary: 'The reports included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:widgets
summary: 'The widgets included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:operationIds
summary: 'The individual operations included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:createdAt
summary: 'When this token scope record was created.'
- key: table:authentik_token_scope;field:rotatedAt
summary: 'When the token associated with this scope was last rotated.'
- key: table:authentik_token_scope;field:lastReminder7d
summary: 'When the seven-day expiry reminder was last recorded.'
- key: table:authentik_token_scope;field:lastReminder1d
summary: 'When the one-day expiry reminder was last recorded.'
- key: process:authentikTokenReminders;slot:help
summary: 'Check approaching token expiries and issue the configured reminders.'
content: |
This background action checks tokens approaching their expiry and records reminder timing in **{{label:authentik_token_scope}}**. Use it to understand when owners were warned to renew or replace credentials. Reminders do not extend expiry or rotate a key automatically. Review **Tokens** for the current expiry, then use **{{label:authentikExtendToken}}**, **Rotate Token** or **Revoke Token** as appropriate for the credential.
- key: process:authentikCreateToken;slot:help
summary: 'Create a personal access token with an expiry and optional access restrictions.'
content: |
Use **Create Token** to create an app-password token for your own account. Review the expiry and any available scope restrictions before submitting. Scopes can narrow your permissions but cannot grant new access. The credential is shown once after creation; keep it in your intended credential store. The **Tokens** list and **{{label:authentik_token_scope}}** retain metadata rather than a recoverable copy of that credential.
- key: process:authentikRotateToken;slot:help
summary: 'Replace a token’s credential and invalidate the previous credential.'
content: |
Use **Rotate Token** when a credential needs replacement. Review the selected token and its ownership before continuing. Supported app-password tokens can be rotated; protected accounts require management in Authentik. Rotation invalidates the old credential, so update the consumers that use it. The new credential is shown once. Review **Tokens** and the recorded scope after completion.
- key: process:authentikRevokeToken;slot:help
summary: 'Invalidate a selected token so it can no longer authenticate.'
content: |
Use **Revoke Token** when access is no longer needed or a token should stop working. Review the identifier and owner before submitting because any consumer relying on that token will lose access. Revocation also removes its local scope metadata. It does not delete the owner’s account. Review the resulting **Tokens** list to confirm the selected token is no longer available.
- key: process:authentikExtendToken;slot:help
summary: 'Update the expiry of an eligible app-password token.'
content: |
Use **{{label:authentikExtendToken}}** when an existing eligible credential should remain valid for a longer allowed period. Review the owner and requested expiry before submitting. The action is limited to supported app-password tokens and permitted ownership; protected accounts are managed in Authentik. Extending resets expiry reminders but does not rotate the credential. Use **Rotate Token** when you need a replacement key.
- key: process:authentikCreateServiceAccount;slot:help
summary: 'Create a service account with checked group access and an expiring app password.'
content: |
Use **Create Service Account** for an integration that needs its own identity. Review the account name, requested groups and expiry before proceeding. Group checks and protection rules still apply. The account receives an app-password credential that is shown once, so store it for the intended integration. Inspect the resulting account and **Tokens** after creation, and use **Rotate Token** for later credential replacement.
- key: widget:authentikAccessTokens;slot:label
summary: 'Access tokens and their ownership, expiry and available management actions.'
- key: table:authentik_invitation;slot:help
summary: Review enrollment invitations and their expiry dates in Authentik.
content: |
Use **{{label:authentik_invitation}}** to review invitations when your host has configured an enrollment flow. Check the email address and expiry before following up with someone. Use **{{label:authentikCancelInvitation}}** to withdraw an invitation.
**How rows get created:** **{{label:authentikInviteUser}}** creates a single-use invitation in Authentik and requests its delivery by email. You read invitations live from Authentik. An empty list means no invitations match your query.
- key: table:authentik_invitation;field:name
summary: The invitation name recorded in Authentik.
- key: table:authentik_invitation;field:email
summary: The email address supplied for the invited person.
- key: table:authentik_invitation;field:singleUse
summary: Whether the invitation can be redeemed only once.
- key: table:authentik_invitation;field:expires
summary: When the invitation stops being valid.
- key: table:authentik_invitation;field:flowSlug
summary: The enrollment flow used by this invitation.
- key: table:authentik_invitation;field:createdBy
summary: The username Authentik records as the invitation creator.
- key: process:authentikInviteUser;slot:help
summary: Invite a new person to enroll through Authentik by email.
content: |
Enter the person's email, an optional full name and a reason. You need permission to manage users, writes must be enabled, and your host must configure an enrollment flow. The email must not already belong to an existing user.
Review the invitation before continuing. Authentik creates a single-use invitation that expires after seven days and sends the email. You can inspect it in **{{label:authentik_invitation}}** or withdraw it with **{{label:authentikCancelInvitation}}**.
- key: process:authentikCancelInvitation;slot:help
summary: Withdraw an invitation so it can no longer be used to enroll.
content: |
Select the invitation and give a reason. Review your selection before continuing. You need permission to manage users and writes must be enabled.
This action deletes the invitation in Authentik. An invitation that is already absent needs no change. Cancelling an invitation does not delete an account that has already enrolled.
- key: process:authentikDeleteUser;slot:help
summary: Permanently delete an eligible account from Authentik.
content: |
Use **{{label:authentikDeactivateUser}}** if you want to keep the account and its history. Deletion is permanent and requires the host to enable deletion as well as user management and writes.
Give a reason and type the username to confirm, then check **Review** before continuing. You cannot delete protected accounts, your own account or the last identity administrator. You need permission to manage users.
- key: process:authentikDeleteGroup;slot:help
summary: Permanently delete an empty group and the access it grants.
content: |
Remove the group's members and child groups first. Deletion removes the group with its permissions, application access and policy. You need permission to manage groups, and your host must enable deletion, group management and writes.
Give a reason and type the group name to confirm. Check **Review** before continuing. Superuser groups and changes that would remove the last identity administrator are refused.
- key: possibleValueSource:authentik_user;slot:help
summary: Choose an Authentik user from the accounts visible to you.
- key: possibleValueSource:authentik_group;slot:help
summary: Choose an Authentik group for the membership action.
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik;
import java.util.EnumSet;
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Coverage of the shipped help with all supported optional metadata present.
*******************************************************************************/
class AuthentikHelpBundleTest
{
/*******************************************************************************
** New objects and non-trivial fields must ship with valid help.
*******************************************************************************/
@Test
void bundleCoversOwnedMetadata() throws Exception
{
var support = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS,
AuthentikFeature.POLICIES, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.DELETE,
AuthentikFeature.MEMBERSHIP, AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS));
HelpBundleAssertions.forInstance(support.qInstance)
.bundles("help/qbit-authentik.yaml")
.owning(target -> target.name().startsWith("authentik"))
.assertComplete();
var bundle = HelpBundles.of(support.qInstance).getBundles().get(0);
assertThat(bundle.getVersion()).isEqualTo(AuthentikQBitProducer.getVersion());
var keys = bundle.getEntries().stream().map(entry -> entry.getKey()).toList();
assertThat(keys).doesNotHaveDuplicates();
// Core's gate currently requires apps, tables, actions and table fields.
// Also pin coverage for this qbit's widgets and possible-value sources.
support.qInstance.getWidgets().keySet().stream().filter(name -> name.startsWith("authentik"))
.forEach(name -> assertThat(keys).contains("widget:" + name + ";slot:label"));
support.qInstance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("authentik"))
.forEach(name -> assertThat(keys).contains("possibleValueSource:" + name + ";slot:help"));
}
/*******************************************************************************
** Metadata-only registration is safe without help storage and follows prefixes.
*******************************************************************************/
@Test
void registersWithoutHelpStorage() throws Exception
{
var instance = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig().withTableNamePrefix("idp"));
assertThat(instance.getTable("helpContent")).isNull();
HelpBundles.syncOnStartup(instance);
var bundles = HelpBundles.of(instance).getBundles();
assertThat(bundles).hasSize(1);
assertThat(bundles.get(0).getEntries()).anySatisfy(entry ->
{
assertThat(entry.getKey()).isEqualTo("table:idp_user;slot:help");
assertThat(entry.getContent()).contains("{{label:idp_user}}").doesNotContain("{{label:authentik");
});
}
}
@@ -0,0 +1,232 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy 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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
** the qbit should be fully using them"). For every authentik_* table:
** - it is used at runtime: an API-backed table through its backend (every row
** is read live from Authentik by AuthentikActionUtil, so it has no rows of
** its own); a local table by runtime code that reads or writes it, not only
** its metadata or the home screen;
** - it has nothing to seed (setup comes from config; every table is live data
** or a log), and says why in STARTS_EMPTY;
** - it has bundle help that says what it is for and how its rows get created
** (shown on the table screen and when it is empty).
** A new table fails this test until it is wired, explained and documented.
*******************************************************************************/
class AuthentikTablesGuardrailTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "authentik");
/*******************************************************************************
** The API-backed tables: rows are read live from Authentik, never stored.
*******************************************************************************/
static final List<String> API_TABLES = List.of("authentik_user", "authentik_group", "authentik_group_member", "authentik_session", "authentik_event",
"authentik_application", "authentik_policy_binding", "authentik_blueprint", "authentik_role", "authentik_token", "authentik_invitation");
/*******************************************************************************
** The qbit's own tables, with the constant runtime code uses to reach them.
*******************************************************************************/
static final Map<String, String> LOCAL_TABLES = Map.of(
"authentik_admin_action", "AuthentikLocalTables.ADMIN_ACTION",
"authentik_approval_request", "AuthentikLocalTables.APPROVAL_REQUEST",
"authentik_policy", "AuthentikLocalTables.POLICY",
"authentik_token_scope", "AuthentikLocalTables.TOKEN_SCOPE");
/*******************************************************************************
** Why no table is seeded.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
Map.entry("authentik_user", "live Authentik data, no rows of its own; setup is the qbit config"),
Map.entry("authentik_group", "live Authentik data, no rows of its own"),
Map.entry("authentik_group_member", "virtual: computed live from group member lists"),
Map.entry("authentik_session", "live Authentik data: signed-in sessions"),
Map.entry("authentik_event", "live Authentik data: its event log"),
Map.entry("authentik_application", "live Authentik data, defined by blueprints in Authentik"),
Map.entry("authentik_policy_binding", "live Authentik data, defined on applications in Authentik"),
Map.entry("authentik_blueprint", "live Authentik data: blueprint status"),
Map.entry("authentik_role", "live Authentik data: RBAC roles"),
Map.entry("authentik_token", "live Authentik data: token metadata, never the key"),
Map.entry("authentik_invitation", "live Authentik data: pending invitations"),
Map.entry("authentik_admin_action", "log: one row per action a person runs"),
Map.entry("authentik_approval_request", "log: a second-person approval is requested"),
Map.entry("authentik_policy", "history: written when a person saves a group policy"),
Map.entry("authentik_token_scope", "history: written when a scoped token is created"));
/*******************************************************************************
** Every produced table is accounted for here, and the other way round.
*******************************************************************************/
@Test
void everyTableIsKnown() throws Exception
{
QInstance instance = support().qInstance;
List<String> produced = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("authentik_")).sorted().toList();
assertThat(produced).containsExactlyInAnyOrderElementsOf(STARTS_EMPTY.keySet());
assertThat(STARTS_EMPTY.keySet()).containsAll(API_TABLES).containsAll(LOCAL_TABLES.keySet());
assertThat(STARTS_EMPTY).hasSize(API_TABLES.size() + LOCAL_TABLES.size());
}
/*******************************************************************************
** Every table is used at runtime: an API table through its backend, a local
** table by non-metadata code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
AdminTestSupport support = support();
for(String table : API_TABLES)
{
QTableMetaData metaData = support.qInstance.getTable(table);
assertThat(metaData).as(table).isNotNull();
assertThat(metaData.getBackendName()).as(table + " is served by the Authentik API backend").isEqualTo(support.config.getApiBackendName());
assertThat(AuthentikTables.get(specName(table))).as(table + " has an API spec").isNotNull();
}
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")))
.filter(p -> !p.endsWith("AuthentikLocalTables.java"))
.map(AuthentikTablesGuardrailTest::read).toList();
}
for(Map.Entry<String, String> local : LOCAL_TABLES.entrySet())
{
assertThat(runtimeSource.stream().anyMatch(source -> source.contains(local.getValue())))
.as(local.getKey() + " (" + local.getValue() + ") must be read or written by runtime code, not only declared").isTrue();
assertThat(support.qInstance.getTable(local.getKey()).getBackendName()).isEqualTo(AdminTestSupport.LOCAL);
}
}
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created,
** and what an empty list means; the API-backed ones say rows are live.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
QInstance instance = support().qInstance;
assertThat(AuthentikHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
for(String table : STARTS_EMPTY.keySet())
{
List<QHelpContent> help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(AuthentikHelp.SLOT);
assertThat(help).as(table + " table help").isNotEmpty();
String text = help.get(0).getContent();
assertThat(text).as(table + " help says how rows get created").contains("How rows get created");
assertThat(text).as(table + " help says what empty means").containsAnyOf("An empty list means", "An empty table means");
if(API_TABLES.contains(table))
{
assertThat(text).as(table + " help says rows are live").containsAnyOf("live", "Live");
}
}
assertThat(instance.getApp("authentikAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT);
}
/*******************************************************************************
** A host with its own table prefix gets the same help on the renamed tables,
** and a host's own help is never replaced.
*******************************************************************************/
@Test
void helpFollowsTheHostPrefixAndKeepsHostHelp() throws Exception
{
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withTableNamePrefix("idp");
QInstance idp = AuthentikTestSupport.produce(config);
assertThat(idp.getTable("idp_user").getHelpContent().get(AuthentikHelp.SLOT).get(0).getContent()).contains("How rows get created");
assertThat(idp.getApp("idpAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT);
QInstance hosted = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig());
hosted.getTable("authentik_user").getHelpContent().put(AuthentikHelp.SLOT, List.of(new QHelpContent().withContentAsMarkdown("host text")));
AuthentikHelp.apply(hosted, AuthentikTestSupport.unitConfig());
assertThat(hosted.getTable("authentik_user").getHelpContent().get(AuthentikHelp.SLOT)).hasSize(1);
assertThat(hosted.getTable("authentik_user").getHelpContent().get(AuthentikHelp.SLOT).get(0).getContent()).isEqualTo("host text");
}
/*******************************************************************************
** Every feature on, local tables on a memory backend, an invitation flow set.
*******************************************************************************/
private static AdminTestSupport support() throws Exception
{
return (AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS, AuthentikFeature.POLICIES, AuthentikFeature.USERS,
AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS)));
}
/*******************************************************************************
** "authentik_group_member" -> "groupMember" (the spec's own name).
*******************************************************************************/
private static String specName(String table)
{
String name = table.substring("authentik_".length());
for(String spec : AuthentikTables.all().keySet())
{
if(AuthentikQBitConfig.toSnakeCase(spec).equals(name))
{
return (spec);
}
}
return (name);
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -33,6 +33,9 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.serviceidentity.QServicePrincipalMetaData;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentitiesMetaData;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -41,13 +44,16 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Host maintenance uses the audited Save Policy path with a local job identity.
** Host maintenance uses the audited Save Policy path as the maintenance service
** principal (a local principal here: the test instance uses MOCK auth).
*******************************************************************************/
class AuthentikMaintenanceTest
{
private static final String GROUP = "kof-website-analysts";
private static final String OPERATOR = "run-once:KOF22_RUN_ONCE";
private static final String REASON = " owner request 2026-10-14: full-access policy for kof22-admins ";
private static final String PRINCIPAL_ID = "service:maintenance";
private static final String AUDIT_REASON = OPERATOR + ": " + REASON;
private AdminTestSupport t;
private String fullAccess;
@@ -62,13 +68,17 @@ class AuthentikMaintenanceTest
{
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.GROUPS, AuthentikFeature.POLICIES));
fullAccess = AuthentikMaintenance.fullAccessDocument(t.config);
Set<String> all = new java.util.TreeSet<>(PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog()));
all.add(IdentityPermission.POLICIES_MANAGE.getSessionPermission());
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("maintenance").withLocalPermissions(all.toArray(new String[0])));
}
/*******************************************************************************
** A system job gets a dedicated, fully expanded session. The audit precedes
** PATCH, the event identifies the job, and an equivalent document is a no-op.
** A system job runs as the maintenance principal (its own grants, nothing
** minted here). The audit precedes PATCH, the operator is the principal,
** the reason names the job, and an equivalent document is a no-op.
*******************************************************************************/
@Test
void appliesFullAccessAuditedAndIdempotently() throws Exception
@@ -84,8 +94,8 @@ class AuthentikMaintenanceTest
{
QSession session = QContext.getQSession();
assertThat(session).isNotInstanceOf(QSystemUserSession.class);
assertThat(session.getUser().getIdReference()).isEqualTo(OPERATOR);
assertThat(session.getUser().getFullName()).isEqualTo("Maintenance (" + OPERATOR + ")");
assertThat(ServiceIdentities.isMintedServiceSession(session)).isTrue();
assertThat(session.getUser().getIdReference()).isEqualTo(PRINCIPAL_ID);
assertThat(session.getPermissions()).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
assertThat(QContext.getActionStack()).isNotSameAs(caller.actionStack());
assertThat(QContext.getAuthorizedActionScopeDepth()).isZero();
@@ -112,15 +122,15 @@ class AuthentikMaintenanceTest
assertThat(t.authentik.groupByName(GROUP).getJSONObject("attributes").getString(t.config.getPolicyAttribute())).isEqualTo(fullAccess);
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
{
assertThat(row.getValueString("operator")).isEqualTo(OPERATOR);
assertThat(row.getValueString("reason")).isEqualTo(REASON);
assertThat(row.getValueString("operator")).isEqualTo(PRINCIPAL_ID);
assertThat(row.getValueString("reason")).isEqualTo(AUDIT_REASON);
assertThat(row.getValueString("status")).isEqualTo("SUCCEEDED");
});
assertThat(t.authentik.events).singleElement().satisfies(event ->
{
assertThat(event.getString("action")).isEqualTo("custom_qqq_admin_action");
assertThat(event.getJSONObject("context").getString("operator")).isEqualTo(OPERATOR);
assertThat(event.getJSONObject("context").getString("reason")).isEqualTo(REASON);
assertThat(event.getJSONObject("context").getString("operator")).isEqualTo(PRINCIPAL_ID);
assertThat(event.getJSONObject("context").getString("reason")).isEqualTo(AUDIT_REASON);
});
assertThat(result.eventPk()).isNotBlank();
int writes = t.writes().size();
@@ -134,12 +144,16 @@ class AuthentikMaintenanceTest
/*******************************************************************************
** Ordinary policies need only their grants and policies.manage, and honor
** Ordinary policies need only their grants and policies.manage (held by the
** principal, not added here), and honor
** the configured spec/version. The maintenance: prefix is accepted too.
*******************************************************************************/
@Test
void appliesNamedPolicyWithLimitedSession() throws Exception
{
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("policy-maintenance")
.withLocalPermissions("cms_page.read", IdentityPermission.POLICIES_MANAGE.getSessionPermission()));
t.config.withMaintenancePrincipal("policy-maintenance");
t.config.withPolicySpec("host-iam");
String document = AuthentikMaintenance.fullAccessDocument(t.config).replace("\"Action\":[\"*\"]", "\"Action\":[\"read\"]")
.replace("\"Resource\":[\"*\"]", "\"Resource\":[\"cms_page\"]");
@@ -245,8 +259,8 @@ class AuthentikMaintenanceTest
assertThat(row.getValueString("status")).isEqualTo("PENDING_APPROVAL"));
assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST)).singleElement().satisfies(row ->
{
assertThat(row.getValueString("requester")).isEqualTo(OPERATOR);
assertThat(row.getValueString("reason")).isEqualTo(REASON);
assertThat(row.getValueString("requester")).isEqualTo(PRINCIPAL_ID);
assertThat(row.getValueString("reason")).isEqualTo(AUDIT_REASON);
});
assertThat(QContext.capture()).isEqualTo(caller);
}
@@ -314,6 +328,52 @@ class AuthentikMaintenanceTest
/*******************************************************************************
** Without a declared principal, or with one that lacks the grants, nothing
** is written: maintenance never creates authority.
*******************************************************************************/
@Test
void needsAPrincipalHoldingTheGrants() throws Exception
{
CapturedContext caller = QContext.capture();
t.config.withMaintenancePrincipal("undeclared");
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("not declared");
assertThat(QContext.capture()).isEqualTo(caller);
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("weak")
.withLocalPermissions(IdentityPermission.POLICIES_MANAGE.getSessionPermission()));
t.config.withMaintenancePrincipal("weak");
AuthentikAdmin.AdminResult result;
try
{
result = apply(GROUP, fullAccess);
assertThat(result.ok()).isFalse();
}
catch(QException e)
{
assertThat(e).isNotNull();
}
assertThat(t.writes()).isEmpty();
assertThat(QContext.capture()).isEqualTo(caller);
}
/*******************************************************************************
** Maintenance never changes a service principal's role group.
*******************************************************************************/
@Test
void refusesServicePrincipalGroups() throws Exception
{
t.authentik.group("service:kof22-scheduler", false, List.of(), List.of());
assertThatThrownBy(() -> apply("service:kof22-scheduler", fullAccess)).hasMessageContaining("Service principal groups");
assertThat(AuthentikMaintenance.isServicePrincipalGroup(new JSONObject().put("name", "x")
.put("parents_obj", new org.json.JSONArray().put(new JSONObject().put("name", "qqq-service-accounts"))))).isTrue();
assertThat(t.writes()).isEmpty();
}
/*******************************************************************************
** Apply using the production public API.
*******************************************************************************/
@@ -175,7 +175,7 @@ class PolicyCutoverTest
assertThat(PolicyCutoverWidgets.bannerHtml(new AccessComparison.State(AccessComparison.Status.ACTIVE, "ok <b>", java.time.Instant.now()))).contains("Enforced.").contains("ok &lt;b&gt;");
String home = com.kingsrook.qbits.authentik.app.AuthentikHomeView.render(com.kingsrook.qbits.authentik.app.AuthentikHomeData.load(t.config));
assertThat(home).contains("Policies at sign-in").contains("mapper off");
assertThat(home).contains("System Health").doesNotContain("Policies at sign-in");
}
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
@@ -299,7 +300,26 @@ class TokenManagementTest
t.config.withTokenBrokerClientSecret("test-secret").withSubjectTokenSource(session -> null);
refused(create(), "signed-in Authentik session");
t.config.withSubjectTokenSource(session -> "subject:3");
TokenKeyDelivery.set(null);
TokenKeyDelivery.set(new TokenKeyDelivery()
{
/*******************************************************************************
** Simulate a host without a usable response channel.
*******************************************************************************/
@Override
public boolean available()
{
return (false);
}
/*******************************************************************************
** Delivery must never be attempted while unavailable.
*******************************************************************************/
@Override
public void deliver(RunBackendStepOutput output, OneTimeKey key)
{
throw (new AssertionError("Unavailable delivery was called"));
}
});
refused(create(), "upgrade QQQ");
seed("own", 3);
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "own", "replace"), "upgrade QQQ");
@@ -530,4 +550,21 @@ class TokenManagementTest
assertThat(t.qInstance.getTable("authentik_token_scope").getFields()).doesNotContainKey("key");
assertThat(t.config.toString()).doesNotContain("test-broker-secret");
}
/*******************************************************************************
** RC.7 delivers credentials only through the response-only secret channel.
*******************************************************************************/
@Test
void coreOneTimeDelivery()
{
TokenKeyDelivery.set(null);
RunBackendStepOutput output = new RunBackendStepOutput();
OneTimeKey key = new OneTimeKey("Token", "test-key-not-a-credential", "Copy once");
assertThat(TokenKeyDelivery.get().available()).isTrue();
assertThat(TokenKeyDelivery.tryDeliver(output, key)).isTrue();
assertThat(output.getOneTimeSecrets()).hasSize(1);
assertThat(output.getOneTimeSecrets().get(0).getValue()).isEqualTo(key.value());
assertThat(output.getValues()).doesNotContainValue(key.value());
}
}
@@ -121,11 +121,15 @@ class TokenScopesTest
JSONObject claims = new JSONObject().put("qqq_token", "limited");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactlyInAnyOrder("cms.publish.hasAccess", "post.read");
assertThat(session.getCredentialScope().getReadOnly()).isTrue();
assertThat(session.getCredentialScope().getTables()).isNull();
t.authentik.tokens.get("sam").clear();
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, claims).orElseThrow().mode()).isEqualTo(Mode.DENY_ALL);
assertThat(session.getCredentialScope().getTables()).isEmpty();
assertThat(session.getCredentialScope().getOperationIds()).isEmpty();
assertThat(new TokenScopeStore(t.config).find("limited")).isNotNull();
}
@@ -137,7 +137,7 @@ class AuthentikAppTest
RenderWidgetOutput output = ((AbstractWidgetRenderer) new AuthentikHomeWidget.Renderer()).render(input);
String html = ((RawHTML) output.getWidgetData()).getHtml();
assertThat(html).contains("Needs attention", "People", "Groups and permissions", "Recent changes", "Connection");
assertThat(html).contains("Needs attention", "People", "Groups and permissions", "Recent changes", "Health", "System Health");
assertThat(html).contains("Active</dt><dd style=\"margin:0;\">4<");
assertThat(html).contains("Inactive</dt><dd style=\"margin:0;\">1<");
assertThat(html).contains("Service accounts</dt><dd style=\"margin:0;\">2<");
@@ -154,6 +154,28 @@ class AuthentikAppTest
/*******************************************************************************
** Home reads the persisted central component and links directly to its record.
*******************************************************************************/
@Test
void centralHealthRollup() throws Exception
{
AdminTestSupport t = AdminTestSupport.create();
new com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider().defineAll(t.qInstance, AdminTestSupport.LOCAL, table -> { });
com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore.getInstance().insert(
new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput().withTableName("health_component")
.withRecord(new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("id", 7).withValue("name", "authentik").withValue("status", "DEGRADED")), true);
AuthentikTestSupport.loginWithPermissions(t.qInstance, java.util.Set.of("health.read"));
AuthentikHomeData data = AuthentikHomeData.load(t.config);
assertThat(data.getHealthStatus()).isEqualTo("DEGRADED");
assertThat(AuthentikHomeView.render(data)).contains("Authentik: DEGRADED", "href=\"/app/health_component/7\"")
.doesNotContain("Policies at sign-in", "Version</dt>");
AuthentikTestSupport.loginWithPermissions(t.qInstance, java.util.Set.of());
assertThat(AuthentikHomeData.load(t.config).getHealthStatus()).isEqualTo("UNKNOWN");
}
/*******************************************************************************
** Nothing but AuthentikAdmin may send a change to Authentik: the util's
** write method is only called from there (the guard's authorization is the
@@ -186,4 +186,20 @@ class AuthentikDriftReportTest
assertThatThrownBy(() -> new AuthentikDriftReportWidgetRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** A malformed entry's health reason keeps the attribute name, never the
** value a person typed.
*******************************************************************************/
@Test
void malformedReasonKeepsOnlyTheAttribute()
{
assertThat(AuthentikDriftReport.attributeOf("qqq.io/permissions: {\"x\":\"typed value\"}")).isEqualTo("qqq.io/permissions");
assertThat(AuthentikDriftReport.attributeOf("qqq.io/policy: line 1: bad")).isEqualTo("qqq.io/policy");
assertThat(AuthentikDriftReport.attributeOf("no separator")).isEqualTo("attribute");
assertThat(AuthentikDriftReport.attributeOf("bad key with spaces: x")).isEqualTo("attribute");
assertThat(AuthentikDriftReport.attributeOf(null)).isEqualTo("attribute");
}
}
@@ -0,0 +1,484 @@
/*
* 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.util.List;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikQBitProducer;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.util.EntityUtils;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Offline probes run under the central runner's health-only permissions.
*******************************************************************************/
class AuthentikHealthChecksTest
{
private static final String SECRET = "S3NT1NEL-secret";
private AuthentikQBitConfig config;
private QInstance instance;
/*******************************************************************************
** Install the existing send seam with poisoned credentials.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
FixtureActionUtil.reset();
config = AuthentikTestSupport.unitConfig().withApiToken(SECRET).withTokenBrokerClientId(SECRET)
.withTokenBrokerClientSecret(SECRET).withGroupCacheTtlSeconds(0);
instance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(instance, config, HealthActionUtil.class);
AuthentikTestSupport.loginWithPermissions(instance, Set.of("health.read", "health.write"));
FixtureActionUtil.handler = request -> FixtureActionUtil.response(200, FixtureActionUtil.EMPTY_PAGE);
}
/*******************************************************************************
** Do not leak static fixture state or thread context between tests.
*******************************************************************************/
@AfterEach
void tearDown()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
** Metadata is registered by production, with no probes during registration.
*******************************************************************************/
@Test
void registration() throws QException
{
var checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
assertThat(checks).extracting(c -> c.getName()).containsExactly("authentik.api", "authentik.api_token", "authentik.mapper", "authentik.token_broker");
assertThat(checks).allSatisfy(c ->
{
assertThat(c.getComponent()).isEqualTo("authentik");
assertThat(c.getCategory()).isEqualTo(HealthCategory.IDENTITY);
assertThat(c.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
assertThat(c.getIntervalSeconds()).isEqualTo(c.getName().equals("authentik.api") || c.getName().equals("authentik.token_broker") ? 300 : 21600);
assertThat(c.getTimeoutMs()).isEqualTo(10000);
});
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
assertThat(instance.getWidget("authentikDriftReport")).isNotNull();
QInstance empty = new QInstance();
assertThat(new AuthentikQBitProducer().isEnabled()).isFalse();
assertThatThrownBy(() -> new AuthentikQBitProducer().produce(empty)).isInstanceOf(QException.class);
assertThat(HealthChecksMetaData.ofOrWithNew(empty).getChecks()).isEmpty();
}
/*******************************************************************************
** Version responses are allowlisted before entering details.
*******************************************************************************/
@Test
void apiVersions() throws QException
{
respond(new JSONObject().put("version_current", "2026.8.3"));
assertThat(run("api").getDetails().getValues()).containsEntry("serverVersion", "2026.8.3");
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.UP);
config.withExpectedServerVersion("2025.10");
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DEGRADED);
config.withExpectedServerVersion(null);
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.UP);
respond(new JSONObject().put("version_current", SECRET));
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
assertClean(run("api"));
respond(new JSONObject());
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
}
/*******************************************************************************
** Present, approaching, expired, missing and ambiguous token metadata.
*******************************************************************************/
@Test
void apiTokenStates() throws QException
{
JSONObject token = new JSONObject().put("intent", "api").put("identifier", SECRET).put("expiring", true);
token.put("expires", Instant.now().plusSeconds(30 * 86400).toString());
page(token);
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UP);
token.put("expires", Instant.now().plusSeconds(13 * 86400).toString());
page(token);
var outcome = run("api_token");
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(outcome.getDetails().getValues()).containsOnlyKeys("daysToExpiry");
token.put("expires", Instant.now().minusSeconds(60).toString());
page(token);
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.DOWN);
token.put("expiring", false);
page(token);
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UP);
token.put("expiring", true).put("expires", SECRET);
page(token);
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UNKNOWN);
page(token, token);
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UNKNOWN);
page();
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(FixtureActionUtil.REQUESTS).allSatisfy(request ->
{
assertThat(request.getURI().getPath()).endsWith("/core/tokens/");
assertThat(request.getURI().getQuery()).contains("intent=api", "user__username=");
});
}
/*******************************************************************************
** Only the two relevant drift checks run. Group findings name the group and
** a safe reason; typed attribute values and people (service-account
** activity subjects) stay private.
*******************************************************************************/
@Test
void mapperStates() throws QException
{
assertThat(run("mapper").getStatus()).isEqualTo(HealthStatus.UP);
//////////////////////////////////////////////////////////////////////////////
// a group with no qqq.io/* attributes (kof22-admins on its static floor) is //
// not drift //
//////////////////////////////////////////////////////////////////////////////
JSONObject floorOnly = new JSONObject().put("pk", "g0").put("name", "kof22-admins").put("attributes", new JSONObject());
groups(floorOnly);
var outcome = run("mapper");
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UP);
assertThat(outcome.getMessage()).isEqualTo("Mapper checks passed");
JSONObject unknown = new JSONObject().put("pk", "g1").put("name", "file-gateway-admins")
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("file_gateway.view").put("file_gateway.admin")));
JSONObject malformed = new JSONObject().put("pk", "g2").put("name", "editors")
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put(new JSONObject().put("x", SECRET))));
JSONObject superuser = new JSONObject().put("pk", "g3").put("name", "authentik Admins").put("is_superuser", true)
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.users.read")));
groups(floorOnly, unknown, malformed, superuser);
outcome = run("mapper");
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: authentik Admins (superuser group carries qqq.io/permissions); "
+ "editors (malformed qqq.io/permissions); file-gateway-admins (grants nothing: file_gateway.admin, file_gateway.view)");
assertThat(outcome.getDetails().getValues())
.containsEntry("findingCodes", "group_attributes.warning")
.containsEntry("flaggedGroups", "authentik Admins, editors, file-gateway-admins")
.containsEntry("summary", outcome.getMessage().substring("Mapper drift: ".length()));
assertClean(outcome);
groups(unknown);
page(new JSONObject().put("action", "model_deleted").put("context", new JSONObject()
.put("model", new JSONObject().put("model_name", "user").put("pk", "1").put("name", SECRET))));
outcome = run("mapper");
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: file-gateway-admins (grants nothing: file_gateway.admin, file_gateway.view); "
+ "service-account activity (see the identity drift report)");
assertClean(outcome);
assertThat(FixtureActionUtil.REQUESTS).allSatisfy(request -> assertThat(request.getURI().getPath())
.doesNotContain("/admin/version/", "/core/tokens/", "/managed/blueprints/"));
}
/*******************************************************************************
** A group check that cannot run, unprintable names and long summaries stay
** readable and within the health limits.
*******************************************************************************/
@Test
void mapperOutcomeEdges()
{
var outcome = AuthentikHealthChecks.mapperOutcome(List.of(
DriftFinding.error("group attributes", "-", "Check could not run: " + SECRET),
DriftFinding.info("group attributes", "x", "info only").withReason("ignored"),
DriftFinding.warning("group attributes", "bad\u0007name", "m").withReason("grants nothing: (unprintable name)"),
DriftFinding.warning("group attributes", "", "m").withReason("malformed attribute")));
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: (unnamed group) (malformed attribute); badname (grants nothing: (unprintable name)); "
+ "group attributes check could not run");
assertThat(outcome.getDetails().getValues()).containsEntry("findingCodes", "group_attributes.error,group_attributes.warning");
assertClean(outcome);
List<DriftFinding> many = new java.util.ArrayList<>();
for(int i = 0; i < 60; i++)
{
many.add(DriftFinding.warning("group attributes", "group-number-" + i, "m").withReason("grants nothing: some.long.permission.name." + i));
}
outcome = AuthentikHealthChecks.mapperOutcome(many);
assertThat(outcome.getMessage()).hasSize(500).endsWith("...");
assertThat(outcome.getDetails().toJson().getBytes(java.nio.charset.StandardCharsets.UTF_8).length).isLessThanOrEqualTo(4096);
assertThat(outcome.getDetails().getValues()).containsKeys("findingCodes", "flaggedGroups", "summary");
}
/*******************************************************************************
** Serve these groups as the group graph.
*******************************************************************************/
private static void groups(JSONObject... groups)
{
JSONArray results = new JSONArray();
for(JSONObject group : groups)
{
results.put(group);
}
FixtureActionUtil.groupGraphBody = new JSONObject(FixtureActionUtil.EMPTY_PAGE).put("results", results).toString();
}
/*******************************************************************************
** The broker check uses the grant the broker uses (token exchange) with no
** subject: invalid_request proves the credentials; nothing is ever issued.
*******************************************************************************/
@Test
void brokerStates() throws Exception
{
refuse("invalid_request");
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.UP);
HttpPost request = (HttpPost) FixtureActionUtil.REQUESTS.getLast();
String body = EntityUtils.toString(request.getEntity());
assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange", "client_id=", "client_secret=");
assertThat(body).doesNotContain("client_credentials", "subject_token");
assertThat(request.getFirstHeader("Authorization")).isNull();
assertThat(request.getURI().toString()).isEqualTo(config.getTokenEndpointUrl());
refuse("invalid_client");
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
refuse("invalid_grant");
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
respond(new JSONObject().put("access_token", SECRET));
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
config.withTokenBrokerClientSecret(null);
FixtureActionUtil.REQUESTS.clear();
var outcome = run("token_broker");
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UNKNOWN);
assertThat(outcome.getMessage()).isEqualTo("not configured");
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
}
/*******************************************************************************
** Serve a token-endpoint refusal with a standard OAuth2 error code.
*******************************************************************************/
private void refuse(String errorCode)
{
FixtureActionUtil.handler = request -> FixtureActionUtil.response(400, new JSONObject().put("error", errorCode).toString());
}
/*******************************************************************************
** Both response bodies and thrown errors may echo every configured secret.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { "api", "api_token", "mapper", "token_broker" })
void failuresNeverExposeSecrets(String name) throws QException
{
FixtureActionUtil.groupGraphHandler = request -> FixtureActionUtil.response(401, SECRET);
FixtureActionUtil.handler = request -> FixtureActionUtil.response(401, SECRET);
assertThat(run(name).getStatus()).isEqualTo(HealthStatus.DOWN);
FixtureActionUtil.handler = request ->
{
throw new IllegalStateException(SECRET);
};
assertThat(run(name).getStatus()).isEqualTo(HealthStatus.DOWN);
}
/*******************************************************************************
** Expired input performs no I/O, including unconfigured and mapper checks.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { "api", "api_token", "mapper", "token_broker" })
void expiredDeadline(String name) throws QException
{
assertThat(run(name, Instant.now().minusSeconds(1)).getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
}
/*******************************************************************************
** Retries and HTTP timeouts use the input budget, never the 30-second default.
*******************************************************************************/
@Test
void boundsRequestsAndRetries() throws QException
{
FixtureActionUtil.handler = request -> FixtureActionUtil.response(429, SECRET, FixtureActionUtil.retryAfter(30));
assertThat(run("api_token", Instant.now().plusSeconds(2)).getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(FixtureActionUtil.REQUESTS).hasSize(1);
var settings = FixtureActionUtil.REQUESTS.getFirst().getConfig();
assertThat(settings.getSocketTimeout()).isBetween(1, 2000);
assertThat(settings.getConnectTimeout()).isBetween(1, 2000);
assertThat(settings.getConnectionRequestTimeout()).isBetween(1, 2000);
}
/*******************************************************************************
** Unlimited normal client settings cannot override the central deadline.
*******************************************************************************/
@Test
void boundsUnlimitedTimeouts() throws QException
{
config.withConnectTimeoutMillis(0).withSocketTimeoutMillis(0);
run("api_token", Instant.now().plusSeconds(2));
var settings = FixtureActionUtil.REQUESTS.getFirst().getConfig();
assertThat(settings.getSocketTimeout()).isBetween(1, 2000);
assertThat(settings.getConnectTimeout()).isBetween(1, 2000);
}
/*******************************************************************************
** A saturated throttle refuses a reservation beyond the health deadline.
*******************************************************************************/
@Test
void boundsThrottle() throws QException
{
TokenBucketThrottle throttle = new TokenBucketThrottle(1, 1);
throttle.acquire(Instant.now().plusSeconds(1));
assertThatThrownBy(() -> throttle.acquire(Instant.now().plusMillis(10))).isInstanceOf(QException.class);
}
/*******************************************************************************
** Central permission does not grant Authentik mutations or arbitrary users.
*******************************************************************************/
@Test
void requiresHealthPermission() throws QException
{
AuthentikTestSupport.loginWithPermissions(instance, Set.of());
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
assertThatThrownBy(() -> AuthentikActionUtil.forBackend(config.getApiBackendName()).withHealthDeadline(Instant.now().plusSeconds(1)))
.hasMessageContaining("permission");
}
/*******************************************************************************
** Run the registered production check, with a short normal budget.
*******************************************************************************/
private HealthCheckOutcome run(String name) throws QException
{
return (run(name, Instant.now().plusSeconds(5)));
}
/*******************************************************************************
** HealthRunner.sanitize is package-private; assert the raw outcome is clean.
*******************************************************************************/
private HealthCheckOutcome run(String name, Instant deadline) throws QException
{
var definition = HealthChecksMetaData.ofOrWithNew(instance).getChecks().stream()
.filter(c -> c.getName().equals("authentik." + name)).findFirst().orElseThrow();
var outcome = definition.getCheckInstance().run(new HealthCheckInput().withQInstance(instance).withDeadline(deadline));
assertClean(outcome);
return (outcome);
}
/*******************************************************************************
** No credential can appear even before core sanitization.
*******************************************************************************/
private void assertClean(HealthCheckOutcome outcome)
{
assertThat(outcome.getMessage() + outcome.getDetails().toJson()).doesNotContain(SECRET, "authentik.example.test", "operator@example.test");
}
/*******************************************************************************
** Serve a JSON response through the existing fixture handler.
*******************************************************************************/
private void respond(JSONObject body)
{
FixtureActionUtil.handler = request -> FixtureActionUtil.response(200, body.toString());
}
/*******************************************************************************
** Serve paginated metadata without any network.
*******************************************************************************/
private void page(JSONObject... records)
{
respond(new JSONObject(FixtureActionUtil.EMPTY_PAGE).put("results", new JSONArray(List.of(records))));
}
/*******************************************************************************
** Let version probes use the same configurable fixture handler as other calls.
*******************************************************************************/
public static class HealthActionUtil extends FixtureActionUtil
{
/***************************************************************************
** Preserve the existing group graph seam and capture version requests.
***************************************************************************/
@Override
protected QHttpResponse send(HttpRequestBase request)
{
if(request.getURI().getPath().endsWith("/admin/version/"))
{
REQUESTS.add(request);
return (handler.apply(request));
}
return (super.send(request));
}
}
}