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 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
35 changed files with 2631 additions and 454 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 -1
View File
@@ -4,7 +4,13 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- 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 by the new `AuthentikHelp` stand-in (`STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.7)`), 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.
- **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.
@@ -14,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.
+44 -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.
@@ -92,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)`. |
@@ -128,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`.
@@ -136,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 -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,6 +24,7 @@ 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;
@@ -31,6 +32,7 @@ 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;
@@ -185,12 +187,15 @@ 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()),
@@ -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"));
}
}
@@ -17,17 +17,16 @@
package com.kingsrook.qbits.authentik.metadata;
import java.io.IOException;
import java.io.InputStream;
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.fasterxml.jackson.dataformat.yaml.YAMLFactory;
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;
@@ -40,8 +39,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
**
** 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.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.7): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
** 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
{
@@ -66,33 +65,14 @@ public final class AuthentikHelp
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = AuthentikHelp.class.getResourceAsStream(RESOURCE))
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())
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-authentik".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
entries.put(entry.getKey(), mapper.valueToTree(entry));
}
return (entries);
}
@@ -103,25 +83,37 @@ public final class AuthentikHelp
*******************************************************************************/
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:"))
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()));
table.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
else if(key.startsWith("app:"))
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()));
app.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
}
@@ -133,9 +125,35 @@ public final class AuthentikHelp
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
static QHelpContent content(JsonNode entry, AuthentikQBitConfig config)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
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())));
}
}
File diff suppressed because it is too large. Load diff
@@ -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");
});
}
}
@@ -24,7 +24,6 @@ import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
@@ -160,11 +159,7 @@ class AuthentikTablesGuardrailTest
}
}
assertThat(instance.getApp("authentikAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT);
for(Map.Entry<String, JsonNode> entry : AuthentikHelp.entries().entrySet())
{
String key = entry.getKey();
assertThat(key.startsWith("app:") || STARTS_EMPTY.containsKey(key.substring(6, key.indexOf(';')))).as("help names only real tables: " + key).isTrue();
}
}
@@ -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));
}
}
}