Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 612307bde8 feat(policy): member preview payload for the policy editor
The editor widget now lists the group's members (direct and through
descendant groups, active and in scope, capped at 200) with each
person's sign-in permissions WITHOUT this group's policy
(basePermissions), plus mapperEnabled. The Next widget computes the
live effective permissions for a chosen member and what the edit adds
or removes. EffectivePermissionResolver gains an exclude-one-policy
resolution; loading requires authentik.policies.manage.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:33 -05:00
75 changed files with 7438 additions and 159 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
+16 -9
View File
@@ -1,10 +1,10 @@
# AGENTS.md
# AGENTS.md / CLAUDE.md
This file provides guidance to Codex and other agents when working with code in this repository. It mirrors CLAUDE.md; keep them in sync.
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md in sync.
## Project Overview
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and (from phase 2) writes through the Authentik API. Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = read-only (phases 0-1).
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and writes through the Authentik API (never its DB). Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = the full user lifecycle and group/permission management (phases 0-3), read-only by default.
## Build Commands
@@ -12,6 +12,7 @@ This file provides guidance to Codex and other agents when working with code in
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
./mvnw test -Dtest=ClassName
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
@@ -29,24 +30,30 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
- Writes go to the guard, which refuses them in 0.1.
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
- `security/AuthentikGuard` handles read and write permission checks, the user scope, and protected-account detection. `security/IdentityPermission` is the `identity.*` catalog (session form `<name>.hasAccess`).
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `authentik.admin`.
## Invariants (do not break)
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
- Never filter client-side; reject what Authentik cannot evaluate.
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions.
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
## Code Style
@@ -54,7 +61,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
## Dependencies
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
## Release
+24 -1
View File
@@ -4,6 +4,29 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- Edit Policy supplies per-member preview data for up to 200 active, in-scope direct and descendant members, with relevant direct group names, base permissions excluding only the edited policy, a truncation flag, and the configured mapper-enabled flag. The Next UI can compute effective permissions and additions/removals locally while preserving inherited, named, static and bootstrap grants.
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
### Added (access tokens 6c)
- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153.
- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked.
- Daily expiry reminders with persistent per-window deduplication and optional Alerts delivery; high-severity guard-bypass alerts.
- A standard Access tokens TABLE widget with owner counts, age buckets and cleanup links, plus the existing record/list bulk revoke action.
### Added (access tokens, DESIGN.md section 13)
- Default-on `TOKENS` with personal create/rotate/revoke/extend, service-account creation and rotation, protected-owner guardrails and optional two-person approval.
- Action-scoped OAuth2 token exchange, response-only one-time key delivery (fail closed on older QQQ), generated usage instructions and non-secret token scope records.
- Token-manager read access, expanded token metadata, secret redaction and in-memory process/persistence tests.
### Changed (core baseline, #20 / F19)
- Build against the released QQQ BOM `4.1.0-RC.6` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. Adopt `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
- Core actions now enforce permissions for non-HTTP callers. Authentik-specific guards remain for direct util calls and domain rules. See [UPGRADE.md](UPGRADE.md) for host impact and feature registration requirements.
- Replace the expired feature-catalog stand-in with real `TableFeaturePermissionsMetaData` registrations and remove the test-only `PolicyCatalog` permission-source overload. Feature discovery uses the core catalog without static mocking.
### Added (automated sessions)
- `AuthentikGroupPermissionMapper` resolves tokenless automated sessions by a unique active Authentik email match, applying direct and inherited group permissions, static fallbacks and the sign-in cut-over guard while preserving existing session permissions.
### Added (table feature permissions)
- Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData
@@ -51,7 +74,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel
### Changed
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
- **Table names are lowercase snake_case with the `authentik_` prefix** (owner naming rule, 2026-10-12): `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event` (were `authentikUser`, ...). New `AuthentikQBitConfig.applyTablePrefix` and `toSnakeCase`; apps, widgets, the backend and processes keep camelCase names (`authentikAdmin`, `authentikApi`). The old names are kept as `@Deprecated` `AuthentikTables.LEGACY_*_TABLE_NAME` constants. See [UPGRADE.md](UPGRADE.md).
### Documentation
+9 -7
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
# AGENTS.md / CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md in sync.
## Project Overview
@@ -12,6 +12,7 @@ This file provides guidance to Claude Code when working with code in this reposi
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
./mvnw test -Dtest=ClassName
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
@@ -29,13 +30,14 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. The only way to change Authentik is `write(WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins that only `AuthentikAdmin` calls it).
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
@@ -43,15 +45,15 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
## Invariants (do not break)
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
- Never filter client-side; reject what Authentik cannot evaluate.
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
- Nothing but `AuthentikAdmin` may call `AuthentikActionUtil.write`. Every write needs a reason, passes the guard, and is audited first.
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key or impersonate permissions; `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
## Code Style
@@ -59,7 +61,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
## Dependencies
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
## Release
+26 -5
View File
@@ -8,6 +8,7 @@ Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065)
| | |
|---|---|
| **Access tokens** | Create and rotate your personal access tokens, revoke (including bulk), extend expiry, and create service accounts. Enabled with any write feature; opt out with `tokensEnabled=false` or `AUTHENTIK_QBIT_TOKENS=false`. Keys are response-only and shown once; key-producing actions require QQQ 4.1.0-RC.7+ one-time output support. See [Access tokens](docs/ACCESS-TOKENS.md). |
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. Edit covers username, name, email, path and other attributes. Delete is a separate, explicit action behind the `DELETE` feature (off by default); deactivate stays the default. |
| **Groups and permissions** | Create, edit (parents, other attributes) and delete groups, add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
@@ -67,13 +68,13 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
The editor discovers features and labels through `PermissionsHelper.getAllAvailablePermissions`; they appear when the host's core version exposes them. This qbit does not compile against the unreleased metadata hook. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
The editor discovers registered features and labels through core RC.6's `PermissionsHelper.getAllAvailablePermissions`. The build uses `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
### Authentik side
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, token-key or impersonate permissions, and holds delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, `view_token_key` or impersonate permissions, and holds user/group delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. Saving a policy uses `change_group`. The `DELETE` feature also needs `delete_user` and `delete_group`. The contract tests grant exactly this list.
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. Saving a policy uses `change_group`. Token management additionally needs `change_token`, `set_token_key` and `add_token` (Authentik requires `add_token` for `service_account` and `set_key`; for a non-superuser it can only create the caller's own token); never grant `view_token_key`. Personal token operations use an exchanged user bearer from the confidential token broker, never the qbit bearer. The `DELETE` feature also needs `delete_user` and `delete_group`. The contract fixtures include the token broker, claim mapping, detective rule and token write permissions.
## Adopting in another app
@@ -82,9 +83,9 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
| Item | What to do |
|---|---|
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
| QQQ version | QQQ 4.1 (see `pom.xml` for the exact RC). Java 21. |
| QQQ version | QQQ 4.1.0-RC.6 (released BOM, no core SNAPSHOT). Java 21. |
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. |
| Tables | Create `authentik_admin_action` and `authentik_approval_request` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
| Tables | Create `authentik_admin_action`, `authentik_approval_request` and `authentik_token_scope` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. |
| Required config | `baseUrl`, `apiToken`, `appKey`. For writes: `features`, `localBackendName`, `recoveryEmailStage` (users and credentials). Optional: `invitationFlow`, `twoPersonApproval` (default on), `userScopePathStartsWith`, `protectedUsernames`, `applicationsInScope`, `linkPrefix` (default `/app`), `throttleRequestsPerSecond` (default 10), `bulkPacingMillis`. |
| Secrets / env | `apiToken` as a `${env.X}` reference; `bootstrapOperatorSubjects` (the lockout escape hatch) defaults to `${env.AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS}`: immutable ids only (the Authentik user pk or uuid, or the token `sub`), never emails, because users can change their own email. Empty it once real group grants exist. Keep secrets in the host's secret store. |
@@ -106,3 +107,23 @@ Contract tests start Authentik (server and worker), Postgres and Mailpit, and ta
## License
Apache 2.0
Access-token metadata is readable with `authentik.audit.read` or `authentik.tokens.manage`. Personal actions have no additional process permission; ownership is checked live. Managing service-account tokens needs `authentik.tokens.manage`, and creating a service account also needs `authentik.users.manage`. Configure `tokenBrokerClientId`, `tokenBrokerClientSecret` (environment references supported), `appClientId`, `appPublicUrl` and `authentikPublicUrl` for exchange and usage instructions. Scope fields remain unavailable until `tokenScopesEnabled=true`.
| Access-token setting | Default | Purpose |
|---|---|---|
| `tokenScopesEnabled` | `false` | Offer read-only, apps and tables fields after the signed claim mapping is installed. Names must exist and the operator must currently have read/hasAccess permission. |
| `tokenScopeClaim` | `qqq_token` | Signed JWT claim containing the Authentik token identifier. Claims are enforced even when offering new scopes is disabled. |
| `tokenUnusedDays` | `60` | Inactivity threshold for cleanup suggestions. With no login found, a known creation date and a threshold at most 90 days are required. |
| `tokenReminderSchedulerName` | First registered scheduler | Run `authentikTokenReminders` daily. Without a scheduler the hidden process is available for a host to invoke. Register the scheduler before producing the qbit. |
The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The 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.
The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`.
Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added.
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
+22
View File
@@ -1,5 +1,27 @@
# Upgrading qbit-authentik
## QQQ 4.1.0-RC.6 baseline (#20 / F19)
Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.6`
and use Java 21. The historical `qqq-snapshot` profile and `qqq.snapshot.version`
property names remain, but the imported core version is a release, not a SNAPSHOT.
Core actions now enforce permissions and field security locks for non-HTTP callers
(QRun-IO/qqq#1061, #1062); callers must supply an appropriately authorized session.
The qbit keeps its guards, scope checks and sensitive-field stripping because they
also protect direct backend calls and implement Authentik-specific rules.
No qbit configuration, stored policy format or schema changes are introduced.
The build adopts `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
The expired #1136 test stand-in is removed: tests now register features through
`TableFeaturePermissionsMetaData` and discover them through the real
`PermissionsHelper.getAllAvailablePermissions`, as production hosts do. The
unused explicit permission-source overload in `PolicyCatalog` is removed.
Duplicate shared-base entries, replacement registrations and malformed suffixes
remain covered. Hosts should register feature suffixes, labels and table
applicability through the core metadata API; no synthetic catalog is needed.
Client-channel attribution and identity/credential channel restrictions remain
separate follow-up work.
## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release)
The qbit's permissions follow the same owner naming rule (2026-10-12) and now
+13 -7
View File
@@ -1,10 +1,10 @@
# Using access tokens
An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6).
An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6).
## Create a token
1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**.
1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**.
2. Choose **Create token** and fill in:
- a description, for example "nightly export script";
- an expiry. It's required: at most 90 days for people, and 365 for service accounts;
@@ -36,7 +36,7 @@ curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData
## Manage your tokens
From **Preferences → Access tokens**:
From **Preferences → Access tokens**:
| Action | What happens |
|---|---|
@@ -44,14 +44,20 @@ From **Preferences → Access tokens**:
| **Extend** | Moves the expiry. The limit counts from today. |
| **Revoke** | Deletes the token in Authentik. Anything using it stops working at once. |
You get a reminder in the notification bell, and by email, 7 days and 1 day before a token expires.
When the host has configured a scheduler and the optional Alerts delivery adapter, the daily job sends owner reminders in the 7-day and 1-day expiry windows. Delivery channels (bell/email) follow the host’s Alerts configuration. Each window sends once; if first seen within one day, only the urgent window sends. Extending a token through the qbit resets its reminder windows. Without delivery support, the job logs and leaves the reminder pending.
## For administrators
People with `authentik.tokens.manage` see **all** tokens in the Identity app (**Tokens**). From there they can:
- filter by person, intent and expiry, and sort by age and last use;
People with `authentik.tokens.manage` see the **Access tokens** TABLE widget on the Identity home page, with a link to **all access tokens**. From there they can:
- filter by person, intent, expiring status or token identifier; view computed creation time, scope and last use (these computed columns cannot be filtered or sorted by the Authentik API);
- revoke tokens one at a time or in bulk, for example the tokens the home page lists as unused for 60 days, or as belonging to deactivated people;
- create a **service account** for an integration (**Users → Create Service Account**). The account gets one app password, shown once, and the groups you choose. Rotate and extend it from its token record.
- create a **service account** for an integration (**Users → Create Service Account**). The account gets one app password, shown once, and the groups you choose. Rotate and extend it from its token record.
The widget shows the top ten owners plus a total, age buckets (`<30d`, `30–90d`, `90–365d`, `>365d`, and unknown), unused tokens and tokens with deactivated owners. Cleanup rows include owner and token links; age buckets link to the explicit token identifiers. Creation dates are known only for tokens created by the qbit. External tokens can receive reminders without acquiring a scope or an invented creation date.
Last use comes from app-password login events over the last 90 days, cached for five minutes. The pinned Authentik schema has no date-range parameter for this endpoint, so the qbit pages newest-first to the window boundary. API-intent tokens show **not tracked** and never appear as unused. A token with no recorded login is suggested as unused only if its known creation date is older than `tokenUnusedDays` and that threshold is at most 90 days.
Create Token offers read-only, apps and tables scopes only when `tokenScopesEnabled` is enabled after installing the signed claim mapping. Enter one metadata name per line. Review checks existence and the operator’s current read/hasAccess permission and shows the scope. The claims mappers intersect permissions after resolving them; scopes never grant access the owner lacks. A deleted token receives no permissions even if its local scope row remains, subject to the 60-second verification cache.
Administrators can never create, rotate or extend a token for **another person**. Each person manages their own.
+29 -12
View File
@@ -24,7 +24,7 @@ Seven design rules shape everything below:
- A group's QQQ permissions live in the group attribute `qqq.io/permissions`.
- The reusable `AuthentikGroupPermissionMapper` turns a user's groups, including inherited parents, into QQQ session permissions.
- Who can operate the qbit is decided by those same permissions (`authentik.*`). No operator group is hard-coded.
4. **The qbit checks permissions itself.** QQQ core actions do not enforce permissions (QRun-IO/qqq#1061), and `FieldSecurityLock` is not enforced (#1062). An `AuthentikGuard` in the backend action layer checks every read and write.
4. **The qbit checks permissions itself.** The original core gaps (QRun-IO/qqq#1061, #1062) are fixed in the RC.5 baseline. Direct backend calls and Authentik-specific rules still require local checks. An `AuthentikGuard` in the backend action layer checks every read and write.
5. **No escalation.**
- Operators can only grant permissions they hold themselves, whether through memberships or permission attributes.
- Operators cannot edit themselves.
@@ -72,7 +72,13 @@ Seven design rules shape everything below:
| 27 | **Guardrails** (§13.4): (a) no minting or rotating for privileged accounts, in the qbit and again in Authentik; (b) self-service is self-only, admins may also target service accounts, never another human; (c) expiry is required, at most 90 days for people and 365 for service accounts (configurable); (d) optional two-person approval for service-account tokens (off by default); (e) an Authentik notification rule fires if the qbit's service account ever touches a protected user's token; (f) everything audited with the client channel. |
| 28 | **Optional per-token scopes** (read only; specific apps and tables), narrower than the user, stored in `authentik_token_scope` and enforced by core scoped credentials (QRun-IO/qqq#1153, `QCredentialScope`) through a signed `qqq_token` claim (§13.5). |
| 29 | **First class:** usage instructions generated from the instance's real URLs; expiry reminders 7 days and 1 day before (bell and email); last used; an unused-token cleanup suggestion; admin views by user and by age with bulk revoke; the doc page [`ACCESS-TOKENS.md`](ACCESS-TOKENS.md). |
| 30 | **Owner-approved revision of the mechanism (2026-10-13, coordinator-approved under the owner's direction).** Authentik 2026.8.3 always creates a token for the *caller* unless the caller is a superuser (`TokenViewSet.perform_create`), so `add_token` cannot mint for anyone else (§13.2). The qbit therefore creates a person's token **as that person**, through RFC 8693 token exchange with a 2-minute API token from the `qqq-<appKey>-token-broker` provider (with a deny policy for protected users). Service accounts use `POST /core/users/service_account/` (create), `set_key` (rotate) and `PATCH` (extend). The qbit role gains `change_token` and `set_token_key`, keeps `delete_token` and `add_user`, and **never** holds `add_token` or `view_token_key`. Limitation: one app password per service account. |
| 30 | **Owner-approved revision of the mechanism (2026-10-13, coordinator-approved under the owner's direction).** Authentik 2026.8.3 always creates a token for the *caller* unless the caller is a superuser (`TokenViewSet.perform_create`), so `add_token` cannot mint for anyone else (§13.2). The qbit therefore creates a person's token **as that person**, through RFC 8693 token exchange with a 2-minute API token from the `qqq-<appKey>-token-broker` provider (with a deny policy for protected users). Service accounts use `POST /core/users/service_account/` (create), `set_key` (rotate) and `PATCH` (extend). The qbit role gains `change_token`, `set_token_key` and `add_token`, keeps `delete_token` and `add_user`, and **never** holds `view_token_key`. `add_token` was found necessary by the contract tests (`service_account` needs `add_user` + `add_token`; `set_key` needs the POST object permission `add_token` + `set_token_key`) and is safe because a non-superuser's create is always the caller's (§13.9). Limitation: one app password per service account. |
**Owner decision (2026-10-14): `add_token` (option A).** The contract tests showed that Authentik 2026.8.3 requires `add_token` for `POST /core/users/service_account/` (with `add_user`) and for `POST /core/tokens/{id}/set_key/` (the POST object permission, with `set_token_key`).
| # | Decision |
|---|---|
| 31 | **Grant `add_token` to the qbit role, so service-account creation and rotation stay in the app.** It is safe: for a non-superuser, `POST /core/tokens/` always creates the token for the caller, so it can never mint a token for anyone else. `view_token_key` stays **not** granted. `TokenContractIT` pins 403 without `add_token` and 200/204 with it. The detective alarm (§13.4 e) also notifies `identity-admins` whenever the qbit's account creates a service account. The blueprint explains this next to the permission (cluster-gitops#103). |
### 0.2 Phase 0 findings (2026-10-11, verified on Authentik 2026.8.3)
@@ -624,7 +630,7 @@ The role `qqq-<appKey>-idadmin` is assigned directly to the service account (boo
**Never granted:**
- `enable_group_superuser` / `disable_group_superuser`. Authentik then refuses every route into superuser ancestry: membership (direct, bulk, by user or by group), `parents` under a superuser group or any descendant, creating a group under one, and flipping `is_superuser` (verified, §0.2 ii).
- `view_token_key` and `add_token` (MHP APPS-1462). `set_token_key` is granted since revision 6 for service-account rotation only, behind §13.4 (§13.2 explains why `add_token` would not help anyway)
- `view_token_key` (MHP APPS-1462). Since revision 6, `set_token_key` and `add_token` are granted for service-account creation and rotation only, behind §13.4; `add_token` cannot mint for others (§13.2, §13.9)
- `impersonate`; `delete_user` and `delete_group` unless the `DELETE` feature is on (§12.5)
- any `change_*` on flows, stages, providers, applications, policies, bindings, brands or blueprints
- any change on its own role. Role permissions are object permissions excluding `qqq-*` roles, and the guard also refuses.
@@ -668,7 +674,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost
- `AuthentikActionUtil.doQuery/doGet/doInsert/doUpdate/doDelete` call `AuthentikGuard.check(table, op, session)` first. The guard checks the QQQ permission, the feature flag, `readOnly` and scope.
- A session with no user is refused, except `MirrorSync`'s internal read-only session.
- Every qbit process's first backend step re-checks `checkProcessPermissionThrowing` and the per-record rules (§5.4). It does not trust that the middleware ran.
- Field-level protection does not use `FieldSecurityLock` (#1062):
- Field-level protection retains explicit stripping for direct backend calls; core also enforces `FieldSecurityLock` in the RC.5 baseline (#1062):
- Sensitive columns are removed in `jsonObjectToRecord` without `authentik.audit.read`. These are `attributes` (except the `qqq.io/*` keys, shown under `users.read`), `last_ip`, `user_agent` and `geo_ip`.
- Filters on those fields are rejected for that session.
- Tables also declare `QPermissionRules`, so the UI hides what the user can't use.
@@ -720,6 +726,10 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace
**Fallback.** If Authentik is unreachable at sign-in, the mapper uses the mirror. If the mirror is empty, it grants only what the static config provides. It never grants everything.
#### Automated sessions
Server-created sessions (scheduled reports and SSH-key users of the SFTP gateway) have no token. `customizeAutomatedSessionForUser` treats `userId` as the email idReference used by `user_email` mode and requires exactly one Authentik user with that email (trimmed, case-insensitive), who must be active. It resolves the API user's direct groups and their ancestors through the same permission resolver, static fallbacks and cut-over guard as sign-in, including the host permission source while the guard blocks. Service accounts follow the same rule. Bootstrap grants match only the resolved user's immutable pk or uuid; email verification is never assumed. Unknown, duplicate, inactive, unreadable or truncated lookups grant only empty-group static fallbacks. Existing session permissions are always preserved; an API failure never grants all permissions.
---
## 6. Data model
@@ -951,7 +961,7 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh
| A blueprint re-apply reverts QQQ edits | No `present` identity entries, no member lists (CI lint); bootstrap as `created` (verified, §4.2); reversion detector |
| Lost update between QQQ and the Authentik UI | Stale check at execute; attribute merge-patch after a fresh read |
| Account takeover through a recovery link | Links are never shown; email only |
| A non-HTTP caller bypasses permissions (#1061) | Guard in the backend util, plus ArchUnit |
| A direct backend caller bypasses core actions | Guard in the backend util, plus ArchUnit |
| A field leaks because `FieldSecurityLock` isn't enforced (#1062) | Sensitive fields stripped in the util |
| A forged webhook | Shared secret, constant-time compare; it only triggers re-reads |
| Stale session permissions after a demotion | Re-evaluation on token refresh plus webhook-triggered marking (§5.6); live groups used for guard checks |
@@ -1029,7 +1039,7 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a
| The service account can lock administrators out (remove members from `authentik Admins`, deactivate break-glass, strip `qqq.io/protected`, §0.2 ii-b). Re-parenting under a superuser group **is** refused by Authentik (§0.2 ii) | Guard refusal of every write that targets a protected account or a superuser group; drift report; recovery through cluster access |
| Load on the shared Postgres | Throttle, caps, mirror |
| Webhook loss | Inbox, 15-minute reconcile, nightly sweep |
| QQQ core gaps (#1061, #1062) | Guard in the backend util, ArchUnit, field stripping |
| Direct backend calls outside core enforcement | Guard in the backend util, ArchUnit, field stripping |
| Token leakage | Redaction tests; no body logging on sensitive paths |
| An Authentik upgrade changes fields | Pinned schema, fixtures per version, version check |
@@ -1128,7 +1138,7 @@ The feature `POLICIES` turns on the editor, the `authentik_policy` table and the
*Edit Policy* is a record action on `authentik_group` and on `authentik_policy`, never in the nav. It is a standard streamed-ETL process with three steps.
**1. Edit** (frontend step). The step shows the widget `authentikPolicyEditor`, type `AUTHENTIK_POLICY_EDITOR`, a lazy-loaded Next UI widget. The server sends it two things.
**1. Edit** (frontend step). The step shows the widget `authentikPolicyEditor`, type `AUTHENTIK_POLICY_EDITOR`, a lazy-loaded Next UI widget. The server sends its catalog, current document and member preview data.
The **catalog** lists every app, table, process, report and protected widget in the QInstance. Each entry has its permission base name, label, kind, app, and the actions QQQ actually checks for it:
- `hasAccess` for apps, processes, reports and widgets
@@ -1145,6 +1155,8 @@ The catalog also follows these rules:
The **current document** is read live from the group in Authentik, the source of truth, together with the group's fingerprint for the stale check. If it differs from the last QQQ save, a notice says it was changed in Authentik.
**Preview for a member.** On editor load only, under `authentik.policies.manage`, the payload adds `members` (up to 200 active, in-scope members of this group or its descendants, sorted by username), `membersTruncated` (true when more eligible people exist), and `mapperEnabled` from config so the UI can say "not enforced at sign-in". Each member has `username`, `label` (`Full Name (username)`), `viaGroups` (sorted direct group names that are this group or its descendants), and `basePermissions` (sorted session permissions with only this group's policy document excluded). Named `qqq.io/permissions`, other groups' and ancestors' policies, wildcard expansion, static direct-group fallbacks and bootstrap-subject grants still count. The Next UI combines that base with the current and edited documents to preview effective permissions and additions/removals locally.
The screen works like this:
- Resources are grouped by app, with a search box.
- Each row has a checkbox per action, plus *all* for the row. Each action column and each app has *select all*.
@@ -1285,7 +1297,7 @@ A QQQ access token is an Authentik **app password** (`intent: app_password`) own
- `password={token}`
- `scope=openid profile email`
Authentik checks that the owner is active, that the token exists with intent `app_password`, and that the app's policies let the owner in. It records a `login` event with `method: token` and the token's identifier.
No client secret: Authentik 2026.8.3 authenticates this grant by the username and app password, even for a confidential client (client authentication is required only for the code, refresh, device and token-exchange grants; pinned by `TokenContractIT`). Authentik checks that the owner is active, that the token exists with intent `app_password`, and that the app's policies let the owner in. It records a `login` event with `method: token` and the token's identifier.
2. **Call the app** (`/qqq/v1/...` and the published APIs) with `Authorization: Bearer {jwt}`. When the JWT expires (the provider's access-token validity), exchange the token again.
The qbit generates these instructions from the instance's real URLs (§13.7).
@@ -1315,6 +1327,8 @@ API-intent tokens (`intent: api`) call Authentik's own API, not the app. QQQ lis
**Limitation (decision 30).** A service account has exactly one app password: the one `service_account` creates. Only a superuser could add a second. To run two credentials side by side, create a second service account.
**Contract finding (2026-10-13, verified on 2026.8.3).** `UserViewSet.service_account` requires `add_user` **and** `add_token`, and `POST …/set_key/` requires `add_token` (Authentik's `ObjectPermissions` maps a detail POST to `add_*`) as well as `set_token_key`. Without `add_token` both return 403. The role therefore holds `add_token` (§13.9); `TokenContractIT` pins both behaviours.
**Race guard.** Before an extend or a rotate, the qbit reads the token (owner pk and intent), and it reads the token again after the `PATCH` or `set_key`. If the owner or the intent changed:
- the run fails;
- the audit row is `FAILED` with "token moved";
@@ -1356,7 +1370,7 @@ Without a broker, the self-service actions are hidden, and Review says why.
| (b) | **Self-service is self-only.** A person without `authentik.tokens.manage` may target only themselves. An admin may also target service accounts, but **never another human**; this covers create, rotate and extend. Revoke is the exception: for incident response, an admin may revoke any token whose owner isn't protected. | The qbit guard; Authentik itself for the as-user calls |
| (c) | **Expiry is required.** There's no non-expiring option. The maximum is `tokenMaxDaysUser` (default 90) for people and `tokenMaxDaysServiceAccount` (default 365) for service accounts. Extend counts from now. Authentik's group attribute is a second cap. | The qbit (Review refuses); Authentik (`token-maximum-lifetime`) |
| (d) | **Two-person approval** for service-account tokens (create service account, rotate): `tokenTwoPersonApproval`, default **off** while the team is one person. When on, the change is queued in `authentik_approval_request`, and the key is shown to the approver who applies it. | The qbit |
| (e) | **Detective control.** An Authentik notification rule and an expression policy fire whenever the qbit's service account creates or updates a token whose owner is protected, reads its key, or sets its key (`model_created`, `model_updated`, `secret_view` or `secret_rotate` on `authentik_core.token`). They notify `identity-admins` through Authentik notifications. The rule should never fire; if it does, guard (a) was bypassed. | The Authentik blueprint (§13.9). The qbit also raises an `authentik_token_guard_bypass` alert through the Alerts producer when its own re-read finds a protected owner. |
| (e) | **Detective control.** An Authentik notification rule and an expression policy fire whenever the qbit's service account creates or updates a token whose owner is protected, reads its key, or sets its key (`model_created`, `model_updated`, `secret_view` or `secret_rotate` on `authentik_core.token`). They notify `identity-admins` through Authentik notifications. The rule should never fire; if it does, guard (a) was bypassed. It also notifies `identity-admins` whenever the qbit's account creates a service account (decision 31). | The Authentik blueprint (§13.9). The qbit also raises an `authentik_token_guard_bypass` alert through the Alerts producer when its own re-read finds a protected owner. |
| (f) | **Everything is audited.** Before each call, every action writes an `authentik_admin_action` row with the client channel (`audit.clientChannel`, for example `web`, `api` or `token:{identifier}`), plus an Authentik `custom_qqq_admin_action` event. The assistant can't reach these tables or actions, because the assistant wall denies `authentik_*`. | The qbit; core audit |
### 13.5 Scopes
@@ -1402,7 +1416,7 @@ STAND-IN(core=QRun-IO/qqq#1153, removeBy=the RC that ships #1153, RC.7 at the ea
**Record actions.** These use core's one-time process output values: `RunBackendStepOutput.addOneTimeSecret(...)`, in the QRun-IO/qqq PR `feature/process-one-time-values`.
- The value travels only in the step's HTTP response.
- `ProcessState`, traces and lifecycle listeners (ESB) see only the masked form: `••••` plus the last 4 characters.
- If the run finishes asynchronously, the key is lost by design, and the result says "Rotate the token to get a new key".
- If the step outlives the middleware's step timeout (3 s) and finishes as a job, core parks the secrets in memory (`OneTimeSecretHandoff`, 5 minutes, never in state) and returns them once, to the first status poll that sees the job complete. A poll that reaches another server instance or comes too late finds nothing: the key is lost by design, and the result says "Rotate the token to get a new key". (Found in the demo: a create through the broker can take longer than 3 s.)
**Where the key never goes.** The qbit never passes it to the audit row, the Authentik event context, `AdminResult.message`, notifications, logs or exceptions. `AuthentikRedactor` also masks any `key` or `token` JSON value in error text.
@@ -1434,9 +1448,11 @@ Reminders are deduplicated through `lastReminder7d` and `lastReminder1d`. Tokens
### 13.9 Authentik changes (cluster-gitops, kof22)
**The qbit role** `qqq-kof22-admin-idadmin`:
- add `authentik_core.change_token` and `authentik_core.set_token_key`;
- add `authentik_core.change_token`, `authentik_core.set_token_key` and `authentik_core.add_token`;
- it already has `delete_token`, `view_token` and `add_user`;
- **not** `add_token` or `view_token_key`.
- **not** `view_token_key`.
`add_token` is safe here: for a non-superuser, `POST /core/tokens/` always creates the token for the **caller**, so it can never mint a token for someone else. It is required by `service_account` and by `set_key` (the POST object permission).
**New Authentik objects:**
- the broker provider and application, with the deny policy (§13.3.1);
@@ -1459,6 +1475,7 @@ Reminders are deduplicated through `lastReminder7d` and `lastReminder1d`. Tokens
|---|---|---|
| `tokensEnabled` | `true` when any write feature is on | Opt-out (`AUTHENTIK_QBIT_TOKENS=false`) |
| `tokenMaxDaysUser` / `tokenMaxDaysServiceAccount` | 90 / 365 | Guardrail (c) |
| `serviceAccountTokenLifetime` | `days=365` | Authentik lifetime cap merged into each newly created service account's USER attributes |
| `tokenProtectedGroups` | `authentik Admins`, `identity-admins` | Added to the hard-coded classes of guardrail (a) |
| `tokenTwoPersonApproval` | `false` | Guardrail (d) |
| `tokenScopesEnabled` | `false` | Offer scopes once the claim mapping is installed |
+1 -1
View File
@@ -105,7 +105,7 @@ Authentik puts the merged document into the token through a scope mapping. Steps
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog continue to accept only standard actions. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the existing available-permission API without compiling against the unreleased `TableFeaturePermissionsMetaData` hook; features become available when the host's core supplies those entries.
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog continue to accept only standard actions. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the existing available-permission API without compiling against the `TableFeaturePermissionsMetaData` hook (absent from the pinned RC.5 release); features become available when the host's core supplies those entries.
The mapping merges the statements of every group in `request.user.all_groups()` (direct groups and their ancestors) whose document has the right spec and version. It always emits a document, with an empty `Statement` array when the user has no policies.
+23
View File
@@ -68,3 +68,26 @@ CREATE TABLE authentik_policy
authentik_fingerprint VARCHAR(64),
CONSTRAINT uk_authentik_policy_group_pk UNIQUE (group_pk)
);
-- Token metadata only (DESIGN section 13.5). No key or key hash is stored.
CREATE TABLE authentik_token_scope
(
id SERIAL PRIMARY KEY,
token_identifier VARCHAR(255) NOT NULL,
owner_username VARCHAR(255),
owner_pk INTEGER,
read_only BOOLEAN,
apps TEXT,
tables TEXT,
processes TEXT,
reports TEXT,
widgets TEXT,
operation_ids TEXT,
created_by VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE,
rotated_at TIMESTAMP WITH TIME ZONE,
last_reminder_7d TIMESTAMP WITH TIME ZONE,
last_reminder_1d TIMESTAMP WITH TIME ZONE,
CONSTRAINT uk_authentik_token_scope_identifier UNIQUE (token_identifier)
);
+5 -3
View File
@@ -18,6 +18,8 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -36,7 +38,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -237,7 +239,7 @@
<profiles>
<!-- Needs qqq 4.1 (QBit producer API). Until qbit-build-parent imports the -->
<!-- released 4.1 BOM, this profile is active by default and imports -->
<!-- qqq-bom-pom 4.1.0-RC.2 ahead of the parent 4.0.0 BOM, as in -->
<!-- qqq-bom-pom 4.1.0-RC.5 ahead of the parent 4.0.0 BOM, as in -->
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
@@ -245,7 +247,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -26,6 +26,9 @@ public enum AuthentikFeature
/** Read-only tables: users, groups, memberships, roles, applications, bindings, blueprints, sessions, events. */
READ,
/** Access tokens (DESIGN section 13). */
TOKENS,
/** Create, invite, edit, deactivate users (phase 3). */
USERS,
@@ -46,15 +46,33 @@ public class AuthentikQBitConfig implements QBitConfig
{
/** Features implemented in this release. Others are refused by validate(). */
public static final Set<AuthentikFeature> AVAILABLE_FEATURES = EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE);
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE, AuthentikFeature.TOKENS);
/** Features that change Authentik data; they need a local backend for the audit trail. */
public static final Set<AuthentikFeature> WRITE_FEATURES = EnumSet.of(AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE);
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE, AuthentikFeature.TOKENS);
private static final Pattern APP_KEY_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,48}[a-z0-9]");
private static final Pattern PREFIX_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9]{0,30}");
private Boolean tokensEnabled = null;
private Integer tokenMaxDaysUser = 90;
private Integer tokenMaxDaysServiceAccount = 365;
private List<String> tokenProtectedGroups = new ArrayList<>(List.of("authentik Admins", "identity-admins"));
private Boolean tokenTwoPersonApproval = false;
private Boolean tokenScopesEnabled = false;
private String tokenScopeClaim = "qqq_token";
private String tokenReminderSchedulerName;
private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}";
private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}";
private String tokenEndpointUrl = null;
private String appPublicUrl = null;
private String authentikPublicUrl = null;
private String appClientId = null;
private Integer tokenUnusedDays = 60;
private String serviceAccountTokenLifetime = "days=365";
private com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource subjectTokenSource = new com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource();
private String baseUrl;
private String apiToken;
private String appKey;
@@ -106,6 +124,23 @@ public class AuthentikQBitConfig implements QBitConfig
@Override
public void validate(QInstance qInstance, List<String> errors)
{
if(tokenMaxDaysUser == null || tokenMaxDaysUser < 1)
{
errors.add("tokenMaxDaysUser must be at least 1");
}
if(tokenMaxDaysServiceAccount == null || tokenMaxDaysServiceAccount < 1)
{
errors.add("tokenMaxDaysServiceAccount must be at least 1");
}
if(tokenScopeClaim == null || tokenScopeClaim.isBlank())
{
errors.add("tokenScopeClaim must not be blank");
}
if(tokenUnusedDays == null || tokenUnusedDays < 1)
{
errors.add("tokenUnusedDays must be at least 1");
}
if(!StringUtils.hasContent(baseUrl))
{
errors.add("baseUrl is required (the Authentik host root, e.g. https://auth.example.com)");
@@ -422,6 +457,11 @@ public class AuthentikQBitConfig implements QBitConfig
*******************************************************************************/
public boolean hasFeature(AuthentikFeature feature)
{
if(feature == AuthentikFeature.TOKENS)
{
return (!Boolean.TRUE.equals(readOnly) && Boolean.TRUE.equals(getTokensEnabled()) && features != null
&& features.stream().anyMatch(WRITE_FEATURES::contains));
}
return (features != null && features.contains(feature));
}
@@ -454,6 +494,19 @@ public class AuthentikQBitConfig implements QBitConfig
public String toString()
{
return ("AuthentikQBitConfig{baseUrl=" + baseUrl + ", appKey=" + appKey + ", tableNamePrefix=" + tableNamePrefix
+ ", tokensEnabled=" + tokensEnabled
+ ", tokenMaxDaysUser=" + tokenMaxDaysUser
+ ", tokenMaxDaysServiceAccount=" + tokenMaxDaysServiceAccount
+ ", tokenProtectedGroups=" + tokenProtectedGroups
+ ", tokenTwoPersonApproval=" + tokenTwoPersonApproval
+ ", tokenScopesEnabled=" + tokenScopesEnabled
+ ", tokenBrokerClientId=" + tokenBrokerClientId
+ ", tokenEndpointUrl=" + tokenEndpointUrl
+ ", appPublicUrl=" + appPublicUrl
+ ", authentikPublicUrl=" + authentikPublicUrl
+ ", appClientId=" + appClientId
+ ", tokenUnusedDays=" + tokenUnusedDays
+ ", serviceAccountTokenLifetime=" + serviceAccountTokenLifetime
+ ", features=" + features + ", readOnly=" + readOnly + ", apiToken=" + (StringUtils.hasContent(apiToken) ? "******" : "(unset)") + "}");
}
@@ -1356,4 +1409,522 @@ public class AuthentikQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** Getter for tokensEnabled.
*******************************************************************************/
public Boolean getTokensEnabled()
{
return (tokensEnabled == null ? !"false".equalsIgnoreCase(String.valueOf(resolve("${env.AUTHENTIK_QBIT_TOKENS}")).trim()) : tokensEnabled);
}
/*******************************************************************************
** Setter for tokensEnabled.
*******************************************************************************/
public void setTokensEnabled(Boolean tokensEnabled)
{
this.tokensEnabled = tokensEnabled;
}
/*******************************************************************************
** Fluent setter for tokensEnabled.
*******************************************************************************/
public AuthentikQBitConfig withTokensEnabled(Boolean tokensEnabled)
{
this.tokensEnabled = tokensEnabled;
return (this);
}
/*******************************************************************************
** Getter for tokenMaxDaysUser.
*******************************************************************************/
public Integer getTokenMaxDaysUser()
{
return (tokenMaxDaysUser);
}
/*******************************************************************************
** Setter for tokenMaxDaysUser.
*******************************************************************************/
public void setTokenMaxDaysUser(Integer tokenMaxDaysUser)
{
this.tokenMaxDaysUser = tokenMaxDaysUser;
}
/*******************************************************************************
** Fluent setter for tokenMaxDaysUser.
*******************************************************************************/
public AuthentikQBitConfig withTokenMaxDaysUser(Integer tokenMaxDaysUser)
{
this.tokenMaxDaysUser = tokenMaxDaysUser;
return (this);
}
/*******************************************************************************
** Getter for tokenMaxDaysServiceAccount.
*******************************************************************************/
public Integer getTokenMaxDaysServiceAccount()
{
return (tokenMaxDaysServiceAccount);
}
/*******************************************************************************
** Setter for tokenMaxDaysServiceAccount.
*******************************************************************************/
public void setTokenMaxDaysServiceAccount(Integer tokenMaxDaysServiceAccount)
{
this.tokenMaxDaysServiceAccount = tokenMaxDaysServiceAccount;
}
/*******************************************************************************
** Fluent setter for tokenMaxDaysServiceAccount.
*******************************************************************************/
public AuthentikQBitConfig withTokenMaxDaysServiceAccount(Integer tokenMaxDaysServiceAccount)
{
this.tokenMaxDaysServiceAccount = tokenMaxDaysServiceAccount;
return (this);
}
/*******************************************************************************
** Getter for tokenProtectedGroups.
*******************************************************************************/
public List<String> getTokenProtectedGroups()
{
return (tokenProtectedGroups);
}
/*******************************************************************************
** Setter for tokenProtectedGroups.
*******************************************************************************/
public void setTokenProtectedGroups(List<String> tokenProtectedGroups)
{
this.tokenProtectedGroups = tokenProtectedGroups;
}
/*******************************************************************************
** Fluent setter for tokenProtectedGroups.
*******************************************************************************/
public AuthentikQBitConfig withTokenProtectedGroups(List<String> tokenProtectedGroups)
{
this.tokenProtectedGroups = tokenProtectedGroups;
return (this);
}
/*******************************************************************************
** Getter for tokenTwoPersonApproval.
*******************************************************************************/
public Boolean getTokenTwoPersonApproval()
{
return (tokenTwoPersonApproval);
}
/*******************************************************************************
** Setter for tokenTwoPersonApproval.
*******************************************************************************/
public void setTokenTwoPersonApproval(Boolean tokenTwoPersonApproval)
{
this.tokenTwoPersonApproval = tokenTwoPersonApproval;
}
/*******************************************************************************
** Fluent setter for tokenTwoPersonApproval.
*******************************************************************************/
public AuthentikQBitConfig withTokenTwoPersonApproval(Boolean tokenTwoPersonApproval)
{
this.tokenTwoPersonApproval = tokenTwoPersonApproval;
return (this);
}
/*******************************************************************************
** Getter for tokenScopesEnabled.
*******************************************************************************/
public Boolean getTokenScopesEnabled()
{
return (tokenScopesEnabled);
}
/*******************************************************************************
** Setter for tokenScopesEnabled.
*******************************************************************************/
public void setTokenScopesEnabled(Boolean tokenScopesEnabled)
{
this.tokenScopesEnabled = tokenScopesEnabled;
}
/*******************************************************************************
** Fluent setter for tokenScopesEnabled.
*******************************************************************************/
public AuthentikQBitConfig withTokenScopesEnabled(Boolean tokenScopesEnabled)
{
this.tokenScopesEnabled = tokenScopesEnabled;
return (this);
}
/*******************************************************************************
** Getter for tokenBrokerClientId.
*******************************************************************************/
public String getTokenBrokerClientId()
{
return (resolve(tokenBrokerClientId));
}
/*******************************************************************************
** Setter for tokenBrokerClientId.
*******************************************************************************/
public void setTokenBrokerClientId(String tokenBrokerClientId)
{
this.tokenBrokerClientId = tokenBrokerClientId;
}
/*******************************************************************************
** Fluent setter for tokenBrokerClientId.
*******************************************************************************/
public AuthentikQBitConfig withTokenBrokerClientId(String tokenBrokerClientId)
{
this.tokenBrokerClientId = tokenBrokerClientId;
return (this);
}
/*******************************************************************************
** Getter for tokenBrokerClientSecret.
*******************************************************************************/
@JsonIgnore
public String getTokenBrokerClientSecret()
{
return (resolve(tokenBrokerClientSecret));
}
/*******************************************************************************
** Setter for tokenBrokerClientSecret.
*******************************************************************************/
public void setTokenBrokerClientSecret(String tokenBrokerClientSecret)
{
this.tokenBrokerClientSecret = tokenBrokerClientSecret;
}
/*******************************************************************************
** Fluent setter for tokenBrokerClientSecret.
*******************************************************************************/
public AuthentikQBitConfig withTokenBrokerClientSecret(String tokenBrokerClientSecret)
{
this.tokenBrokerClientSecret = tokenBrokerClientSecret;
return (this);
}
/*******************************************************************************
** Getter for tokenEndpointUrl.
*******************************************************************************/
public String getTokenEndpointUrl()
{
return (tokenEndpointUrl == null ? getBaseUrl().replaceAll("/+$", "") + "/application/o/token/" : resolve(tokenEndpointUrl));
}
/*******************************************************************************
** Setter for tokenEndpointUrl.
*******************************************************************************/
public void setTokenEndpointUrl(String tokenEndpointUrl)
{
this.tokenEndpointUrl = tokenEndpointUrl;
}
/*******************************************************************************
** Fluent setter for tokenEndpointUrl.
*******************************************************************************/
public AuthentikQBitConfig withTokenEndpointUrl(String tokenEndpointUrl)
{
this.tokenEndpointUrl = tokenEndpointUrl;
return (this);
}
/*******************************************************************************
** Getter for appPublicUrl.
*******************************************************************************/
public String getAppPublicUrl()
{
return (resolve(appPublicUrl));
}
/*******************************************************************************
** Setter for appPublicUrl.
*******************************************************************************/
public void setAppPublicUrl(String appPublicUrl)
{
this.appPublicUrl = appPublicUrl;
}
/*******************************************************************************
** Fluent setter for appPublicUrl.
*******************************************************************************/
public AuthentikQBitConfig withAppPublicUrl(String appPublicUrl)
{
this.appPublicUrl = appPublicUrl;
return (this);
}
/*******************************************************************************
** Getter for authentikPublicUrl.
*******************************************************************************/
public String getAuthentikPublicUrl()
{
return (authentikPublicUrl == null ? getBaseUrl() : resolve(authentikPublicUrl));
}
/*******************************************************************************
** Setter for authentikPublicUrl.
*******************************************************************************/
public void setAuthentikPublicUrl(String authentikPublicUrl)
{
this.authentikPublicUrl = authentikPublicUrl;
}
/*******************************************************************************
** Fluent setter for authentikPublicUrl.
*******************************************************************************/
public AuthentikQBitConfig withAuthentikPublicUrl(String authentikPublicUrl)
{
this.authentikPublicUrl = authentikPublicUrl;
return (this);
}
/*******************************************************************************
** Getter for appClientId.
*******************************************************************************/
public String getAppClientId()
{
return (resolve(appClientId));
}
/*******************************************************************************
** Setter for appClientId.
*******************************************************************************/
public void setAppClientId(String appClientId)
{
this.appClientId = appClientId;
}
/*******************************************************************************
** Fluent setter for appClientId.
*******************************************************************************/
public AuthentikQBitConfig withAppClientId(String appClientId)
{
this.appClientId = appClientId;
return (this);
}
/*******************************************************************************
** Getter for tokenUnusedDays.
*******************************************************************************/
public Integer getTokenUnusedDays()
{
return (tokenUnusedDays);
}
/*******************************************************************************
** Setter for tokenUnusedDays.
*******************************************************************************/
public void setTokenUnusedDays(Integer tokenUnusedDays)
{
this.tokenUnusedDays = tokenUnusedDays;
}
/*******************************************************************************
** Fluent setter for tokenUnusedDays.
*******************************************************************************/
public AuthentikQBitConfig withTokenUnusedDays(Integer tokenUnusedDays)
{
this.tokenUnusedDays = tokenUnusedDays;
return (this);
}
/*******************************************************************************
** Getter for serviceAccountTokenLifetime.
*******************************************************************************/
public String getServiceAccountTokenLifetime()
{
return (serviceAccountTokenLifetime);
}
/*******************************************************************************
** Setter for serviceAccountTokenLifetime.
*******************************************************************************/
public void setServiceAccountTokenLifetime(String serviceAccountTokenLifetime)
{
this.serviceAccountTokenLifetime = serviceAccountTokenLifetime;
}
/*******************************************************************************
** Fluent setter for serviceAccountTokenLifetime.
*******************************************************************************/
public AuthentikQBitConfig withServiceAccountTokenLifetime(String serviceAccountTokenLifetime)
{
this.serviceAccountTokenLifetime = serviceAccountTokenLifetime;
return (this);
}
/*******************************************************************************
** Getter for subjectTokenSource.
*******************************************************************************/
@JsonIgnore
public com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource getSubjectTokenSource()
{
return (subjectTokenSource);
}
/*******************************************************************************
** Setter for subjectTokenSource.
*******************************************************************************/
public void setSubjectTokenSource(com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource subjectTokenSource)
{
this.subjectTokenSource = subjectTokenSource;
}
/*******************************************************************************
** Fluent setter for subjectTokenSource.
*******************************************************************************/
public AuthentikQBitConfig withSubjectTokenSource(com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource subjectTokenSource)
{
this.subjectTokenSource = subjectTokenSource;
return (this);
}
/*******************************************************************************
** Signed JWT claim identifying the Authentik token.
*******************************************************************************/
public String getTokenScopeClaim()
{
return (tokenScopeClaim);
}
/*******************************************************************************
** Set the host's signed token claim name.
*******************************************************************************/
public void setTokenScopeClaim(String value)
{
tokenScopeClaim = value;
}
/*******************************************************************************
** Fluent signed token claim name.
*******************************************************************************/
public AuthentikQBitConfig withTokenScopeClaim(String value)
{
tokenScopeClaim = value;
return (this);
}
/*******************************************************************************
** Optional scheduler; otherwise use the instance's first scheduler.
*******************************************************************************/
public String getTokenReminderSchedulerName()
{
return (tokenReminderSchedulerName);
}
/*******************************************************************************
** Select a scheduler when the host defines several.
*******************************************************************************/
public void setTokenReminderSchedulerName(String value)
{
tokenReminderSchedulerName = value;
}
/*******************************************************************************
** Fluent scheduler selection.
*******************************************************************************/
public AuthentikQBitConfig withTokenReminderSchedulerName(String value)
{
tokenReminderSchedulerName = value;
return (this);
}
}
@@ -46,10 +46,13 @@ public class AuthentikRuntime
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
private final Memoization<String, String> responseCache;
private final Memoization<String, GroupGraph> groupGraphCache;
private final Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> tokenScopeCache = new Memoization<>(Duration.ofSeconds(60));
private final Memoization<String, Map<String, java.time.Instant>> tokenActivityCache = new Memoization<>(Duration.ofMinutes(5));
private volatile java.util.Set<String> permissionCatalog;
private volatile PolicyCatalog policyCatalog;
private volatile AccessComparison.State mapperState;
private volatile boolean mapperInvoked;
private com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink tokenAlerts = new com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts()::send;
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
@@ -130,6 +133,8 @@ public class AuthentikRuntime
public void invalidateCache()
{
responseCache.clear();
tokenScopeCache.clear();
tokenActivityCache.clear();
groupGraphCache.clear();
}
@@ -280,4 +285,37 @@ public class AuthentikRuntime
mapperState = null;
}
}
/*******************************************************************************
** Token claims are cached independently of the general list TTL.
*******************************************************************************/
public Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> getTokenScopeCache()
{
return (tokenScopeCache);
}
/*******************************************************************************
** Login-event aggregation is shared by every token record in this runtime.
*******************************************************************************/
public Memoization<String, Map<String, java.time.Instant>> getTokenActivityCache()
{
return (tokenActivityCache);
}
/*******************************************************************************
** Optional Alerts delivery, replaceable by a host or a capturing test sink.
*******************************************************************************/
public com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink getTokenAlerts()
{
return (tokenAlerts);
}
/*******************************************************************************
** Install a sink for this runtime only.
*******************************************************************************/
public AuthentikRuntime withTokenAlerts(com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink sink)
{
tokenAlerts = java.util.Objects.requireNonNull(sink);
return (this);
}
}
@@ -27,6 +27,13 @@ import com.kingsrook.qbits.authentik.security.IdentityPermission;
*******************************************************************************/
public enum AdminAction
{
// Null permission means self-service: AuthentikTokens verifies the live owner.
// Processes add no permission beyond their table; cross-owner actions require TOKENS_MANAGE.
CREATE_TOKEN("Create token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
ROTATE_TOKEN("Rotate token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
REVOKE_TOKEN("Revoke token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
EXTEND_TOKEN("Extend expiry", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
CREATE_SERVICE_ACCOUNT("Create service account", "user", IdentityPermission.TOKENS_MANAGE, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
CREATE_USER("Create user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
INVITE_USER("Invite user", "invitation", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
EDIT_USER("Edit user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
@@ -108,6 +108,7 @@ public class AuthentikAdmin
private final AuthentikQBitConfig config;
private final AuthentikGuard guard;
private final AdminActionAuditor auditor;
private final AuthentikTokens tokens;
private Operator operator;
private GroupGraph graph;
@@ -141,8 +142,150 @@ public class AuthentikAdmin
** @param message what happened, for the operator
** @param eventPk the Authentik audit event, when posted
*******************************************************************************/
public record AdminResult(boolean ok, boolean pending, boolean noChange, String message, String eventPk)
public static final class AdminResult
{
private final boolean ok;
private final boolean pending;
private final boolean noChange;
private final String message;
private final String eventPk;
private transient OneTimeKey secret;
private transient SecretCleanup cleanup;
/*******************************************************************************
** Construct a result without a secret.
*******************************************************************************/
public AdminResult(boolean ok, boolean pending, boolean noChange, String message, String eventPk)
{
this.ok = ok;
this.pending = pending;
this.noChange = noChange;
this.message = message;
this.eventPk = eventPk;
}
/*******************************************************************************
** Getter for ok.
*******************************************************************************/
public boolean ok()
{
return (ok);
}
/*******************************************************************************
** Getter for pending.
*******************************************************************************/
public boolean pending()
{
return (pending);
}
/*******************************************************************************
** Getter for noChange.
*******************************************************************************/
public boolean noChange()
{
return (noChange);
}
/*******************************************************************************
** Getter for message.
*******************************************************************************/
public String message()
{
return (message);
}
/*******************************************************************************
** Getter for eventPk.
*******************************************************************************/
public String eventPk()
{
return (eventPk);
}
/*******************************************************************************
** Attach a response-only secret and its emergency cleanup.
*******************************************************************************/
public AdminResult withSecret(OneTimeKey key, SecretCleanup cleanup)
{
this.secret = key;
this.cleanup = cleanup;
return (this);
}
/*******************************************************************************
** Take the secret exactly once; no serializer getter is exposed.
*******************************************************************************/
public OneTimeKey takeSecret()
{
OneTimeKey key = secret;
secret = null;
return (key);
}
/*******************************************************************************
** Revoke a newly issued credential when response delivery fails.
*******************************************************************************/
public void discardSecret() throws QException
{
if(cleanup != null)
{
cleanup.run();
cleanup = null;
}
}
/*******************************************************************************
** Drop the action-scoped cleanup (which may hold an exchanged bearer).
*******************************************************************************/
public void delivered()
{
cleanup = null;
}
/*******************************************************************************
** No secret or usage text in diagnostics.
*******************************************************************************/
@Override
public String toString()
{
return ("AdminResult{ok=" + ok + ", pending=" + pending + ", message=" + message + "}");
}
}
/*******************************************************************************
** Action-local cleanup, never serialized or retained in process state.
*******************************************************************************/
@FunctionalInterface
public interface SecretCleanup
{
/*******************************************************************************
** Discard the credential after a delivery failure.
*******************************************************************************/
void run() throws QException;
}
@@ -157,6 +300,7 @@ public class AuthentikAdmin
this.config = runtime.getConfig();
this.guard = runtime.getGuard();
this.auditor = new AdminActionAuditor(config);
this.tokens = new AuthentikTokens(this);
}
@@ -272,6 +416,7 @@ public class AuthentikAdmin
return switch(action)
{
case CREATE_TOKEN, ROTATE_TOKEN, REVOKE_TOKEN, EXTEND_TOKEN, CREATE_SERVICE_ACCOUNT -> tokens.plan(plan);
case CREATE_USER -> planCreateUser(plan, request);
case INVITE_USER -> planInvite(plan, request);
case CANCEL_INVITATION -> planCancelInvitation(plan, request);
@@ -299,7 +444,12 @@ public class AuthentikAdmin
*******************************************************************************/
public AdminResult execute(AdminRequest request) throws QException
{
if(request.action().getFeature() == com.kingsrook.qbits.authentik.AuthentikFeature.TOKENS)
{
request.params().remove("approved");
}
runtime.invalidateCache();
operator = null;
graph = null;
membersGraph = null;
userFacts = null;
@@ -314,6 +464,16 @@ public class AuthentikAdmin
*******************************************************************************/
public AdminResult execute(AdminPlan plan, String approver) throws QException
{
if(plan.getRequest().action().getFeature() == com.kingsrook.qbits.authentik.AuthentikFeature.TOKENS)
{
if(approver == null)
{
plan.getRequest().params().remove("approved");
}
operator = null;
graph = null;
plan = plan(plan.getRequest());
}
String correlationId = UUID.randomUUID().toString();
String operatorName = guard.operatorEmail();
AdminAction action = plan.getRequest().action();
@@ -335,9 +495,18 @@ public class AuthentikAdmin
Serializable auditId = auditor.begin(plan, correlationId, operatorName, AuthentikLocalTables.ActionStatus.RUNNING, approver);
String message;
AdminResult tokenResult = null;
try
{
message = perform(plan, authorization, correlationId);
if(action.getFeature() == com.kingsrook.qbits.authentik.AuthentikFeature.TOKENS)
{
tokenResult = tokens.perform(plan, authorization);
message = tokenResult.message();
}
else
{
message = perform(plan, authorization, correlationId);
}
}
catch(Exception e)
{
@@ -363,7 +532,13 @@ public class AuthentikAdmin
{
LOG.warn("Could not mark the audit row succeeded", logPair("id", auditId));
}
return (new AdminResult(true, false, false, message, eventPk));
AdminResult result = new AdminResult(true, false, false, message, eventPk);
if(tokenResult != null)
{
result.withSecret(tokenResult.takeSecret(), tokenResult.cleanup);
tokenResult.delivered();
}
return (result);
}
@@ -1679,7 +1854,7 @@ public class AuthentikAdmin
** group, and the operator holds everything it confers (the subset rule),
** including app access. Returns the refusal text, or null.
*******************************************************************************/
private String checkGroupGrantable(GroupGraph.Node node) throws QException
String checkGroupGrantable(GroupGraph.Node node) throws QException
{
if(graph().isSuperuserInherited(node.pk()))
{
@@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
*******************************************************************************/
public final class AuthentikLocalTables
{
public static final String TOKEN_SCOPE = "token_scope";
public static final String ADMIN_ACTION = "admin_action";
public static final String APPROVAL_REQUEST = "approval_request";
public static final String POLICY = "policy";
@@ -269,4 +270,40 @@ public final class AuthentikLocalTables
return (new QFieldMetaData(name, type).withIsEditable(false));
}
/*******************************************************************************
** Non-secret scope metadata only (DESIGN section 13.5).
*******************************************************************************/
public static QTableMetaData tokenScope(AuthentikQBitConfig config)
{
QTableMetaData table = base(config, TOKEN_SCOPE, "Token Scopes", "key", IdentityPermission.TOKENS_MANAGE)
.withPermissionRules(com.kingsrook.qbits.authentik.security.TokenReadPermission.rules())
.withRecordLabelFormatAndFields("%s", "tokenIdentifier")
.withUniqueKey(new UniqueKey("tokenIdentifier"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
.withField(ro("tokenIdentifier", QFieldType.STRING).withMaxLength(255))
.withField(ro("ownerUsername", QFieldType.STRING).withMaxLength(255))
.withField(ro("ownerPk", QFieldType.INTEGER))
.withField(ro("readOnly", QFieldType.BOOLEAN))
.withField(ro("createdBy", QFieldType.STRING).withMaxLength(255));
table.getFields().remove("createDate");
table.getFields().remove("modifyDate");
for(String name : List.of("apps", "tables", "processes", "reports", "widgets", "operationIds"))
{
table.withField(ro(name, QFieldType.TEXT));
}
for(String name : List.of("createdAt", "rotatedAt"))
{
table.withField(ro(name, QFieldType.DATE_TIME));
}
////////////////////////////////////////////////////////////////////////
// explicit column names: a camel-to-snake mapping would give //
// last_reminder7d, while docs/schema.sql (and hosts) use _7d / _1d //
////////////////////////////////////////////////////////////////////////
table.withField(ro("lastReminder7d", QFieldType.DATE_TIME).withBackendName("last_reminder_7d"));
table.withField(ro("lastReminder1d", QFieldType.DATE_TIME).withBackendName("last_reminder_1d"));
return (table);
}
}
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.logging.QLogger;
/*******************************************************************************
** Optional Alerts bridge. The qbit compiles on RC.2 without either Alerts API.
** No credentials enter this interface. Hosts and tests can supply a sink.
*******************************************************************************/
public class AuthentikTokenAlerts
{
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenAlerts.class);
/*******************************************************************************
** Safe notification envelope. A null recipient denotes the admin audience.
*******************************************************************************/
public record Notice(String key, String severity, String recipient, String message)
{
}
/*******************************************************************************
** Return true only when delivery was accepted, so absence never dedupes.
*******************************************************************************/
@FunctionalInterface
public interface Sink
{
/*******************************************************************************
** Accept a safe notification, returning false when delivery is unavailable.
*******************************************************************************/
boolean send(Notice notice);
}
/*******************************************************************************
** Constructor for the reflection adapter.
*******************************************************************************/
public AuthentikTokenAlerts()
{
}
/*******************************************************************************
** Guard incidents are raised through core QAlerts; owner reminders through
** core QAlerts.notify (qqq#1125, QQQ 4.1.0-RC.6+). Never another qbit's API.
*******************************************************************************/
public boolean send(Notice notice)
{
try
{
if("high".equals(notice.severity()))
{
QAlerts.raise(new AlertSignal("authentik.token.guard-bypass")
.withDedupKey(notice.key())
.withTitle("Access token guard bypass")
.withSummary(notice.message())
.withSeverity(AlertSeverity.CRITICAL));
return (true);
}
///////////////////////////////////////////////////////////////////
// owner reminders: core's notification SPI (bell and email via //
// whichever alerts producer the host installed; qqq#1125) //
///////////////////////////////////////////////////////////////////
QAlerts.notify(new NotificationSignal("Access token expiry reminder")
.withBody(notice.message())
.withSourceName("authentik")
.withSourceKey(notice.key())
.withAudience(NotificationAudience.user(notice.recipient())));
return (true);
}
catch(RuntimeException e)
{
///////////////////////////////////////////////////////////
// never fail the caller; never log the notice payload //
///////////////////////////////////////////////////////////
LOG.info("Access-token notification delivery failed; notification skipped.");
return (false);
}
}
}
@@ -0,0 +1,703 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker;
import com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Access-token actions (DESIGN section 13). All target checks run at Review and
** again at execution; credentials exist only in the action and one-time result.
*******************************************************************************/
public class AuthentikTokens
{
public static final String DELIVERY_UNAVAILABLE = "This QQQ version cannot show a key once; upgrade QQQ (4.1.0-RC.7+)";
public static final String TOKEN_MOVED = "Token moved: owner or intent changed after the update; investigate in Authentik.";
private static final QLogger LOG = QLogger.getLogger(AuthentikTokens.class);
private static final SecureRandom RANDOM = new SecureRandom();
private final AuthentikAdmin admin;
private final AuthentikActionUtil util;
private final AuthentikQBitConfig config;
private final TokenScopeStore scopes;
/*******************************************************************************
** Delegate with the existing admin's live operator and subset checks.
*******************************************************************************/
public AuthentikTokens(AuthentikAdmin admin)
{
this.admin = admin;
util = admin.getUtil();
config = util.getRuntime().getConfig();
scopes = new TokenScopeStore(config);
}
/*******************************************************************************
** Review all constraints, without exchanging or creating a credential.
*******************************************************************************/
public AdminPlan plan(AdminPlan plan) throws QException
{
AdminRequest request = plan.getRequest();
AdminAction action = request.action();
util.getRuntime().invalidateCache();
try
{
if(action == AdminAction.CREATE_SERVICE_ACCOUNT)
{
if(!request.str("name").matches("[A-Za-z0-9][A-Za-z0-9._-]{0,99}"))
{
return (plan.refuse("A service-account name (letters, numbers, dot, dash or underscore) is required."));
}
if(admin.userPkByUsername(request.str("name")) != null)
{
return (plan.refuse("That username already exists."));
}
JSONObject candidate = new JSONObject().put("username", request.str("name")).put("type", "service_account");
checkProtected(candidate);
expiry(request, true);
if(!TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
for(GroupGraph.Node group : serviceGroups(request))
{
String problem = admin.checkGroupGrantable(group);
if(problem != null)
{
return (plan.refuse(problem));
}
checkProtectedGroup(group);
}
plan.withTargetLabel(request.str("name"));
return (approval(plan));
}
JSONObject token = action == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
Integer operatorPk = admin.operator().pk();
int ownerPk = token == null ? (operatorPk == null ? -1 : operatorPk) : ownerPk(token);
if(action == AdminAction.CREATE_TOKEN && (!request.targetKey().isBlank() && !request.targetKey().equals(String.valueOf(operatorPk))))
{
return (plan.refuse("Create Token only creates a token for you."));
}
if(ownerPk < 0)
{
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
}
JSONObject owner = owner(ownerPk);
boolean own = Objects.equals(operatorPk, ownerPk);
boolean service = "service_account".equals(owner.optString("type"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if(service && action == AdminAction.CREATE_TOKEN)
{
return (plan.refuse("Service accounts have one app password. Use Rotate Token or Create Service Account."));
}
if(!own && !manager || service && !manager)
{
return (plan.refuse("You may only manage your own tokens; service accounts need authentik.tokens.manage."));
}
if(!own && !service && action != AdminAction.REVOKE_TOKEN)
{
return (plan.refuse("You cannot create, rotate or extend another person's token."));
}
checkProtected(owner);
plan.withTargetLabel(token == null ? owner.getString("username") : request.targetKey());
if(action != AdminAction.REVOKE_TOKEN && token != null && !"app_password".equals(token.optString("intent")))
{
return (plan.refuse("Only app-password tokens can be rotated or extended here. API tokens can be revoked."));
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.EXTEND_TOKEN)
{
expiry(request, service);
}
if(action == AdminAction.ROTATE_TOKEN)
{
checkExpiry(token, service);
}
if(action == AdminAction.CREATE_TOKEN)
{
if(request.str("description").isBlank())
{
return (plan.refuse("A description is required."));
}
if(!Boolean.TRUE.equals(config.getTokenScopesEnabled()) && TokenScopeStore.SCOPE_FIELDS.stream().anyMatch(request.params()::containsKey))
{
return (plan.refuse("Scopes are not enabled on this app yet."));
}
}
if(action == AdminAction.CREATE_TOKEN && Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.validate(request);
QRecord scope = new QRecord();
request.params().forEach(scope::setValue);
plan.withChange(null, TokenScopeStore.summary(scope));
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
{
if(!TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
}
if(own && !service)
{
if(blank(config.getTokenBrokerClientId()) || blank(config.getTokenBrokerClientSecret()))
{
return (plan.refuse("Self-service tokens need a configured Authentik token broker."));
}
if(config.getSubjectTokenSource() == null || blank(config.getSubjectTokenSource().subjectToken(QContext.getQSession())))
{
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
}
}
return (service && action == AdminAction.ROTATE_TOKEN ? approval(plan) : plan);
}
catch(QUserFacingException e)
{
return (plan.refuse(e.getMessage()));
}
}
/*******************************************************************************
** Optional approval for new service accounts and service-account rotation.
*******************************************************************************/
private AdminPlan approval(AdminPlan plan)
{
if(Boolean.TRUE.equals(config.getTokenTwoPersonApproval()) && !plan.getRequest().bool("approved"))
{
plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("Service-account tokens require a second person's approval.");
}
return (plan);
}
/*******************************************************************************
** Resolve requested groups with the same subset rules used for membership.
*******************************************************************************/
private List<GroupGraph.Node> serviceGroups(AdminRequest request) throws QException
{
Set<String> names = new LinkedHashSet<>(AuthentikSafety.parseList(request.str("groups")));
GroupGraph graph = util.groupGraph();
List<GroupGraph.Node> groups = new ArrayList<>();
for(String name : names)
{
GroupGraph.Node node = graph.byName(name);
if(node == null)
{
throw (new QUserFacingException("Group not found: " + name));
}
groups.add(node);
}
return (groups);
}
/*******************************************************************************
** Required expiry measured from now, with separate human and service caps.
*******************************************************************************/
private Instant expiry(AdminRequest request, boolean service) throws QUserFacingException
{
int maximum = service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser();
try
{
int days = Integer.parseInt(request.str("expiresInDays"));
if(days >= 1 && days <= maximum)
{
return (Instant.now().plus(days, ChronoUnit.DAYS));
}
}
catch(NumberFormatException ignored)
{
// Missing, fractional and malformed values are all refused.
}
throw (new QUserFacingException("Expiry is required: expiresInDays must be between 1 and " + maximum + "."));
}
/*******************************************************************************
** Rotation preserves a valid existing expiry, never a non-expiring token.
*******************************************************************************/
private Instant checkExpiry(JSONObject token, boolean service) throws QUserFacingException
{
try
{
Instant expires = Instant.parse(token.getString("expires"));
if(token.optBoolean("expiring") && expires.isAfter(Instant.now())
&& !expires.isAfter(Instant.now().plus(service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser(), ChronoUnit.DAYS)))
{
return (expires);
}
}
catch(RuntimeException ignored)
{
// No expiry is never acceptable for a new credential.
}
throw (new QUserFacingException("Token expiry is missing, expired or exceeds the maximum lifetime."));
}
/*******************************************************************************
** Built-in protected groups cannot be removed by configuration.
*******************************************************************************/
private void checkProtectedGroup(GroupGraph.Node group) throws QException
{
Set<String> protectedNames = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
protectedNames.addAll(List.of("authentik Admins", "identity-admins"));
if(config.getTokenProtectedGroups() != null)
{
protectedNames.addAll(config.getTokenProtectedGroups());
}
for(GroupGraph.Node ancestor : util.groupGraph().selfAndAncestors(group.pk()))
{
if(ancestor.ownSuperuser() || protectedNames.contains(ancestor.name()))
{
throw (protectedAccount("member of " + ancestor.name()));
}
}
}
/*******************************************************************************
** Reuse account protection, adding inherited protected-group membership.
*******************************************************************************/
private void checkProtected(JSONObject owner) throws QException
{
AuthentikGuard.ProtectionResult protection = util.getRuntime().getGuard().protection(owner);
if(protection.isProtected())
{
throw (protectedAccount(protection.reason()));
}
JSONArray groups = owner.optJSONArray("groups_obj");
for(int i = 0; groups != null && i < groups.length(); i++)
{
GroupGraph.Node node = util.groupGraph().byPk(groups.getJSONObject(i).getString("pk"));
if(node == null)
{
throw (protectedAccount("group protection could not be verified"));
}
checkProtectedGroup(node);
}
}
/*******************************************************************************
** Consistent protected-owner refusal, including service-account set_key.
*******************************************************************************/
private static QUserFacingException protectedAccount(String reason)
{
return (new QUserFacingException("Protected account (" + reason + "): tokens for it are managed in the Authentik admin UI."));
}
/*******************************************************************************
** Fetch token metadata without retaining a bearer or key.
*******************************************************************************/
private JSONObject token(String identifier) throws QException
{
util.getRuntime().invalidateCache();
JSONObject token = util.getApiJsonOrNull(path(identifier));
if(token == null)
{
throw (new QUserFacingException("That token no longer exists."));
}
return (token);
}
/*******************************************************************************
** Token owner pk from the pinned schema.
*******************************************************************************/
private static int ownerPk(JSONObject token)
{
return (token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk"));
}
/*******************************************************************************
** Fetch live owner facts, refusing missing owners.
*******************************************************************************/
private JSONObject owner(int pk) throws QException
{
util.getRuntime().invalidateCache();
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
if(owner == null)
{
throw (new QUserFacingException("The token's owner no longer exists."));
}
return (owner);
}
/*******************************************************************************
** Validated token URL component.
*******************************************************************************/
private static String path(String identifier) throws QException
{
return ("/core/tokens/" + AuthentikAdmin.safeKey(identifier) + "/");
}
/*******************************************************************************
** Whether a resolved config string is absent.
*******************************************************************************/
private static boolean blank(String value)
{
return (value == null || value.isBlank());
}
/*******************************************************************************
** Execute only after auditing has begun; return the key outside audit data.
*******************************************************************************/
public AuthentikAdmin.AdminResult perform(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
if((request.action() == AdminAction.CREATE_TOKEN || request.action() == AdminAction.ROTATE_TOKEN || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
&& !TokenKeyDelivery.get().available())
{
throw (new QUserFacingException(DELIVERY_UNAVAILABLE));
}
if(request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
{
return (createServiceAccount(plan, auth));
}
JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before));
checkProtectedAfterRead(owner);
boolean service = "service_account".equals(owner.optString("type"));
boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if((!own || service) && !manager || !own && !service && request.action() != AdminAction.REVOKE_TOKEN)
{
throw (new QUserFacingException("Token ownership changed; review again."));
}
String bearer = own && !service ? new AuthentikTokenBroker().exchange(util, auth) : config.getResolvedApiToken();
switch(request.action())
{
case CREATE_TOKEN:
return (createPersonal(request, owner, expiry(request, false), null, bearer, auth));
case REVOKE_TOKEN:
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
scopes.delete(request.targetKey());
return (success("Token revoked."));
case EXTEND_TOKEN:
util.writeAs(bearer, auth, "PATCH", path(request.targetKey()), new JSONObject().put("intent", before.getString("intent"))
.put("user", owner.getInt("pk")).put("expires", expiry(request, service).toString()).put("expiring", true));
raceGuard(before, token(request.targetKey()));
scopes.resetReminders(request.targetKey());
return (success("Token expiry updated."));
case ROTATE_TOKEN:
if(!service)
{
return (createPersonal(request, owner, checkExpiry(before, false), before, bearer, auth));
}
String scope = scopeSummary(request.targetKey());
Instant expires = checkExpiry(before, true);
byte[] random = new byte[48];
RANDOM.nextBytes(random);
String key = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
util.writeAs(bearer, auth, "POST", path(request.targetKey()) + "set_key/", new JSONObject().put("key", key));
JSONObject after = token(request.targetKey());
raceGuard(before, after);
return (success("Service-account token rotated. The old key no longer works.")
.withSecret(oneTime(request.targetKey(), key, owner, expires, scope), () ->
{
throw (new QUserFacingException("The old key no longer works and the new key is lost. Rotate it in the Authentik admin UI."));
}));
default:
throw (new QException("Unsupported token action"));
}
}
/*******************************************************************************
** Guard owner and intent after every PATCH or set_key, with a safe warning.
*******************************************************************************/
private void raceGuard(JSONObject before, JSONObject after) throws QException
{
if(ownerPk(before) != ownerPk(after) || !before.getString("intent").equals(after.getString("intent")))
{
alertGuardBypass();
LOG.warn(TOKEN_MOVED);
throw (new QUserFacingException(TOKEN_MOVED));
}
checkProtectedAfterRead(owner(ownerPk(after)));
}
/*******************************************************************************
** Create as the operator, fetch their key, and optionally replace the old id.
** On partial failure discard the new credential; the old one stays usable.
*******************************************************************************/
private AuthentikAdmin.AdminResult createPersonal(AdminRequest request, JSONObject owner, Instant expires, JSONObject old,
String bearer, WriteAuthorization auth) throws QException
{
String identifier = identifier(owner.getString("username"));
String description = old == null ? request.str("description") : old.optString("description");
util.writeAs(bearer, auth, "POST", "/core/tokens/", new JSONObject().put("identifier", identifier).put("intent", "app_password")
.put("description", description).put("expiring", true).put("expires", expires.toString()));
try
{
JSONObject created = util.getAs(bearer, auth, path(identifier));
checkProtectedAfterRead(owner(ownerPk(created)));
if(ownerPk(created) != owner.getInt("pk") || !"app_password".equals(created.optString("intent")))
{
throw (new QUserFacingException(TOKEN_MOVED));
}
String key = util.getAs(bearer, auth, path(identifier) + "view_key/").getString("key");
Instant actualExpiry = checkExpiry(created, false);
if(old == null)
{
scopes.create(identifier, owner, request, admin.operator().email());
}
else
{
QRecord previous = scopes.find(request.targetKey());
if(previous == null)
{
scopes.create(identifier, owner, AdminRequest.of(request.action(), identifier, request.reason()), admin.operator().email());
}
else
{
scopes.move(request.targetKey(), identifier);
}
}
OneTimeKey secret = oneTime(identifier, key, owner, actualExpiry, scopeSummary(identifier));
if(old != null)
{
try
{
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
}
catch(QException e)
{
scopes.move(identifier, request.targetKey());
throw (new QUserFacingException("Rotation failed: the old token could not be deleted and remains listed."));
}
}
return (success(old == null ? "Token created. The key is shown once." : "Token rotated. The old token was deleted.")
.withSecret(secret, () -> revokeIssued(identifier, bearer, auth)));
}
catch(Exception e)
{
revokeIssued(identifier, bearer, auth);
if(e instanceof QUserFacingException facing)
{
throw (facing);
}
throw (new QUserFacingException("The new token could not be completed and was revoked."));
}
}
/*******************************************************************************
** Create the single service-account app password and grant checked groups.
*******************************************************************************/
private AuthentikAdmin.AdminResult createServiceAccount(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
String identifier = "service-account-" + request.str("name") + "-password";
JSONObject created = util.write(auth, "POST", "/core/users/service_account/", new JSONObject().put("name", request.str("name"))
.put("create_group", false).put("expiring", true).put("expires", expiry(request, true).toString()));
try
{
Integer pk = created.has("user_pk") ? created.getInt("user_pk") : admin.userPkByUsername(created.optString("username", request.str("name")));
if(pk == null)
{
throw (new QException("The service account could not be found."));
}
JSONObject current = owner(pk);
JSONObject attributes = new JSONObject(current.optJSONObject("attributes") == null ? "{}" : current.getJSONObject("attributes").toString());
attributes.put("goauthentik.io/user/token-maximum-lifetime", config.getServiceAccountTokenLifetime());
util.write(auth, "PATCH", "/core/users/" + pk + "/", new JSONObject().put("attributes", attributes));
for(GroupGraph.Node group : serviceGroups(request))
{
checkProtectedGroup(group);
String problem = admin.checkGroupGrantable(group);
if(problem != null)
{
throw (new QUserFacingException(problem));
}
util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk));
}
JSONObject owner = owner(pk);
checkProtectedAfterRead(owner);
JSONObject after = token(identifier);
checkProtectedAfterRead(owner(ownerPk(after)));
scopes.create(identifier, owner, request, admin.operator().email());
return (success("Service account created. " + String.join(" ", plan.getNotes()))
.withSecret(oneTime(identifier, created.getString("token"), owner, checkExpiry(after, true), "Owner permissions"),
() -> revokeIssued(identifier, config.getResolvedApiToken(), auth)));
}
catch(Exception e)
{
revokeIssued(identifier, config.getResolvedApiToken(), auth);
throw (new QUserFacingException("The service account was created, but setup failed. Its token was revoked; finish setup in Authentik."));
}
}
/*******************************************************************************
** Emergency revocation for a newly created token whose key cannot be shown.
*******************************************************************************/
private void revokeIssued(String identifier, String bearer, WriteAuthorization auth) throws QException
{
try
{
util.writeAs(bearer, auth, "DELETE", path(identifier), null);
scopes.delete(identifier);
}
catch(QException e)
{
throw (new QUserFacingException("The new key could not be delivered and token revocation failed. Revoke it in the Authentik admin UI."));
}
}
/*******************************************************************************
** Scope summary contains metadata only.
*******************************************************************************/
private String scopeSummary(String identifier) throws QException
{
QRecord row = scopes.find(identifier);
List<String> parts = new ArrayList<>();
for(String field : TokenScopeStore.SCOPE_FIELDS)
{
if(row != null && row.getValue(field) != null)
{
parts.add(field + ": " + row.getValueString(field));
}
}
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
}
/*******************************************************************************
** Build the isolated one-time object, including its response-only usage text.
*******************************************************************************/
private OneTimeKey oneTime(String identifier, String key, JSONObject owner, Instant expires, String scope)
{
return (new OneTimeKey(identifier, key, TokenUsageText.build(config, owner.getString("username"), key, expires, scope)));
}
/*******************************************************************************
** Non-secret result text.
*******************************************************************************/
private static AuthentikAdmin.AdminResult success(String message)
{
return (new AuthentikAdmin.AdminResult(true, false, false, message, null));
}
/*******************************************************************************
** Collision-resistant identifier, independent of the secret value.
*******************************************************************************/
private static String identifier(String username)
{
String alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder suffix = new StringBuilder();
for(int i = 0; i < 6; i++)
{
suffix.append(alphabet.charAt(RANDOM.nextInt(alphabet.length())));
}
String slug = username.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-]+", "-");
return ("qqq-pat-" + slug + "-" + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC).format(Instant.now()) + "-" + suffix);
}
/*******************************************************************************
** A protected owner observed after Review or a write is a guard incident.
*******************************************************************************/
private void checkProtectedAfterRead(JSONObject owner) throws QException
{
try
{
checkProtected(owner);
}
catch(QUserFacingException e)
{
alertGuardBypass();
throw (e);
}
}
/*******************************************************************************
** Safe incident payload: no response bodies, credentials or owner details.
*******************************************************************************/
private void alertGuardBypass()
{
try
{
util.getRuntime().getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null,
"An access token changed owner or intent, or a protected owner was observed after Review. Investigate in Authentik."));
}
catch(RuntimeException e)
{
LOG.warn("Access-token guard alert delivery failed.");
}
}
}
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
/*******************************************************************************
** A response-only value. Deliberately not Serializable; never a process value.
*******************************************************************************/
public final class OneTimeKey
{
private final String label;
private final String value;
private final String usageText;
/*******************************************************************************
** Construct the one-time response.
*******************************************************************************/
public OneTimeKey(String label, String value, String usageText)
{
this.label = label;
this.value = value;
this.usageText = usageText;
}
/*******************************************************************************
** Getter for label.
*******************************************************************************/
public String label()
{
return (label);
}
/*******************************************************************************
** Getter for value.
*******************************************************************************/
public String value()
{
return (value);
}
/*******************************************************************************
** Getter for masked.
*******************************************************************************/
public String masked()
{
return ("\u2022\u2022\u2022\u2022" + value.substring(Math.max(0, value.length() - 4)));
}
/*******************************************************************************
** Getter for usageText.
*******************************************************************************/
public String usageText()
{
return (usageText);
}
/*******************************************************************************
** Never render the key or its usage text.
*******************************************************************************/
@Override
public String toString()
{
return ("OneTimeKey{label=" + label + ", value=[one-time]}" );
}
}
@@ -0,0 +1,192 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Non-secret bookkeeping. No method accepts a key or a bearer.
*******************************************************************************/
public class TokenScopeStore
{
public static final List<String> SCOPE_FIELDS = List.of("readOnly", "apps", "tables", "processes", "reports", "widgets", "operationIds");
private final AuthentikQBitConfig config;
/*******************************************************************************
** Bind to one qbit's local backend.
*******************************************************************************/
public TokenScopeStore(AuthentikQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** Local table name.
*******************************************************************************/
private String table()
{
return (config.applyTablePrefix(AuthentikLocalTables.TOKEN_SCOPE));
}
/*******************************************************************************
** Fetch the unique scope row as a trusted SYSTEM read (QueryInput default).
*******************************************************************************/
public QRecord find(String identifier) throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput().withTableName(table())
.withFilter(new QQueryFilter(new QFilterCriteria("tokenIdentifier", QCriteriaOperator.EQUALS, identifier)))).getRecords();
return (rows.isEmpty() ? null : rows.get(0));
}
/*******************************************************************************
** Record a new identifier, even when scopes are disabled.
*******************************************************************************/
public void create(String identifier, JSONObject owner, AdminRequest request, String operator) throws QException
{
QRecord row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
.withValue("ownerUsername", owner.getString("username")).withValue("createdBy", operator).withValue("createdAt", Instant.now());
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
for(String field : SCOPE_FIELDS)
{
if(request.params().containsKey(field))
{
row.setValue(field, request.params().get(field));
}
}
}
List<QRecord> rows = new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords();
check(rows);
}
/*******************************************************************************
** Keep all scope and reminder fields when rotating a personal credential.
*******************************************************************************/
public void move(String oldIdentifier, String newIdentifier) throws QException
{
QRecord row = find(oldIdentifier);
if(row != null)
{
row.setValue("tokenIdentifier", newIdentifier);
row.setValue("rotatedAt", Instant.now());
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
/*******************************************************************************
** Revoke removes only the corresponding metadata row.
*******************************************************************************/
public void delete(String identifier) throws QException
{
QRecord row = find(identifier);
if(row != null)
{
new DeleteAction().execute(new DeleteInput().withTableName(table()).withPrimaryKeys(List.of(row.getValue("id"))));
}
}
/*******************************************************************************
** Fail closed if local storage rejects the write.
*******************************************************************************/
private static void check(List<QRecord> rows) throws QException
{
if(rows.isEmpty() || !rows.get(0).getErrors().isEmpty())
{
throw (new QException("Token scope metadata could not be saved."));
}
}
/*******************************************************************************
** Human-readable, non-secret scope for lists and Review.
*******************************************************************************/
public static String summary(QRecord row)
{
java.util.ArrayList<String> parts = new java.util.ArrayList<>();
for(String field : SCOPE_FIELDS)
{
if(row != null && row.getValue(field) != null && !row.getValueString(field).isBlank())
{
parts.add(field + ": " + row.getValueString(field));
}
}
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
}
/*******************************************************************************
** Persist dedupe metadata. External tokens get no invented creation date or
** restrictions; their token row remains unscoped.
*******************************************************************************/
public void reminded(String identifier, JSONObject owner, String field, Instant at) throws QException
{
QRecord row = find(identifier);
if(row == null)
{
row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
.withValue("ownerUsername", owner.getString("username")).withValue(field, at);
check(new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
else
{
row.setValue(field, at);
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
/*******************************************************************************
** A new expiry starts a new set of reminder windows.
*******************************************************************************/
public void resetReminders(String identifier) throws QException
{
QRecord row = find(identifier);
if(row != null)
{
row.setValue("lastReminder7d", null);
row.setValue("lastReminder1d", null);
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
}
@@ -0,0 +1,69 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
/*******************************************************************************
** Usage instructions held exclusively inside the one-time response.
*******************************************************************************/
public final class TokenUsageText
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TokenUsageText()
{
}
/*******************************************************************************
** Generate shell-quoted exchange and application requests from public URLs.
*******************************************************************************/
public static String build(AuthentikQBitConfig config, String username, String key, Instant expires, String scope)
{
String clientId = config.getAppClientId();
if(clientId == null && QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth)
{
clientId = oauth.getClientId();
}
String endpoint = config.getAuthentikPublicUrl().replaceAll("/+$", "") + "/application/o/token/";
String app = config.getAppPublicUrl() == null ? "{appPublicUrl}" : config.getAppPublicUrl().replaceAll("/+$", "");
return ("Expires: " + expires + "\nScope: " + scope + "\n\n"
+ "curl -X POST " + quote(endpoint) + " --data-urlencode 'grant_type=client_credentials'"
+ " --data-urlencode " + quote("client_id=" + (clientId == null ? "{appClientId}" : clientId))
+ " --data-urlencode " + quote("username=" + username) + " --data-urlencode " + quote("password=" + key)
+ " --data-urlencode 'scope=openid profile email'\n\n"
+ "curl -H 'Authorization: Bearer {jwt}' " + quote(app + "/qqq/v1/metaData"));
}
/*******************************************************************************
** Quote literal shell arguments, including usernames containing apostrophes.
*******************************************************************************/
private static String quote(String value)
{
return ("'" + value.replace("'", "'\"'\"'") + "'");
}
}
@@ -0,0 +1,180 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Standard TABLE widget: inventory, age and actionable cleanup suggestions.
** Computed fields never become API filters; links use owner or token keys.
*******************************************************************************/
public class AuthentikTokenWidget extends AbstractWidgetRenderer
{
/*******************************************************************************
** Check the permission here as well as in metadata (non-HTTP callers).
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backend = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get("backendName"));
return (new RenderWidgetOutput(data(AuthentikActionUtil.forBackend(backend), Instant.now())));
}
/*******************************************************************************
** Report complete inventory and explicit unknown ages. An API token's
** activity is untracked and never qualifies as unused.
*******************************************************************************/
public static TableData data(AuthentikActionUtil util, Instant now) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
{
throw (new QPermissionDeniedException("Access token widgets require authentik.tokens.manage."));
}
AuthentikQBitConfig config = util.getRuntime().getConfig();
List<JSONObject> tokens = util.getAllPages("/core/tokens/", Integer.MAX_VALUE);
Map<String, Instant> activity = AuthentikTokenActivity.lastUsed(util);
Map<String, List<String>> owners = new LinkedHashMap<>();
Map<String, List<String>> ages = new LinkedHashMap<>();
for(String bucket : List.of("<30d", "30-90d", "90-365d", ">365d", "unknown"))
{
ages.put(bucket, new ArrayList<>());
}
Map<Integer, JSONObject> users = new LinkedHashMap<>();
List<Map<String, Object>> cleanup = new ArrayList<>();
TokenScopeStore scopes = new TokenScopeStore(config);
for(JSONObject token : tokens)
{
String id = token.getString("identifier");
JSONObject owner = token.getJSONObject("user_obj");
String username = owner.getString("username");
owners.computeIfAbsent(username, k -> new ArrayList<>()).add(id);
QRecord scope = scopes.find(id);
Instant created = scope == null ? null : scope.getValueInstant("createdAt");
long days = created == null ? -1 : ChronoUnit.DAYS.between(created, now);
ages.get(days < 0 ? "unknown" : days < 30 ? "<30d" : days < 90 ? "30-90d" : days <= 365 ? "90-365d" : ">365d").add(id);
int pk = owner.getInt("pk");
if(!users.containsKey(pk))
{
users.put(pk, util.getApiJsonOrNull("/core/users/" + pk + "/"));
}
JSONObject live = users.get(pk);
String links = link(list(config, "username", List.of(username)), username) + " / " + link(list(config, "identifier", List.of(id)), id);
if(live != null && !live.optBoolean("is_active", true))
{
cleanup.add(row("Owner deactivated", 1, links));
}
Instant last = activity.get(id);
Instant cutoff = now.minus(config.getTokenUnusedDays(), ChronoUnit.DAYS);
// Without a creation time or an event we cannot prove age. A 90-day
// event window cannot prove inactivity beyond 90 days either.
boolean unused = last != null ? last.isBefore(cutoff)
: config.getTokenUnusedDays() <= 90 && created != null && created.isBefore(cutoff);
if("app_password".equals(token.optString("intent")) && unused)
{
cleanup.add(row("Unused for " + config.getTokenUnusedDays() + " days", 1, links));
}
}
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(row("Total", tokens.size(), link(list(config, null, List.of()), "All access tokens")));
owners.entrySet().stream().sorted(Comparator.<Map.Entry<String, List<String>>>comparingInt(e -> e.getValue().size()).reversed().thenComparing(Map.Entry::getKey))
.limit(10).forEach(e -> rows.add(row("Owner: " + e.getKey(), e.getValue().size(), link(list(config, "username", List.of(e.getKey())), "Tokens"))));
ages.forEach((bucket, ids) -> rows.add(row("Age: " + bucket, ids.size(), ids.isEmpty() ? "" : link(list(config, "identifier", ids), "Tokens"))));
rows.addAll(cleanup);
return (new TableData("Access tokens", List.of(
new TableData.Column("default", "Category", "category", "35%", "left"),
new TableData.Column("default", "Count", "count", "10%", "right"),
new TableData.Column("html", "Access tokens", "tokens", "55%", "left")), rows)
.withRowsPerPage(25).withLinkText("All access tokens / bulk revoke").withLinkURL(list(config, null, List.of())));
}
/*******************************************************************************
** A standard table row; only the link cell contains escaped anchor markup.
*******************************************************************************/
private static Map<String, Object> row(String category, int count, String tokens)
{
return (Map.of("category", category, "count", count, "tokens", tokens));
}
/*******************************************************************************
** Native list filter format shared by Material and Next. Primary-key IN is
** supported by the API adapter as individual GETs; no computed filtering.
*******************************************************************************/
private static String list(AuthentikQBitConfig config, String field, List<String> values)
{
String url = (config.getLinkPrefix() == null ? "" : config.getLinkPrefix()) + "/" + config.applyTablePrefix("token");
if(field != null)
{
JSONObject criteria = new JSONObject().put("fieldName", field).put("operator", values.size() == 1 ? "EQUALS" : "IN").put("values", values);
String filter = new JSONObject().put("booleanOperator", "AND").put("criteria", new JSONArray().put(criteria)).toString();
url += "?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8);
}
return (url);
}
/*******************************************************************************
** Escape both the link target and text; descriptions are never HTML.
*******************************************************************************/
private static String link(String url, String text)
{
return ("<a href=\"" + escape(url) + "\">" + escape(text) + "</a>");
}
/*******************************************************************************
** Minimal HTML escaping for a standard table link cell.
*******************************************************************************/
private static String escape(String value)
{
return (value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
}
@@ -22,6 +22,7 @@ import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
@@ -30,6 +31,7 @@ import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
@@ -376,6 +378,16 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
** @param body request body (null for none)
*******************************************************************************/
public JSONObject write(WriteAuthorization authorization, String method, String pathAndQuery, JSONObject body) throws QException
{
return (writeAs(token(), authorization, method, pathAndQuery, body));
}
/*******************************************************************************
** Explicit bearer for an authorized token action, through the same send seam.
*******************************************************************************/
public JSONObject writeAs(String bearer, WriteAuthorization authorization, String method, String pathAndQuery, JSONObject body) throws QException
{
if(authorization == null)
{
@@ -386,6 +398,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
throw (new QException("Bad Authentik path"));
}
checkAuthorization(authorization);
String url = runtime().getConfig().getApiRoot() + pathAndQuery;
HttpRequestBase request = switch(method)
{
@@ -401,7 +414,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
try
{
QHttpResponse response = execute(request);
QHttpResponse response = execute(request, bearer, false);
if(response.getStatusCode() != null && response.getStatusCode() == 404)
{
throw (new QUserFacingException("Authentik no longer has that record (it may have been removed in the Authentik admin UI)."));
@@ -421,6 +434,65 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** Read token metadata or its new key as its owner; never use the read cache.
*******************************************************************************/
public JSONObject getAs(String bearer, WriteAuthorization auth, String path) throws QException
{
checkAuthorization(auth);
if(path == null || !path.startsWith("/core/tokens/") || path.contains("..") || path.contains("?"))
{
throw (new QException("Bad token path"));
}
QHttpResponse response = execute(new HttpGet(runtime().getConfig().getApiRoot() + path), bearer, false);
try
{
return (response.getStatusCode() == 404 ? null : new JSONObject(response.getContent()));
}
catch(JSONException e)
{
throw (new QUserFacingException("Authentik returned an invalid token response."));
}
}
/*******************************************************************************
** Exchange through the shared throttle/send seam with no service bearer.
*******************************************************************************/
public JSONObject exchangeToken(WriteAuthorization auth, HttpPost request) throws QException
{
checkAuthorization(auth);
if(!request.getURI().toString().equals(runtime().getConfig().getTokenEndpointUrl()))
{
throw (new QException("Bad token endpoint"));
}
try
{
return (new JSONObject(execute(request, null, true).getContent()));
}
catch(JSONException e)
{
throw (new QUserFacingException("Authentik refused to act as you (invalid_response)"));
}
}
/*******************************************************************************
** Revalidate the authorization against the current session and config.
*******************************************************************************/
private void checkAuthorization(WriteAuthorization auth) throws QException
{
if(auth == null || !java.util.Objects.equals(auth.getOperator(), runtime().getGuard().operatorEmail()))
{
throw (new QPermissionDeniedException("Authentik data can only be changed through the guard."));
}
runtime().getGuard().authorize(com.kingsrook.qbits.authentik.admin.AdminAction.valueOf(auth.getAction()));
}
/*******************************************************************************
** Fetch a single Authentik object by key (null when it doesn't exist).
** No permission check: callers check first.
@@ -733,6 +805,10 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
record.setDisplayValue("isSuperuserInherited", UNAVAILABLE);
}
}
else if(AuthentikTables.TOKEN.equals(spec.getName()))
{
AuthentikTokenActivity.enrich(this, record);
}
return (record);
}
catch(JSONException | IllegalArgumentException e)
@@ -855,6 +931,51 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** A bounded, username-ordered policy-preview lookup. Membership, active
** status and user scope are evaluated by Authentik before the limit.
** This read needs only the editor permission, not the users table permission.
*******************************************************************************/
public List<JSONObject> policyPreviewUsers(Collection<String> groupPks, int maxObjects) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.POLICIES_MANAGE))
{
throw (new QPermissionDeniedException("You do not have permission to preview policies (authentik.policies.manage)."));
}
if(groupPks.isEmpty())
{
return (List.of());
}
try
{
URIBuilder builder = new URIBuilder("/core/users/");
builder.addParameter("include_groups", "true");
builder.addParameter("include_roles", "false");
builder.addParameter("is_active", "true");
builder.addParameter("ordering", "username");
for(String pk : groupPks)
{
builder.addParameter("groups_by_pk", pk);
}
addUserScope(builder, List.of());
List<JSONObject> users = new ArrayList<>();
for(JSONObject user : getAllPages(builder.build().toString(), maxObjects))
{
if(runtime().getGuard().isUserInScope(user) && user.optBoolean("is_active", false))
{
users.add(user);
}
}
return (users);
}
catch(java.net.URISyntaxException e)
{
throw (new QException("Bad Authentik URL", e));
}
}
/*******************************************************************************
** A resolver over the current group graph and the instance's permissions.
*******************************************************************************/
@@ -1232,13 +1353,22 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
** error mapping, logging. Returns 2xx and 404 responses.
*******************************************************************************/
public QHttpResponse execute(HttpRequestBase request) throws QException
{
return (execute(request, token(), false));
}
/*******************************************************************************
** Shared request loop; exchange responses and all token traffic stay secret.
*******************************************************************************/
private QHttpResponse execute(HttpRequestBase request, String token, boolean exchange) throws QException
{
String path = pathOf(request.getURI().toString());
int attempts = 0;
int backoffMs = INITIAL_BACKOFF_MS;
String token = token();
if(!StringUtils.hasContent(token))
if(!exchange && !StringUtils.hasContent(token))
{
throw (new QException("The Authentik API token is not configured (apiToken resolved to an empty value)."));
}
@@ -1247,11 +1377,14 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
attempts++;
runtime().getThrottle().acquire();
request.setHeader("Authorization", "Bearer " + token);
if(!exchange)
{
request.setHeader("Authorization", "Bearer " + token);
}
request.setHeader("Accept", "application/json");
if(request instanceof HttpEntityEnclosingRequest)
{
request.setHeader("Content-Type", "application/json");
request.setHeader("Content-Type", exchange ? "application/x-www-form-urlencoded" : "application/json");
}
long start = System.currentTimeMillis();
@@ -1262,7 +1395,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
catch(Exception e)
{
String message = AuthentikRedactor.redactText(e.getMessage(), token);
String message = exchange || AuthentikRedactor.isSensitivePath(path) ? "Sensitive request failed" : AuthentikRedactor.redactText(e.getMessage(), token);
LOG.warn("Authentik API call failed", logPair("method", request.getMethod()), logPair("path", path), logPair("error", message));
if(attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
@@ -1275,7 +1408,10 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
int status = response.getStatusCode() == null ? 0 : response.getStatusCode();
LOG.info("Authentik API call", logPair("method", request.getMethod()), logPair("path", path), logPair("status", status), logPair("millis", System.currentTimeMillis() - start));
logOutboundCall(request, path, response, token);
if(!exchange)
{
logOutboundCall(request, path, response, token);
}
checkVersionOnce(status);
if(status == 429 && attempts <= MAX_RETRIES)
@@ -1295,6 +1431,23 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
return (response);
}
if(exchange)
{
String error = "exchange_failed";
try
{
String code = new JSONObject(response.getContent()).optString("error");
if(List.of("invalid_grant", "access_denied", "invalid_client", "invalid_request", "unsupported_grant_type", "invalid_scope").contains(code))
{
error = code;
}
}
catch(JSONException ignored)
{
// Never echo an untrusted broker error body.
}
throw (new QUserFacingException("Authentik refused to act as you (" + error + ")"));
}
throw (mapError(request, path, status, response.getContent(), token));
}
}
@@ -58,17 +58,23 @@ public final class AuthentikApiSurface
{
List<Operation> rs = new ArrayList<>();
rs.add(new Operation("get", "/admin/version/", Set.of()));
rs.add(new Operation("post", "/core/tokens/", Set.of()));
rs.add(new Operation("patch", "/core/tokens/{key}/", Set.of()));
rs.add(new Operation("delete", "/core/tokens/{key}/", Set.of()));
rs.add(new Operation("get", "/core/tokens/{key}/view_key/", Set.of()));
rs.add(new Operation("post", "/core/tokens/{key}/set_key/", Set.of()));
rs.add(new Operation("post", "/core/users/service_account/", Set.of()));
///////////////////////////////////////////////////////////
// virtual membership table, group graph, drift report //
///////////////////////////////////////////////////////////
rs.add(new Operation("get", "/core/groups/", Set.of("include_users", "include_parents", "include_children", "name", "ordering", "page", "page_size")));
rs.add(new Operation("get", "/core/groups/{key}/", Set.of("include_users")));
rs.add(new Operation("get", "/core/users/", Set.of("username", "include_groups", "attributes", "type", "path_startswith", "groups_by_name", "page", "page_size")));
rs.add(new Operation("get", "/core/users/", Set.of("username", "include_groups", "include_roles", "attributes", "type", "path_startswith", "groups_by_name", "groups_by_pk", "is_active", "ordering", "page", "page_size")));
rs.add(new Operation("get", "/core/users/{key}/", Set.of()));
rs.add(new Operation("get", "/managed/blueprints/", Set.of("page", "page_size")));
rs.add(new Operation("get", "/core/tokens/", Set.of("user__username", "intent", "page", "page_size")));
rs.add(new Operation("get", "/events/events/", Set.of("username", "ordering", "page", "page_size")));
rs.add(new Operation("get", "/events/events/", Set.of("username", "action", "ordering", "page", "page_size")));
return (rs);
}
@@ -37,10 +37,10 @@ public final class AuthentikRedactor
/** Path fragments whose request and response bodies are never logged. */
private static final List<String> SENSITIVE_PATH_FRAGMENTS = List.of(
"/recovery", "/recovery_email", "/set_password", "/set_password_hash", "/core/tokens/", "/service_account", "/impersonate", "/view_key", "/set_key");
"/application/o/token/", "/recovery", "/recovery_email", "/set_password", "/set_password_hash", "/core/tokens/", "/service_account", "/impersonate", "/view_key", "/set_key");
private static final Pattern SECRET_JSON_VALUE = Pattern.compile(
"(\"(?:key|token|link|password|secret|client_secret|password_hash|recovery_link)\"\\s*:\\s*)\"((?:[^\"\\\\]|\\\\.)*)\"", Pattern.CASE_INSENSITIVE);
"(\"(?:key|token|access_token|subject_token|link|password|secret|client_secret|password_hash|recovery_link)\"\\s*:\\s*)\"((?:[^\"\\\\]|\\\\.)*)\"", Pattern.CASE_INSENSITIVE);
private static final Pattern BEARER = Pattern.compile("(?i)(bearer\\s+)[A-Za-z0-9._~+/=-]+");
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Supplies the current operator's subject JWT for one token action.
*******************************************************************************/
@FunctionalInterface
public interface AuthentikSubjectTokenSource
{
/*******************************************************************************
** Return a subject JWT without retaining or logging it.
*******************************************************************************/
String subjectToken(QSession session) throws QException;
}
@@ -41,6 +41,7 @@ public class AuthentikTableSpec
private final Map<String, AuthentikFieldSpec> fields = new LinkedHashMap<>();
private final List<NameValuePair> defaultListParams = new ArrayList<>();
private final List<NameValuePair> defaultGetParams = new ArrayList<>();
private IdentityPermission alternativeReadPermission;
private IdentityPermission readPermission = IdentityPermission.USERS_READ;
private String defaultOrdering;
private List<String> recordLabelFields = new ArrayList<>();
@@ -298,4 +299,34 @@ public class AuthentikTableSpec
return (requiredFilterField);
}
/*******************************************************************************
** Add a second permission that also permits reads.
*******************************************************************************/
public AuthentikTableSpec withAlternativeReadPermission(IdentityPermission permission)
{
alternativeReadPermission = permission;
return (this);
}
/*******************************************************************************
** Getter for the optional alternative permission.
*******************************************************************************/
public IdentityPermission getAlternativeReadPermission()
{
return (alternativeReadPermission);
}
/*******************************************************************************
** Token tables need OR rules in core as well as in the API guard.
*******************************************************************************/
public com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules readRules()
{
return (alternativeReadPermission == null ? readPermission.rules() : com.kingsrook.qbits.authentik.security.TokenReadPermission.rules());
}
}
@@ -400,12 +400,13 @@ public final class AuthentikTables
/*******************************************************************************
** /core/tokens/ (identifier). Metadata only: there is NO key field, and the
** service account is never given token-key permissions. Needs audit.read.
** service account never reads token keys. Auditors and token managers may read.
*******************************************************************************/
static AuthentikTableSpec tokens()
{
AuthentikTableSpec spec = new AuthentikTableSpec(TOKEN, "Tokens", "key", "/core/tokens/", "identifier")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withAlternativeReadPermission(IdentityPermission.TOKENS_MANAGE)
.withDefaultOrdering("identifier")
.withRecordLabelFields("identifier");
spec.withField(new AuthentikFieldSpec("identifier", "Identifier", QFieldType.STRING, string("identifier")).withSearchable().withOrdering("identifier"));
@@ -414,6 +415,14 @@ public final class AuthentikTables
spec.withField(new AuthentikFieldSpec("description", "Description", QFieldType.STRING, string("description")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("expiring", "Expiring", QFieldType.BOOLEAN, bool("expiring")).withFilter(QCriteriaOperator.EQUALS, "expiring"));
spec.withField(new AuthentikFieldSpec("ownerPk", "Owner Id", QFieldType.INTEGER, intAt("user_obj", "pk")));
spec.withField(new AuthentikFieldSpec("ownerType", "Owner Type", QFieldType.STRING, at("user_obj", "type")));
spec.withField(new AuthentikFieldSpec("ownerName", "Owner", QFieldType.STRING, at("user_obj", "name")));
spec.withField(new AuthentikFieldSpec("managed", "Managed", QFieldType.STRING, string("managed")));
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, o -> null));
spec.withField(new AuthentikFieldSpec("lastUsedNote", "Last Used Note", QFieldType.STRING, o -> null));
spec.withField(new AuthentikFieldSpec("createdAt", "Created", QFieldType.DATE_TIME, o -> null));
spec.withField(new AuthentikFieldSpec("scopeSummary", "Scope", QFieldType.STRING, o -> null));
return (spec);
}
@@ -0,0 +1,112 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Non-secret local metadata and the most recent app-password login per token.
*******************************************************************************/
public final class AuthentikTokenActivity
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikTokenActivity()
{
}
/*******************************************************************************
** The pinned schema has no created__gt. Walk descending pages until the
** 90-day boundary instead of sending a silently ignored query parameter.
*******************************************************************************/
public static Map<String, Instant> lastUsed(AuthentikActionUtil util) throws QException
{
return (util.getRuntime().getTokenActivityCache().getResultThrowing("login", key ->
{
Map<String, Instant> latest = new LinkedHashMap<>();
Instant cutoff = Instant.now().minus(90, ChronoUnit.DAYS);
for(int page = 1; ; page++)
{
String path = "/events/events/?action=login&ordering=-created&page=" + page + "&page_size=100";
util.getRuntime().getResponseCache().clearKey(util.getRuntime().getConfig().getApiRoot() + path);
JSONObject response = util.getApiJson(path);
JSONArray events = response.getJSONArray("results");
for(int i = 0; i < events.length(); i++)
{
JSONObject event = events.getJSONObject(i);
Instant at = Instant.parse(event.getString("created"));
if(at.isBefore(cutoff))
{
return (Map.copyOf(latest));
}
JSONObject context = event.optJSONObject("context");
if("login".equals(event.optString("action")) && context != null && "token".equals(context.optString("method")))
{
JSONObject args = context.optJSONObject("method_args");
String identifier = args == null ? "" : args.optString("identifier");
if(!identifier.isBlank())
{
latest.merge(identifier, at, (a, b) -> a.isAfter(b) ? a : b);
}
}
}
if(events.isEmpty() || page >= response.getJSONObject("pagination").getInt("total_pages"))
{
return (Map.copyOf(latest));
}
}
}).orElseThrow());
}
/*******************************************************************************
** Enrich only token records, never leaking token keys into the table.
*******************************************************************************/
public static void enrich(AuthentikActionUtil util, QRecord record) throws QException
{
var config = util.getRuntime().getConfig();
QRecord scope = QContext.getQInstance().getTable(config.applyTablePrefix("token_scope")) == null ? null
: new TokenScopeStore(config).find(record.getValueString("identifier"));
record.setValue("createdAt", scope == null ? null : scope.getValue("createdAt"));
record.setValue("scopeSummary", TokenScopeStore.summary(scope));
if("api".equals(record.getValueString("intent")))
{
record.setValue("lastUsedNote", "not tracked");
record.setDisplayValue("lastUsed", "not tracked");
}
else
{
record.setValue("lastUsed", lastUsed(util).get(record.getValueString("identifier")));
record.setValue("lastUsedNote", record.getValue("lastUsed") == null ? "No login in the last 90 days" : null);
}
}
}
@@ -0,0 +1,61 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.nio.charset.StandardCharsets;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.json.JSONObject;
/*******************************************************************************
** RFC 8693 exchange for a two-minute API bearer, never cached or logged.
*******************************************************************************/
public class AuthentikTokenBroker
{
/*******************************************************************************
** Exchange the operator's JWT through the configured protected-user broker.
*******************************************************************************/
public String exchange(AuthentikActionUtil util, WriteAuthorization auth) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
String subject = config.getSubjectTokenSource().subjectToken(QContext.getQSession());
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
request.setEntity(new UrlEncodedFormEntity(List.of(
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret()),
new BasicNameValuePair("subject_token", subject),
new BasicNameValuePair("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"),
new BasicNameValuePair("scope", "goauthentik.io/api")), StandardCharsets.UTF_8));
JSONObject result = util.exchangeToken(auth, request);
String bearer = result.optString("access_token", "");
if(bearer.isBlank())
{
throw (new QUserFacingException("Authentik refused to act as you (invalid_response)"));
}
return (bearer);
}
}
@@ -0,0 +1,75 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Reads the OAuth2 module's user-session row (uuid and accessToken in RC.2).
** No memoization: the credential lives only for the current action.
*******************************************************************************/
public class OAuth2SubjectTokenSource implements AuthentikSubjectTokenSource
{
public static final String UNAVAILABLE = "Self-service tokens need a signed-in Authentik session.";
/*******************************************************************************
** Look up the exact current session, including its hidden access token.
*******************************************************************************/
@Override
public String subjectToken(QSession session) throws QException
{
if(session != null && session == QContext.getQSession() && !(session instanceof QSystemUserSession) && session.getUser() != null && session.getUuid() != null && QContext.getQInstance() != null
&& QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth
&& oauth.getUserSessionTableName() != null)
{
QRecord row;
QSession previous = QContext.getQSession();
try
{
// Core uses a system read here too, avoiding recursive OAuth validation.
// The UUID is taken only from the already authorized current session.
QContext.setQSession(new QSystemUserSession());
row = new GetAction().executeForRecord(new GetInput(oauth.getUserSessionTableName())
.withUniqueKey(Map.of("uuid", session.getUuid())).withShouldMaskPasswords(false).withShouldOmitHiddenFields(false));
}
catch(QException e)
{
throw (new QUserFacingException(UNAVAILABLE));
}
finally
{
QContext.setQSession(previous);
}
if(row != null && row.getValueString("accessToken") != null && !row.getValueString("accessToken").isBlank())
{
return (row.getValueString("accessToken"));
}
}
throw (new QUserFacingException(UNAVAILABLE));
}
}
@@ -83,7 +83,7 @@ public abstract class AbstractAuthentikTableProducer extends QBitComponentMetaDa
.withBackendName(config.getApiBackendName())
.withPrimaryKeyField(spec.getKeyFieldName())
.withBackendDetails(new APITableBackendDetails().withTablePath(spec.getPath()))
.withPermissionRules(spec.getReadPermission().rules())
.withPermissionRules(spec.readRules())
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_EXPORT)
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE, Capability.QUERY_STATS);
@@ -74,7 +74,7 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION);
List<String> widgets = new ArrayList<>();
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME),
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikTokenWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME),
config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.COMPARE)))
{
if(produced.get(QWidgetMetaData.class, widgetName) != null)
@@ -39,7 +39,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Renders the drift report as a TableData. Re-checks authentik.admin itself
** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
** for direct renderer calls as well as calls through core RenderWidgetAction.
*******************************************************************************/
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
{
@@ -0,0 +1,72 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
/*******************************************************************************
** Daily background task, hidden from navigation; no scheduler dependency.
*******************************************************************************/
public class AuthentikTokenReminderProcessProducer extends QBitComponentMetaDataProducer<QProcessMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Reminders require local dedupe storage and the TOKENS feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
** Use the configured scheduler or the instance's first registered scheduler.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance instance)
{
AuthentikQBitConfig config = getQBitConfig();
QProcessMetaData process = new QProcessMetaData().withName(config.applyPrefix("tokenReminders"))
.withLabel("Access token reminders").withIsHidden(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules())
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(AuthentikTokenReminders.class))
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("backendName", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING)
.withDefaultValue(config.getApiBackendName()))));
String scheduler = config.getTokenReminderSchedulerName();
if(scheduler == null && !instance.getSchedulers().isEmpty())
{
scheduler = instance.getSchedulers().keySet().iterator().next();
}
if(scheduler != null)
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(scheduler).withRepeatSeconds(86400).withInitialDelaySeconds(3600));
}
return (process);
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Token scope metadata, with no credential material.
*******************************************************************************/
public class AuthentikTokenScopeTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend and the TOKENS feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.tokenScope(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** Access token administration uses a standard TABLE widget (guardrail G5).
*******************************************************************************/
public class AuthentikTokenWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
public static final String NAME = "accessTokens";
/*******************************************************************************
** Token inventory needs the local metadata table.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
** Register before the app, alongside the existing home widgets.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (500);
}
/*******************************************************************************
** Permission is checked again in the renderer.
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance instance)
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData().withName(config.applyPrefix(NAME)).withLabel("Access tokens").withType(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType())
.withGridColumns(12).withIsCard(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules())
.withCodeReference(new QCodeReference(AuthentikTokenWidget.class)).withDefaultValue("backendName", config.getApiBackendName()));
}
}
@@ -152,7 +152,19 @@ public final class AccessComparison
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid)
{
Set<String> rs = new TreeSet<>(resolver.resolveGroups(groupPks).sessionPermissions());
return (mapperPermissions(config, resolver, groupPks, directGroupNames, userPk, userUuid, null));
}
/*******************************************************************************
** Preview the mapper with one group's policy removed, preserving the
** static floor and immutable bootstrap-subject grants.
*******************************************************************************/
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid, String excludePolicyOfGroupPk)
{
Set<String> rs = new TreeSet<>(resolver.resolveGroups(groupPks, excludePolicyOfGroupPk).sessionPermissions());
rs.addAll(staticPermissions(config, directGroupNames));
Set<String> subjects = config.getResolvedBootstrapSubjects();
if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid)))
@@ -17,6 +17,9 @@
package com.kingsrook.qbits.authentik.permissions;
import java.io.Serializable;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
@@ -167,6 +170,133 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims);
}
/*******************************************************************************
** Automated sessions have no claims. Resolve their email idReference to one
** active API user before trusting any groups or immutable bootstrap id.
*******************************************************************************/
@Override
public void customizeAutomatedSessionForUser(QInstance qInstance, QSession session, Serializable userId)
{
AuthentikRuntime runtime = runtime();
Set<String> permissions = new TreeSet<>();
String email = userId == null ? "" : userId.toString().trim();
try
{
JSONObject found = null;
int matches = 0;
List<JSONObject> users = email.isEmpty() ? List.of() : client(qInstance).getAllPages(
"/core/users/?email=" + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&include_groups=true&include_roles=false", 10);
for(JSONObject user : users)
{
if(email.equalsIgnoreCase(user.optString("email", "").trim()))
{
found = user;
matches++;
}
}
// A bounded, possibly truncated lookup cannot prove uniqueness.
if(users.size() < 10 && matches == 1 && found.optBoolean("is_active", false))
{
List<String> groups = new ArrayList<>();
JSONArray groupObjects = found.optJSONArray("groups_obj");
if(groupObjects != null)
{
for(int i = 0; i < groupObjects.length(); i++)
{
groups.add(groupObjects.getJSONObject(i).getString("name"));
}
}
else if(found.opt("groups") instanceof JSONArray groupPks && !groupPks.isEmpty())
{
GroupGraph graph = client(qInstance).groupGraph();
for(int i = 0; i < groupPks.length(); i++)
{
GroupGraph.Node group = graph.byPk(groupPks.getString(i));
if(group != null)
{
groups.add(group.name());
}
}
}
String pk = found.optString("pk", "");
String sub = runtime != null && runtime.getConfig().getResolvedBootstrapSubjects().contains(pk) ? pk : found.optString("uuid", pk);
String userEmail = found.getString("email").trim();
boolean verified = found.optBoolean("email_verified", false);
permissions.addAll(resolve(qInstance, groups, sub, userEmail, verified));
if(runtime != null)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups));
permissions = automatedSessionPermissions(qInstance, runtime, permissions, groups, sub, userEmail, verified);
}
}
else
{
LOG.info("Automated session has no unique active Authentik user; granting only the static fallbacks");
}
}
catch(Exception e)
{
permissions.clear();
// Exception messages can contain the lookup email; log only the type.
LOG.warn("Authentik could not be read for an automated session; granting only the static fallbacks", logPair("errorType", e.getClass().getSimpleName()));
}
if(runtime != null)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), List.of()));
}
if(session.getPermissions() != null)
{
permissions.addAll(session.getPermissions());
}
session.setPermissions(permissions);
}
/*******************************************************************************
** Mirror the sign-in cut-over floor without changing the claims path.
*******************************************************************************/
private Set<String> automatedSessionPermissions(QInstance qInstance, AuthentikRuntime runtime, Set<String> permissions,
List<String> groups, String sub, String email, boolean verified)
{
runtime.markMapperInvoked();
AccessComparison.State state;
try
{
state = AccessComparison.state(runtime, client(qInstance));
}
catch(Exception e)
{
state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now());
}
if(state.enforced())
{
return (permissions);
}
Set<String> floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups));
for(String group : groups)
{
floor.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
}
if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified))
{
for(IdentityPermission permission : IdentityPermission.values())
{
floor.add(permission.getSessionPermission());
}
}
AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource();
if(source != null)
{
floor.addAll(source.userPermissions(email, groups));
}
LOG.warn("Policies are not enforced for this automated session; granting today's access", logPair("status", state.status()));
return (floor);
}
@@ -86,6 +86,17 @@ public class EffectivePermissionResolver
** Effective permissions of a set of groups (e.g. a user's direct groups).
*******************************************************************************/
public Resolution resolveGroups(Collection<String> groupPks)
{
return (resolveGroups(groupPks, null));
}
/*******************************************************************************
** Resolve normally, except for one group's policy document. Its named
** permissions and every other group's policy still contribute.
*******************************************************************************/
public Resolution resolveGroups(Collection<String> groupPks, String excludePolicyOfGroupPk)
{
Set<String> permissions = new TreeSet<>();
Map<String, Set<String>> grantedBy = new LinkedHashMap<>();
@@ -103,7 +114,10 @@ public class EffectivePermissionResolver
}
appAccess.addAll(node.appAccess());
List<String> raws = new ArrayList<>(node.permissions());
raws.addAll(node.policyPermissions());
if(!node.pk().equals(excludePolicyOfGroupPk))
{
raws.addAll(node.policyPermissions());
}
for(String raw : raws)
{
Set<String> expanded = expand(raw);
@@ -92,6 +92,9 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
if(claims == null)
{
LOG.info("No policy claim in the token; no policy permissions added", logPair("claim", format.claim()));
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession,
context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY)));
return;
}
@@ -101,6 +104,9 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession,
context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY)));
}
@@ -139,17 +139,7 @@ public final class PolicyCatalog
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance)
{
return (of(qInstance, PermissionsHelper.getAllAvailablePermissions(qInstance)));
}
/*******************************************************************************
** Build using an explicit permission source, including features registered
** by core. This needs no dependency on the feature metadata hook itself.
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance, Collection<AvailablePermission> availablePermissions)
{
Collection<AvailablePermission> availablePermissions = PermissionsHelper.getAllAvailablePermissions(qInstance);
Map<String, String> appOfObject = appsOfObjects(qInstance);
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
@@ -17,15 +17,26 @@
package com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
@@ -33,6 +44,7 @@ import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -41,8 +53,8 @@ import org.json.JSONObject;
** 12.4). It is shown on the Edit Policy step; the Next UI widget of the same
** type draws the matrix and hands the generated document back to the step as
** the process value policyDocument. The data here: the catalog of what a
** policy can grant, and the group's current document read live from
** Authentik.
** policy can grant, the group's current document read live from Authentik,
** and the base permissions of members who inherit it.
*******************************************************************************/
public final class PolicyEditorWidget
{
@@ -58,6 +70,8 @@ public final class PolicyEditorWidget
/** Widget parameter (process value) with the group's pk. */
public static final String GROUP_PK_PARAM = "groupPk";
private static final int MAX_MEMBERS = 200;
/*******************************************************************************
@@ -74,12 +88,17 @@ public final class PolicyEditorWidget
*******************************************************************************/
public static Data load(AuthentikQBitConfig config, String groupPk) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.POLICIES_MANAGE))
{
throw (new QPermissionDeniedException("You do not have permission to edit policies (authentik.policies.manage)."));
}
AuthentikRuntime runtime = AuthentikRuntime.forBackend(config.getApiBackendName());
PolicyFormat format = config.getPolicyFormat();
Data data = new Data()
.withSpec(format.spec())
.withVersion(format.version())
.withCatalog(runtime.getPolicyCatalog().toJson().toList())
.withMapperEnabled(config.getMapperEnabled())
.withOutputFieldName(AuthentikAdmin.POLICY_DOCUMENT);
if(!StringUtils.hasContent(groupPk))
@@ -119,11 +138,61 @@ public final class PolicyEditorWidget
{
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format, runtime.getPolicyCatalog())));
}
loadMembers(config, groupPk, data);
return (data);
}
/*******************************************************************************
** Members who inherit this policy, with only the edited document removed
** from their sign-in grants. Fetch one extra eligible member to detect truncation.
*******************************************************************************/
private static void loadMembers(AuthentikQBitConfig config, String groupPk, Data data) throws QException
{
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName());
GroupGraph graph = util.groupGraph();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, util.getRuntime().getPermissionCatalog());
Set<String> affectedGroups = graph.nodes().stream()
.filter(node -> node.pk().equals(groupPk) || graph.ancestorPks(node.pk()).contains(groupPk))
.map(GroupGraph.Node::pk).collect(Collectors.toSet());
List<JSONObject> users = new ArrayList<>(util.policyPreviewUsers(affectedGroups, MAX_MEMBERS + 1));
users.sort(Comparator.comparing(user -> user.optString("username")));
List<Map<String, Object>> members = new ArrayList<>();
for(JSONObject user : users)
{
List<String> groupPks = new ArrayList<>();
JSONArray groups = user.optJSONArray("groups_obj");
for(int i = 0; groups != null && i < groups.length(); i++)
{
String pk = groups.getJSONObject(i).getString("pk");
if(graph.byPk(pk) != null)
{
groupPks.add(pk);
}
}
List<String> viaGroups = groupPks.stream().filter(affectedGroups::contains).map(pk -> graph.byPk(pk).name()).sorted().toList();
if(viaGroups.isEmpty())
{
continue;
}
if(members.size() == MAX_MEMBERS)
{
data.withMembersTruncated(true);
break;
}
List<String> names = groupPks.stream().map(pk -> graph.byPk(pk).name()).toList();
Set<String> basePermissions = AccessComparison.mapperPermissions(config, resolver, groupPks, names,
String.valueOf(user.getInt("pk")), user.optString("uuid"), groupPk);
String username = user.optString("username");
members.add(Map.of("username", username, "label", user.optString("name") + " (" + username + ")",
"viaGroups", viaGroups, "basePermissions", basePermissions.stream().sorted().toList()));
}
data.withMembers(members);
}
/*******************************************************************************
** Renderer. QQQ checks the widget's permission rules
** (authentik.policies.manage) before calling it.
@@ -158,6 +227,9 @@ public final class PolicyEditorWidget
private String documentProblem;
private Boolean changedInAuthentik = false;
private String outputFieldName;
private List<Map<String, Object>> members = List.of();
private Boolean membersTruncated = false;
private Boolean mapperEnabled;
@@ -337,5 +409,68 @@ public final class PolicyEditorWidget
this.outputFieldName = outputFieldName;
return (this);
}
/*******************************************************************************
** Getter for members.
*******************************************************************************/
public List<Map<String, Object>> getMembers()
{
return (members);
}
/*******************************************************************************
** Fluent setter for members.
*******************************************************************************/
public Data withMembers(List<Map<String, Object>> members)
{
this.members = members;
return (this);
}
/*******************************************************************************
** Getter for membersTruncated.
*******************************************************************************/
public Boolean getMembersTruncated()
{
return (membersTruncated);
}
/*******************************************************************************
** Fluent setter for membersTruncated.
*******************************************************************************/
public Data withMembersTruncated(Boolean membersTruncated)
{
this.membersTruncated = membersTruncated;
return (this);
}
/*******************************************************************************
** Getter for mapperEnabled.
*******************************************************************************/
public Boolean getMapperEnabled()
{
return (mapperEnabled);
}
/*******************************************************************************
** Fluent setter for mapperEnabled.
*******************************************************************************/
public Data withMapperEnabled(Boolean mapperEnabled)
{
this.mapperEnabled = mapperEnabled;
return (this);
}
}
}
@@ -91,7 +91,9 @@ public final class AuthentikProcesses
/** Selected approval requests. */
APPROVALS,
/** Selected policy records (authentik_policy); the target is the record's group. */
POLICIES
POLICIES,
/** Tokens, keyed by identifier. */
TOKENS
}
/*******************************************************************************
@@ -139,6 +141,28 @@ public final class AuthentikProcesses
String users = AuthentikTables.USER;
String groups = AuthentikTables.GROUP;
List<QFieldMetaData> createTokenFields = new ArrayList<>(List.of(
new QFieldMetaData("description", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(30)));
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
createTokenFields.add(new QFieldMetaData("readOnly", QFieldType.BOOLEAN));
createTokenFields.add(new QFieldMetaData("apps", QFieldType.TEXT).withLabel("Apps (names, one per line)"));
createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT).withLabel("Tables (names, one per line)"));
}
createTokenFields.add(reason);
defs.add(new Def("createToken", "Create Token", AdminAction.CREATE_TOKEN, Kind.CREATE, AuthentikTables.TOKEN, "key", false,
createTokenFields, "Creates a token for you. The key is shown once, on the next screen."));
defs.add(new Def("rotateToken", "Rotate Token", AdminAction.ROTATE_TOKEN, Kind.TOKENS, AuthentikTables.TOKEN, "key", false, List.of(reason), null));
defs.add(new Def("revokeToken", "Revoke Token", AdminAction.REVOKE_TOKEN, Kind.TOKENS, AuthentikTables.TOKEN, "key_off", false, List.of(reason), null));
defs.add(new Def("extendToken", "Extend Expiry", AdminAction.EXTEND_TOKEN, Kind.TOKENS, AuthentikTables.TOKEN, "event", false, List.of(
new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true), reason), null));
defs.add(new Def("createServiceAccount", "Create Service Account", AdminAction.CREATE_SERVICE_ACCOUNT, Kind.CREATE, users, "person_add", false, List.of(
new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("groups", QFieldType.TEXT).withLabel("Groups (names, one per line)"),
new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(365), reason),
"Creates a service account with one app password, shown once."));
defs.add(new Def("createUser", "Create User", AdminAction.CREATE_USER, Kind.CREATE, users, "person_add", false, List.of(
new QFieldMetaData("username", QFieldType.STRING).withLabel("Username").withIsRequired(true),
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name").withIsRequired(true),
@@ -219,7 +243,8 @@ public final class AuthentikProcesses
defs.removeIf(def -> def.action() != null && !config.hasFeature(def.action().getFeature()));
defs.removeIf(def -> (def.action() == AdminAction.DELETE_USER && !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.USERS))
|| (def.action() == AdminAction.DELETE_GROUP && !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS)));
if(!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS) || !config.getTwoPersonApproval())
if((!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS) || !config.getTwoPersonApproval())
&& !(config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.TOKENS) && Boolean.TRUE.equals(config.getTokenTwoPersonApproval())))
{
defs.removeIf(def -> def.kind() == Kind.APPROVALS);
}
@@ -291,10 +316,20 @@ public final class AuthentikProcesses
process.setLabel(def.label());
process.setTableName(table);
process.setIcon(new QIcon(def.icon()));
process.setPermissionRules((def.action() == null ? IdentityPermission.APPROVE : def.action().getPermission()).rules());
IdentityPermission permission = def.action() == null ? IdentityPermission.APPROVE : def.action().getPermission();
// Null means self-service; the table still controls the record-action entry.
if(permission != null)
{
process.setPermissionRules(permission.rules());
}
switch(def.kind())
{
case CREATE -> process.setMaxInputRecords(0);
case TOKENS ->
{
process.setMinInputRecords(1);
process.setMaxInputRecords(def.action() == AdminAction.REVOKE_TOKEN ? AuthentikAdmin.MAX_BULK : 1);
}
case USERS, GROUP_USERNAMES, GROUPS, INVITATIONS, APPROVALS, POLICIES ->
{
process.setMinInputRecords(1);
@@ -377,6 +412,11 @@ public final class AuthentikProcesses
case SEND_RECOVERY_EMAIL -> w("password reset email will be sent", "password reset emails will be sent", "password reset email was sent", "password reset emails were sent");
case RESET_MFA -> w("user's MFA will be reset", "users' MFA will be reset", "user's MFA was reset", "users' MFA were reset");
case REVOKE_APP_PASSWORDS -> w("user's app passwords will be revoked", "users' app passwords will be revoked", "user's app passwords were revoked", "users' app passwords were revoked");
case CREATE_TOKEN -> w("token will be created", "tokens will be created", "token was created", "tokens were created");
case ROTATE_TOKEN -> w("token will be rotated", "tokens will be rotated", "token was rotated", "tokens were rotated");
case REVOKE_TOKEN -> w("token will be revoked", "tokens will be revoked", "token was revoked", "tokens were revoked");
case EXTEND_TOKEN -> w("expiry will be extended", "expiries will be extended", "expiry was extended", "expiries were extended");
case CREATE_SERVICE_ACCOUNT -> w("service account will be created", "service accounts will be created", "service account was created", "service accounts were created");
case REVOKE_SESSIONS -> w("user's sessions will be revoked", "users' sessions will be revoked", "user's sessions were revoked", "users' sessions were revoked");
};
}
@@ -447,6 +487,7 @@ public final class AuthentikProcesses
AdminRequest request = withParams(AdminRequest.of(def.action(), String.valueOf(record.getValue("pk") != null ? record.getValue("pk") : record.getValue("uuid")), reason), def, input);
rs.add("generatePolicy".equals(def.key()) ? admin.withGeneratedPolicy(request) : request);
}
case TOKENS -> rs.add(withParams(AdminRequest.of(def.action(), record.getValueString("identifier"), reason), def, input));
case POLICIES -> rs.add(withParams(AdminRequest.of(def.action(), record.getValueString("groupPk"), reason), def, input));
default ->
{
@@ -470,6 +511,16 @@ public final class AuthentikProcesses
{
request.with(AuthentikAdmin.POLICY_DOCUMENT, input.getValueString(AuthentikAdmin.POLICY_DOCUMENT));
}
if(def.action() == AdminAction.CREATE_TOKEN)
{
for(String field : com.kingsrook.qbits.authentik.admin.TokenScopeStore.SCOPE_FIELDS)
{
if(input.getValue(field) != null)
{
request.with(field, input.getValue(field));
}
}
}
for(QFieldMetaData field : def.fields())
{
if(field.getName().equals("reason"))
@@ -857,6 +908,7 @@ public final class AuthentikProcesses
if(def.kind() == Kind.APPROVALS)
{
AuthentikAdmin.AdminResult result = "approve".equals(def.key()) ? admin.approve(record.getValue("id")) : admin.reject(record.getValue("id"));
result = deliver(output, result);
tally(result, w);
output.getRecords().add(record);
continue;
@@ -878,6 +930,7 @@ public final class AuthentikProcesses
{
result = admin.execute(request);
}
result = deliver(output, result);
tally(result, w);
output.getRecords().add(record);
pace(admin);
@@ -887,6 +940,36 @@ public final class AuthentikProcesses
/*******************************************************************************
** Send the one-time key only through the response-only adapter.
*******************************************************************************/
private AuthentikAdmin.AdminResult deliver(RunBackendStepOutput output, AuthentikAdmin.AdminResult result)
{
com.kingsrook.qbits.authentik.admin.OneTimeKey key = result.takeSecret();
if(key == null)
{
return (result);
}
if(TokenKeyDelivery.tryDeliver(output, key))
{
result.delivered();
return (result);
}
try
{
result.discardSecret();
return (new AuthentikAdmin.AdminResult(false, false, false,
"The key could not be shown: this QQQ version cannot return one-time values. The token was revoked; nothing was kept.", null));
}
catch(QException e)
{
return (new AuthentikAdmin.AdminResult(false, false, false,
"The key could not be shown. Cleanup failed: the old key may no longer work and the new key is lost. Revoke or rotate the token in Authentik.", null));
}
}
/*******************************************************************************
** Pace bulk work (Authentik shares its database with the app).
*******************************************************************************/
@@ -0,0 +1,105 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.processes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Daily app-password expiry reminders, deduplicated in local token metadata.
*******************************************************************************/
public class AuthentikTokenReminders implements BackendStep
{
/*******************************************************************************
** The scheduler invokes the same guarded path as manual process execution.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
run(AuthentikActionUtil.forBackend(input.getValueString("backendName")), Instant.now());
}
/*******************************************************************************
** Two non-overlapping windows avoid two reminders when first seen on day 1.
** Synchronization prevents overlapping local scheduled runs from duplicating.
*******************************************************************************/
public static void run(AuthentikActionUtil util, Instant now) throws QException
{
if(!(QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession)
&& !AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
{
throw (new QPermissionDeniedException("Token reminders require authentik.tokens.manage."));
}
AuthentikRuntime runtime = util.getRuntime();
synchronized(runtime)
{
TokenScopeStore store = new TokenScopeStore(runtime.getConfig());
for(JSONObject token : util.getAllPages("/core/tokens/?intent=app_password", Integer.MAX_VALUE))
{
if(!"app_password".equals(token.optString("intent")) || !token.optBoolean("expiring") || token.isNull("expires"))
{
continue;
}
Instant expires = Instant.parse(token.getString("expires"));
if(!expires.isAfter(now) || expires.isAfter(now.plus(7, ChronoUnit.DAYS)))
{
continue;
}
String field = expires.isAfter(now.plus(1, ChronoUnit.DAYS)) ? "lastReminder7d" : "lastReminder1d";
String id = token.getString("identifier");
QRecord scope = store.find(id);
Instant remindedAt = scope == null ? null : scope.getValueInstant(field);
Instant windowStart = expires.minus(field.equals("lastReminder7d") ? 7 : 1, ChronoUnit.DAYS);
if(remindedAt != null && !remindedAt.isBefore(windowStart))
{
continue;
}
int pk = token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk");
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
if(owner == null || owner.optString("email").isBlank())
{
continue;
}
String message = "Your access token " + token.optString("description") + " (" + id + ") expires on " + expires
+ ". Extend or rotate it in Preferences > Access tokens.";
if(runtime.getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_expiry:" + id + ":" + field + ":" + expires,
"info", owner.getString("email"), message)))
{
store.reminded(id, owner, field, now);
}
}
}
}
}
@@ -0,0 +1,158 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.processes;
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
/*******************************************************************************
** Response-only delivery SPI. Hosts may install their core-compatible adapter.
*******************************************************************************/
@FunctionalInterface
public interface TokenKeyDelivery
{
/*******************************************************************************
** Deliver only into a response-only core channel, never process values.
*******************************************************************************/
void deliver(RunBackendStepOutput output, OneTimeKey key);
/*******************************************************************************
** Whether this implementation can show a key once.
*******************************************************************************/
default boolean available()
{
return (true);
}
/*******************************************************************************
** Current adapter; null restores the safe reflection adapter.
*******************************************************************************/
static void set(TokenKeyDelivery delivery)
{
Holder.delivery = delivery == null ? new ReflectiveDelivery() : delivery;
}
/*******************************************************************************
** Current adapter.
*******************************************************************************/
static TokenKeyDelivery get()
{
return (Holder.delivery);
}
/*******************************************************************************
** Fail closed if runtime delivery becomes unavailable after Review.
*******************************************************************************/
static boolean tryDeliver(RunBackendStepOutput output, OneTimeKey key)
{
try
{
TokenKeyDelivery delivery = get();
if(!delivery.available())
{
return (false);
}
delivery.deliver(output, key);
return (true);
}
catch(RuntimeException e)
{
return (false);
}
}
/*******************************************************************************
** Mutable holder, without putting a credential in static state.
*******************************************************************************/
final class Holder
{
private static volatile TokenKeyDelivery delivery = new ReflectiveDelivery();
/*******************************************************************************
** Utility holder.
*******************************************************************************/
private Holder()
{
}
}
/*******************************************************************************
** 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.
*******************************************************************************/
final class ReflectiveDelivery 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.
*******************************************************************************/
@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"));
}
}
}
}
@@ -36,9 +36,9 @@ import org.json.JSONObject;
/*******************************************************************************
** The qbit's own enforcement point (DESIGN.md section 5.3). QQQ core actions
** do not enforce permissions for non-HTTP callers (QRun-IO/qqq#1061), so every
** call into AuthentikActionUtil is checked here first:
** The qbit's own enforcement point (DESIGN.md section 5.3). Core actions now
** enforce permissions (QRun-IO/qqq#1061); direct AuthentikActionUtil calls and
** Authentik-specific rules still need this guard:
** - reads need the table's authentik.* permission (system sessions may read,
** for the mirror sync);
** - sensitive fields need authentik.audit.read;
@@ -80,7 +80,7 @@ public class AuthentikGuard
return;
}
requireUser(session);
if(!has(session, spec.getReadPermission()))
if(!has(session, spec.getReadPermission()) && !(spec.getAlternativeReadPermission() != null && has(session, spec.getAlternativeReadPermission())))
{
throw (new QPermissionDeniedException("You do not have permission to view " + spec.getLabel() + "."));
}
@@ -130,10 +130,14 @@ public class AuthentikGuard
{
throw (new QPermissionDeniedException(action.getLabel() + " is not enabled (feature " + action.getFeature() + ")."));
}
if(!has(session, action.getPermission()))
if(action.getPermission() != null && !has(session, action.getPermission()))
{
throw (new QPermissionDeniedException("You do not have permission to do this (" + action.getPermission().getShortName() + ")."));
}
if(action == AdminAction.CREATE_SERVICE_ACCOUNT && !has(session, IdentityPermission.USERS_MANAGE))
{
throw (new QPermissionDeniedException("Creating a service account also needs authentik.users.manage."));
}
return (new WriteAuthorization(action.name(), operatorEmail()));
}
@@ -0,0 +1,475 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.security;
import java.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;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.admin.AdminRequest;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import 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.QSession;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.json.JSONObject;
/*******************************************************************************
** Signed token claims can only narrow the permissions computed by a host.
*******************************************************************************/
public final class AuthentikTokenScopes
{
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenScopes.class);
/*******************************************************************************
** Distinguish unrestricted credentials from revoked credentials.
*******************************************************************************/
public enum Mode
{
SCOPED,
UNSCOPED,
DENY_ALL
}
/*******************************************************************************
** Immutable, non-secret credential restrictions, including advanced scopes.
*******************************************************************************/
public record TokenScope(String identifier, Mode mode, boolean readOnly, Set<String> apps, Set<String> tables,
Set<String> processes, Set<String> reports, Set<String> widgets, Set<String> operationIds)
{
/*******************************************************************************
** A missing row is unrestricted; a missing token is denied.
*******************************************************************************/
public static TokenScope marker(String identifier, Mode mode)
{
return (new TokenScope(identifier, mode, false, Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), Set.of()));
}
/*******************************************************************************
** Decode a local metadata row without retaining the mutable record.
*******************************************************************************/
public static TokenScope from(String identifier, QRecord row)
{
return (new TokenScope(identifier, Mode.SCOPED, Boolean.TRUE.equals(row.getValueBoolean("readOnly")),
names(row.getValueString("apps")), names(row.getValueString("tables")), names(row.getValueString("processes")),
names(row.getValueString("reports")), names(row.getValueString("widgets")), names(row.getValueString("operationIds"))));
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikTokenScopes()
{
}
/*******************************************************************************
** One name per line; immutable sets keep cached scopes safe to share.
*******************************************************************************/
public static Set<String> names(String value)
{
if(value == null || value.isBlank())
{
return (Set.of());
}
return (Set.copyOf(Arrays.stream(value.split("\\R")).map(String::trim).filter(s -> !s.isEmpty()).toList()));
}
/*******************************************************************************
** Resolve only qbit backends present in this instance. Cache by identifier
** within each runtime, so tenants never share a scope or token existence.
*******************************************************************************/
public static Optional<TokenScope> forClaims(QInstance instance, JSONObject claims) throws QException
{
if(claims == null)
{
return (Optional.empty());
}
if(instance != null)
{
for(var backend : instance.getBackends().values())
{
AuthentikRuntime runtime;
try
{
runtime = AuthentikRuntime.forBackend(backend.getName());
}
catch(IllegalStateException ignored)
{
continue;
}
String claim = runtime.getConfig().getTokenScopeClaim();
if(!claims.has(claim))
{
continue;
}
Object value = claims.opt(claim);
if(!(value instanceof String identifier) || !identifier.matches("[A-Za-z0-9._-]+"))
{
return (Optional.of(TokenScope.marker("invalid", Mode.DENY_ALL)));
}
return (runtime.getTokenScopeCache().getResultThrowing(identifier, id -> lookupAsSystem(instance, () ->
{
APIBackendMetaData api = (APIBackendMetaData) backend;
AuthentikActionUtil util = (AuthentikActionUtil) QCodeLoader.getAdHoc(BaseAPIActionUtil.class, api.getActionUtil());
util.setBackendMetaData(api);
// Check existence even when a local row remains after an external revoke.
// Do not let a longer general response TTL extend the 60-second
// revocation window of this dedicated cache.
runtime.getResponseCache().clearKey(runtime.getConfig().getApiRoot() + "/core/tokens/" + id + "/");
if(util.getApiJsonOrNull("/core/tokens/" + id + "/") == null)
{
return (TokenScope.marker(id, Mode.DENY_ALL));
}
if(instance.getTable(runtime.getConfig().applyTablePrefix("token_scope")) == null)
{
throw (new QException("Token scope metadata is unavailable."));
}
QRecord row = new TokenScopeStore(runtime.getConfig()).find(id);
return (row == null ? TokenScope.marker(id, Mode.UNSCOPED) : TokenScope.from(id, row));
})));
}
}
// A default token claim must never silently lose enforcement if the qbit is missing.
return (claims.has("qqq_token") ? Optional.of(TokenScope.marker(claims.optString("qqq_token"), Mode.DENY_ALL)) : Optional.empty());
}
/*******************************************************************************
** A lookup step that may throw.
*******************************************************************************/
@FunctionalInterface
private interface Lookup
{
/***************************************************************************
** Run the lookup.
***************************************************************************/
TokenScope get() throws Exception;
}
/*******************************************************************************
** The scope lookup runs while a session is still being built (no user yet),
** so it reads Authentik and the scope row as the internal system session,
** then restores the caller's context.
*******************************************************************************/
private static TokenScope lookupAsSystem(QInstance instance, Lookup lookup) throws QException
{
QInstance previousInstance = QContext.getQInstance();
com.kingsrook.qqq.backend.core.model.session.QSession previousSession = QContext.getQSession();
try
{
if(previousInstance == null)
{
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession());
}
else
{
QContext.setQSession(new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession());
}
return (lookup.get());
}
catch(QException e)
{
throw (e);
}
catch(Exception e)
{
throw (new QException("Token scope lookup failed", e));
}
finally
{
if(previousInstance == null)
{
QContext.clear();
}
else
{
QContext.setQSession(previousSession);
}
}
}
/*******************************************************************************
** Hosts with their own customizer call this AFTER computing permissions.
*******************************************************************************/
public static void apply(QSession session, JSONObject claims)
{
apply(QContext.getQInstance(), session, claims);
}
/*******************************************************************************
** Explicit instance variant for authentication, before QContext is installed.
*******************************************************************************/
public static void apply(QInstance instance, QSession session, JSONObject claims)
{
try
{
forClaims(instance, claims).ifPresent(scope -> narrow(instance, session, scope));
}
catch(Exception e)
{
session.setPermissions(new TreeSet<>());
LOG.warn("Token scope could not be verified; denying credential permissions.", e);
}
}
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7): intersect, never grant.
*******************************************************************************/
public static void narrow(QSession session, TokenScope scope)
{
narrow(QContext.getQInstance(), session, scope);
}
/*******************************************************************************
** Keep only permitted bases and read operations, then attach core's scope
** when available. Management hasAccess permissions are not read operations.
*******************************************************************************/
private static void narrow(QInstance instance, QSession session, TokenScope scope)
{
Set<String> permissions = new TreeSet<>(session.getPermissions() == null ? Set.of() : session.getPermissions());
if(scope.mode() == Mode.DENY_ALL)
{
permissions.clear();
}
else if(scope.mode() == Mode.SCOPED)
{
if(scope.readOnly())
{
permissions.removeIf(p -> p.startsWith("authentik.") || !(p.endsWith(".read") || p.endsWith(".hasAccess")));
}
if(!scope.apps().isEmpty() || !scope.tables().isEmpty() || !scope.processes().isEmpty() || !scope.reports().isEmpty() || !scope.widgets().isEmpty())
{
Set<String> bases = new LinkedHashSet<>();
if(instance != null)
{
for(String name : scope.tables())
{
if(instance.getTable(name) != null)
{
bases.add(base(instance, instance.getTable(name), name));
}
}
// Policy-format names exclude dots; host permission aliases need
// direct metadata traversal as well as the policy catalog.
for(String appName : scope.apps())
{
var app = instance.getApp(appName);
if(app == null)
{
continue;
}
bases.add(base(instance, app, appName));
Set<String> appTables = new LinkedHashSet<>();
if(app.getChildren() != null)
{
for(var child : app.getChildren())
{
if(child instanceof MetaDataWithPermissionRules secured)
{
bases.add(base(instance, secured, child.getName()));
}
if(instance.getTable(child.getName()) != null)
{
appTables.add(child.getName());
}
}
}
for(var process : instance.getProcesses().values())
{
if(process.getTableName() != null && appTables.contains(process.getTableName()))
{
bases.add(base(instance, process, process.getName()));
}
}
if(app.getWidgets() != null)
{
for(String name : app.getWidgets())
{
if(instance.getWidget(name) != null)
{
bases.add(base(instance, instance.getWidget(name), name));
}
}
}
}
for(PolicyCatalog.Group group : PolicyCatalog.of(instance).getGroups())
{
for(PolicyCatalog.Resource resource : group.resources())
{
if((group.app() != null && scope.apps().contains(group.app())) || scope.processes().contains(resource.name())
|| scope.reports().contains(resource.name()) || scope.widgets().contains(resource.name()))
{
bases.add(resource.name());
}
}
}
}
permissions.removeIf(p -> p.lastIndexOf('.') < 0 || !bases.contains(p.substring(0, p.lastIndexOf('.'))));
}
}
session.setPermissions(permissions);
session.setValue("qqq.credentialScope", scope.identifier());
attachCoreScope(session, scope);
}
/*******************************************************************************
** Use the permission base, which may differ from a table's metadata name.
*******************************************************************************/
private static String base(QInstance instance, MetaDataWithPermissionRules object, String name)
{
String base = PermissionsHelper.getEffectivePermissionRules(object, instance).getPermissionBaseName();
return (base == null || base.isBlank() ? name : base);
}
/*******************************************************************************
** Validate against current operator permissions at Review and execution.
*******************************************************************************/
public static void validate(AdminRequest request) throws QUserFacingException
{
QInstance instance = QContext.getQInstance();
for(String field : List.of("apps", "tables"))
{
for(String name : names(request.str(field)))
{
MetaDataWithPermissionRules object = field.equals("apps") ? instance.getApp(name) : instance.getTable(name);
if(object == null)
{
throw (new QUserFacingException("Unknown " + field + " scope: " + name));
}
String base = base(instance, object, name);
QSession session = QContext.getQSession();
if(!session.hasPermission(base + ".hasAccess") && !session.hasPermission(base + ".read"))
{
throw (new QUserFacingException("You do not currently have read access to scope: " + name));
}
}
}
for(String advanced : List.of("processes", "reports", "widgets", "operationIds"))
{
if(!request.str(advanced).isBlank())
{
throw (new QUserFacingException("Advanced token scopes are not offered by Create Token."));
}
}
}
/*******************************************************************************
** 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).
*******************************************************************************/
private static Set<String> nullIfEmpty(Set<String> set)
{
return (set == null || set.isEmpty() ? null : set);
}
/*******************************************************************************
** 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.
*******************************************************************************/
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.");
}
}
}
@@ -42,6 +42,7 @@ public enum IdentityPermission
ROLES_MANAGE("authentik.roles.manage"),
CREDENTIALS_RESET("authentik.credentials.reset"),
SESSIONS_REVOKE("authentik.sessions.revoke"),
TOKENS_MANAGE("authentik.tokens.manage"),
AUDIT_READ("authentik.audit.read"),
APPROVE("authentik.approve"),
ADMIN("authentik.admin");
@@ -0,0 +1,56 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.security;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
/*******************************************************************************
** Either auditors or token managers may read token metadata.
*******************************************************************************/
public class TokenReadPermission implements CustomPermissionChecker
{
/*******************************************************************************
** Shared metadata rules for the API and local token tables.
*******************************************************************************/
public static QPermissionRules rules()
{
return (IdentityPermission.AUDIT_READ.rules().withCustomPermissionChecker(new QCodeReference(TokenReadPermission.class)));
}
/*******************************************************************************
** Require either permission; core invokes this for table-level access.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.AUDIT_READ)
&& !AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
{
throw (new QPermissionDeniedException("Token metadata requires authentik.audit.read or authentik.tokens.manage."));
}
}
}
@@ -202,4 +202,38 @@ class AuthentikQBitConfigTest
assertThat(AuthentikQBitConfig.toSnakeCase("auth2Factor")).isEqualTo("auth2_factor");
}
/*******************************************************************************
** Token defaults, minimum lifetimes and secret-safe diagnostics.
*******************************************************************************/
@Test
void tokenConfig()
{
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig();
assertThat(config.getTokenMaxDaysUser()).isEqualTo(90);
assertThat(config.getTokenMaxDaysServiceAccount()).isEqualTo(365);
assertThat(config.getTokenUnusedDays()).isEqualTo(60);
assertThat(config.getTokenProtectedGroups()).containsExactly("authentik Admins", "identity-admins");
assertThat(config.getTokenScopesEnabled()).isFalse();
assertThat(config.getTokenTwoPersonApproval()).isFalse();
assertThat(config.getTokenEndpointUrl()).isEqualTo(config.getBaseUrl() + "/application/o/token/");
assertThat(config.getAuthentikPublicUrl()).isEqualTo(config.getBaseUrl());
assertThat(config.getServiceAccountTokenLifetime()).isEqualTo("days=365");
config.withServiceAccountTokenLifetime("days=200");
assertThat(config.getServiceAccountTokenLifetime()).isEqualTo("days=200");
config.setServiceAccountTokenLifetime("days=365");
config.withTokenBrokerClientSecret("do-not-serialize-this");
assertThat(config.toString()).doesNotContain("do-not-serialize-this");
assertThat(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(config)).doesNotContain("do-not-serialize-this");
config.setTokenMaxDaysUser(0);
config.setTokenMaxDaysServiceAccount(-1);
config.setTokenUnusedDays(null);
assertThat(errors(config)).contains("tokenMaxDaysUser must be at least 1", "tokenMaxDaysServiceAccount must be at least 1", "tokenUnusedDays must be at least 1");
config.withTokenMaxDaysUser(1).withTokenMaxDaysServiceAccount(1).withTokenUnusedDays(1);
assertThat(errors(config)).isEmpty();
config.withTokenBrokerClientId("${env.QBIT_TEST_UNSET_BROKER_ID}").withTokenBrokerClientSecret("${env.QBIT_TEST_UNSET_BROKER_SECRET}");
assertThat(config.getTokenBrokerClientId()).isNull();
assertThat(config.getTokenBrokerClientSecret()).isNull();
}
}
@@ -174,4 +174,18 @@ public class SchemaDriftTest
return (null);
}
/*******************************************************************************
** Login aggregation uses only parameters documented by the pinned schema.
*******************************************************************************/
@Test
void tokenActivitySurfacePinsLoginOrderingWithoutUnsupportedDateFilter()
{
assertThat(AuthentikApiSurface.operations()).anySatisfy(operation ->
{
assertThat(operation.path()).isEqualTo("/events/events/");
assertThat(operation.method()).isEqualTo("get");
assertThat(operation.params()).contains("action", "ordering", "page", "page_size").doesNotContain("created__gt");
});
}
}
@@ -44,6 +44,7 @@ import org.json.JSONObject;
*******************************************************************************/
public class FakeAuthentik
{
public final JSONObject brandAttributes = new JSONObject();
public final Map<Integer, JSONObject> users = new LinkedHashMap<>();
public final Map<String, JSONObject> groups = new LinkedHashMap<>();
public final Map<String, List<JSONObject>> tokens = new LinkedHashMap<>();
@@ -54,6 +55,12 @@ public class FakeAuthentik
public final List<JSONObject> events = new ArrayList<>();
public final List<JSONObject> writeBodies = new ArrayList<>();
public final Map<String, String> tokenKeys = new LinkedHashMap<>();
public final java.util.Set<Integer> deniedSubjects = new java.util.HashSet<>();
public final List<Integer> tokenCallers = new ArrayList<>();
public final List<JSONObject> loginEvents = new ArrayList<>();
public boolean moveTokenAfterUpdate;
public boolean failTokenDelete;
private int nextUserPk = 100;
@@ -204,7 +211,7 @@ public class FakeAuthentik
{
JSONObject rs = new JSONObject(user.toString());
JSONArray groupsObj = new JSONArray();
boolean superuser = false;
boolean superuser = user.optBoolean("is_superuser");
for(JSONObject group : groups.values())
{
if(contains(group.getJSONArray("users"), user.getInt("pk")))
@@ -251,7 +258,27 @@ public class FakeAuthentik
String path = uri.getPath().replace("/api/v3", "");
String method = request.getMethod();
Map<String, List<String>> query = parseQuery(uri.getRawQuery());
if(path.equals("/application/o/token/"))
{
try
{
Map<String, List<String>> form = parseQuery(EntityUtils.toString(((HttpEntityEnclosingRequest) request).getEntity()));
String subject = first(form, "subject_token");
int pk = subject != null && subject.startsWith("subject:") ? Integer.parseInt(subject.substring(8)) : -1;
if(!users.containsKey(pk) || deniedSubjects.contains(pk))
{
return (ok(400, new JSONObject().put("error", "invalid_grant")));
}
return (ok(200, new JSONObject().put("access_token", "user-bearer:" + pk).put("expires_in", 120)));
}
catch(Exception e)
{
return (ok(400, new JSONObject().put("error", "invalid_request")));
}
}
JSONObject body = body(request);
String authorization = request.getFirstHeader("Authorization") == null ? "" : request.getFirstHeader("Authorization").getValue();
int caller = authorization.startsWith("Bearer user-bearer:") ? Integer.parseInt(authorization.substring("Bearer user-bearer:".length())) : 5;
if(!method.equals("GET"))
{
@@ -259,6 +286,20 @@ public class FakeAuthentik
writeBodies.add(body == null ? new JSONObject() : body);
}
if(path.startsWith("/core/tokens/") && !path.equals("/core/tokens/") || path.equals("/core/tokens/") && method.equals("POST"))
{
return (tokenRequest(method, path, body, caller));
}
if(path.equals("/core/users/service_account/"))
{
int pk = nextUserPk++;
String name = body.getString("name");
user(pk, name, "", "service_account", true);
users.get(pk).getJSONObject("attributes").put("goauthentik.io/user/token-expires", body.optBoolean("expiring", true));
String identifier = "service-account-" + name + "-password";
seedToken(identifier, pk, "app_password", body.getString("expires"));
return (ok(200, new JSONObject().put("username", name).put("user_pk", pk).put("user_uid", UUID.randomUUID().toString()).put("token", tokenKeys.get(identifier))));
}
if(method.equals("GET"))
{
return (get(path, query));
@@ -419,8 +460,38 @@ public class FakeAuthentik
{
continue;
}
if(query.containsKey("is_active") && user.optBoolean("is_active") != Boolean.parseBoolean(first(query, "is_active")))
{
continue;
}
if(query.containsKey("path_startswith") && !user.optString("path").startsWith(first(query, "path_startswith")))
{
continue;
}
List<String> names = groupNamesOf(user.getInt("pk"));
if(query.containsKey("groups_by_name") && names.stream().noneMatch(query.get("groups_by_name")::contains))
{
continue;
}
if(query.containsKey("groups_by_pk") && names.stream().map(this::pk).noneMatch(query.get("groups_by_pk")::contains))
{
continue;
}
rs.add(rendered);
}
if("username".equals(first(query, "ordering")))
{
rs.sort(java.util.Comparator.comparing(user -> user.getString("username")));
}
if(query.containsKey("page_size"))
{
int size = Integer.parseInt(first(query, "page_size"));
int number = Integer.parseInt(first(query, "page"));
int from = Math.min(rs.size(), (number - 1) * size);
int to = Math.min(rs.size(), from + size);
return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", rs.size()).put("current", number)
.put("total_pages", Math.max(1, (rs.size() + size - 1) / size))).put("results", new JSONArray(rs.subList(from, to)))));
}
return (page(rs));
}
if(path.matches("/core/users/\\d+/"))
@@ -449,7 +520,16 @@ public class FakeAuthentik
}
if(path.equals("/core/tokens/"))
{
List<JSONObject> rs = new ArrayList<>(tokens.getOrDefault(first(query, "user__username"), List.of()));
List<JSONObject> rs = new ArrayList<>();
if(query.containsKey("user__username"))
{
rs.addAll(tokens.getOrDefault(first(query, "user__username"), List.of()));
}
else
{
tokens.values().forEach(rs::addAll);
}
rs.replaceAll(this::renderToken);
String intent = first(query, "intent");
if(intent != null)
{
@@ -480,7 +560,26 @@ public class FakeAuthentik
JSONObject invitation = invitations.get(path.split("/")[4]);
return (invitation == null ? FixtureActionUtil.response(404, "{}") : ok(200, invitation));
}
if(path.equals("/events/events/") || path.equals("/managed/blueprints/") || path.equals("/core/applications/"))
if(path.equals("/events/events/"))
{
List<JSONObject> selected = new ArrayList<>(loginEvents);
String action = first(query, "action");
if(action != null)
{
selected.removeIf(event -> !action.equals(event.optString("action")));
}
if("-created".equals(first(query, "ordering")))
{
selected.sort(java.util.Comparator.comparing((JSONObject event) -> java.time.Instant.parse(event.getString("created"))).reversed());
}
int size = query.containsKey("page_size") ? Integer.parseInt(first(query, "page_size")) : 100;
int number = query.containsKey("page") ? Integer.parseInt(first(query, "page")) : 1;
int from = Math.min(selected.size(), (number - 1) * size);
int to = Math.min(selected.size(), from + size);
return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", selected.size()).put("current", number)
.put("total_pages", Math.max(1, (selected.size() + size - 1) / size))).put("results", new JSONArray(selected.subList(from, to)))));
}
if(path.equals("/managed/blueprints/") || path.equals("/core/applications/"))
{
return (page(new ArrayList<>()));
}
@@ -489,6 +588,152 @@ public class FakeAuthentik
/*******************************************************************************
** Seed an app password or API token with an independent fake key.
*******************************************************************************/
public JSONObject seedToken(String identifier, int owner, String intent, String expires)
{
JSONObject token = new JSONObject().put("identifier", identifier).put("user", owner).put("intent", intent)
.put("description", "Test access").put("expiring", true).put("expires", expires).put("managed", JSONObject.NULL);
tokens.computeIfAbsent(users.get(owner).getString("username"), k -> new ArrayList<>()).add(token);
tokenKeys.put(identifier, UUID.randomUUID().toString() + UUID.randomUUID());
return (token);
}
/*******************************************************************************
** Metadata never contains a key, including list and detail responses.
*******************************************************************************/
private JSONObject renderToken(JSONObject token)
{
JSONObject result = new JSONObject(token.toString());
if(token.has("user") && users.containsKey(token.getInt("user")))
{
result.put("user_obj", render(users.get(token.getInt("user"))));
}
return (result);
}
/*******************************************************************************
** Authentik 2026.8.3 caller ownership, PATCH defaults, caps and key access.
*******************************************************************************/
private QHttpResponse tokenRequest(String method, String path, JSONObject body, int caller)
{
tokenCallers.add(caller);
if(path.equals("/core/tokens/") && method.equals("POST"))
{
int owner = render(users.get(caller)).optBoolean("is_superuser") ? body.optInt("user", caller) : caller;
JSONObject token = seedToken(body.getString("identifier"), owner, body.optString("intent", "api"), body.optString("expires", java.time.Instant.now().plusSeconds(1800).toString()));
token.put("description", body.optString("description"));
if(!capExpiry(token))
{
tokens.values().forEach(list -> list.remove(token));
tokenKeys.remove(token.getString("identifier"));
return (ok(400, new JSONObject().put("expires", "Token expires exceeds maximum lifetime")));
}
return (ok(201, renderToken(token)));
}
String identifier = path.split("/")[3];
JSONObject token = tokens.values().stream().flatMap(List::stream).filter(t -> identifier.equals(t.optString("identifier"))).findFirst().orElse(null);
if(token == null)
{
return (ok(404, new JSONObject()));
}
if(path.endsWith("/view_key/"))
{
return (token.optInt("user") == caller ? ok(200, new JSONObject().put("key", tokenKeys.get(identifier))) : ok(403, new JSONObject()));
}
if(method.equals("GET"))
{
return (ok(200, renderToken(token)));
}
if(caller != 5 && token.optInt("user") != caller)
{
return (ok(403, new JSONObject()));
}
if(method.equals("DELETE"))
{
if(failTokenDelete)
{
return (ok(400, new JSONObject().put("detail", "delete refused")));
}
tokens.values().forEach(list -> list.removeIf(t -> identifier.equals(t.optString("identifier"))));
tokenKeys.remove(identifier);
return (ok(204, null));
}
if(path.endsWith("/set_key/"))
{
tokenKeys.put(identifier, body.getString("key"));
}
else if(method.equals("PATCH"))
{
// validate_user runs for explicitly supplied user; setdefault is later.
if(body.has("user") && body.getInt("user") != token.getInt("user"))
{
return (ok(400, new JSONObject().put("user", "User cannot be changed")));
}
JSONObject original = new JSONObject(token.toString());
token.put("intent", body.optString("intent", "api")).put("user", body.optInt("user", caller));
if(body.has("expires"))
{
token.put("expires", body.getString("expires"));
}
token.put("expiring", body.optBoolean("expiring", token.optBoolean("expiring")));
if(!capExpiry(token))
{
original.keySet().forEach(key -> token.put(key, original.get(key)));
return (ok(400, new JSONObject().put("expires", "Token expires exceeds maximum lifetime")));
}
}
if(moveTokenAfterUpdate)
{
token.put("user", 4).put("intent", "api");
}
return (ok(200, renderToken(token)));
}
/*******************************************************************************
** Brand attributes, then group attributes, then user attributes set the cap.
*******************************************************************************/
private boolean capExpiry(JSONObject token)
{
java.time.Instant now = java.time.Instant.now();
long seconds = 1800;
if("app_password".equals(token.optString("intent")))
{
JSONObject attributes = new JSONObject(brandAttributes.toString());
com.kingsrook.qbits.authentik.permissions.GroupGraph graph = com.kingsrook.qbits.authentik.permissions.GroupGraph.fromGroups(groups.values());
graph.groupsOfUser(token.getInt("user")).stream().flatMap(pk -> graph.selfAndAncestors(pk).stream())
.distinct().sorted(java.util.Comparator.comparing(com.kingsrook.qbits.authentik.permissions.GroupGraph.Node::name)).forEach(group ->
{
JSONObject values = groups.get(group.pk()).getJSONObject("attributes");
values.keySet().forEach(key -> attributes.put(key, values.get(key)));
});
JSONObject values = users.get(token.getInt("user")).getJSONObject("attributes");
values.keySet().forEach(key -> attributes.put(key, values.get(key)));
String lifetime = attributes.optString("goauthentik.io/user/token-maximum-lifetime");
if(lifetime.matches("days=[0-9]+"))
{
seconds = Long.parseLong(lifetime.substring(5)) * 86400;
}
}
java.time.Instant cap = now.plusSeconds(seconds);
java.time.Instant requested = java.time.Instant.parse(token.getString("expires"));
if("api".equals(token.optString("intent")))
{
token.put("expires", cap.toString());
return (true);
}
return (!requested.isAfter(cap));
}
/*******************************************************************************
**
*******************************************************************************/
@@ -0,0 +1,105 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import java.util.List;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPatch;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Pin the fake's dangerous defaults independently of the qbit's safe requests.
*******************************************************************************/
class FakeAuthentikTokenTest
{
/*******************************************************************************
** Non-superuser create ignores an explicit other owner; keys are owner-only.
*******************************************************************************/
@Test
void callerOwnsCreateAndKey()
{
FakeAuthentik fake = directory();
HttpPost request = new HttpPost("https://example.test/api/v3/core/tokens/");
request.setHeader("Authorization", "Bearer user-bearer:3");
request.setEntity(new StringEntity(new JSONObject().put("identifier", "caller-owned").put("user", 6)
.put("intent", "app_password").put("expires", Instant.now().plusSeconds(600).toString()).toString(), ContentType.APPLICATION_JSON));
JSONObject token = new JSONObject(fake.handle(request).getContent());
assertThat(token.getInt("user")).isEqualTo(3);
assertThat(Instant.parse(token.getString("expires"))).isBefore(Instant.now().plusSeconds(1801));
HttpGet key = new HttpGet("https://example.test/api/v3/core/tokens/caller-owned/view_key/");
assertThat(fake.handle(key).getStatusCode()).isEqualTo(403);
key.setHeader("Authorization", "Bearer user-bearer:3");
assertThat(fake.handle(key).getStatusCode()).isEqualTo(200);
assertThat(token.has("key")).isFalse();
}
/*******************************************************************************
** Omitted PATCH intent/user really do cause a dangerous default transition.
*******************************************************************************/
@Test
void patchDefaultsAndGroupLifetime()
{
FakeAuthentik fake = directory();
fake.group("lifetime", false, List.of(), List.of());
fake.groupByName("lifetime").getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=90");
fake.groupByName("lifetime").getJSONArray("users").put(3);
fake.seedToken("app-password", 3, "app_password", Instant.now().plusSeconds(86400).toString());
HttpPatch patch = new HttpPatch("https://example.test/api/v3/core/tokens/app-password/");
patch.setEntity(new StringEntity(new JSONObject().put("intent", "app_password").put("user", 3)
.put("expires", Instant.now().plusSeconds(30 * 86400L).toString()).toString(), ContentType.APPLICATION_JSON));
JSONObject updated = new JSONObject(fake.handle(patch).getContent());
assertThat(updated.getInt("user")).isEqualTo(3);
assertThat(updated.getString("intent")).isEqualTo("app_password");
assertThat(Instant.parse(updated.getString("expires"))).isBefore(Instant.now().plusSeconds(90 * 86400L + 1));
patch.setEntity(new StringEntity(new JSONObject().put("intent", "app_password").put("user", 3)
.put("expires", Instant.now().plusSeconds(100 * 86400L).toString()).toString(), ContentType.APPLICATION_JSON));
assertThat(fake.handle(patch).getStatusCode()).isEqualTo(400);
fake.brandAttributes.put("goauthentik.io/user/token-maximum-lifetime", "days=10");
fake.users.get(3).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365");
assertThat(fake.handle(patch).getStatusCode()).isEqualTo(200);
fake.users.get(3).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=5");
assertThat(fake.handle(patch).getStatusCode()).isEqualTo(400);
patch.setEntity(new StringEntity(new JSONObject().put("user", 6).toString(), ContentType.APPLICATION_JSON));
assertThat(fake.handle(patch).getContent()).contains("User cannot be changed");
patch.setEntity(new StringEntity("{}", ContentType.APPLICATION_JSON));
updated = new JSONObject(fake.handle(patch).getContent());
assertThat(updated.getInt("user")).isEqualTo(5);
assertThat(updated.getString("intent")).isEqualTo("api");
assertThat(Instant.parse(updated.getString("expires"))).isBefore(Instant.now().plusSeconds(1801));
}
/*******************************************************************************
** Minimal directory including the qbit's caller.
*******************************************************************************/
private FakeAuthentik directory()
{
return (new FakeAuthentik().user(3, "sam", "sam@example.test", "internal", true)
.user(5, "qbit", "", "service_account", true).user(6, "service", "", "service_account", true));
}
}
@@ -23,6 +23,8 @@ import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
@@ -360,4 +362,154 @@ class PolicyEditorTest
RenderWidgetOutput output = new RenderWidgetAction().execute(input);
assertThat(output.getWidgetData().getType()).isEqualTo("AUTHENTIK_POLICY_EDITOR");
}
/*******************************************************************************
** Direct and descendant members get all other grants, including inherited
** policies, wildcard attributes and static permissions of direct groups.
*******************************************************************************/
@Test
void memberPreviewPreservesOtherGrants() throws Exception
{
String group = "kof-website-analysts";
t.authentik.groupByName(group).getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT);
t.authentik.group("child", false, List.of(), List.of(), group)
.group("grandchild", false, List.of(), List.of(), "child")
.group("other-parent", false, List.of(), List.of())
.group("other", false, List.of("cms_page.*"), List.of(), "other-parent")
.user(10, "zoe", "zoe@example.test", "internal", true, group, "child", "other")
.user(11, "amy", "amy@example.test", "external", true, "child", "other-parent")
.user(12, "ben", "ben@example.test", "internal", true, "grandchild")
.user(13, "inactive", "inactive@example.test", "internal", false, group);
t.authentik.groupByName("other-parent").getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT.replace("\"read\",\"edit\"", "\"insert\""));
t.config.withStaticGroupPermissions(Map.of("child", List.of("static.floor"), group, List.of("direct.floor")));
PolicyEditorWidget.Data data = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
assertThat(data.getMembers()).extracting(m -> m.get("username")).containsExactly("amy", "ben", "zoe");
assertThat(data.getMembersTruncated()).isFalse();
assertThat(data.getMembers().get(0)).containsExactlyInAnyOrderEntriesOf(Map.of(
"username", "amy", "label", "amy Name (amy)", "viaGroups", List.of("child"),
"basePermissions", List.of("cms_page.insert", "kof22.analytics.hasAccess", "static.floor")));
assertThat(data.getMembers().get(1)).containsEntry("viaGroups", List.of("grandchild"))
.containsEntry("basePermissions", List.of("kof22.analytics.hasAccess"));
assertThat(data.getMembers().get(2)).containsEntry("viaGroups", List.of("child", group))
.containsEntry("basePermissions", List.of("cms_page.delete", "cms_page.edit", "cms_page.insert", "cms_page.read", "direct.floor", "kof22.analytics.hasAccess", "static.floor"));
}
/*******************************************************************************
** The cap is applied after eligibility and username ordering, and duplicate
** memberships do not consume multiple slots. Exactly 200 is not truncated.
*******************************************************************************/
@Test
void memberPreviewCapsEligiblePeople() throws Exception
{
String group = "kof-website-analysts";
t.authentik.group("child", false, List.of(), List.of(), group);
for(int i = 199; i >= 0; i--)
{
String username = "person%03d".formatted(i);
t.authentik.user(100 + i, username, username + "@example.test", "internal", true, group, "child");
}
t.authentik.user(400, "aaa-inactive", "inactive@example.test", "internal", false, group);
PolicyEditorWidget.Data exact = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
assertThat(exact.getMembers()).hasSize(200);
assertThat(exact.getMembersTruncated()).isFalse();
t.authentik.user(401, "aaa-active", "active@example.test", "internal", true, "child");
t.admin().getUtil().getRuntime().invalidateCache();
FixtureActionUtil.REQUESTS.clear();
PolicyEditorWidget.Data capped = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
assertThat(capped.getMembers()).hasSize(200);
assertThat(capped.getMembersTruncated()).isTrue();
assertThat(capped.getMembers()).extracting(m -> (String) m.get("username")).isSorted().doesNotContain("person199", "aaa-inactive");
assertThat(capped.getMembers().get(0)).containsEntry("username", "aaa-active");
List<String> queries = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().equals("/api/v3/core/users/"))
.map(r -> r.getURI().getQuery()).toList();
assertThat(queries).hasSize(3).allSatisfy(query -> assertThat(query)
.contains("ordering=username", "is_active=true", "groups_by_pk=" + t.authentik.pk(group), "groups_by_pk=" + t.authentik.pk("child")));
}
/*******************************************************************************
** Scope restrictions apply even to inherited members; no extra user-read
** permission is needed beyond the editor permission.
*******************************************************************************/
@Test
void memberPreviewRespectsScopeAndEditorPermission() throws Exception
{
String group = "kof-website-analysts";
t.authentik.group("child", false, List.of(), List.of(), group)
.group("scope", false, List.of(), List.of())
.user(10, "allowed", "allowed@example.test", "internal", true, "child", "scope")
.user(11, "wrong-path", "path@example.test", "internal", true, group, "scope")
.user(12, "wrong-group", "group@example.test", "internal", true, group);
t.authentik.users.get(11).put("path", "outside");
t.config.withUserScopePathStartsWith("users").withUserScopeGroupNames(List.of("scope"));
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.POLICIES_MANAGE);
assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getMembers())
.extracting(m -> m.get("username")).containsExactly("allowed");
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ);
FixtureActionUtil.REQUESTS.clear();
assertThatThrownBy(() -> PolicyEditorWidget.load(t.config, t.authentik.pk(group))).hasMessageContaining("authentik.policies.manage");
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
assertThatThrownBy(() -> t.admin().getUtil().policyPreviewUsers(List.of(t.authentik.pk(group)), 201)).hasMessageContaining("authentik.policies.manage");
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
}
/*******************************************************************************
** Bootstrap subjects retain identity grants by immutable pk or uuid; a
** superuser flag alone never grants QQQ permissions or leaks private fields.
*******************************************************************************/
@Test
void memberPreviewIncludesBootstrapWithoutSuperuserEscalation() throws Exception
{
String group = "kof-website-analysts";
t.authentik.user(10, "bootstrap-pk", "pk@example.test", "internal", true, group)
.user(11, "bootstrap-uuid", "uuid@example.test", "internal", true, group)
.user(12, "superuser", "superuser@example.test", "internal", true, group, "authentik Admins");
t.authentik.users.get(11).put("uuid", "immutable-subject");
t.config.withBootstrapOperatorSubjects("10,immutable-subject");
List<Map<String, Object>> members = PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getMembers();
for(Map<String, Object> member : members.subList(0, 2))
{
assertThat((List<?>) member.get("basePermissions")).hasSize(IdentityPermission.values().length + 1);
for(IdentityPermission permission : IdentityPermission.values())
{
assertThat(((List<?>) member.get("basePermissions")).contains(permission.getSessionPermission())).isTrue();
}
}
assertThat(members.get(2)).containsEntry("basePermissions", List.of("kof22.analytics.hasAccess"));
assertThat(members.get(2)).containsOnlyKeys("username", "label", "viaGroups", "basePermissions");
}
/*******************************************************************************
** The added fields serialize like the existing widget fields, including an
** empty member list when no group is selected and the configured mapper flag.
*******************************************************************************/
@Test
void memberPreviewPayloadAndMapperFlag() throws Exception
{
t.config.withMapperEnabled(false);
FixtureActionUtil.REQUESTS.clear();
PolicyEditorWidget.Data empty = PolicyEditorWidget.load(t.config, null);
assertThat(empty.getMembers()).isEmpty();
assertThat(empty.getMembersTruncated()).isFalse();
assertThat(empty.getMapperEnabled()).isFalse();
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
t.config.withMapperEnabled(true);
PolicyEditorWidget.Data data = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-publishers"));
JSONObject json = new JSONObject(data);
assertThat(json.getBoolean("mapperEnabled")).isTrue();
assertThat(json.getBoolean("membersTruncated")).isFalse();
assertThat(json.getJSONArray("members").getJSONObject(0).keySet()).containsExactlyInAnyOrder("username", "label", "viaGroups", "basePermissions");
}
}
@@ -0,0 +1,533 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
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.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
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.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.Logger;
import org.apache.logging.log4j.core.appender.AbstractAppender;
import org.apache.logging.log4j.core.config.Property;
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 static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Token actions against the in-memory Authentik 2026.8.3 model. No Docker.
*******************************************************************************/
class TokenManagementTest
{
private AdminTestSupport t;
private final List<OneTimeKey> delivered = new ArrayList<>();
private final List<String> logLines = new ArrayList<>();
private AbstractAppender appender;
/*******************************************************************************
** A non-protected person, broker, lifetime groups and capturing response SPI.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
t = AdminTestSupport.create();
t.config.withTokenBrokerClientId("test-broker").withTokenBrokerClientSecret("test-broker-secret")
.withSubjectTokenSource(session -> "subject:3").withAppClientId("test-app").withAppPublicUrl("https://app.example.test")
.withAuthentikPublicUrl("https://auth.example.test");
t.authentik.group("token-users", false, List.of(), List.of());
t.authentik.groupByName("token-users").getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=90");
t.authentik.groupByName("token-users").getJSONArray("users").put(3);
t.authentik.users.get(6).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365");
t.loginAs("sam@example.test");
TokenKeyDelivery.set((output, key) -> delivered.add(key));
appender = new AbstractAppender("token-test-capture", null, null, false, Property.EMPTY_ARRAY)
{
/*******************************************************************************
** Capture formatted log messages without writing them elsewhere.
*******************************************************************************/
@Override
public void append(LogEvent event)
{
logLines.add(event.getMessage().getFormattedMessage());
}
};
appender.start();
((Logger) LogManager.getRootLogger()).addAppender(appender);
}
/*******************************************************************************
** Do not leak a test adapter into other tests.
*******************************************************************************/
@AfterEach
void tearDown()
{
TokenKeyDelivery.set(null);
((Logger) LogManager.getRootLogger()).removeAppender(appender);
appender.stop();
}
/*******************************************************************************
** A valid personal create request.
*******************************************************************************/
private AdminRequest create()
{
return (AdminRequest.of(AdminAction.CREATE_TOKEN, "", "automation").with("description", "CI access").with("expiresInDays", 30));
}
/*******************************************************************************
** A seeded expiring token.
*******************************************************************************/
private JSONObject seed(String id, int owner)
{
return (t.authentik.seedToken(id, owner, "app_password", Instant.now().plusSeconds(86400).toString()));
}
/*******************************************************************************
** Assert a Review refusal before Authentik mutates anything.
*******************************************************************************/
private void refused(AdminRequest request, String message) throws QException
{
int before = t.changes().size();
AdminPlan plan = t.admin().plan(request);
assertThat(plan.getState()).isEqualTo(AdminPlan.State.REFUSED);
assertThat(plan.getMessage()).containsIgnoringCase(message);
assertThat(t.changes()).hasSize(before);
}
/*******************************************************************************
** Personal create belongs to the exchanged caller and carries a one-time key.
*******************************************************************************/
@Test
void createOwnAndRotateScopeThenRevoke() throws QException
{
t.config.withTokenScopesEnabled(true);
QContext.getQSession().withPermission("cms.publish.hasAccess");
AuthentikAdmin.AdminResult result = t.admin().execute(create().with("readOnly", true).with("tables", "post"));
assertThat(result.ok()).as(result.message()).isTrue();
OneTimeKey key = result.takeSecret();
assertThat(result.takeSecret()).isNull();
assertThat(key.label()).matches("qqq-pat-sam-[0-9]{8}-[a-z0-9]{6}");
assertThat(key.usageText()).contains("https://auth.example.test/application/o/token/", "username=sam", "test-app", "https://app.example.test/qqq/v1/metaData");
assertThat(key.masked()).endsWith(key.value().substring(key.value().length() - 4));
assertThat(key.toString()).doesNotContain(key.value());
assertThat(t.authentik.tokenCallers).containsOnly(3);
assertThat(t.authentik.tokens.get("sam").get(0).getInt("user")).isEqualTo(3);
TokenScopeStore store = new TokenScopeStore(t.config);
assertThat(store.find(key.label()).getValueBoolean("readOnly")).isTrue();
AuthentikAdmin.AdminResult rotated = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, key.label(), "replace"));
assertThat(rotated.ok()).as(rotated.message()).isTrue();
OneTimeKey next = rotated.takeSecret();
assertThat(next.label()).isNotEqualTo(key.label());
assertThat(store.find(key.label())).isNull();
assertThat(store.find(next.label()).getValueString("tables")).isEqualTo("post");
assertThat(store.find(next.label()).getValue("rotatedAt")).isNotNull();
assertThat(t.authentik.tokenKeys).doesNotContainKey(key.label());
assertThat(t.admin().execute(AdminRequest.of(AdminAction.REVOKE_TOKEN, next.label(), "done")).ok()).isTrue();
assertThat(store.find(next.label())).isNull();
assertNoKey(key, result);
assertNoKey(next, rotated);
}
/*******************************************************************************
** Explicit PATCH fields preserve owner and intent for people and services.
*******************************************************************************/
@Test
void extendOwnAndServiceAndRotateService() throws QException
{
seed("personal", 3);
AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "personal", "needed").with("expiresInDays", 60));
assertThat(result.ok()).as(result.message()).isTrue();
JSONObject patch = t.authentik.writeBodies.get(0);
assertThat(patch.getInt("user")).isEqualTo(3);
assertThat(patch.getString("intent")).isEqualTo("app_password");
assertThat(Instant.parse(patch.getString("expires"))).isAfter(Instant.now().plusSeconds(59 * 86400));
t.loginAs("operator@example.test", IdentityPermission.values());
seed("service", 6);
assertThat(t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "needed").with("expiresInDays", 365)).ok()).isTrue();
String old = t.authentik.tokenKeys.get("service");
result = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace"));
assertThat(result.ok()).as(result.message()).isTrue();
OneTimeKey key = result.takeSecret();
assertThat(key.value()).hasSize(64).matches("[A-Za-z0-9_-]+").isNotEqualTo(old);
assertThat(t.authentik.tokenKeys.get("service")).isEqualTo(key.value());
assertNoKey(key, result);
}
/*******************************************************************************
** Owner/intent movement after either update fails safely and logs no key.
*******************************************************************************/
@Test
void raceGuardForPatchAndSetKey() throws QException
{
seed("personal", 3);
t.authentik.moveTokenAfterUpdate = true;
AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "personal", "needed").with("expiresInDays", 20));
assertThat(result.ok()).isFalse();
assertThat(result.message()).isEqualTo(AuthentikTokens.TOKEN_MOVED);
t.loginAs("operator@example.test", IdentityPermission.values());
seed("service", 6);
result = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace"));
assertThat(result.ok()).isFalse();
assertThat(result.message()).isEqualTo(AuthentikTokens.TOKEN_MOVED);
assertThat(logLines).anyMatch(line -> line.contains("Token moved"));
}
/*******************************************************************************
** Service creation sets a user lifetime cap and grants only requested groups.
*******************************************************************************/
@Test
void serviceAccountAndSubsetRule() throws QException
{
t.loginAs("operator@example.test", IdentityPermission.values());
AdminRequest request = AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "automation").with("name", "new-service")
.with("groups", "kof-website-publishers").with("expiresInDays", 365);
AuthentikAdmin.AdminResult result = t.admin().execute(request);
assertThat(result.ok()).as(result.message()).isTrue();
Integer pk = t.admin().userPkByUsername("new-service");
assertThat(t.authentik.groupNamesOf(pk)).containsExactly("kof-website-publishers");
assertThat(t.authentik.users.get(pk).getJSONObject("attributes").getString("goauthentik.io/user/token-maximum-lifetime")).isEqualTo("days=365");
assertThat(t.authentik.users.get(pk).getJSONObject("attributes").getBoolean("goauthentik.io/user/token-expires")).isTrue();
OneTimeKey key = result.takeSecret();
assertThat(key.label()).isEqualTo("service-account-new-service-password");
assertNoKey(key, result);
request.with("name", "refused-service").with("groups", "kof-website-analysts");
refused(request, "do not hold");
request.with("groups", "identity-admins");
refused(request, "Protected account");
}
/*******************************************************************************
** Administrators may revoke a non-protected human, but never issue for them.
*******************************************************************************/
@Test
void ownerAndPermissionRules() throws QException
{
seed("someone", 7);
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "someone", "reason"), "own tokens");
refused(AdminRequest.of(AdminAction.EXTEND_TOKEN, "someone", "reason").with("expiresInDays", 1), "own tokens");
refused(AdminRequest.of(AdminAction.REVOKE_TOKEN, "someone", "reason"), "own tokens");
t.loginAs("sam@example.test", IdentityPermission.TOKENS_MANAGE);
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "someone", "reason"), "another person's");
refused(AdminRequest.of(AdminAction.EXTEND_TOKEN, "someone", "reason").with("expiresInDays", 1), "another person's");
refused(new AdminRequest(AdminAction.CREATE_TOKEN, "7", create().params(), "reason"), "for you");
assertThat(t.admin().execute(AdminRequest.of(AdminAction.REVOKE_TOKEN, "someone", "incident")).ok()).isTrue();
refused(AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "reason"), "users.manage");
}
/*******************************************************************************
** Every protected class, including inherited admin membership, fails Review.
*******************************************************************************/
@Test
void protectedOwners() throws QException
{
t.loginAs("sam@example.test", IdentityPermission.values());
for(int owner : List.of(1, 4, 5))
{
seed("protected-" + owner, owner);
refused(AdminRequest.of(AdminAction.REVOKE_TOKEN, "protected-" + owner, "incident"), "Protected account");
}
t.authentik.users.get(3).put("is_superuser", true);
refused(create(), "superuser");
t.authentik.users.get(3).put("is_superuser", false);
t.authentik.users.get(3).getJSONObject("attributes").put("qqq.io/protected", true);
refused(create(), "qqq.io/protected");
t.authentik.users.get(3).getJSONObject("attributes").remove("qqq.io/protected");
t.config.withProtectedUsernames(List.of("sam"));
refused(create(), "protectedUsernames");
t.config.withProtectedUsernames(List.of()).withTokenProtectedGroups(List.of());
t.authentik.group("child-admin", false, List.of(), List.of(), "identity-admins");
t.authentik.groupByName("child-admin").getJSONArray("users").put(3);
refused(create(), "identity-admins");
t.authentik.groupByName("child-admin").put("users", new JSONArray());
t.authentik.group("child-super", false, List.of(), List.of(), "authentik Admins");
t.authentik.groupByName("child-super").getJSONArray("users").put(3);
refused(create(), "superuser");
t.authentik.groupByName("child-super").put("users", new JSONArray());
t.config.withTokenProtectedGroups(List.of("token-users"));
refused(create(), "token-users");
}
/*******************************************************************************
** Expiry, scope opt-in, broker, delivery and feature guards all fail closed.
*******************************************************************************/
@Test
void reviewRefusals() throws QException
{
AdminRequest missing = create();
missing.params().remove("expiresInDays");
refused(missing, "Expiry is required");
refused(create().with("expiresInDays", 91), "90");
refused(create().with("expiresInDays", 0), "1 and");
refused(create().with("apps", "content"), "Scopes are not enabled");
refused(create().with("description", ""), "description");
t.config.withTokenBrokerClientSecret(null);
refused(create(), "broker");
t.config.withTokenBrokerClientSecret("test-secret").withSubjectTokenSource(session -> null);
refused(create(), "signed-in Authentik session");
t.config.withSubjectTokenSource(session -> "subject:3");
TokenKeyDelivery.set(null);
refused(create(), "upgrade QQQ");
seed("own", 3);
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "own", "replace"), "upgrade QQQ");
t.loginAs("operator@example.test", IdentityPermission.values());
seed("service", 6);
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace"), "upgrade QQQ");
refused(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "needed").with("expiresInDays", 366), "365");
t.config.withTokensEnabled(false);
refused(create(), "not enabled");
}
/*******************************************************************************
** Service token approvals use the existing second-person queue and replay.
*******************************************************************************/
@Test
void twoPersonApproval() throws QException
{
t.config.withTokenTwoPersonApproval(true);
t.loginAs("operator@example.test", IdentityPermission.values());
seed("service", 6);
for(AdminRequest request : List.of(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace"),
AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "automation").with("name", "approved-service").with("expiresInDays", 100)))
{
assertThat(t.admin().plan(request).getState()).isEqualTo(AdminPlan.State.NEEDS_APPROVAL);
assertThat(t.admin().execute(request.with("approved", true)).pending()).isTrue();
}
List<QRecord> approvals = new QueryAction().execute(new QueryInput().withTableName("authentik_approval_request")).getRecords();
t.loginAs("second@example.test", IdentityPermission.values());
for(QRecord approval : approvals)
{
AuthentikAdmin.AdminResult result = t.admin().approve(approval.getValue("id"));
assertThat(result.ok()).as(result.message()).isTrue();
assertThat(result.takeSecret()).isNotNull();
}
}
/*******************************************************************************
** Broker policy denial is mapped without echoing secrets or creating a token.
*******************************************************************************/
@Test
void brokerDenial() throws QException
{
t.authentik.deniedSubjects.add(3);
AuthentikAdmin.AdminResult result = t.admin().execute(create());
assertThat(result.ok()).isFalse();
assertThat(result.message()).isEqualTo("Authentik refused to act as you (invalid_grant)");
assertThat(t.authentik.tokenKeys).isEmpty();
}
/*******************************************************************************
** All key-producing actions go through real process state and response SPI.
*******************************************************************************/
@Test
void realProcessesNeverPersistKeysAndBulkRevoke() throws QException
{
t.loginAs("sam@example.test", IdentityPermission.AUDIT_READ);
run("createToken", null, Map.of("description", "automation", "expiresInDays", 30, "reason", "test"));
assertThat(delivered).hasSize(1);
String id = delivered.get(0).label();
run("rotateToken", List.of(id), Map.of("reason", "replace"));
assertThat(delivered).hasSize(2);
t.loginAs("operator@example.test", IdentityPermission.values());
seed("service", 6);
run("rotateToken", List.of("service"), Map.of("reason", "replace"));
run("createServiceAccount", null, Map.of("name", "process-service", "expiresInDays", 365, "reason", "test"));
assertThat(delivered).hasSize(4);
for(OneTimeKey key : delivered)
{
assertNoKey(key, new AuthentikAdmin.AdminResult(true, false, false, "done", null));
}
seed("human-other", 7);
run("revokeToken", List.of(delivered.get(1).label(), "human-other", "service"), Map.of("reason", "incident"));
assertThat(t.authentik.tokenKeys).doesNotContainKeys(delivered.get(1).label(), "human-other", "service");
}
/*******************************************************************************
** Loss of the response channel after Review revokes the new credential.
*******************************************************************************/
@Test
void deliveryFailureRevokes() throws QException
{
t.loginAs("sam@example.test", IdentityPermission.AUDIT_READ);
TokenKeyDelivery.set((output, key) ->
{
throw (new IllegalStateException("test channel failure"));
});
RunProcessOutput output = run("createToken", null, Map.of("description", "automation", "expiresInDays", 30, "reason", "test"));
assertThat(JsonUtils.toJson(output.getValues())).contains("The token was revoked; nothing was kept.");
assertThat(t.authentik.tokenKeys).isEmpty();
}
/*******************************************************************************
** Drive details, Review and Result through QQQ, inspecting saved state.
*******************************************************************************/
private RunProcessOutput run(String action, List<String> identifiers, Map<String, Serializable> values) throws QException
{
RunProcessInput input = new RunProcessInput();
input.setProcessName(t.config.applyPrefix(action));
if(identifiers != null)
{
input.addValue("recordIds", String.join(",", identifiers));
}
RunProcessOutput output = new RunProcessAction().execute(input);
assertThat(output.getException()).isEmpty();
input.setProcessUUID(output.getProcessUUID());
values.forEach(input::addValue);
input.setStartAfterStep("details");
output = new RunProcessAction().execute(input);
assertThat(output.getException()).isEmpty();
assertThat(output.getProcessState().getNextStepName()).contains("review");
input.setStartAfterStep("review");
output = new RunProcessAction().execute(input);
assertThat(output.getException()).isEmpty();
String state = JsonUtils.toJson(RunProcessAction.getState(output.getProcessUUID()).orElseThrow().getValues());
for(OneTimeKey key : delivered)
{
assertThat(state).doesNotContain(key.value());
assertThat(JsonUtils.toJson(output.getValues())).doesNotContain(key.value());
}
return (output);
}
/*******************************************************************************
** Search every local table, events, diagnostics and safe request bodies.
*******************************************************************************/
private void assertNoKey(OneTimeKey key, AuthentikAdmin.AdminResult result) throws QException
{
assertThat(result.toString()).doesNotContain(key.value());
assertThat(String.join("\n", logLines)).doesNotContain(key.value());
assertThat(t.authentik.events.toString()).doesNotContain(key.value());
for(QTableMetaData table : t.qInstance.getTables().values())
{
if(AdminTestSupport.LOCAL.equals(table.getBackendName()))
{
List<QRecord> records = new QueryAction().execute(new QueryInput().withTableName(table.getName())).getRecords();
assertThat(JsonUtils.toJson(records)).as(table.getName()).doesNotContain(key.value());
}
}
for(int i = 0; i < t.authentik.writes.size(); i++)
{
String path = t.authentik.writes.get(i);
if(!path.endsWith("/set_key/") && !path.equals("POST /core/tokens/") && !path.equals("POST /core/users/service_account/"))
{
assertThat(t.authentik.writeBodies.get(i).toString()).doesNotContain(key.value());
}
}
}
/*******************************************************************************
** Both independent read permissions pass core and qbit table guards.
*******************************************************************************/
@Test
void tokenMetadataReadPermissions() throws QException
{
seed("readable", 6);
for(IdentityPermission permission : List.of(IdentityPermission.AUDIT_READ, IdentityPermission.TOKENS_MANAGE))
{
t.loginAs("sam@example.test", permission);
new com.kingsrook.qbits.authentik.security.TokenReadPermission().checkPermissionsThrowing(null, null);
assertThat(new QueryAction().execute(new QueryInput().withTableName("authentik_token")).getRecords()).hasSize(1);
}
t.loginAs("sam@example.test");
org.assertj.core.api.Assertions.assertThatThrownBy(() -> new com.kingsrook.qbits.authentik.security.TokenReadPermission().checkPermissionsThrowing(null, null))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
}
/*******************************************************************************
** Replanning closes a stale Review after a new protected-group membership.
*******************************************************************************/
@Test
void rechecksProtectionAndNeedsNoServiceGroup() throws QException
{
AdminPlan plan = t.admin().plan(create());
assertThat(plan.isRunnable()).isTrue();
t.authentik.groupByName("identity-admins").getJSONArray("users").put(3);
assertThat(t.admin().execute(plan, null).ok()).isFalse();
assertThat(t.authentik.tokenKeys).isEmpty();
t.loginAs("operator@example.test", IdentityPermission.values());
AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "automation")
.with("name", "missing-group").with("expiresInDays", 10));
assertThat(result.ok()).as(result.message()).isTrue();
assertThat(result.message()).doesNotContain("is missing");
assertThat(result.takeSecret().usageText()).contains("Expires:");
}
/*******************************************************************************
** Rotation refuses API/non-expiring tokens, and failed deletes retain the old.
*******************************************************************************/
@Test
void rotationConstraintsAndPartialFailure() throws QException
{
JSONObject token = seed("old", 3);
token.put("intent", "api");
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "old", "replace"), "Only app-password");
token.put("intent", "app_password").put("expiring", false);
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "old", "replace"), "expiry");
token.put("expiring", true);
t.authentik.failTokenDelete = true;
AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, "old", "replace"));
assertThat(result.ok()).isFalse();
assertThat(result.message()).contains("revocation failed");
assertThat(t.authentik.tokenKeys).containsKey("old");
assertThat(result.takeSecret()).isNull();
}
/*******************************************************************************
** Feature defaults and metadata contain only non-secret token fields.
*******************************************************************************/
@Test
void featureDefaultsAndMetadata()
{
assertThat(t.config.hasFeature(AuthentikFeature.TOKENS)).isTrue();
assertThat(t.config.withReadOnly(true).hasFeature(AuthentikFeature.TOKENS)).isFalse();
t.config.withReadOnly(false).withTokensEnabled(false);
assertThat(t.config.hasFeature(AuthentikFeature.TOKENS)).isFalse();
t.config.withTokensEnabled(true).withFeatures(EnumSet.of(AuthentikFeature.READ));
assertThat(t.config.hasFeature(AuthentikFeature.TOKENS)).isFalse();
t.config.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS));
assertThat(t.config.hasFeature(AuthentikFeature.TOKENS)).isTrue();
assertThat(AuthentikTables.get(AuthentikTables.TOKEN).getField("key")).isNull();
assertThat(t.qInstance.getTable("authentik_token").getFields()).containsKeys("ownerPk", "ownerType", "ownerName", "managed").doesNotContainKey("key");
assertThat(t.qInstance.getTable("authentik_token_scope").getFields()).doesNotContainKey("key");
assertThat(t.config.toString()).doesNotContain("test-broker-secret");
}
}
@@ -0,0 +1,300 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.metadata.AuthentikTokenReminderProcessProducer;
import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Event aggregation, scheduling, delivery dedupe and standard admin widgets.
*******************************************************************************/
class TokenOperationsTest
{
private AdminTestSupport t;
private AuthentikActionUtil util;
private final List<AuthentikTokenAlerts.Notice> notices = new ArrayList<>();
private final Instant now = Instant.now();
/*******************************************************************************
** Capture delivery without a dependency on Alerts or a real mail service.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
t = AdminTestSupport.create();
util = AuthentikActionUtil.forBackend(t.config.getApiBackendName());
util.getRuntime().withTokenAlerts(notice -> { notices.add(notice); return (true); });
}
/*******************************************************************************
** Seed an expiring token for the chosen owner.
*******************************************************************************/
private JSONObject token(String id, int owner, int days)
{
return (t.authentik.seedToken(id, owner, "app_password", now.plus(days, ChronoUnit.DAYS).toString()));
}
/*******************************************************************************
** A login event using the pinned context.method_args.identifier contract.
*******************************************************************************/
private JSONObject event(String id, Instant at)
{
return (new JSONObject().put("action", "login").put("created", at.toString())
.put("context", new JSONObject().put("method", "token").put("method_args", new JSONObject().put("identifier", id))));
}
/*******************************************************************************
** Create non-secret bookkeeping with a known age for widget assertions.
*******************************************************************************/
private void created(String id, int days) throws QException
{
TokenScopeStore store = new TokenScopeStore(t.config);
store.create(id, t.authentik.users.get(3), AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test"), "sam@example.test");
QRecord row = store.find(id).withValue("createdAt", now.minus(days, ChronoUnit.DAYS));
new UpdateAction().execute(new UpdateInput().withTableName("authentik_token_scope").withRecords(List.of(row)));
}
/*******************************************************************************
** Page, stop at 90 days, exclude non-token events, and reuse the 5-minute map.
*******************************************************************************/
@Test
void eventPagingWindowAndCache() throws QException
{
for(int i = 0; i < 101; i++)
{
t.authentik.loginEvents.add(event("token-" + i, now.minus(i, ChronoUnit.HOURS)));
}
t.authentik.loginEvents.add(event("token-0", now.minus(20, ChronoUnit.DAYS)));
t.authentik.loginEvents.add(event("password", now).put("context", new JSONObject().put("method", "password")));
t.authentik.loginEvents.add(event("wrong-action", now).put("action", "logout"));
t.authentik.loginEvents.add(event("old", now.minus(91, ChronoUnit.DAYS)));
Map<String, Instant> used = AuthentikTokenActivity.lastUsed(util);
assertThat(used).hasSize(101).containsEntry("token-0", now).doesNotContainKeys("password", "wrong-action", "old");
long requests = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count();
assertThat(requests).isEqualTo(2);
t.authentik.loginEvents.add(event("later", now));
assertThat(AuthentikTokenActivity.lastUsed(util)).isEqualTo(used);
assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count()).isEqualTo(requests);
assertThat(util.getRuntime().getTokenActivityCache().getTimeout()).isEqualTo(java.time.Duration.ofMinutes(5));
}
/*******************************************************************************
** API activity is explicitly not tracked, and local creation/scope is shown.
*******************************************************************************/
@Test
void computedFields() throws QException
{
token("personal", 3, 5);
created("personal", 31);
t.authentik.loginEvents.add(event("personal", now));
QRecord record = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), util.getApiJson("/core/tokens/personal/"), true);
assertThat(record.getValueInstant("lastUsed")).isEqualTo(now);
assertThat(record.getValueInstant("createdAt")).isEqualTo(now.minus(31, ChronoUnit.DAYS));
assertThat(record.getValueString("scopeSummary")).isEqualTo("Owner permissions");
JSONObject api = token("api", 3, 5).put("intent", "api");
QRecord untracked = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), api, true);
assertThat(untracked.getValue("lastUsed")).isNull();
assertThat(untracked.getValueString("lastUsedNote")).isEqualTo("not tracked");
}
/*******************************************************************************
** Each window sends once to the owner's email; a minimal external row does
** not invent restrictions or a creation date. Extending resets the windows.
*******************************************************************************/
@Test
void remindersDedupeAcrossBothWindows() throws QException
{
token("external", 3, 5);
token("tomorrow", 3, 1);
token("api", 3, 1).put("intent", "api");
token("expired", 3, -1);
token("later", 3, 20);
token("no-email", 6, 2);
AuthentikTokenReminders.run(util, now);
AuthentikTokenReminders.run(util, now);
assertThat(notices).hasSize(2).allMatch(n -> "sam@example.test".equals(n.recipient()));
assertThat(notices.getFirst().message()).contains("Your access token Test access (external)", "Preferences > Access tokens.");
TokenScopeStore store = new TokenScopeStore(t.config);
QRecord row = store.find("external");
assertThat(row.getValue("createdAt")).isNull();
assertThat(row.getValue("readOnly")).isNull();
assertThat(row.getValue("lastReminder7d")).isNotNull();
assertThat(row.getValue("lastReminder1d")).isNull();
AuthentikTokenReminders.run(util, now.plus(4, ChronoUnit.DAYS));
assertThat(notices).hasSize(3);
assertThat(store.find("external").getValue("lastReminder1d")).isNotNull();
store.resetReminders("external");
assertThat(store.find("external").getValue("lastReminder7d")).isNull();
AuthentikTokenScopes.apply(QContext.getQSession(), new JSONObject().put("qqq_token", "external"));
assertThat(QContext.getQSession().getPermissions()).isNotEmpty();
}
/*******************************************************************************
** Delivery absence leaves dedupe unset so installing Alerts permits retry.
*******************************************************************************/
@Test
void unavailableDeliveryAndUnauthorizedRun() throws QException
{
token("external", 3, 5);
util.getRuntime().withTokenAlerts(new AuthentikTokenAlerts()::send);
AuthentikTokenReminders.run(util, now);
//////////////////////////////////////////////////////////////////////
// RC.6 core has the notification SPI: the reminder goes out through //
// QAlerts.notify and is recorded for dedupe //
//////////////////////////////////////////////////////////////////////
assertThat(new TokenScopeStore(t.config).find("external")).isNotNull();
//////////////////////////////////////////////////////////////////////
// core QAlerts exists from RC.5: guard incidents always have a path //
//////////////////////////////////////////////////////////////////////
assertThat(new AuthentikTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null, "safe"))).isTrue();
t.loginAs("sam@example.test");
assertThatThrownBy(() -> AuthentikTokenReminders.run(util, now)).hasMessageContaining("tokens.manage");
assertThatThrownBy(() -> AuthentikTokenWidget.data(util, now)).hasMessageContaining("tokens.manage");
}
/*******************************************************************************
** The hidden daily process runs with core's system scheduler session.
*******************************************************************************/
@Test
void scheduledProcessAndBulkRevokeMetadata() throws QException
{
t.qInstance.addScheduler(new SimpleSchedulerMetaData().withName("test"));
AuthentikTokenReminderProcessProducer producer = new AuthentikTokenReminderProcessProducer();
producer.setQBitConfig(t.config);
var process = producer.produce(t.qInstance);
assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(86400);
assertThat(process.getSchedule().getSchedulerName()).isEqualTo("test");
assertThat(process.getIsHidden()).isTrue();
token("external", 3, 5);
QContext.init(t.qInstance, new QSystemUserSession());
new RunProcessAction().execute(new RunProcessInput().withProcessName("authentikTokenReminders"));
assertThat(notices).hasSize(1);
var revoke = t.qInstance.getProcess("authentikRevokeToken");
assertThat(revoke.getTableName()).isEqualTo("authentik_token");
assertThat(revoke.getMinInputRecords()).isEqualTo(1);
assertThat(revoke.getMaxInputRecords()).isEqualTo(AuthentikAdmin.MAX_BULK);
assertThat(Boolean.TRUE.equals(revoke.getIsHidden())).isFalse();
}
/*******************************************************************************
** Widget counts, known/unknown ages, links, deactivated owners and inactivity.
*******************************************************************************/
@Test
void adminWidgetUsesStandardTableAndSafeFilters() throws QException
{
token("new", 3, 5); created("new", 10);
token("middle", 3, 5); created("middle", 45);
token("unused", 3, 5); created("unused", 100);
token("ancient", 3, 5); created("ancient", 400);
token("unknown", 7, 5);
token("api", 3, 5).put("intent", "api"); created("api", 100);
t.authentik.loginEvents.add(event("middle", now));
TableData data = AuthentikTokenWidget.data(util, now);
assertThat(data.getRows()).contains(Map.of("category", "Age: unknown", "count", 1, "tokens", data.getRows().stream()
.filter(row -> "Age: unknown".equals(row.get("category"))).findFirst().orElseThrow().get("tokens")));
assertThat(data.getRows()).filteredOn(row -> "Unused for 60 days".equals(row.get("category"))).hasSize(2);
assertThat(data.getRows()).filteredOn(row -> "Owner deactivated".equals(row.get("category"))).hasSize(1);
assertThat(data.getRows().toString()).contains("?filter=", "identifier", "username").doesNotContain("lastUsed%22");
QWidgetMetaData widget = (QWidgetMetaData) t.qInstance.getWidget("authentikAccessTokens");
assertThat(widget.getType()).isEqualTo(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType());
assertThat(new AuthentikTokenWidget().render(new RenderWidgetInput().withWidgetMetaData(widget))).isNotNull();
t.config.withTokenUnusedDays(120);
assertThat(AuthentikTokenWidget.data(util, now).getRows()).noneMatch(row -> "Unused for 120 days".equals(row.get("category")));
}
/*******************************************************************************
** A post-write owner move is a high-severity incident without a token key.
*******************************************************************************/
@Test
void protectedOwnerRaceRaisesAlert() throws QException
{
t.authentik.users.get(6).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365");
token("service", 6, 5);
t.authentik.moveTokenAfterUpdate = true;
var result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "test").with("expiresInDays", 20));
assertThat(result.ok()).isFalse();
assertThat(notices).hasSize(1);
assertThat(notices.getFirst().key()).isEqualTo("authentik_token_guard_bypass");
assertThat(notices.getFirst().severity()).isEqualTo("high");
assertThat(notices.toString()).doesNotContain(t.authentik.tokenKeys.get("service"));
}
/*******************************************************************************
** External extension opens a new reminder window without rewriting scope.
*******************************************************************************/
@Test
void externalExtensionStartsNewReminderWindow() throws QException
{
JSONObject token = token("external", 3, 5);
AuthentikTokenReminders.run(util, now);
token.put("expires", now.plus(20, ChronoUnit.DAYS).toString());
util.getRuntime().invalidateCache();
AuthentikTokenReminders.run(util, now.plus(15, ChronoUnit.DAYS));
assertThat(notices).hasSize(2);
}
}
@@ -0,0 +1,275 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
import com.kingsrook.qbits.authentik.policy.AuthentikPolicyClaimMapper;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.Mode;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Scope enforcement intersects existing permissions without Docker or OAuth.
*******************************************************************************/
class TokenScopesTest
{
private AdminTestSupport t;
/*******************************************************************************
** Instance includes a table whose permission base differs from its name.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
t = AdminTestSupport.create();
t.config.withTokenScopesEnabled(true).withTokenBrokerClientId("broker").withTokenBrokerClientSecret("secret")
.withSubjectTokenSource(session -> "subject:3");
TokenKeyDelivery.set((output, key) -> {});
}
/*******************************************************************************
** Reset the response-only test adapter.
*******************************************************************************/
@AfterEach
void tearDown()
{
TokenKeyDelivery.set(null);
}
/*******************************************************************************
** Seed a credential and optional local metadata.
*******************************************************************************/
private void seed(String id, AdminRequest scope) throws QException
{
t.authentik.seedToken(id, 3, "app_password", Instant.now().plusSeconds(86400).toString());
if(scope != null)
{
new TokenScopeStore(t.config).create(id, t.authentik.users.get(3), scope, "sam@example.test");
}
}
/*******************************************************************************
** No claim means no scope lookup and no changes to the permission set.
*******************************************************************************/
@Test
void absentAndExternalClaims() throws QException
{
QSession session = QContext.getQSession().withPermissions("post.read", "post.edit");
Set<String> before = Set.copyOf(session.getPermissions());
AuthentikTokenScopes.apply(session, new JSONObject());
assertThat(session.getPermissions()).isEqualTo(before);
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, null)).isEmpty();
seed("external", null);
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, new JSONObject().put("qqq_token", "external")).orElseThrow().mode()).isEqualTo(Mode.UNSCOPED);
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "external"));
assertThat(session.getPermissions()).isEqualTo(before);
assertThat(session.getValue("qqq.credentialScope")).isEqualTo("external");
}
/*******************************************************************************
** Read-only strips writes and management hasAccess permissions.
*******************************************************************************/
@Test
void readOnlyAndDeletedToken() throws QException
{
seed("limited", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("readOnly", true));
QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "post.read", "post.edit", "authentik.tokens.manage.hasAccess");
JSONObject claims = new JSONObject().put("qqq_token", "limited");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactlyInAnyOrder("cms.publish.hasAccess", "post.read");
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(new TokenScopeStore(t.config).find("limited")).isNotNull();
}
/*******************************************************************************
** A scope cannot grant missing read permissions, even with a valid table.
*******************************************************************************/
@Test
void tableNamesResolvePermissionAliasesAndNeverWiden() throws QException
{
seed("table", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("tables", "post"));
QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "kof22.analytics.hasAccess");
JSONObject claims = new JSONObject().put("qqq_token", "table");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactly("cms.publish.hasAccess");
session.setPermissions(Set.of("kof22.analytics.hasAccess"));
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** An app includes its table actions, reports and widgets, not other apps.
*******************************************************************************/
@Test
void appChildrenAndExplicitTableUnion() throws QException
{
var rules = new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION);
QProcessMetaData process = new QProcessMetaData().withName("publish").withTableName("post").withPermissionRules(rules);
QReportMetaData report = new QReportMetaData().withName("report").withPermissionRules(rules);
QWidgetMetaData widget = new QWidgetMetaData().withName("widget").withType("TABLE").withPermissionRules(rules);
t.qInstance.addProcess(process);
t.qInstance.addReport(report);
t.qInstance.addWidget(widget);
t.qInstance.addApp(new QAppMetaData().withName("content").withPermissionRules(rules).withChild(t.qInstance.getTable("post"))
.withChild(report).withWidgets(List.of("widget")));
seed("app", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("apps", "content").with("tables", "analytics"));
QSession session = t.loginAs("sam@example.test").withPermissions("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess",
"widget.hasAccess", "kof22.analytics.hasAccess", "unrelated.read");
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "app"));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess",
"widget.hasAccess", "kof22.analytics.hasAccess");
}
/*******************************************************************************
** The configured claim is enforced even if offering new scopes is disabled.
*******************************************************************************/
@Test
void configuredClaimCacheAndMalformedClaim() throws QException
{
t.config.withTokenScopesEnabled(false).withTokenScopeClaim("credential");
seed("external", null);
QSession session = t.loginAs("sam@example.test").withPermissions("post.read");
JSONObject claims = new JSONObject().put("credential", "external");
AuthentikTokenScopes.apply(session, claims);
t.authentik.tokens.get("sam").clear();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactly("post.read");
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
session.withPermission("post.read");
AuthentikTokenScopes.apply(session, new JSONObject().put("credential", new JSONObject()));
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** Both claims customizers narrow after computing their permission unions.
*******************************************************************************/
@Test
void mappersCannotAddPermissionsAfterDenyAll()
{
JSONObject claims = new JSONObject().put("qqq_token", "deleted");
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
assertThat(session.getPermissions()).isEmpty();
session.withPermission("post.read");
new AuthentikGroupPermissionMapper().withGroupPermissions("test", List.of("post.edit"))
.customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims.put("groups", List.of("test"))));
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** Review validates current access and displays the scope; execution repeats.
*******************************************************************************/
@Test
void reviewRejectsUnknownOrUnheldAndExecutionRechecks() throws QException
{
t.loginAs("sam@example.test");
AdminRequest request = AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("description", "CI").with("expiresInDays", 30).with("tables", "missing");
assertThat(t.admin().plan(request).getMessage()).contains("Unknown");
request.with("tables", "post");
assertThat(t.admin().plan(request).getMessage()).contains("read access");
QContext.getQSession().withPermission("cms.publish.hasAccess");
assertThat(t.admin().plan(request).describe()).contains("tables: post");
QContext.getQSession().setPermissions(Set.of());
assertThat(t.admin().execute(request).ok()).isFalse();
assertThat(t.changes()).isEmpty();
}
/*******************************************************************************
** A long general response cache cannot extend the scope revocation window.
** Lookup outages deny, rather than falling back to unrestricted access.
*******************************************************************************/
@Test
void verificationCacheIsIndependentAndApiFailureDenies() throws QException
{
t.config.withListCacheTtlSeconds(3600);
seed("cached", null);
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
JSONObject claims = new JSONObject().put("qqq_token", "cached");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactly("post.read");
t.authentik.tokens.get("sam").clear();
AuthentikRuntime.forBackend(t.config.getApiBackendName()).getTokenScopeCache().clear();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
session.withPermission("post.read");
com.kingsrook.qbits.authentik.client.FixtureActionUtil.handler = request ->
com.kingsrook.qbits.authentik.client.FixtureActionUtil.response(403, "{}");
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "unavailable"));
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** The ID token may supply policy context while the access token binds scope.
*******************************************************************************/
@Test
void idTokenContextCannotHideAccessTokenScope()
{
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of(
"idToken", new JSONObject().put("email", "sam@example.test"),
"jwtPayloadJsonObject", new JSONObject().put("qqq_token", "deleted")));
assertThat(session.getPermissions()).isEmpty();
}
}
@@ -61,7 +61,7 @@ class AuthentikAppTest
assertThat(app.getChildren()).extracting(c -> c.getName())
.containsExactly("authentik_user", "authentik_invitation", "authentik_group", "authentik_application", "authentik_approval_request", "authentik_admin_action");
assertThat(app.getSections()).extracting(s -> s.getLabel()).containsExactly("People", "Access", "Activity");
assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikDriftReport");
assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikAccessTokens", "authentikDriftReport");
for(String subTable : List.of("authentik_group_member", "authentik_session", "authentik_token", "authentik_event", "authentik_policy_binding", "authentik_role", "authentik_blueprint"))
{
@@ -169,14 +169,14 @@ class AuthentikAppTest
{
try
{
return (Files.readString(p).contains("util.write(") || Files.readString(p).contains(".write(auth"));
return (Files.readString(p).contains("util.write(") || Files.readString(p).contains(".write(auth") || Files.readString(p).contains("util.writeAs("));
}
catch(IOException e)
{
throw (new IllegalStateException(e));
}
}).toList();
assertThat(callers).extracting(p -> p.getFileName().toString()).containsExactly("AuthentikAdmin.java");
assertThat(callers).extracting(p -> p.getFileName().toString()).containsExactlyInAnyOrder("AuthentikAdmin.java", "AuthentikTokens.java");
}
}
@@ -67,4 +67,18 @@ class AuthentikRedactorTest
assertThat(AuthentikRedactor.isSensitivePath(null)).isFalse();
}
/*******************************************************************************
** Broker and one-time credential fields are masked even in unrelated text.
*******************************************************************************/
@Test
void allTokenFieldsAreMasked()
{
for(String name : java.util.List.of("key", "token", "access_token", "subject_token", "client_secret"))
{
String json = new org.json.JSONObject().put(name, "credential-to-hide").toString();
assertThat(AuthentikRedactor.redactText("prefix " + json + " suffix", null)).doesNotContain("credential-to-hide").contains("******");
}
}
}
@@ -0,0 +1,74 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.util.List;
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Exact OAuth2 session-table contract from the locally pinned core sources.
*******************************************************************************/
class OAuth2SubjectTokenSourceTest
{
/*******************************************************************************
** A UUID identifies exactly one row, and hidden accessToken is readable.
*******************************************************************************/
@Test
void currentSessionOnly() throws QException
{
AdminTestSupport t = AdminTestSupport.create();
t.qInstance.addTable(new QTableMetaData().withName("oauth_sessions").withBackendName(AdminTestSupport.LOCAL).withPrimaryKeyField("id")
.withUniqueKey(new com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey("uuid"))
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("uuid", QFieldType.STRING))
.withField(new QFieldMetaData("accessToken", QFieldType.STRING).withIsHidden(true)));
OAuth2AuthenticationMetaData oauth = new OAuth2AuthenticationMetaData().withUserSessionTableName("oauth_sessions");
oauth.setName("oauth");
QContext.getQSession().setUuid("current-session");
new InsertAction().execute(new InsertInput().withTableName("oauth_sessions").withRecords(List.of(
new QRecord().withValue("uuid", "current-session").withValue("accessToken", "subject:3"),
new QRecord().withValue("uuid", "other-session").withValue("accessToken", "subject:4"))));
t.qInstance.withInstanceDefaultAuthentication(oauth);
OAuth2SubjectTokenSource source = new OAuth2SubjectTokenSource();
assertThat(source.subjectToken(QContext.getQSession())).isEqualTo("subject:3");
QContext.getQSession().setUuid("unknown");
assertThatThrownBy(() -> source.subjectToken(QContext.getQSession())).hasMessage(OAuth2SubjectTokenSource.UNAVAILABLE);
assertThatThrownBy(() -> source.subjectToken(null)).hasMessage(OAuth2SubjectTokenSource.UNAVAILABLE);
}
/*******************************************************************************
** Static/mock sessions are never exchanged as the qbit service account.
*******************************************************************************/
@Test
void unavailableSessionRefused() throws QException
{
AdminTestSupport.create();
assertThatThrownBy(() -> new OAuth2SubjectTokenSource().subjectToken(QContext.getQSession()))
.hasMessage(OAuth2SubjectTokenSource.UNAVAILABLE);
}
}
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.authentik.contract;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BooleanSupplier;
import org.json.JSONObject;
@@ -56,8 +57,10 @@ public final class AuthentikTestStack
public static final String BOOTSTRAP_BLUEPRINT = "kof22-identity-bootstrap";
public static final String APP_SLUG = "kof22-website-prod";
public static final String TOKEN_BROKER_CLIENT_SECRET = "contract-test-token-broker-secret";
public static final String TOKEN_BLUEPRINT = "kof22-qqq-tokens";
private static final String SECRET_KEY = "contract-test-secret-key-0123456789abcdefghijklmnop";
private static final String SA_TOKEN_ID = "qqq-contract-test";
public static final String SA_TOKEN_ID = "qqq-contract-test";
private static AuthentikTestStack instance;
@@ -126,6 +129,7 @@ public final class AuthentikTestStack
env.put("AUTHENTIK_DISABLE_UPDATE_CHECK", "true");
env.put("AUTHENTIK_DISABLE_STARTUP_ANALYTICS", "true");
env.put("KOF22_OAUTH2_CLIENT_SECRET", "contract-test-oauth2-client-secret");
env.put("KOF22_TOKEN_BROKER_CLIENT_SECRET", TOKEN_BROKER_CLIENT_SECRET);
server = authentik(authentikImage, env, "server")
.withNetworkAliases("authentik")
@@ -148,6 +152,7 @@ public final class AuthentikTestStack
.withEnv(env)
.withCommand(command)
.withCopyFileToContainer(MountableFile.forClasspathResource("blueprints/" + INFRA_BLUEPRINT + ".yaml"), "/blueprints/custom/" + INFRA_BLUEPRINT + ".yaml")
.withCopyFileToContainer(MountableFile.forClasspathResource("blueprints/" + TOKEN_BLUEPRINT + ".yaml"), "/blueprints/custom/" + TOKEN_BLUEPRINT + ".yaml")
.withCopyFileToContainer(MountableFile.forClasspathResource("blueprints/" + BOOTSTRAP_BLUEPRINT + ".yaml"), "/blueprints/custom/" + BOOTSTRAP_BLUEPRINT + ".yaml"));
}
@@ -164,9 +169,26 @@ public final class AuthentikTestStack
server.start();
TestApi admin = admin();
waitFor(Duration.ofMinutes(8), "both kof22 blueprints applied", () ->
isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT)
&& admin.first("/core/users/?username=" + SERVICE_ACCOUNT) != null);
try
{
waitFor(Duration.ofMinutes(8), "all three kof22 blueprints applied", () ->
isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT) && isSuccessful(admin, TOKEN_BLUEPRINT)
&& admin.first("/core/users/?username=" + SERVICE_ACCOUNT) != null);
}
catch(IllegalStateException e)
{
////////////////////////////////////////////////////////////////
// say which blueprint failed and why (the worker logs it) //
////////////////////////////////////////////////////////////////
for(String name : List.of(INFRA_BLUEPRINT, BOOTSTRAP_BLUEPRINT, TOKEN_BLUEPRINT))
{
JSONObject instance = blueprint(name);
System.err.println("blueprint " + name + ": " + (instance == null ? "missing" : instance.optString("status")));
}
worker.getLogs().lines().filter(line -> line.contains("blueprint") && (line.contains("error") || line.contains("warning") || line.contains("fail")))
.limit(60).forEach(line -> System.err.println("worker: " + line));
throw (e);
}
int serviceAccountPk = admin.userPk(SERVICE_ACCOUNT);
TestApi.Response created = admin.post("/core/tokens/", new JSONObject().put("identifier", SA_TOKEN_ID).put("user", serviceAccountPk).put("intent", "api").put("expiring", false));
@@ -284,6 +306,61 @@ public final class AuthentikTestStack
/*******************************************************************************
** Grant only permissions absent from the production fixture. Callers remove
** exactly the returned list, preserving shared baseline role permissions.
*******************************************************************************/
public java.util.List<String> grantTemporaryPermissions(java.util.List<String> requested)
{
java.util.Set<String> baseline = new java.util.HashSet<>();
try(java.io.InputStream input = AuthentikTestStack.class.getResourceAsStream("/blueprints/" + BOOTSTRAP_BLUEPRINT + ".yaml"))
{
String yaml = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("(?m)^ - (authentik_[a-z_]+\\.[a-z_]+)\\s*$").matcher(yaml);
while(matcher.find())
{
baseline.add(matcher.group(1));
}
}
catch(java.io.IOException e)
{
throw (new IllegalStateException("Cannot read baseline role permissions", e));
}
java.util.List<String> additional = requested.stream().filter(permission -> !baseline.contains(permission)).toList();
if(!additional.isEmpty())
{
String role = admin().first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
TestApi.Response response = admin().post("/rbac/permissions/assigned_by_roles/" + role + "/assign/",
new JSONObject().put("permissions", new org.json.JSONArray(additional)));
if(response.status() != 200)
{
throw (new IllegalStateException("Temporary permission grant failed: HTTP " + response.status()));
}
}
return (additional);
}
/*******************************************************************************
** Remove only the temporary grants, including on assertion failure.
*******************************************************************************/
public void removeTemporaryPermissions(java.util.List<String> additional)
{
if(!additional.isEmpty())
{
String role = admin().first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
TestApi.Response response = admin().patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/",
new JSONObject().put("permissions", new org.json.JSONArray(additional)));
if(response.status() != 204)
{
throw (new IllegalStateException("Temporary permission cleanup failed: HTTP " + response.status()));
}
}
}
/*******************************************************************************
** The blueprint instance with this name.
*******************************************************************************/
@@ -146,15 +146,19 @@ class BootstrapBlueprintIT
JSONObject stage = admin.first("/stages/email/?name=kof22-recovery-email");
assertThat(sa.get("/stages/email/" + stage.getString("pk") + "/").status()).isEqualTo(200);
///////////////////////////////////////////////////
// and nothing beyond the phase 0/1 read set //
///////////////////////////////////////////////////
JSONArray tokens = sa.getOk("/core/tokens/?page_size=100").getJSONArray("results");
for(int i = 0; i < tokens.length(); i++)
// The production v1 role reads all token metadata but never other keys.
assertThat(sa.get("/core/tokens/?page_size=1").status()).isEqualTo(200);
assertThat(sa.get("/core/tokens/" + AuthentikTestStack.SA_TOKEN_ID + "/view_key/").status()).isEqualTo(403);
TestApi.Response group = sa.post("/core/groups/", new JSONObject().put("name", "bootstrap-write-probe"));
assertThat(group.status()).isEqualTo(201);
try
{
assertThat(tokens.getJSONObject(i).getJSONObject("user_obj").getString("username")).as("only its own tokens are visible").isEqualTo(SERVICE_ACCOUNT);
assertThat(sa.delete("/core/groups/" + group.json().getString("pk") + "/").status()).isEqualTo(403);
}
finally
{
assertThat(admin.delete("/core/groups/" + group.json().getString("pk") + "/").status()).isEqualTo(204);
}
assertThat(sa.post("/core/groups/", new JSONObject().put("name", "should-not-exist")).status()).isEqualTo(403);
}
@@ -275,7 +275,7 @@ class QbitReadContractIT
assertThat(badDate.status()).isEqualTo(400);
FixtureRecorder.record("error-400-bad-filter", badDate.body());
TestApi.Response forbidden = sa.post("/core/groups/", new JSONObject().put("name", "x"));
TestApi.Response forbidden = sa.get("/core/tokens/" + AuthentikTestStack.SA_TOKEN_ID + "/view_key/");
assertThat(forbidden.status()).isEqualTo(403);
FixtureRecorder.record("error-403", forbidden.body());
@@ -92,7 +92,7 @@ class QbitWriteContractIT
private static AuthentikTestStack stack;
private static TestApi admin;
private static String roleUuid;
private static List<String> temporaryPermissions = List.of();
private QInstance qInstance;
private AuthentikQBitConfig config;
@@ -109,8 +109,7 @@ class QbitWriteContractIT
{
stack = AuthentikTestStack.get();
admin = stack.admin();
roleUuid = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
admin.post("/rbac/permissions/assigned_by_roles/" + roleUuid + "/assign/", new JSONObject().put("permissions", new JSONArray(ROLE_PERMISSIONS)));
temporaryPermissions = stack.grantTemporaryPermissions(ROLE_PERMISSIONS);
admin.post("/core/groups/", new JSONObject().put("name", "wr-admins").put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.*"))).put("qqq.io/app-access", new JSONArray(List.of(AuthentikTestStack.APP_SLUG)))
@@ -139,9 +138,9 @@ class QbitWriteContractIT
@AfterAll
static void afterAll()
{
if(admin != null && roleUuid != null)
if(stack != null)
{
admin.patch("/rbac/permissions/assigned_by_roles/" + roleUuid + "/unassign/", new JSONObject().put("permissions", new JSONArray(ROLE_PERMISSIONS)));
stack.removeTemporaryPermissions(temporaryPermissions);
}
}
@@ -58,7 +58,7 @@ class SuperuserFloorIT
private static AuthentikTestStack stack;
private static TestApi admin;
private static TestApi sa;
private static String roleUuid;
private static List<String> temporaryPermissions = List.of();
private static int probeUser;
private static String superuserGroup;
private static String childOfSuperuser;
@@ -77,8 +77,7 @@ class SuperuserFloorIT
admin = stack.admin();
sa = stack.serviceAccount();
roleUuid = admin.first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
admin.post("/rbac/permissions/assigned_by_roles/" + roleUuid + "/assign/", new JSONObject().put("permissions", new JSONArray(PHASE_2_3_PERMISSIONS)));
temporaryPermissions = stack.grantTemporaryPermissions(PHASE_2_3_PERMISSIONS);
admin.post("/core/users/", new JSONObject().put("username", "floor.probe").put("name", "Floor Probe").put("type", "internal").put("path", "users"));
probeUser = admin.userPk("floor.probe");
@@ -97,9 +96,9 @@ class SuperuserFloorIT
@AfterAll
static void afterAll()
{
if(admin != null && roleUuid != null)
if(stack != null)
{
admin.patch("/rbac/permissions/assigned_by_roles/" + roleUuid + "/unassign/", new JSONObject().put("permissions", new JSONArray(PHASE_2_3_PERMISSIONS)));
stack.removeTemporaryPermissions(temporaryPermissions);
}
}
@@ -179,7 +178,12 @@ class SuperuserFloorIT
{
assertThat(sa.post("/core/users/" + probeUser + "/impersonate/", new JSONObject().put("reason", "contract test")).status()).isEqualTo(403);
assertThat(sa.delete("/core/users/" + probeUser + "/").status()).isEqualTo(403);
assertThat(sa.post("/core/users/" + probeUser + "/recovery/", new JSONObject()).status()).isEqualTo(403);
///////////////////////////////////////////////////////////////////////////
// with the production write role (reset_user_password, DESIGN.md 5.1) //
// the permission check passes and only the request is invalid (400): //
// the qbit never calls /recovery/ (5.5); it is never a 2xx link here //
///////////////////////////////////////////////////////////////////////////
assertThat(sa.post("/core/users/" + probeUser + "/recovery/", new JSONObject()).status()).isIn(400, 403);
}
@@ -0,0 +1,809 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.qbits.authentik.contract;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikQBitProducer;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.admin.AdminRequest;
import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestReporter;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** DESIGN 13.11 against the pinned server and production token blueprint.
** Each test owns its users, groups and tokens; credentials stay in memory.
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(45)
class TokenContractIT
{
private static final String LIFETIME = "goauthentik.io/user/token-maximum-lifetime";
private static final HttpClient HTTP = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10)).build();
private static AuthentikTestStack stack;
private static TestApi admin;
private final List<Integer> users = new ArrayList<>();
private final List<String> groups = new ArrayList<>();
private final List<String> tokens = new ArrayList<>();
private final List<OneTimeKey> delivered = new ArrayList<>();
private final List<String> eventIds = new ArrayList<>();
private String appGroup;
private AuthentikQBitConfig config;
/*******************************************************************************
** All three blueprints must be successful before any probe.
*******************************************************************************/
@BeforeAll
static void start()
{
stack = AuthentikTestStack.get();
admin = stack.admin();
assertThat(stack.blueprint(AuthentikTestStack.TOKEN_BLUEPRINT).getString("status")).isEqualTo("successful");
}
/*******************************************************************************
** Independent app-access membership for every test.
*******************************************************************************/
@BeforeEach
void setup()
{
JSONObject created = expect(admin.post("/core/groups/", new JSONObject().put("name", unique("access")).put("attributes",
new JSONObject().put("qqq.io/app-access", new JSONArray().put(AuthentikTestStack.APP_SLUG)))), 201);
appGroup = created.getString("pk");
groups.add(appGroup);
}
/*******************************************************************************
** Cascading user deletion also removes managed roles and credentials.
*******************************************************************************/
@AfterEach
void cleanup()
{
TokenKeyDelivery.set(null);
QContext.clear();
for(String identifier : tokens)
{
assertThat(admin.delete(tokenPath(identifier)).status()).isIn(204, 404);
}
for(Integer pk : users)
{
assertThat(admin.delete("/core/users/" + pk + "/").status()).isIn(204, 404);
}
for(String pk : groups)
{
assertThat(admin.delete("/core/groups/" + pk + "/").status()).isIn(204, 404);
}
delivered.clear();
}
/*******************************************************************************
** Explicit other-owner input does not override caller-owned creation.
*******************************************************************************/
@Test
void nonSuperuserCreateBelongsToCaller()
{
int other = user("other");
String identifier = unique("caller-owned");
tokens.add(identifier);
JSONObject created = expect(stack.serviceAccount().post("/core/tokens/", tokenBody(identifier, other, 1)), 201);
assertOwner(created, admin.userPk(AuthentikTestStack.SERVICE_ACCOUNT));
}
/*******************************************************************************
** PATCH defaults move intent and owner; explicit fields retain both.
*******************************************************************************/
@Test
void patchDefaultsAreDangerous()
{
int owner = user("patch");
String unsafe = appPassword(owner, 1);
JSONObject changed = expect(stack.serviceAccount().patch(tokenPath(unsafe), new JSONObject().put("expires", expires(30))), 200);
assertThat(changed.getString("intent")).isEqualTo("api");
assertThat(changed.getInt("user")).isEqualTo(admin.userPk(AuthentikTestStack.SERVICE_ACCOUNT));
String safe = appPassword(owner, 1);
assertOwner(expect(stack.serviceAccount().patch(tokenPath(safe), new JSONObject()
.put("intent", "app_password").put("user", owner).put("expires", expires(30))), 200), owner);
}
/*******************************************************************************
** Remove the brand cap temporarily, restoring it even on assertion failure.
*******************************************************************************/
@Test
void expiryCapUsesBrandThenGroupsThenUser()
{
int owner = user("cap");
TestApi caller = apiAs(owner);
JSONObject brand = admin.first("/core/brands/?default=true");
String path = "/core/brands/" + brand.getString("brand_uuid") + "/";
JSONObject original = brand.getJSONObject("attributes");
try
{
JSONObject without = new JSONObject(original.toString());
without.remove(LIFETIME);
expect(admin.patch(path, new JSONObject().put("attributes", without)), 200);
refusedLifetime(caller, owner, 30);
expect(admin.patch(path, new JSONObject().put("attributes", new JSONObject(original.toString()).put(LIFETIME, "days=90"))), 200);
String identifier = unique("brand-cap");
tokens.add(identifier);
assertOwner(expect(caller.post("/core/tokens/", tokenBody(identifier, owner, 30)), 201), owner);
refusedLifetime(caller, owner, 120);
expect(admin.patch("/core/groups/" + appGroup + "/", new JSONObject().put("attributes", new JSONObject()
.put("qqq.io/app-access", new JSONArray().put(AuthentikTestStack.APP_SLUG)).put(LIFETIME, "days=10"))), 200);
refusedLifetime(caller, owner, 30);
attributes(owner, new JSONObject().put(LIFETIME, "days=365"));
String overridden = unique("user-cap");
tokens.add(overridden);
assertOwner(expect(caller.post("/core/tokens/", tokenBody(overridden, owner, 200)), 201), owner);
}
finally
{
expect(admin.patch(path, new JSONObject().put("attributes", original)), 200);
}
}
/*******************************************************************************
** Broker bearers create, read keys, extend and delete only their own tokens.
*******************************************************************************/
@Test
void brokerSupportsPersonalLifecycleAndIsolatesOwners() throws Exception
{
int owner = user("broker");
TestApi caller = exchanged(owner);
TestApi other = exchanged(user("isolated"));
String identifier = unique("broker-created");
tokens.add(identifier);
assertOwner(expect(caller.post("/core/tokens/", tokenBody(identifier, owner, 30)), 201), owner);
String key = expect(caller.get(tokenPath(identifier) + "view_key/"), 200).getString("key");
assertThat(key.isBlank()).isFalse();
assertThat(other.get(tokenPath(identifier)).status()).isIn(403, 404);
assertThat(other.get(tokenPath(identifier) + "view_key/").status()).isIn(403, 404);
assertThat(other.delete(tokenPath(identifier)).status()).isIn(403, 404);
assertOwner(expect(caller.patch(tokenPath(identifier), new JSONObject().put("intent", "app_password")
.put("user", owner).put("expires", expires(40))), 200), owner);
expect(caller.delete(tokenPath(identifier)), 204);
}
/*******************************************************************************
** Each privileged class is independently denied by the broker policy.
*******************************************************************************/
@Test
void brokerDeniesProtectedOwners(TestReporter reporter) throws Exception
{
for(String protection : List.of("identity-admins", "authentik Admins", "attribute"))
{
int owner = user("denied");
if("attribute".equals(protection))
{
attributes(owner, new JSONObject().put("qqq.io/protected", true));
}
else
{
expect(admin.post("/core/groups/" + admin.groupPk(protection) + "/add_user/", new JSONObject().put("pk", owner)), 204);
}
TestApi.Response response = exchange(subject(owner));
assertThat(response.status()).as("broker denial for %s: %s", protection, safeError(response)).isNotEqualTo(200);
assertThat(response.json().optString("error")).isNotBlank();
reporter.publishEntry(protection, "HTTP " + response.status() + ": " + safeError(response));
}
}
/*******************************************************************************
** service_account needs add_user AND add_token (2026.8.3), and set_key needs
** add_token (POST object permission) as well as set_token_key: the role
** holds all three (DESIGN.md 13.9), and add_token cannot mint for others.
*******************************************************************************/
@Test
void serviceAccountCreateSetKeyAndExtend() throws Exception
{
String name = unique("service");
JSONObject request = new JSONObject().put("name", name).put("create_group", false).put("expiring", true).put("expires", expires(300));
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", request), 200);
int owner = created.getInt("user_pk");
users.add(owner);
String identifier = "service-account-" + name + "-password";
tokens.add(identifier);
assertThat(created.getString("token").isBlank()).isFalse();
JSONObject before = admin.getOk(tokenPath(identifier));
assertOwner(before, owner);
assertThat(Instant.parse(before.getString("expires"))).isAfter(Instant.now().plus(299, ChronoUnit.DAYS));
attributes(owner, new JSONObject().put(LIFETIME, "days=365"));
expect(admin.post("/core/groups/" + appGroup + "/add_user/", new JSONObject().put("pk", owner)), 204);
String key = UUID.randomUUID().toString() + UUID.randomUUID();
expect(stack.serviceAccount().post(tokenPath(identifier) + "set_key/", new JSONObject().put("key", key)), 204);
expect(credentials(name, key, "openid profile email"), 200);
assertThat(admin.getOk(tokenPath(identifier) + "view_key/").getString("key").equals(key)).isTrue();
assertOwner(expect(stack.serviceAccount().patch(tokenPath(identifier), new JSONObject().put("intent", "app_password")
.put("user", owner).put("expires", expires(200))), 200), owner);
}
/*******************************************************************************
** Profile controls the signed token binding and groups on the access JWT.
*******************************************************************************/
@Test
void clientCredentialsClaimsRequireProfile() throws Exception
{
int owner = user("claims");
String identifier = appPassword(owner, 1);
String key = admin.getOk(tokenPath(identifier) + "view_key/").getString("key");
String username = admin.getOk("/core/users/" + owner + "/").getString("username");
JSONObject full = claims(expect(credentials(username, key, "openid profile email"), 200).getString("access_token"));
assertThat(full.getString("qqq_token")).isEqualTo(identifier);
assertThat(full.has("groups")).isTrue();
JSONObject limited = claims(expect(credentials(username, key, "openid email"), 200).getString("access_token"));
assertThat(limited.has("qqq_token")).isFalse();
assertThat(limited.has("groups")).isFalse();
}
/*******************************************************************************
** Local notifications identify the actual protected-token update event.
** Observe the negative case for a full worker-processing interval as well.
*******************************************************************************/
@Test
void detectiveRuleNotifiesOnlyForProtectedOwner() throws Exception
{
int protectedOwner = user("alarm");
expect(admin.post("/core/groups/" + admin.groupPk("identity-admins") + "/add_user/", new JSONObject().put("pk", protectedOwner)), 204);
String protectedToken = appPassword(protectedOwner, 1);
JSONObject created = expect(admin.post("/core/users/service_account/", new JSONObject().put("name", unique("ordinary"))
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
users.add(created.getInt("user_pk"));
String ordinaryToken = "service-account-" + created.getString("username") + "-password";
tokens.add(ordinaryToken);
String ordinaryEvent = setKeyEvent(ordinaryToken);
String protectedEvent = setKeyEvent(protectedToken);
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "protected-token local notification", () ->
notifications(protectedOwner, protectedEvent).length() > 0);
Instant deadline = Instant.now().plusSeconds(60);
do
{
assertThat(notifications(protectedOwner, ordinaryEvent).length()).isZero();
Thread.sleep(2_000);
}
while(Instant.now().isBefore(deadline));
}
/*******************************************************************************
** Owner decision A (2026-10-14): the role holds add_token because 2026.8.3
** needs it for service_account and for set_key (POST object permission).
** Pin both: 403 without add_token, 200/204 with it (restored in finally).
*******************************************************************************/
@Test
void serviceAccountActionsNeedAddToken() throws Exception
{
JSONObject seed = expect(admin.post("/core/users/service_account/", new JSONObject().put("name", unique("needs-add"))
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
users.add(seed.getInt("user_pk"));
String seedToken = "service-account-" + seed.getString("username") + "-password";
tokens.add(seedToken);
String role = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
JSONObject permissions = new JSONObject().put("permissions", new org.json.JSONArray().put("authentik_core.add_token"));
String name = unique("no-add-token");
assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", permissions).status()).isEqualTo(204);
try
{
expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name)
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 403);
assertThat(admin.first("/core/users/?username=" + name)).isNull();
expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 403);
}
finally
{
assertThat(admin.post("/rbac/permissions/assigned_by_roles/" + role + "/assign/", permissions).status()).isEqualTo(200);
}
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name)
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
users.add(created.getInt("user_pk"));
tokens.add("service-account-" + name + "-password");
expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 204);
}
/*******************************************************************************
** The alarm also notifies identity-admins whenever the qbit's account
** creates a service account (owner decision 2026-10-14).
*******************************************************************************/
@Test
void detectiveRuleNotifiesOnServiceAccountCreation() throws Exception
{
int member = user("alarm-sa");
expect(admin.post("/core/groups/" + admin.groupPk("identity-admins") + "/add_user/", new JSONObject().put("pk", member)), 204);
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", unique("alarmed"))
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
int servicePk = created.getInt("user_pk");
users.add(servicePk);
tokens.add("service-account-" + created.getString("username") + "-password");
String[] eventPk = { null };
AuthentikTestStack.waitFor(Duration.ofSeconds(30), "service-account model_created event", () ->
{
JSONArray events = admin.getOk("/events/events/?action=model_created&ordering=-created&page_size=50&username="
+ TestApi.encode(AuthentikTestStack.SERVICE_ACCOUNT)).getJSONArray("results");
for(int i = 0; i < events.length(); i++)
{
JSONObject model = events.getJSONObject(i).getJSONObject("context").optJSONObject("model");
if(model != null && "user".equals(model.optString("model_name")) && String.valueOf(servicePk).equals(model.optString("pk")))
{
eventPk[0] = events.getJSONObject(i).getString("pk");
return (true);
}
}
return (false);
});
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "service-account creation notification", () ->
notifications(member, eventPk[0]).length() > 0);
}
/*******************************************************************************
** Real qbit calls, local audit/scope persistence and the response-only SPI.
*******************************************************************************/
@Test
void qbitLifecycleNeverPersistsOneTimeKeys() throws Exception
{
int owner = user("qbit");
String username = admin.getOk("/core/users/" + owner + "/").getString("username");
configureQbit(owner, subject(owner));
OneTimeKey first = issued(run(AdminRequest.of(AdminAction.CREATE_TOKEN, "", "contract personal create")
.with("description", "Contract access").with("expiresInDays", 30)));
expect(credentials(username, first.value(), "openid profile email"), 200);
run(AdminRequest.of(AdminAction.EXTEND_TOKEN, first.label(), "contract extend").with("expiresInDays", 40));
assertOwner(admin.getOk(tokenPath(first.label())), owner);
OneTimeKey rotated = issued(run(AdminRequest.of(AdminAction.ROTATE_TOKEN, first.label(), "contract rotate")));
expect(credentials(username, rotated.value(), "openid profile email"), 200);
assertThat(admin.get(tokenPath(first.label())).status()).isEqualTo(404);
run(AdminRequest.of(AdminAction.REVOKE_TOKEN, rotated.label(), "contract revoke"));
assertThat(admin.get(tokenPath(rotated.label())).status()).isEqualTo(404);
String serviceName = unique("qbit-service");
try
{
AdminRequest createService = AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "contract service create")
.with("name", serviceName).with("groups", admin.getOk("/core/groups/" + appGroup + "/").getString("name")).with("expiresInDays", 300);
OneTimeKey service = issued(run(createService));
int servicePk = admin.userPk(serviceName);
JSONObject attributes = admin.getOk("/core/users/" + servicePk + "/").getJSONObject("attributes");
assertThat(attributes.getString(LIFETIME)).isEqualTo("days=365");
assertThat(attributes.getBoolean("goauthentik.io/user/token-expires")).isTrue();
expect(credentials(serviceName, service.value(), "openid profile email"), 200);
OneTimeKey next = issued(run(AdminRequest.of(AdminAction.ROTATE_TOKEN, service.label(), "contract service rotate")));
expect(credentials(serviceName, next.value(), "openid profile email"), 200);
assertThat(credentials(serviceName, service.value(), "openid profile email").status()).isNotEqualTo(200);
assertOwner(admin.getOk(tokenPath(next.label())), servicePk);
run(AdminRequest.of(AdminAction.EXTEND_TOKEN, next.label(), "contract service extend").with("expiresInDays", 200));
}
finally
{
JSONObject service = admin.first("/core/users/?username=" + TestApi.encode(serviceName));
if(service != null)
{
users.add(service.getInt("pk"));
}
}
for(OneTimeKey key : delivered)
{
for(String table : List.of("authentik_admin_action", "authentik_token_scope", "authentik_approval_request"))
{
String rows = com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(new QueryAction().execute(new QueryInput().withTableName(table)).getRecords());
assertThat(rows.contains(key.value())).as("No secret in %s", table).isFalse();
}
for(String eventId : eventIds)
{
assertThat(admin.getOk("/events/events/" + eventId + "/").toString().contains(key.value()))
.as("No secret in qbit custom event").isFalse();
}
}
assertThat(delivered).hasSize(4);
assertThat(eventIds).hasSize(7);
}
/*******************************************************************************
** A real runtime using the qbit bearer and a normal user's signed subject JWT.
*******************************************************************************/
private void configureQbit(int owner, String subject) throws Exception
{
MemoryRecordStore.getInstance().reset();
config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin")
.withListCacheTtlSeconds(0).withGroupCacheTtlSeconds(0).withBulkPacingMillis(0).withLocalBackendName("memory")
.withRecoveryEmailStage("kof22-recovery-email")
.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.TOKENS))
.withTokenBrokerClientId("qqq-kof22-token-broker").withTokenBrokerClientSecret(AuthentikTestStack.TOKEN_BROKER_CLIENT_SECRET)
.withTokenEndpointUrl(stack.baseUrl() + "/application/o/token/").withSubjectTokenSource(session -> subject)
.withAppClientId(AuthentikTestStack.APP_SLUG).withAuthentikPublicUrl(stack.baseUrl()).withAppPublicUrl("https://app.example.test");
QInstance instance = new QInstance();
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
new AuthentikQBitProducer().withQBitConfig(config).produce(instance);
Set<String> permissions = new HashSet<>();
for(IdentityPermission permission : IdentityPermission.values())
{
permissions.add(permission.getSessionPermission());
}
QSession session = new QSession();
session.setUser(new QUser().withIdReference(admin.getOk("/core/users/" + owner + "/").getString("email")));
session.setPermissions(permissions);
QContext.init(instance, session);
TokenKeyDelivery.set((output, key) -> delivered.add(key));
}
/*******************************************************************************
** Preserve custom event identifiers without retaining any response secret.
*******************************************************************************/
private AuthentikAdmin.AdminResult run(AdminRequest request) throws Exception
{
AuthentikAdmin.AdminResult result = new AuthentikAdmin(AuthentikActionUtil.forBackend(config.getApiBackendName())).execute(request);
assertThat(result.ok()).as(result.message()).isTrue();
assertThat(result.eventPk()).isNotBlank();
eventIds.add(result.eventPk());
return (result);
}
/*******************************************************************************
** Exercise the one-time adapter and ensure a second take is empty.
*******************************************************************************/
private OneTimeKey issued(AuthentikAdmin.AdminResult result)
{
OneTimeKey key = result.takeSecret();
assertThat(key).isNotNull();
assertThat(result.takeSecret()).isNull();
assertThat(TokenKeyDelivery.tryDeliver(new RunBackendStepOutput(), key)).isTrue();
tokens.add(key.label());
result.delivered();
return (key);
}
/*******************************************************************************
** New ordinary user with independently owned membership and cleanup.
*******************************************************************************/
private int user(String prefix)
{
String name = unique(prefix);
JSONObject created = expect(admin.post("/core/users/", new JSONObject().put("username", name).put("name", name)
.put("email", name + "@example.test").put("type", "internal").put("path", "users")), 201);
int pk = created.getInt("pk");
users.add(pk);
expect(admin.post("/core/groups/" + appGroup + "/add_user/", new JSONObject().put("pk", pk)), 204);
return (pk);
}
/*******************************************************************************
** Merge test attributes, preserving service-account flags.
*******************************************************************************/
private void attributes(int owner, JSONObject extra)
{
String path = "/core/users/" + owner + "/";
JSONObject attributes = admin.getOk(path).getJSONObject("attributes");
extra.keySet().forEach(key -> attributes.put(key, extra.get(key)));
expect(admin.patch(path, new JSONObject().put("attributes", attributes)), 200);
}
/*******************************************************************************
** Bootstrap setup can create a credential for another user.
*******************************************************************************/
private String appPassword(int owner, int days)
{
String identifier = unique("password");
tokens.add(identifier);
assertOwner(expect(admin.post("/core/tokens/", tokenBody(identifier, owner, days)), 201), owner);
return (identifier);
}
/*******************************************************************************
** API bearer for cap probes, independent of the broker under test.
*******************************************************************************/
private TestApi apiAs(int owner)
{
String identifier = unique("api");
tokens.add(identifier);
expect(admin.post("/core/tokens/", new JSONObject().put("identifier", identifier).put("user", owner).put("intent", "api")), 201);
return (new TestApi(stack.apiRoot(), admin.getOk(tokenPath(identifier) + "view_key/").getString("key")));
}
/*******************************************************************************
** Mint an app JWT using the explicitly enabled client-credentials grant.
*******************************************************************************/
private String subject(int owner) throws Exception
{
String identifier = appPassword(owner, 1);
String key = admin.getOk(tokenPath(identifier) + "view_key/").getString("key");
return (expect(credentials(admin.getOk("/core/users/" + owner + "/").getString("username"), key, "openid profile email"), 200).getString("access_token"));
}
/*******************************************************************************
** Exchange through the production broker, without substituting admin access.
*******************************************************************************/
private TestApi exchanged(int owner) throws Exception
{
JSONObject response = expect(exchange(subject(owner)), 200);
assertThat(response.getInt("expires_in")).isBetween(1, 120);
return (new TestApi(stack.apiRoot(), response.getString("access_token")));
}
/*******************************************************************************
** The primary provider consumes an app password to authenticate its owner.
*******************************************************************************/
private TestApi.Response credentials(String username, String key, String scope) throws Exception
{
//////////////////////////////////////////////////////////////////////
// no client_secret: exactly what a PAT holder sends (the generated //
// usage text). 2026.8.3 authenticates client_credentials by the //
// username and app password even for a confidential client //
//////////////////////////////////////////////////////////////////////
return (form(Map.of("grant_type", "client_credentials", "client_id", AuthentikTestStack.APP_SLUG,
"username", username, "password", key, "scope", scope)));
}
/*******************************************************************************
** RFC 8693 request used by both allowed and denied subject cases.
*******************************************************************************/
private TestApi.Response exchange(String subject) throws Exception
{
return (form(Map.of("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange", "client_id", "qqq-kof22-token-broker",
"client_secret", AuthentikTestStack.TOKEN_BROKER_CLIENT_SECRET, "subject_token", subject,
"subject_token_type", "urn:ietf:params:oauth:token-type:jwt", "scope", "goauthentik.io/api")));
}
/*******************************************************************************
** HTTP/1.1 avoids Authentik's h2c POST trap. No form in error messages.
*******************************************************************************/
private TestApi.Response form(Map<String, String> fields) throws Exception
{
String body = fields.entrySet().stream().map(entry -> TestApi.encode(entry.getKey()) + "=" + TestApi.encode(entry.getValue())).collect(Collectors.joining("&"));
HttpResponse<String> response = HTTP.send(HttpRequest.newBuilder(URI.create(stack.baseUrl() + "/application/o/token/"))
.timeout(Duration.ofSeconds(30)).header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
return (new TestApi.Response(response.statusCode(), response.body()));
}
/*******************************************************************************
** Pin refusal instead of accepting a silently shortened lifetime.
*******************************************************************************/
private void refusedLifetime(TestApi caller, int owner, int days)
{
String identifier = unique("too-long");
tokens.add(identifier);
JSONObject response = expect(caller.post("/core/tokens/", tokenBody(identifier, owner, days)), 400);
assertThat(response.getJSONArray("expires").toString()).contains("exceeds maximum lifetime");
}
/*******************************************************************************
** Match the update by immutable token PK and the service-account actor.
*******************************************************************************/
private String setKeyEvent(String identifier)
{
String pk = admin.getOk(tokenPath(identifier)).getString("pk");
expect(stack.serviceAccount().post(tokenPath(identifier) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 204);
String[] found = { null };
try
{
AuthentikTestStack.waitFor(Duration.ofSeconds(30), "token model_updated event", () ->
{
//////////////////////////////////////////////////////////////////////
// set_key logs model_to_dict(token): identifier, intent, user (key //
// masked), not the usual (app, model_name, pk) model reference //
//////////////////////////////////////////////////////////////////////
JSONArray events = admin.getOk("/events/events/?action=model_updated&ordering=-created&page_size=50"
+ "&username=" + TestApi.encode(AuthentikTestStack.SERVICE_ACCOUNT)).getJSONArray("results");
for(int i = 0; i < events.length(); i++)
{
JSONObject event = events.getJSONObject(i);
JSONObject model = event.getJSONObject("context").optJSONObject("model");
if(model != null && (pk.replace("-", "").equals(model.optString("pk").replace("-", "")) || identifier.equals(model.optString("identifier")))
&& AuthentikTestStack.SERVICE_ACCOUNT.equals(event.getJSONObject("user").optString("username")))
{
found[0] = event.getString("pk");
return (true);
}
}
return (false);
});
}
catch(IllegalStateException e)
{
JSONArray recent = admin.getOk("/events/events/?ordering=-created&page_size=15").getJSONArray("results");
for(int i = 0; i < recent.length(); i++)
{
JSONObject event = recent.getJSONObject(i);
System.err.println("recent event " + event.optString("action") + " user=" + event.optJSONObject("user") + " ctx=" + event.optJSONObject("context"));
}
throw (e);
}
return (found[0]);
}
/*******************************************************************************
** Exclude unrelated notifications with destination and exact event filters.
*******************************************************************************/
private JSONArray notifications(int user, String event)
{
return (admin.getOk("/events/notifications/?user=" + user + "&event=" + event).getJSONArray("results"));
}
/*******************************************************************************
** Status diagnostics include redacted errors, never success bodies.
*******************************************************************************/
private static JSONObject expect(TestApi.Response response, int status)
{
assertThat(response.status()).as("Expected HTTP %s; %s", status, safeError(response)).isEqualTo(status);
return (response.json());
}
/*******************************************************************************
** An unexpected success must not leak a key through assertion diagnostics.
*******************************************************************************/
private static String safeError(TestApi.Response response)
{
if(response.status() < 400)
{
return ("HTTP " + response.status() + " (body withheld)");
}
return (com.kingsrook.qbits.authentik.client.AuthentikRedactor.redactText(response.body(), null));
}
/*******************************************************************************
** Metadata assertions contain no credentials.
*******************************************************************************/
private static void assertOwner(JSONObject token, int owner)
{
assertThat(token.getInt("user")).isEqualTo(owner);
assertThat(token.getString("intent")).isEqualTo("app_password");
}
/*******************************************************************************
** Supply every field whose omission triggers dangerous defaults.
*******************************************************************************/
private static JSONObject tokenBody(String identifier, int owner, int days)
{
return (new JSONObject().put("identifier", identifier).put("user", owner).put("intent", "app_password")
.put("description", "Contract test").put("expiring", true).put("expires", expires(days)));
}
/*******************************************************************************
** Decode the server-issued payload without printing the JWT.
*******************************************************************************/
private static JSONObject claims(String jwt)
{
return (new JSONObject(new String(Base64.getUrlDecoder().decode(jwt.split("\\.")[1]), StandardCharsets.UTF_8)));
}
/*******************************************************************************
** Identifiers compatible with token URLs and Django slugify.
*******************************************************************************/
private static String unique(String prefix)
{
return ("tc-" + prefix + "-" + UUID.randomUUID().toString().substring(0, 8));
}
/*******************************************************************************
** Relative expiry comfortably away from the cap boundary.
*******************************************************************************/
private static String expires(int days)
{
return (Instant.now().plus(days, ChronoUnit.DAYS).toString());
}
/*******************************************************************************
** All identifiers here are internally generated safe URL components.
*******************************************************************************/
private static String tokenPath(String identifier)
{
return ("/core/tokens/" + identifier + "/");
}
}
@@ -17,11 +17,13 @@
package com.kingsrook.qbits.authentik.permissions;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureRouter;
@@ -35,6 +37,9 @@ 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.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
@@ -130,6 +135,177 @@ class AuthentikGroupPermissionMapperTest
/*******************************************************************************
** Direct API groups grant their own and inherited permissions to both
** people and service accounts, preserving the host's session permissions.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { "internal", "service_account" })
void automatedSessionsResolveGroups(String type)
{
JSONObject user = user(99, " Person@Example.Test ").put("type", type)
.put("groups_obj", new JSONArray().put(new JSONObject().put("name", "child")));
usersWithEmail(user, user(100, "other@example.test"));
FixtureActionUtil.groupGraphBody = new JSONObject()
.put("pagination", new JSONObject().put("count", 2).put("current", 1).put("total_pages", 1))
.put("results", new JSONArray()
.put(new JSONObject().put("pk", "parent-pk").put("name", "parent").put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.users.read"))))
.put(new JSONObject().put("pk", "child-pk").put("name", "child").put("parents", new JSONArray().put("parent-pk")).put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.groups.manage"))))).toString();
config.withStaticGroupPermissions(Map.of("child", List.of("config.fallback")));
QSession session = new QSession();
session.setPermissions(Set.of("already.there"));
new AuthentikGroupPermissionMapper().withGroupPermissions("child", List.of("mapper.fallback"))
.customizeAutomatedSessionForUser(qInstance, session, " person@example.test ");
assertThat(session.getPermissions()).containsExactlyInAnyOrder("already.there", "config.fallback", "mapper.fallback",
IdentityPermission.USERS_READ.getSessionPermission(), IdentityPermission.GROUPS_MANAGE.getSessionPermission());
assertThat(FixtureActionUtil.REQUESTS).anySatisfy(request -> assertThat(request.getURI().getRawQuery())
.contains("email=person%40example.test", "include_groups=true", "include_roles=false"));
}
/*******************************************************************************
** Unknown, blank, ambiguous and inactive identities never acquire groups,
** host permissions or bootstrap permissions from an email alone.
*******************************************************************************/
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { "unknown", "duplicate", "inactive", "missing-active", "bounded" })
void automatedSessionsRejectUnprovenUsers(String scenario)
{
JSONObject candidate = user(42, "owner@example.test").put("groups_obj",
new JSONArray().put(new JSONObject().put("name", "phase1-child-of-identity-admins")));
if("duplicate".equals(scenario))
{
usersWithEmail(candidate, user(99, " OWNER@example.test ").put("is_active", false));
}
else if("inactive".equals(scenario))
{
usersWithEmail(candidate.put("is_active", false));
}
else if("missing-active".equals(scenario))
{
candidate.remove("is_active");
usersWithEmail(candidate);
}
else if("bounded".equals(scenario))
{
JSONObject[] candidates = new JSONObject[10];
java.util.Arrays.fill(candidates, user(99, "other@example.test"));
candidates[0] = candidate;
usersWithEmail(candidates);
}
else
{
usersWithEmail();
}
config.withStaticGroupPermissions(Map.of("phase1-child-of-identity-admins", List.of("config.fallback")))
.withHostPermissionSource((email, groups) -> Set.of("host.permission"));
QSession session = new QSession();
session.setPermissions(Set.of("already.there"));
new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("mapper.fallback"))
.customizeAutomatedSessionForUser(qInstance, session, scenario == null || scenario.isEmpty() ? scenario : "owner@example.test");
assertThat(session.getPermissions()).containsExactly("already.there");
}
/*******************************************************************************
** Failed API lookups and absent runtimes keep only existing permissions.
*******************************************************************************/
@Test
void automatedSessionLookupFailure()
{
FixtureActionUtil.handler = request -> FixtureActionUtil.response(403, "{\"detail\":\"Forbidden\"}");
QSession session = new QSession();
session.setPermissions(Set.of("already.there"));
new AuthentikGroupPermissionMapper().customizeAutomatedSessionForUser(qInstance, session, "owner@example.test");
assertThat(session.getPermissions()).containsExactly("already.there");
AuthentikGroupPermissionMapper noRuntime = new AuthentikGroupPermissionMapper()
{
/*******************************************************************************
** Deliberately missing backend.
*******************************************************************************/
@Override
protected String getApiBackendName()
{
return ("missingAutomatedBackend");
}
};
noRuntime.customizeAutomatedSessionForUser(qInstance, session, "owner@example.test");
assertThat(session.getPermissions()).containsExactly("already.there");
}
/*******************************************************************************
** A readable user's direct group names retain static fallbacks when the
** group graph fails; group UUIDs can also be resolved when objects are absent.
*******************************************************************************/
@Test
void automatedGroupFallbacks()
{
JSONObject child = new JSONObject(FixtureActionUtil.groupGraphBody).getJSONArray("results").toList().stream()
.map(value -> new JSONObject((Map<?, ?>) value)).filter(group -> "phase1-child-of-identity-admins".equals(group.optString("name")))
.findFirst().orElseThrow();
usersWithEmail(user(99, "person@example.test").put("groups", new JSONArray().put(child.getString("pk"))));
QSession session = new QSession();
new AuthentikGroupPermissionMapper().customizeAutomatedSessionForUser(qInstance, session, "person@example.test");
assertThat(session.getPermissions()).contains(IdentityPermission.USERS_READ.getSessionPermission());
usersWithEmail(user(99, "person@example.test").put("groups_obj", new JSONArray().put(child)));
FixtureActionUtil.groupGraphBody = "not json";
config.withStaticGroupPermissions(Map.of(child.getString("name"), List.of("config.fallback")));
session = new QSession();
new AuthentikGroupPermissionMapper().withGroupPermissions(child.getString("name"), List.of("mapper.fallback"))
.customizeAutomatedSessionForUser(qInstance, session, "person@example.test");
assertThat(session.getPermissions()).containsExactlyInAnyOrder("config.fallback", "mapper.fallback");
}
/*******************************************************************************
** The cut-over floor is identical to sign-in and bootstrap recognizes both
** API immutable ids without inventing an email_verified claim.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { "42", "uuid-42", "not-this-user" })
void automatedCutoverFloor(String bootstrapSubject)
{
config.withBootstrapOperatorSubjects(bootstrapSubject)
.withStaticGroupPermissions(Map.of("phase1-child-of-identity-admins", List.of("config.fallback")))
.withHostPermissionSource((email, groups) ->
{
assertThat(email).isEqualTo("owner@example.test");
assertThat(groups).containsExactly("phase1-child-of-identity-admins");
return (Set.of("host.permission"));
});
AuthentikRuntime.forBackend(config.getApiBackendName()).markMapperInvoked();
AuthentikRuntime.forBackend(config.getApiBackendName()).setMapperState(
new AccessComparison.State(AccessComparison.Status.BLOCKED, "Test guard", Instant.now()));
usersWithEmail(user(42, "owner@example.test").put("groups_obj",
new JSONArray().put(new JSONObject().put("name", "phase1-child-of-identity-admins"))));
QSession session = new QSession();
new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("mapper.fallback"))
.customizeAutomatedSessionForUser(qInstance, session, "owner@example.test");
if("not-this-user".equals(bootstrapSubject))
{
assertThat(session.getPermissions()).containsExactlyInAnyOrder("config.fallback", "mapper.fallback", "host.permission");
}
else
{
assertThat(session.getPermissions()).contains("config.fallback", "mapper.fallback", "host.permission");
for(IdentityPermission permission : IdentityPermission.values())
{
assertThat(session.getPermissions()).contains(permission.getSessionPermission());
}
}
}
/*******************************************************************************
** Bootstrap operators are keyed on immutable ids, never on an email: a
** spoofed email (duplicate, or a different user) is denied, an unverified
@@ -173,4 +173,27 @@ class EffectivePermissionResolverTest
assertThat(resolver.expand(null)).isEmpty();
}
/*******************************************************************************
** Exclusion applies through ancestors and duplicate paths without removing
** overlapping named grants or policies from other groups, or mutating the graph.
*******************************************************************************/
@Test
void excludesOnlyTheSelectedPolicy()
{
GroupGraph graph = new GroupGraph()
.add(new GroupGraph.Node("parent", "Parent", false, List.of(), List.of("cms.publish"), List.of("app"), List.of(), List.of(), List.of("cms.*", "missing")))
.add(new GroupGraph.Node("child", "Child", false, List.of("parent"), List.of(), List.of(), List.of(), List.of(), List.of("cms.edit")));
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, CATALOG);
EffectivePermissionResolver.Resolution base = resolver.resolveGroups(List.of("child", "parent"), "parent");
assertThat(base.sessionPermissions()).containsExactly("cms.edit", "cms.publish");
assertThat(base.grantedBy().get("cms.edit")).containsExactly("Child");
assertThat(base.grantedBy().get("cms.publish")).containsExactly("Parent");
assertThat(base.appAccess()).containsExactly("app");
assertThat(base.unknown()).isEmpty();
assertThat(resolver.resolveGroups(List.of("child")).sessionPermissions()).contains("cms.delete");
assertThat(resolver.resolveGroups(List.of("child"), "absent")).isEqualTo(resolver.resolveGroups(List.of("child")));
}
}
@@ -26,26 +26,25 @@ import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermission;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Table features use generic permission suffixes from core's existing catalog
** API. No test or production code depends on the unreleased metadata hook.
** Table features are discovered through core's registered metadata and catalog.
*******************************************************************************/
class PolicyFeaturePermissionsTest
{
@@ -61,31 +60,27 @@ class PolicyFeaturePermissionsTest
QInstance instance = PolicyCatalogTest.instance();
instance.addTable(new QTableMetaData().withName("person_records").withLabel("People").withBackendName("memory")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withPermissionBaseName("person")));
instance.addTable(new QTableMetaData().withName("person_extra").withLabel("Extra People").withBackendName("memory")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.ofOrWithNew(instance);
features.withPermission(feature("comment", "Comment", "person_records", "legacy"))
.withPermission(feature("analyze", "Old label", "person_records"))
.withPermission(feature("analyze", "Analyze", "person_records", "gate", "open_table"))
.withPermission(feature("tag", "Tag", "shared_one", "shared_two"))
.withPermission(feature("share", "Share", "person_extra"))
.withPermission(feature("", "Empty", "person_records"));
return (instance);
}
/*******************************************************************************
** The shape returned by PermissionsHelper when core registers a feature.
** Register a feature only for the named tables; core supplies permission names.
*******************************************************************************/
private static AvailablePermission feature(String name, String label)
private static TableFeaturePermission feature(String suffix, String label, String... tables)
{
return (new AvailablePermission().withName(name).withObjectName("People").withObjectType("Table").withPermissionType(label));
}
/*******************************************************************************
** Deliberately unsorted; duplicates and standard suffixes are not features.
*******************************************************************************/
private static List<AvailablePermission> available()
{
return (List.of(feature("person.comment", "Comment"), feature("person.analyze", "Analyze"), feature("person.analyze", "Analyze"),
feature("person.read", "Read"), feature("person.insert", "Insert"), feature("person.edit", "Edit"), feature("person.delete", "Delete"),
feature("person.hasAccess", "Access"), feature("person_extra.share", "Share"), feature("person.", "Empty"),
feature("gate.analyze", "Analyze"), feature("legacy.comment", "Comment"), feature("legacy.write", "Write"),
feature("shared_base.tag", "Tag"), feature("open_table.analyze", "Analyze")));
return (new TableFeaturePermission().withSuffix(suffix).withLabel(label)
.withAppliesTo((instance, table) -> List.of(tables).contains(table.getName())));
}
@@ -106,11 +101,13 @@ class PolicyFeaturePermissionsTest
@Test
void catalogListsFeaturesSeparately()
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
QInstance instance = instance();
assertThat(PermissionsHelper.getAllAvailablePermissions(instance)).filteredOn(permission -> permission.getName().equals("shared_base.tag")).hasSize(2);
PolicyCatalog catalog = PolicyCatalog.of(instance);
assertThat(catalog.get("person").actions()).containsExactly(PolicyAction.READ, PolicyAction.INSERT, PolicyAction.EDIT, PolicyAction.DELETE);
assertThat(catalog.get("person").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"), new PolicyCatalog.Feature("comment", "Comment"));
assertThat(catalog.get("gate").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"));
assertThat(catalog.get("legacy").features()).containsExactly(new PolicyCatalog.Feature("comment", "Comment"), new PolicyCatalog.Feature("write", "Write"));
assertThat(catalog.get("legacy").features()).containsExactly(new PolicyCatalog.Feature("comment", "Comment"), new PolicyCatalog.Feature("write", "WRITE"));
assertThat(catalog.get("shared_base").features()).containsExactly(new PolicyCatalog.Feature("tag", "Tag"));
assertThat(catalog.get("open_table")).isNull();
assertThat(catalog.permissionNames()).contains("person.analyze", "person.comment", "gate.analyze", "legacy.comment", "legacy.write", "shared_base.tag")
@@ -136,7 +133,7 @@ class PolicyFeaturePermissionsTest
{
QInstance instance = instance();
instance.getTable("person_records").withoutCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
PolicyCatalog.Resource resource = PolicyCatalog.of(instance, available()).get("person");
PolicyCatalog.Resource resource = PolicyCatalog.of(instance).get("person");
assertThat(resource.actions()).isEmpty();
assertThat(resource.features()).hasSize(2);
}
@@ -149,7 +146,7 @@ class PolicyFeaturePermissionsTest
@Test
void parserValidatesFeaturesPerResource() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
assertThat(PolicyParser.parseStrict(document(List.of("read", "analyze"), "person"), FORMAT, catalog).permissions())
.containsExactly("person.analyze", "person.read");
assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "gate"), FORMAT, catalog)).containsExactly("gate.analyze", "person.analyze");
@@ -171,7 +168,7 @@ class PolicyFeaturePermissionsTest
@Test
void wildcardDoesNotGrantFeatures()
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
assertThat(PolicyParser.parse(document(List.of("*"), "person"), FORMAT, catalog))
.containsExactlyInAnyOrder("person.hasAccess", "person.read", "person.insert", "person.edit", "person.delete");
assertThat(PolicyParser.parse(document(List.of("*", "analyze"), "person"), FORMAT, catalog)).hasSize(6).contains("person.analyze").doesNotContain("person.comment");
@@ -185,7 +182,7 @@ class PolicyFeaturePermissionsTest
@Test
void generatorRoundTripsFeatures() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
List<String> permissions = List.of("person.comment", "person.read", "person.analyze", "gate.analyze");
JSONObject generated = PolicyGenerator.generate(permissions, FORMAT, catalog);
String compact = PolicyGenerator.toCompact(generated);
@@ -206,22 +203,18 @@ class PolicyFeaturePermissionsTest
/*******************************************************************************
** The real mapper discovers simulated core entries and adds their exact names.
** The real mapper discovers registered core features and adds their exact names.
*******************************************************************************/
@Test
void claimMapperUsesCoreCatalog()
{
QInstance instance = instance();
try(MockedStatic<PermissionsHelper> helper = Mockito.mockStatic(PermissionsHelper.class, Mockito.CALLS_REAL_METHODS))
{
helper.when(() -> PermissionsHelper.getAllAvailablePermissions(instance)).thenReturn(available());
QSession session = new QSession();
session.setPermissions(Set.of("existing.read"));
JSONObject claims = new JSONObject().put(FORMAT.claim(), document(List.of("analyze"), "person"));
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.read", "person.analyze");
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance))).containsExactly("person.analyze");
}
QSession session = new QSession();
session.setPermissions(Set.of("existing.read"));
JSONObject claims = new JSONObject().put(FORMAT.claim(), document(List.of("analyze"), "person"));
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.read", "person.analyze");
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance))).containsExactly("person.analyze");
}
@@ -232,7 +225,7 @@ class PolicyFeaturePermissionsTest
@Test
void comparisonIncludesInheritedFeatures() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
JSONObject parent = new JSONObject().put("pk", "parent").put("name", "Analysts")
.put("attributes", new JSONObject().put(FORMAT.attribute(), document(List.of("analyze"), "person")));
JSONObject child = new JSONObject().put("pk", "child").put("name", "Staff").put("parents", new JSONArray(List.of("parent")))
@@ -131,7 +131,14 @@ class AuthentikProcessesTest
for(QProcessMetaData process : t.qInstance.getProcesses().values())
{
assertThat(process.getTableName()).as(process.getName()).isNotBlank();
if(!process.getName().equals("authentikTokenReminders"))
{
assertThat(process.getTableName()).as(process.getName()).isNotBlank();
}
else
{
assertThat(process.getIsHidden()).isTrue();
}
assertThat(t.qInstance.getApp("authentikAdmin").getChildren()).extracting(c -> c.getName()).doesNotContain(process.getName());
}
assertThat(t.qInstance.getProcess("authentikCreateUser").getMaxInputRecords()).isEqualTo(0);
@@ -41,6 +41,53 @@ entries:
- authentik_events.add_event
- authentik_blueprints.view_blueprintinstance
# v1 write permissions (qbit-authentik DESIGN.md 5.1): never enable_group_superuser, view_token_key, impersonate, delete_user
- authentik_core.add_user
- authentik_core.change_user
- authentik_core.reset_user_password
- authentik_core.add_group
- authentik_core.change_group
- authentik_core.add_user_to_group
- authentik_core.remove_user_from_group
- authentik_core.view_token
- authentik_core.delete_token
# QQQ access tokens (qbit-authentik DESIGN.md 13, owner decisions
# 2026-10-13). Guardrails, because token permissions are model-wide:
# - view_token_key is NOT granted. add_token IS granted, and is safe: for a
# non-superuser, POST /core/tokens/ always creates the token for the
# CALLER (TokenViewSet.perform_create), so it can never mint a token for
# someone else. Authentik needs it for POST /core/users/service_account/
# (add_user + add_token) and for POST .../set_key/ (POST object
# permission = add_token, plus set_token_key). Verified on 2026.8.3.
# People's tokens are created AS THAT PERSON through the
# qqq-kof22-token-broker exchange (kof22-qqq-tokens.yaml), whose deny
# policy refuses superusers, authentik Admins, identity-admins,
# qqq.io/protected and this service account.
# - change_token extends expiry; every PATCH sends intent, user and
# expires (Authentik's serializer defaults would otherwise turn an app
# password into an API token or move it to this account), and the qbit
# re-reads the token afterwards.
# - set_token_key rotates SERVICE-ACCOUNT app passwords only; the qbit
# refuses protected owners before the call, and the
# qqq-kof22-token-alarm rule (kof22-qqq-tokens.yaml) notifies
# identity-admins if this account ever touches a protected user's token.
- authentik_core.add_token
- authentik_core.change_token
- authentik_core.set_token_key
- authentik_core.delete_authenticatedsession
- authentik_stages_authenticator_totp.view_totpdevice
- authentik_stages_authenticator_totp.delete_totpdevice
- authentik_stages_authenticator_static.view_staticdevice
- authentik_stages_authenticator_static.delete_staticdevice
- authentik_stages_authenticator_webauthn.view_webauthndevice
- authentik_stages_authenticator_webauthn.delete_webauthndevice
- authentik_stages_authenticator_email.view_emaildevice
- authentik_stages_authenticator_email.delete_emaildevice
- authentik_stages_authenticator_sms.view_smsdevice
- authentik_stages_authenticator_sms.delete_smsdevice
- authentik_stages_authenticator_duo.view_duodevice
- authentik_stages_authenticator_duo.delete_duodevice
# The qbit service account. Its API token is minted once by hand (never here).
- model: authentik_core.user
state: created
@@ -13,6 +13,29 @@ metadata:
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_providers_oauth2.scopemapping
id: qqq-kof22-token-claim
state: present
identifiers:
name: "QQQ - access token id"
attrs:
name: "QQQ - access token id"
scope_name: profile
description: "Identifier of the QQQ access token a client_credentials JWT was minted from"
expression: |
from authentik.core.models import Token
try:
http = http_request
except NameError:
http = None
if http is None or http.POST.get("grant_type", "") != "client_credentials":
return {}
password = http.POST.get("password", "")
if not password:
return {}
token = Token.objects.filter(key=password, intent="app_password", user=request.user).first()
return {"qqq_token": token.identifier} if token else {}
- model: authentik_flows.flow
id: kof22-recovery-flow
state: present
@@ -101,6 +124,14 @@ entries:
client_type: confidential
client_id: kof22-website-prod
client_secret: !Env KOF22_OAUTH2_CLIENT_SECRET
# Blueprint-created providers start with no grants on 2026.8.
grant_types: [authorization_code, refresh_token, client_credentials]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, "authentik Self-signed Certificate"]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !KeyOf qqq-kof22-token-claim
redirect_uris:
- matching_mode: strict
url: "https://admin.kof22.test/token"
@@ -370,3 +401,13 @@ entries:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-user-write
order: 10
# Preserve the built-in test brand and its login flow. The stack resolves the
# default brand for localhost, so set the same production lifetime attribute.
- model: authentik_brands.brand
state: present
identifiers:
default: true
attrs:
attributes:
goauthentik.io/user/token-maximum-lifetime: days=90
@@ -0,0 +1,203 @@
# Verbatim copy of cluster-gitops
# tenants/kof22/website-authentik/kof22-qqq-tokens.yaml
# Source: KofTwentyTwo/cluster-gitops PR #103.
# QQQ access tokens (qbit-authentik DESIGN.md 13, owner decisions 2026-10-13).
# Infrastructure only, `present`: the token broker the admin backend uses to
# create a person's token AS THAT PERSON (RFC 8693 token exchange), the deny
# policy for privileged accounts, and the detective alarm. A separate file so
# a problem here (for example a missing broker secret) fails only this
# blueprint, never sign-in.
version: 1
metadata:
name: kof22-qqq-tokens
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: kof22-identity-bootstrap
required: true
# Guardrail (a) inside Authentik: no broker token (so no self-service token
# create, rotate or extend) for superusers (including inherited), members of
# authentik Admins or identity-admins, anyone marked qqq.io/protected, or the
# qbit's own service account.
- model: authentik_policies_expression.expressionpolicy
id: qqq-token-broker-deny
state: present
identifiers:
name: qqq-kof22-token-broker-deny-protected
attrs:
name: qqq-kof22-token-broker-deny-protected
expression: |
user = request.user
if user is None or not user.is_authenticated:
return False
if user.username == "qqq-kof22-admin-idadmin":
return False
if (user.attributes or {}).get("qqq.io/protected") is True:
return False
for group in user.all_groups():
if group.is_superuser or group.name in ("authentik Admins", "identity-admins"):
return False
return True
# The broker: a confidential client the backend alone holds. It accepts only
# the token-exchange grant, for subject tokens signed by the kof22 admin
# provider, and issues 2-minute tokens with the authentik API scope.
- model: authentik_providers_oauth2.oauth2provider
id: qqq-token-broker-provider
state: present
identifiers:
name: qqq-kof22-token-broker
attrs:
name: qqq-kof22-token-broker
authorization_flow: !Find [authentik_flows.flow, [slug, kof22-authorization]]
invalidation_flow: !Find [authentik_flows.flow, [slug, kof22-invalidation-prod]]
client_type: confidential
client_id: qqq-kof22-token-broker
client_secret: !Env KOF22_TOKEN_BROKER_CLIENT_SECRET
grant_types:
- urn:ietf:params:oauth:grant-type:token-exchange
# No browser flow: the broker only answers token exchange.
redirect_uris: []
signing_key: !Find [authentik_crypto.certificatekeypair, [name, "authentik Self-signed Certificate"]]
access_code_validity: minutes=1
access_token_validity: minutes=2
refresh_token_validity: minutes=2
sub_mode: user_email
issuer_mode: per_provider
jwt_federation_providers:
- !Find [authentik_providers_oauth2.oauth2provider, [name, kof22-website-prod]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-authentik_api]]
- model: authentik_core.application
id: qqq-token-broker-application
state: present
identifiers:
slug: qqq-kof22-token-broker
attrs:
name: "QQQ kof22 token broker"
slug: qqq-kof22-token-broker
provider: !KeyOf qqq-token-broker-provider
policy_engine_mode: all
meta_hide: true
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-token-broker-application
policy: !KeyOf qqq-token-broker-deny
attrs:
target: !KeyOf qqq-token-broker-application
policy: !KeyOf qqq-token-broker-deny
order: 0
enabled: true
negate: false
timeout: 30
failure_result: false
# Detective control, guardrail (e): the qbit's service account must never
# create, change, read or re-key a token whose owner is protected (a
# superuser, a member of authentik Admins or identity-admins, or
# qqq.io/protected). If it ever happens, guard (a) was bypassed:
# identity-admins get an Authentik notification and an email.
# It also notifies whenever that account creates a service account (each
# comes with an app password; owner decision 2026-10-14), so in-app
# service-account creation is always visible to identity-admins.
- model: authentik_policies_expression.expressionpolicy
id: qqq-token-alarm-policy
state: present
identifiers:
name: qqq-kof22-token-alarm
attrs:
name: qqq-kof22-token-alarm
expression: |
from authentik.core.models import Token, User
event = request.context.get("event")
if event is None:
return False
if event.action not in ("model_created", "model_updated", "model_deleted", "secret_view", "secret_rotate"):
return False
if (event.user or {}).get("username") != "qqq-kof22-admin-idadmin":
return False
model = event.context.get("model") or event.context.get("secret") or {}
# Also notify (owner decision 2026-10-14) whenever the qbit's account
# creates a service account: each one comes with an app password.
if event.action == "model_created" and model.get("app") == "authentik_core" and model.get("model_name") == "user":
created = User.objects.filter(pk=model.get("pk")).first()
return created is not None and created.type == "service_account"
# Two shapes: the usual model reference (app, model_name, pk), and set_key's
# model_to_dict(token) (token_uuid, identifier, intent, user; key masked).
is_reference = model.get("app") == "authentik_core" and model.get("model_name") == "token"
is_dict = "identifier" in model and "intent" in model
if not is_reference and not is_dict:
return False
owner = None
token = None
if is_reference:
token = Token.objects.including_expired().filter(pk=model.get("pk")).first()
elif model.get("identifier"):
token = Token.objects.including_expired().filter(identifier=model.get("identifier")).first()
if token is not None:
owner = token.user
elif model.get("user"):
owner = User.objects.filter(pk=model.get("user")).first()
if owner is None:
return False
if (owner.attributes or {}).get("qqq.io/protected") is True:
return True
for group in owner.all_groups():
if group.is_superuser or group.name in ("authentik Admins", "identity-admins"):
return True
return False
- model: authentik_events.notificationtransport
id: qqq-token-alarm-local
state: present
identifiers:
name: qqq-kof22-token-alarm-local
attrs:
name: qqq-kof22-token-alarm-local
mode: local
send_once: false
- model: authentik_events.notificationtransport
id: qqq-token-alarm-email
state: present
identifiers:
name: qqq-kof22-token-alarm-email
attrs:
name: qqq-kof22-token-alarm-email
mode: email
send_once: false
- model: authentik_events.notificationrule
id: qqq-token-alarm-rule
state: present
identifiers:
name: qqq-kof22-token-alarm
attrs:
name: qqq-kof22-token-alarm
severity: alert
destination_group: !Find [authentik_core.group, [name, identity-admins]]
destination_event_user: false
transports:
- !KeyOf qqq-token-alarm-local
- !KeyOf qqq-token-alarm-email
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-token-alarm-rule
policy: !KeyOf qqq-token-alarm-policy
attrs:
target: !KeyOf qqq-token-alarm-rule
policy: !KeyOf qqq-token-alarm-policy
order: 0
enabled: true
negate: false
timeout: 30
failure_result: false