Compare commits

..
Author SHA1 Message Date
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 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 2aaedacffd docs(spec): token lifetime cap on the brand, claim bound to profile, separate broker blueprint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:42 -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
james.maesandClaude Opus 5.5 d9431e7e6b docs(spec): access tokens as QQQ's personal access tokens (revision 6)
Owner decisions (2026-10-13) 23-30: tokens managed in the app and stored
in Authentik, shown once, self-service through Preferences, on by default
with an opt-out, guardrails (a)-(f), optional per-token scopes enforced
through core QCredentialScope (qqq#1153), reminders, last used, admin
views and bulk revoke.

Decision 30 records the owner-approved mechanism revision: Authentik
2026.8.3 always creates a token for the caller unless it is a superuser,
so people's tokens are created as that person through RFC 8693 token
exchange (token broker with a deny policy), service accounts through
service_account, set_key and PATCH. The qbit role gains change_token and
set_token_key and never holds add_token or view_token_key.

Adds docs/ACCESS-TOKENS.md ("Using access tokens").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:42:28 -05:00
james.maes 7fb96d7d2a Merge pull request #19 from QRun-IO/feature/policy-feature-permissions
feat(policy): grant table feature permissions (analyze, comment, ...)
2026-10-13 15:39:36 -05:00
james.maesandClaude Opus 5.5 d3b740785f feat(policy): grant table feature permissions (analyze, comment, ...)
The catalog lists each table's feature permissions, read generically from
PermissionsHelper.getAllAvailablePermissions (core
TableFeaturePermissionsMetaData), as a features array per table resource.
Policies grant them by name; the * wildcard still means the five
standard actions only, so existing policies grant nothing new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:30:15 -05:00
james.maes 0e981bc4f7 Merge pull request #18 from QRun-IO/feature/policy-cutover
feat(policy): safe cut-over to policies (status, compare, guard, generate)
2026-10-13 14:48:20 -05:00
james.maes 716a40df1b Merge pull request #17 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 14:42:55 -05:00
james.maesandClaude Opus 5.5 af970d32db feat(policy): safe cut-over to policies (status, compare, guard, generate)
- AuthentikHostPermissionSource SPI (today's access) and config
  mapperEnabled, cutoverGuardGroups, mapperOverride, staticGroupPermissions.
- Enforcement status OFF/ACTIVE/OVERRIDDEN/BLOCKED: banner widget on group
  and policy records and the Edit Policy step ("Policies are saved but NOT
  enforced at sign-in on this instance (mapper off)"), and a row in the home's
  Connection card.
- Compare Access widget (authentik.admin, read only): per active person,
  today vs the mapper, added/removed/unchanged, guard result.
- Cut-over guard: while a guarded-group member would lose a permission (no
  override), the mapper grants today's access plus the static fallback and
  bootstrap subjects instead of the policies.
- Generate Policy From Current Access group action; operator's access today
  counts for the subset rule.
- DESIGN.md 12.8 with the one-switch rollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:39:05 -05:00
james.maesandClaude Opus 5.5 0a827955b8 ci: automate RC releases; hotfix/* pushes no longer publish
Removes publish_hotfix_release: a hotfix/* branch push published a GA
release to Maven Central with no tag or review (QRun-IO/qbit-cdn#12,
QRun-IO/qqq#1121). hotfix/* branches now run test_only; a hotfix
release publishes from a reviewed vX.Y.Z tag through publish_release.
Adds .github/workflows/release-rc.yml (reusable QRun-IO/.github
qbit-release-rc): a manual dispatch, or merging a develop PR labelled
release:rc, merges develop into the release line and the release/* push
runs the existing CircleCI RC publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:36:57 -05:00
james.maes a5a74af0e2 Merge pull request #16 from QRun-IO/feature/user-group-crud
feat(crud): phase 5c full user and group CRUD
2026-10-13 14:12:24 -05:00
james.maesandClaude Opus 5.5 a79f2d2d4b feat(crud): phase 5c full user and group CRUD
- Edit User: username (validated, unique) and other attributes (JSON object;
  qqq.io/ and goauthentik.io/ keys kept and refused; blank keeps them; needs
  authentik.audit.read, which also gates showing them). Edit Group: other
  attributes the same way.
- Delete User and Delete Group behind the new DELETE feature (off by default;
  Authentik delete_user/delete_group): typed confirmation, never yourself, a
  protected account, a superuser group or the last identity administrator; a
  group needs no members and no child groups; its policy row goes with it.
- Tests on the in-memory Authentik and a delete contract test on 2026.8.3;
  README, STATUS, CHANGELOG, CLAUDE.md invariant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:56:23 -05:00
james.maes a2fbb2516e Merge pull request #15 from QRun-IO/feature/policy-editor
feat(policy): phase 5b policy editor and save to Authentik
2026-10-13 13:54:58 -05:00
james.maes 9751296d6c Merge pull request #14 from QRun-IO/feature/policy-model
feat(policy): phase 5a policy model, parser, generator and runtime mapper
2026-10-13 13:44:04 -05:00
james.maes 93d3afa869 Merge pull request #13 from QRun-IO/feature/policy-spec
docs(spec): policies (IAM-style statements) and full user/group CRUD
2026-10-13 13:28:00 -05:00
79 changed files with 7747 additions and 173 deletions

No files matched your search

+1 -12
View File
@@ -22,7 +22,7 @@ workflows:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
@@ -58,14 +58,3 @@ workflows:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only: [/hotfix\/.*/]
+25
View File
@@ -0,0 +1,25 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
type: boolean
default: false
pull_request:
types: [closed]
branches: [develop]
permissions:
contents: write
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+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
+29
View File
@@ -4,6 +4,35 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- 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, 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.5` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. No Java API compatibility fixes were needed.
- 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 the remaining test stand-in.
### Added (automated sessions)
- `AuthentikGroupPermissionMapper` resolves tokenless automated sessions by a unique active Authentik email match, applying direct and inherited group permissions, static fallbacks and the sign-in cut-over guard while preserving existing session permissions.
### Added (table feature permissions)
- Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData
### Added (policy cut-over, DESIGN.md section 12.8)
- `AuthentikHostPermissionSource` SPI (today's access) and config `mapperEnabled`, `cutoverGuardGroups`, `mapperOverride`, `staticGroupPermissions`.
- **Enforcement status**: banner widget `authentikPolicyEnforcement` (first on group and policy records and on the Edit Policy step) and a "Policies at sign-in" row in the home's Connection card: OFF, ACTIVE, OVERRIDDEN or BLOCKED.
- **Compare Access** widget `authentikCompareAccess` on the app home (`authentik.admin`, read only): per active person, today vs the mapper, with added, removed and unchanged, and the guard result.
- **Cut-over guard**: while a member of a guarded group would lose a permission (and no override), the mapper grants today's access plus the static fallback and bootstrap subjects instead of the policies.
- **Generate Policy From Current Access** group action: the expressible part of today's access (joined with the current policy) becomes the policy, named permissions go to `qqq.io/permissions`, the rest is listed; Review shows the document and diff.
### Added (full user and group CRUD, phase 5c: DESIGN.md section 12.5)
- **Edit User** also changes the username (validated, unique) and the other attributes (a JSON object; reserved `qqq.io/` and `goauthentik.io/` keys are kept and cannot be set; blank leaves them as they are; editing attributes needs `authentik.audit.read`, which is also what shows them on the form). **Edit Group** edits other attributes the same way.
- **Delete User** and **Delete Group** record actions behind the new `DELETE` feature (off by default; needs Authentik's `delete_user` / `delete_group`). Typed confirmation; never yourself, a protected account, a superuser group or the last identity administrator; a group must have no members and no child groups first; the group's `authentik_policy` row goes with it. Deactivate stays the default for people.
### Added (policy editor, phase 5b: DESIGN.md section 12.4)
- **Edit Policy** record action on groups and on policies (feature `POLICIES`, permission `authentik.policies.manage`): a standard streamed-ETL action whose step shows the `AUTHENTIK_POLICY_EDITOR` widget (Next UI). Review parses the submitted document (fail closed), regenerates it in canonical form with the round-trip check, refuses new resources this app does not define, applies the subset rule to the permissions it adds, and lists what is added and removed. Result merge-patches `qqq.io/policy` into the group's current attributes (removed when empty). Optional second-person approval (`policyTwoPersonApproval`).
- **`PolicyCatalog`**: every app, table, process (hidden record actions included), report and protected widget a policy can grant, with the actions QQQ checks, grouped by app. Its permission names join the permission catalog.
+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, impersonate or delete permissions.
- 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
+23 -10
View File
@@ -2,14 +2,16 @@
A QQQ QBit to manage [Authentik](https://goauthentik.io) identities from a QQQ app: **the full user lifecycle, and groups with the permissions they grant**. Authentik stays the source of truth. The qbit reads Authentik live and writes through the Authentik API, never its database. Changes made in the Authentik admin UI are equally valid, and the qbit never overwrites them blindly.
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065). Status: [`docs/STATUS.md`](docs/STATUS.md). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Table DDL: [`docs/schema.sql`](docs/schema.sql).
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065). Access tokens (PATs): [`docs/ACCESS-TOKENS.md`](docs/ACCESS-TOKENS.md). Status: [`docs/STATUS.md`](docs/STATUS.md). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Table DDL: [`docs/schema.sql`](docs/schema.sql).
## What you get
| | |
|---|---|
| **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. No hard delete. |
| **Groups and permissions** | Create and edit groups (parents), 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). |
| **Access tokens** | Create and rotate your personal access tokens, revoke (including bulk), extend expiry, and create service accounts. Enabled with any write feature; opt out with `tokensEnabled=false` or `AUTHENTIK_QBIT_TOKENS=false`. Keys are response-only and shown once; key-producing actions require QQQ 4.1.0-RC.7+ one-time output support. See [Access tokens](docs/ACCESS-TOKENS.md). |
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. Edit covers username, name, email, path and other attributes. Delete is a separate, explicit action behind the `DELETE` feature (off by default); deactivate stays the default. |
| **Groups and permissions** | Create, edit (parents, other attributes) and delete groups, add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, connection), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
@@ -23,7 +25,7 @@ AuthentikQBitConfig config = new AuthentikQBitConfig()
.withBaseUrl("https://auth.example.com") // host root; /api/v3 is appended
.withApiToken("${env.AUTHENTIK_API_TOKEN}") // service-account token, resolved by the qbit
.withAppKey("my-app-admin") // service account: qqq-my-app-admin-idadmin
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS))
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS, POLICIES)) // + DELETE for delete actions
.withLocalBackendName("rdbms") // audit trail and approval requests (see docs/schema.sql)
.withRecoveryEmailStage("my-recovery-email") // Authentik email stage (name or uuid)
.withInvitationFlow("my-enrollment"); // optional: enables invitations
@@ -38,10 +40,10 @@ Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill s
| | |
|---|---|
| API backend | `authentikApi` |
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_policy` (with `POLICIES`), `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
| Child lists (on their parents, not in the nav) | `authentik_group_member`, `authentik_session`, `authentik_token` (metadata only, never a key), `authentik_event`, `authentik_policy_binding`, `authentik_role`, `authentik_blueprint` |
| App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report |
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject |
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group |
### Permissions
@@ -54,6 +56,7 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
| `authentik.groups.manage` | create and edit groups |
| `authentik.memberships.manage` | add and remove members (subset rule) |
| `authentik.permissions.manage` | edit a group's permissions and app access (subset rule) |
| `authentik.policies.manage` | edit a group's policy (subset rule) |
| `authentik.credentials.reset` | password reset email, reset MFA, revoke app passwords |
| `authentik.sessions.revoke` | revoke sessions |
| `authentik.audit.read` | sessions, tokens, events, the audit trail, sensitive fields |
@@ -63,11 +66,15 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
`authentik.*` grants all of them.
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
The editor discovers features and labels through `PermissionsHelper.getAllAvailablePermissions`; they appear when the host's core version exposes them. The pinned RC.5 release does not include `TableFeaturePermissionsMetaData`; its later core implementation is a follow-up for the next baseline upgrade. 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, impersonate or delete permissions. [`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. 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
@@ -76,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.5 (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. |
@@ -100,3 +107,9 @@ 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`.
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`).
+27
View File
@@ -1,5 +1,32 @@
# Upgrading qbit-authentik
## QQQ 4.1.0-RC.5 baseline (#20 / F19)
Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.5`
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.
Stand-in audit against core tag `v4.1.0-RC.5`:
- `PolicyFeaturePermissionsTest` supplies a synthetic available-permission catalog:
`STAND-IN(core=QRun-IO/qqq#1136, removeBy=4.1.0-RC.6)`. RC.6 is the removal target,
subject to verifying that release contains the metadata hook. Retain malformed
and duplicate catalog test cases when adding real metadata registration coverage.
- The requested stand-in/RC grep found only the old baseline references; the broader
audit found the test catalog above and stale core-enforcement explanations.
No production bridge can safely be removed by this baseline bump.
- `TableFeaturePermissionsMetaData` (core commit `9a302136d`, #1136) and client
channels (core commit `741bb4974`, PR #1141) exist after RC.5, but neither commit
is an ancestor of the RC.5 tag and neither API is present in that release.
Follow up on a released baseline containing them: verify the policy editor's
catalog with real feature metadata and adopt client-channel attribution and
identity/credential channel restrictions. These features are not added here.
## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release)
The qbit's permissions follow the same owner naming rule (2026-10-12) and now
+65
View File
@@ -0,0 +1,65 @@
# Using access tokens
An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6).
## Create a token
1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**.
2. Choose **Create token** and fill in:
- a description, for example "nightly export script";
- an expiry. It's required: at most 90 days for people, and 365 for service accounts;
- optionally, a **scope**: read only, and/or only some apps and tables. A scoped token can never do more than you can, and loses access as soon as you do.
3. Copy the key from the dialog. **It is shown once.** The app does not keep it, and nobody can show it again. If you lose it, rotate the token.
The dialog also shows ready-to-run examples for this app, with its real URLs.
## Use a token
Tokens are exchanged for a short-lived access token (a JWT) at the Authentik token endpoint. You then call the app with that JWT.
```sh
# 1. Exchange the token for a JWT (valid for the app's access-token lifetime)
JWT=$(curl -s -X POST "https://auth.example.com/application/o/token/" \
-d grant_type=client_credentials \
-d client_id="$APP_CLIENT_ID" \
-d username="$YOUR_USERNAME" \
-d password="$QQQ_TOKEN" \
-d scope="openid profile email" | jq -r .access_token)
# 2. Call the app
curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData"
```
- Keep the token in a secret store or an environment variable. Never commit it.
- When the JWT expires (the API answers 401), repeat step 1.
- Every exchange is recorded as a sign-in in Authentik. The app shows the token's **last used** time from those records.
## Manage your tokens
From **Preferences → Access tokens**:
| Action | What happens |
|---|---|
| **Rotate** | A new key replaces the old one. The description, expiry and scope stay the same. The old key stops working at once. |
| **Extend** | Moves the expiry. The limit counts from today. |
| **Revoke** | Deletes the token in Authentik. Anything using it stops working at once. |
You get a reminder in the notification bell, and by email, 7 days and 1 day before a token expires.
## 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;
- 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.
Administrators can never create, rotate or extend a token for **another person**. Each person manages their own.
Accounts that can administer Authentik, the break-glass account and the app's own service account can't get tokens from the app at all. Use the Authentik admin UI for them if you really need one.
## Security notes
- A token can do what its owner can do, or less if it's scoped. Prefer a scoped token and a short expiry.
- Tokens for people are created as that person, through Authentik. The app's own service account cannot create a token for a person.
- Every create, rotate, extend and revoke is audited in the app (`authentik_admin_action`, with the channel it came from) and in Authentik's event log.
- The key exists only in Authentik and in the one response that showed it to you.
+321 -13
View File
@@ -1,6 +1,6 @@
# Authentik qbit: Design Spec
Status: Approved, revision 5 (adds §12 policies and full user/group CRUD; owner decisions of 2026-10-13) · 2026-10-13 · Author: James Maes (drafted with Claude)
Status: Approved, revision 6 (adds §13 access tokens; revision 5 added §12 policies and full user/group CRUD; owner decisions of 2026-10-13) · 2026-10-13 · Author: James Maes (drafted with Claude)
Targets: QQQ 4.1.x, QQQ Next UI 1.0.x, Authentik 2026.8.x (researched against the live `auth.kof22.com` schema, `info.version: 2026.8.3`), new repo `QRun-IO/qbit-authentik`
## 0. Summary
@@ -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.
@@ -33,7 +33,8 @@ Seven design rules shape everything below:
- Authentik also enforces a hard floor: the service account cannot touch superuser groups, token keys or impersonation.
6. **Credentials never pass through QQQ.**
- New users and password resets go through Authentik's own emails (enrollment or recovery).
- QQQ never sets passwords, never shows recovery links and never displays token keys.
- QQQ never sets passwords and never shows recovery links.
- Token keys are the one exception (§13): a new or rotated access-token key is shown **once**, in the response that created it, and is never stored, logged or audited in QQQ.
7. **Every change is audited on both sides.** QQQ writes a local append-only `authentik_admin_action` row. Authentik receives a `custom_qqq_admin_action` event naming the human operator.
### 0.1 Owner decisions (2026-10-11)
@@ -60,6 +61,19 @@ Seven design rules shape everything below:
| 21 | **Delete is allowed as an explicit action** (supersedes decision 4's "no hard delete"): deactivate stays the default; *Delete User* and *Delete Group* are separate typed-confirmation actions behind the new `DELETE` feature (off by default), which also needs Authentik's `delete_user` / `delete_group`. |
| 22 | **Write permissions split as read / users / groups / policies**: `authentik.users.read`, `authentik.users.manage`, `authentik.groups.manage` (+ `authentik.memberships.manage`), and the new `authentik.policies.manage`. Two-person approval for policy saves is optional (`policyTwoPersonApproval`, off by default). |
**Owner decisions (2026-10-13): access tokens (revision 6).** The owner asked: "I want to manage tokens in app, like we want to do with users and groups and permissions - all through authentik still, but exposed in app to make it easier", then: "it should be on by default. if this is gonna be our PAT solution, it needs to be first class." Authentik tokens, through this qbit, are **the** personal access token (PAT) solution for QQQ apps that sign in with Authentik. Full design in §13.
| # | Decision |
|---|---|
| 23 | **Tokens are managed in QQQ, stored in Authentik.** The `authentik_token` table lists them (owner, identifier, intent, expiry, last used, description, scope). Record actions: *Create Token*, *Rotate Token*, *Revoke Token*, *Extend Expiry*, and *Create Service Account*. |
| 24 | **The key is shown exactly once**, in the response of the create or rotate, with copy and usage examples, and is never stored in QQQ: not in a table, the audit, logs, process state, ESB, notifications or the assistant (core `OneTimeSecret`, §13.6). |
| 25 | **Self-service.** Every signed-in user manages their **own** tokens in the Preferences section "Access tokens" (core Preferences contract, QRun-IO/qqq#1162), also reachable from the user menu. |
| 26 | **On by default** whenever the qbit runs with writes enabled (feature `TOKENS`). A host can opt out (`tokensEnabled=false`, kof22 env `AUTHENTIK_QBIT_TOKENS=false`). No opt-in flag. |
| 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`, `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. |
### 0.2 Phase 0 findings (2026-10-11, verified on Authentik 2026.8.3)
The four open points of revision 2 were resolved against a disposable Authentik 2026.8.3 (Testcontainers contract tests in `QRun-IO/qbit-authentik`). Six further facts changed the design; each is pinned by a contract test, so an Authentik upgrade that changes it fails CI.
@@ -602,13 +616,15 @@ The role `qqq-<appKey>-idadmin` is assigned directly to the service account (boo
| `authentik_core.add_user_to_group`, `remove_user_from_group` | Membership | 2 |
| `authentik_core.delete_authenticatedsession` | Revoke sessions | 3 |
| `authentik_core.delete_token` | Revoke app passwords | 3 |
| `authentik_core.change_token` | Extend a token's expiry (§13); `PATCH` always sends `intent`, `user` and `expires` | 6 |
| `authentik_core.set_token_key` | Rotate a **service account's** app password with a key the qbit generates (§13). Guarded by §13.4 (a) and the detective rule (e) | 6 |
| Device `delete_*` for TOTP, WebAuthn, static, email, SMS | MFA reset | 3 |
| `authentik_stages_invitation.add_invitation`, `change_invitation`, `delete_invitation` | Invites | 3 |
| `authentik_rbac.change_role`, `assign` (object, on non-qbit roles) | Role management | 4 |
**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`, `set_token_key`, `add_token` (MHP APPS-1462)
- `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.
@@ -652,7 +668,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.
@@ -685,7 +701,8 @@ This is weaker than revision 1's per-group object permissions. That is the cost
| Invite user | Yes (`USERS`) | Invitation (`fixed_data` email and name, `single_use`, expires in 7 days) + `send_email`, through the `kof22-enrollment` flow. Groups are applied by QQQ when the `invitation_used` webhook arrives. |
| Send password-reset email | Yes (`CREDENTIALS`) | `recovery_email` with `recoveryEmailStage`, `token_duration` 30 minutes |
| Show or copy recovery link, set password, set hash | **No** | Never called. A link given to the operator amounts to account takeover. |
| View or set token keys; create tokens | **No** | Not permitted for the service account; not exposed |
| Create, rotate, extend, revoke access tokens | Yes (`TOKENS`, §13) | A person's own tokens: as that person, through the token broker. Service accounts: `service_account`, `set_key`, `PATCH`. The key is shown once and never stored |
| Read token keys (`view_key`) as the service account | **No** | Never granted; a key is read only by its owner, in the owner's own exchange |
| Revoke sessions | Yes (`SESSIONS`) | `bulk_delete?user_pks=` / per-session `DELETE` |
| Reset MFA | Yes (`CREDENTIALS`) | Device list, then per-type `DELETE`, with typed confirmation and a reason |
| Revoke app passwords | Yes (`CREDENTIALS`) | `DELETE /core/tokens/{identifier}/`, intent `app_password` only |
@@ -703,6 +720,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
@@ -722,7 +743,7 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace
| `authentik_policy_binding` | `/policies/bindings/?target=` | uuid | Query | Read only. Shows the policy, group or user, plus negate, order and expiry. |
| `authentik_session` | `/core/authenticated_sessions/` | uuid | Query, delete (revoke) | Sensitive |
| `authentik_mfa_device` | `/authenticators/admin/all/?user=` | `type:id` | Query, delete (reset) | Metadata only. Query requires a `user` criterion. |
| `authentik_token` | `/core/tokens/?user__username=` | identifier | Query, delete (revoke) | **No key field.** Only `intent`, `expires`, `description`. |
| `authentik_token` | `/core/tokens/?user__username=` | identifier | Query; changed only by the token actions (§13) | **No key field.** Owner, intent, expires, description, managed, plus computed `lastUsed`, `scopeSummary`, `ownerType`, `isProtectedOwner`. |
| `authentik_event` | `/events/events/` | uuid | Query, get | Sensitive. Ordered `-created`. `context` is rendered as JSON. |
| `authentik_invitation` | `/stages/invitation/invitations/` | uuid | Query; insert via *Invite*; delete (cancel) | |
| `authentik_blueprint` | `/managed/blueprints/` | uuid | Query | Read only. Used by the drift report. |
@@ -927,13 +948,14 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh
| Threat | Control |
|---|---|
| A compromised QQQ backend escalates in Authentik | The service account can't create superusers, touch superuser groups, read or mint tokens, impersonate, delete users, or change flows and policies (§5.1) |
| A compromised QQQ backend escalates in Authentik | The service account can't create superusers, touch superuser groups, read token keys, create tokens for others, impersonate, delete users, or change flows and policies (§5.1). Since revision 6 it can set a token's key (`set_token_key`, for service-account rotation): guard §13.4 (a) plus the detective rule (e) |
| A person's token is minted by someone else | Tokens for people are created only as that person, through token exchange (§13.3.1); Authentik enforces it |
| A QQQ operator escalates themselves or a friend | Subset rule, no self-edits, two-person approval for `authentik.*` grants, protected accounts |
| Lockout (last admin removed or deactivated) | Last-admin guard; break-glass superuser outside QQQ; `bootstrapOperatorSubjects` |
| 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 |
@@ -1011,7 +1033,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 |
@@ -1057,7 +1079,9 @@ A **policy** is a JSON document on an Authentik group that grants QQQ permission
}
```
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` = all five actions). Those are QQQ permission names, so a QQQ session that holds them passes QQQ's own permission checks. Policies cover the instance's tables, processes, apps, reports and widgets. Named permissions that are not `{resource}.{action}` (for example `authentik.users.manage`) stay in `qqq.io/permissions` (§5.6); a policy cannot grant them, because a resource cannot contain a dot.
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` = only the five standard actions: `hasAccess`, `read`, `insert`, `edit`, `delete`). Those are QQQ permission names, so a QQQ session that holds them passes QQQ's own permission checks. Policies cover the instance's tables, processes, apps, reports and widgets. Named permissions that are not `{resource}.{action}` (for example `authentik.users.manage`) stay in `qqq.io/permissions` (§5.6); a policy cannot grant them, because a resource cannot contain a dot.
Table feature actions are generic strings registered in the table's permission catalog: for example `analyze` (qbit-data-analysis), or `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration). `{"Effect":"Allow","Action":["analyze"],"Resource":["person"]}` grants `person.analyze`, checked with `session.hasPermission("person.analyze")`. A feature must exist for every resource in its statement. Unknown features reject the document. `"*"` never includes features, so existing policies do not acquire new grants when a feature is registered.
**Fail closed.** Any of these grants **zero** permissions from that document and logs a warning:
- an unknown `PolicySpec` or an unsupported `PolicyVersion`
@@ -1113,11 +1137,14 @@ The feature `POLICIES` turns on the editor, the `authentik_policy` table and the
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
- `read`, `insert`, `edit` and `delete` for tables, limited by the table's capabilities and permission level
- Table features from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, matched by the table's effective permission base name plus `.`. Their labels come from `AvailablePermission.permissionType`; no dependency on the unreleased `TableFeaturePermissionsMetaData` hook is required. The five standard suffixes are excluded.
The widget JSON keeps `actions` unchanged and adds a `features` array to each table resource, for example `"actions":["read","insert","edit","delete"],"features":[{"name":"analyze","label":"Analyze"},{"name":"comment","label":"Comment"}]`. Tables without features have `"features":[]`. Features also appear for `HAS_ACCESS` and `READ_WRITE` tables. Shared permission base names merge their features. Feature checkboxes must emit explicit action names.
The catalog also follows these rules:
- Hidden processes are included. Record actions are hidden from the nav but are still permission-checked.
- Objects that are in no app are listed under "Other".
- Tables that use `READ_WRITE` permissions are shown with `read` only, and a note, because `write` is not in the action set yet.
- Tables that use `READ_WRITE` permissions are shown with the standard action `read` and any registered features, and a note about their single write permission. The generic matching rule also exposes their available `write` permission as an explicit named action; `*` does not grant it.
- Objects with a custom permission checker, or `NOT_PROTECTED`, are left out because a policy cannot grant them.
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.
@@ -1145,7 +1172,7 @@ Refusals name the reason.
- clears the caches
Saving an empty matrix removes the attribute. The canonical form is the reference generator's:
- one statement per action, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`
- one statement per action, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`, followed by feature names sorted alphabetically
- resources sorted
- a resource with every action is still written as five statements, not `*`, so diffs stay readable
@@ -1178,6 +1205,54 @@ Deactivate stays the default for people, and *Delete User* says so in its help t
The token needs these only when writes are on. A read-only deployment (`readOnly`, or no write features) keeps the phase 1 read set.
### 12.8 Cut-over: making policies take effect safely
Saved policies do nothing until the host signs people in through the qbit mapper. The qbit makes that state visible, helps seed policies from today's access, and refuses to switch over in a way that would lock administrators out.
**Host SPI.** `AuthentikHostPermissionSource` returns what a user (or a group's members) gets *today* from the host, typically a hard-coded grant such as "admins get `adminPermissions()`". The host sets it with `withHostPermissionSource(...)`.
**Config.**
| Setting | Default | Meaning |
|---|---|---|
| `mapperEnabled` | `false` | The host's own switch (kof22: `AUTHENTIK_QBIT_MAPPER`). Off means "policies are saved but not enforced". A host that calls the mapper without setting it is treated as on. |
| `cutoverGuardGroups` | none | Groups whose members must lose no permission at the cut-over (kof22: `kof22-admins`) |
| `mapperOverride` | `false` | The owner's explicit override of a failing guard |
| `staticGroupPermissions` | none | Permissions always granted to a group's members (the floor), e.g. today's admin grant |
**Status** (`AccessComparison.state`, cached 60 s):
- `OFF`: the mapper is not in use.
- `ACTIVE`: the guard passed, or nothing is guarded.
- `OVERRIDDEN`: the owner overrode a failing guard.
- `BLOCKED`: the guard failed or could not reach Authentik.
The status is shown in three places:
- the *Policy enforcement* banner, first on group and policy records and on the Edit Policy step. When off it reads "Policies are saved but NOT enforced at sign-in on this instance (mapper off)".
- the home's Connection card ("Policies at sign-in")
- **Compare Access**
**Compare Access** is a read-only `TABLE` widget on the app home (`authentik.admin`). For every active person it compares two sets:
- **today**: the host source
- **with the mapper**: the person's groups' policies and `qqq.io/permissions` including ancestors, the static fallback of their direct groups, and all `authentik.*` for a bootstrap subject
It shows what is added and removed, and the unchanged count. Guarded people are marked, and the footer says whether the guard passes.
**Guard.** While the status is `BLOCKED`, the mapper does not apply policies. A sign-in gets:
- today's access from the host source
- the static fallbacks
- the bootstrap subjects
So switching the mapper on can never take access away from a guarded admin until Compare Access shows no removal for them, or the owner sets `mapperOverride`. The static fallback and the bootstrap subject (the owner's immutable Authentik pk) stay in force in every state, so a lockout is impossible.
**Generate Policy From Current Access** is a record action on groups that saves a policy. It takes what the group's members get today (`groupPermissions`):
- Permissions a policy can express are joined with the group's current policy, so nothing is removed, and become the document.
- Named permissions the app defines but a policy cannot express (dotted names, `write`) are merged into `qqq.io/permissions`.
- Anything else is listed as skipped.
Review shows the generated document, the diff and the subset rule before anything is saved. For the subset rule, the operator's own access today (host source plus static fallback) counts.
**Rollback** is one switch: set the host flag off (kof22: `AUTHENTIK_QBIT_MAPPER=false`). Sign-in returns to the host's own grant immediately. Saved policies stay in Authentik and in `authentik_policy`, unused.
### 12.6 Tests
- **Parser:** every fail-closed case (null, blank, not JSON, unknown spec, unsupported version, `Deny`, unknown action, bad resource, non-array fields), `*` expansion, and merging several statements.
@@ -1199,6 +1274,239 @@ The token needs these only when writes are on. A read-only deployment (`readOnly
---
## 13. Access tokens: QQQ's personal access tokens (revision 6)
Decisions 23–30 (§0.1). Authentik stays the source of truth: every token is an Authentik `Token` row. QQQ keeps only non-secret metadata about the tokens it created: the scope, who created it and when.
### 13.1 What a QQQ access token is
A QQQ access token is an Authentik **app password** (`intent: app_password`) owned by a person or a service account. A client uses it in two steps:
1. **Exchange it for a short-lived JWT** at the app's Authentik token endpoint: `POST {authentikPublicUrl}/application/o/token/` with:
- `grant_type=client_credentials`
- `client_id={app client id}`
- `username={owner username}`
- `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.
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).
API-intent tokens (`intent: api`) call Authentik's own API, not the app. QQQ lists them and can revoke them, but does not create them, because an API token acts with all of its owner's Authentik permissions.
### 13.2 Why the qbit can't just call `POST /core/tokens/` (verified in the 2026.8.3 source)
- **Create belongs to the caller.** `TokenViewSet.perform_create` saves every new token with `user = request.user` unless the caller is a superuser, and `add_token` changes nothing. The qbit's service account is not a superuser and never will be (§5.1), so it **can't** create a token for anyone else.
- **PATCH has dangerous defaults.** `TokenSerializer.validate` runs `attrs.setdefault("intent", "api")` and `attrs.setdefault("user", request.user)`, and forces `expires` to the default for API tokens.
- A `PATCH` without `intent` turns an app password into an API token with the default expiry.
- A `PATCH` without `user` moves the token to the caller.
- So **every `PATCH` the qbit sends carries `intent`, `user` (the current owner's pk) and `expires`**, and the qbit re-reads the token afterwards (the race guard in §13.3).
- **Expiry is capped.** An app password's expiry is capped by `goauthentik.io/user/token-maximum-lifetime` (for example `days=90`) from `User.group_attributes`, which merges the **brand's** attributes, then the user's groups, then the user. Without it the cap is the tenant's default token duration, which is short. The blueprint sets the attribute on the brands (§13.9); the qbit sets `days=365` on each service account it creates.
- **Expired tokens linger.** They stay in the database until Authentik's cleanup task removes them, and the token-endpoint lookup (`Token.objects.filter(key=...)`) doesn't check `expires`. So the qbit never relies on an expired token being unusable. Revoke deletes the token.
### 13.3 How each action works
| Action | Where | Who | Authentik calls |
|---|---|---|---|
| *Create Token* (own) | Preferences "Access tokens"; `authentik_token` table action | Any signed-in user, for themselves | As the user, through token exchange (§13.3.1): `POST /core/tokens/` (`intent: app_password`, identifier `qqq-pat-{username}-{yyyymmdd}-{6 random}`, `description`, `expires`, `expiring: true`), then `GET /core/tokens/{id}/view_key/` (the owner may read their own new key) |
| *Rotate Token* (own) | same | The owner | As the user: create a new token with the same description, expiry and scope, read its key, then `DELETE` the old one. The new identifier replaces the old one in `authentik_token_scope`. If the delete fails, the result says so and the old token stays listed. |
| *Revoke Token* | Preferences (own); table record and bulk action (`authentik.tokens.manage`) | The owner, or an admin for any owner who isn't protected | `DELETE /core/tokens/{identifier}/`. Own tokens: as the user. Admin: as the service account, with `delete_token`. |
| *Extend Expiry* | Preferences (own); table record action | The owner; an admin for service accounts | Own tokens: as the user. Service accounts: as the qbit's service account, with `change_token`. Always `PATCH {intent, user, expires}`, then a re-read. |
| *Create Service Account* | `authentik_user` table action | `authentik.tokens.manage` + `authentik.users.manage` | `POST /core/users/service_account/` (`name`, `create_group: false`, `expiring: true`, `expires`). The response's `token` is the one-time key. Then set `goauthentik.io/user/token-maximum-lifetime: days=365` on the new account (its expiry cap, §13.9) and add it to the groups chosen in the policy editor, subject to the subset rule (§5.4). |
| *Rotate Token* (service account) | `authentik_token` record action | `authentik.tokens.manage` | The qbit generates a key (48 random bytes, URL-safe base64) and calls `POST /core/tokens/{identifier}/set_key/`. The identifier, expiry and scope don't change. |
**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";
- an alert is raised (§13.8).
Authentik's `validate_user` refuses a different user, and sending `user` explicitly closes the `setdefault` path.
#### 13.3.1 Acting as the user: token exchange
**The broker.** The blueprint adds a confidential OAuth2 provider and application, `qqq-{appKey}-token-broker`:
- access-token validity `minutes=2`;
- property mappings: only the default `goauthentik.io/api` scope;
- `jwt_federation_providers`: the app's own sign-in provider;
- a **deny policy** bound to the application for superusers, members of `authentik Admins` and `identity-admins`, users with `qqq.io/protected`, and the qbit's own service account (§13.4 a).
**The exchange.** The qbit takes the signed-in user's current access JWT for the app. In kof22 this is the token the OAuth2 module stores in `qqq_user_session`; a host can supply another source with `withSubjectTokenSource(...)`. The qbit posts:
- `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`
- `client_id` and `client_secret` of the broker
- `subject_token={jwt}`
- `subject_token_type=urn:ietf:params:oauth:token-type:jwt`
- `scope=goauthentik.io/api`
**What Authentik does.** It verifies the JWT against the federated provider and applies the broker app's policies, so the deny policy runs inside Authentik. It then returns a 2-minute access token **for that user**. The qbit uses it for the token calls of that one action, then drops it; it is never stored or logged.
**Why this is safe.** The calls are made as the user, so Authentik itself enforces "own tokens only": a user can create, and read the key of, only their own tokens.
**Config.**
- `tokenBrokerClientId`: `${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}`
- `tokenBrokerClientSecret`: `${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}`
- `tokenEndpointUrl`: default `{baseUrl}/application/o/token/`
Without a broker, the self-service actions are hidden, and Review says why.
### 13.4 Guardrails (checked at Review and again at execute)
| | Rule | Where enforced |
|---|---|---|
| (a) | **No minting or rotating for privileged accounts.** Refused for: superusers, including by inheritance; members of `authentik Admins`, `identity-admins` and the configurable `tokenProtectedGroups`; the break-glass account and anything with `qqq.io/protected`; `protectedUsernames`; and the qbit's own service account. Hard-coded for the built-in classes, plus the configurable list. | The `AuthentikTokens` guard; the broker app's deny policy (self-service); for protected owners, `set_key` is refused before the call |
| (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. |
| (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
**What a scope is.** A token may be narrower than its owner: **read only**, and/or limited to a list of **apps** and **tables**. Advanced scopes can also list processes, reports, widgets and operationIds. No scope means the token can do everything its owner can.
**Where scopes are stored.** In the local table `authentik_token_scope`, one row per token the qbit created:
- `id`
- `tokenIdentifier` (unique)
- `ownerUsername`, `ownerPk`
- `readOnly`
- `apps`, `tables`, `processes`, `reports`, `widgets`, `operationIds` (TEXT, one per line)
- `createdBy`, `createdAt`, `rotatedAt`
- `lastReminder7d`, `lastReminder1d`
There is no key, and no hash of a key. Generic insert, edit and delete are off; only the token actions change the table. Record-level audit is on.
**Binding the JWT to its token.** A scope mapping on the app's provider (scope name `profile`) adds `qqq_token: {identifier}` to JWTs minted by `client_credentials` with an app password. It finds the app password the request presented, owned by the same user. Authentik signs the claim. It is bound to `profile` because the app needs the `groups` claim (also `profile`) to accept a token at all, so a client can't drop the claim and keep access.
**Enforcement.** The mapper reads `qqq_token`, loads the scope row (cached 60 s) and sets core's `QCredentialScope` (QRun-IO/qqq#1153) on the session. Core then:
- intersects the scope with the owner's live permissions in `PermissionsHelper`;
- checks `operationIds` in the UI API;
- records the channel `token:{identifier}`.
STAND-IN(core=QRun-IO/qqq#1153, removeBy=the RC that ships #1153, RC.7 at the earliest). Until then the mapper narrows the session's permission set itself:
- read only keeps only `.read` and `.hasAccess`;
- apps and tables keep only permissions whose base name belongs to a listed app or table.
**Fail closed.**
- Scoped tokens are offered only when the host declares the claim mapping installed (`tokenScopesEnabled`; kof22 sets it to `true` once the blueprint is applied).
- A JWT whose `qqq_token` names a token with no scope row is unscoped (the token was created outside the qbit).
- A `qqq_token` naming a token that no longer exists gets **no** permissions.
### 13.6 The key is shown once
**The type.** Core's `com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret` (`label`, `value`, `masked`, `usageText`) from QRun-IO/qqq#1162. Its `toString()` never includes the value.
**Preferences.**
- The provider's `runAction` returns the secret in `UserPreferenceActionResult.secrets`.
- The endpoint responds with `Cache-Control: no-store`.
- Next shows it in a show-once dialog with *Copy* and the usage text.
**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".
**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.
**The test.** A test runs every action, then searches for the key in every QQQ table, the captured log output, the stored process state and the published events (§13.11).
### 13.7 How to use it (generated)
The result screen, the Preferences dialog and `docs/ACCESS-TOKENS.md` show:
- the token endpoint (`{authentikPublicUrl}/application/o/token/`), the app's client id and the owner's username;
- a `curl` that gets a JWT;
- a `curl` to `{appBaseUrl}/qqq/v1/metaData`, and one to a published API (`{appBaseUrl}/api/{version}/...`) when the instance publishes one;
- the token's expiry and scope.
The URLs come from config (`appPublicUrl`, `authentikPublicUrl`, `appClientId`), defaulting to the OAuth2 module's metadata. The key appears in the usage text only inside the one-time response.
### 13.8 Reminders, last used and cleanup
**Last used.** Read from Authentik `login` events with `method: token` (`context.method_args.identifier`), through `/events/events/?action=login`, over a 90-day window, cached for 5 minutes. API tokens show "not tracked".
**Expiry reminders.** A daily scheduled job finds tokens expiring within 7 days and within 1 day, and notifies each owner (bell and email):
- through the Alerts producer SPI when qbit-alerts is installed;
- otherwise through core notifications, if present;
- otherwise not at all.
Reminders are deduplicated through `lastReminder7d` and `lastReminder1d`. Tokens created outside the qbit have no scope row, so they are reminded by owner and identifier.
**Cleanup suggestion.** The app home lists tokens not used for 60 days (`tokenUnusedDays`, configurable) and tokens of deactivated users. It links to the filtered list and the bulk *Revoke Token* action.
### 13.9 Authentik changes (cluster-gitops, kof22)
**The qbit role** `qqq-kof22-admin-idadmin`:
- 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** `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);
- the `qqq_token` scope mapping on the kof22 admin provider (§13.5);
- `goauthentik.io/user/token-maximum-lifetime: days=90` on the brands (people). Groups are `state: created` and can't take new attributes from a blueprint; the qbit sets `days=365` on service accounts it creates;
- the broker in its own blueprint file (`kof22-qqq-tokens.yaml`), so a problem there can't affect sign-in;
- the notification rule and policy (§13.4 e).
**Rollout (kof22).** Seed the Infisical key `/kof22-website-authentik-api/token-broker-client-secret` first, then merge the role-permissions gitops PR, then deploy the backend. `TOKENS` is on by default, so there is no flag PR. The ESO secrets are `AUTHENTIK_TOKEN_BROKER_CLIENT_ID` and `AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET` (Infisical `/kof22-website-authentik-api/`).
### 13.10 Permissions, tables and config
| QQQ permission | Grants |
|---|---|
| (none beyond signing in) | The "Access tokens" Preferences section, for one's own tokens |
| `authentik.tokens.manage` | The `authentik_token` table (all tokens); revoking any token whose owner isn't protected (single and bulk); service-account create, rotate and extend; the admin widgets |
| `authentik.audit.read` | Still reads `authentik_token` (unchanged) |
| Setting | Default | Meaning |
|---|---|---|
| `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 |
| `tokenBrokerClientId` / `tokenBrokerClientSecret` / `tokenEndpointUrl` | env / env / `{baseUrl}/application/o/token/` | §13.3.1 |
| `appPublicUrl`, `authentikPublicUrl`, `appClientId` | from the OAuth2 module | §13.7 |
| `serviceAccountTokenLifetime` | `days=365` | Set on service accounts the qbit creates |
| `tokenUnusedDays` | 60 | The cleanup suggestion |
### 13.11 Tests
**In-memory Authentik.** `FakeAuthentik` is extended to model the 2026.8.3 behaviour of §13.2: caller-owned create, `setdefault` on `PATCH`, the lifetime cap, `service_account`, `set_key` and token exchange. Tests cover:
- create, rotate, revoke and extend, both for self and for service accounts;
- every guardrail refusal: the protected classes, another human, a missing or too-long expiry, no broker;
- the race guard;
- approval when (d) is on;
- scope rows, reminders and last used.
**The key is never persisted or logged.** After each action, the key is absent from every local table, the audit rows, the captured log, the stored `ProcessState`, the published ESB events and the Authentik event body the qbit posted.
**Contract tests on Authentik 2026.8.3** (Testcontainers):
- the §13.2 facts: a non-superuser create belongs to the caller, a `PATCH` without `intent` changes the token, and the lifetime cap applies;
- token exchange as a user through the broker;
- the deny policy refuses a protected user;
- `service_account`, `set_key` and `PATCH` for a service account;
- the `qqq_token` claim on a `client_credentials` JWT;
- the detective notification rule fires for a protected owner.
### 13.12 Delivery
| Phase | Scope |
|---|---|
| 6a | This spec and `docs/ACCESS-TOKENS.md` |
| 6b | The `TOKENS` feature, the `authentik_token` columns, `authentik_token_scope`, the token actions with guardrails, service accounts, the one-time key (core PR), tests and contract tests |
| 6c | Scopes and the `qqq_token` claim, last used, reminders, admin widgets and bulk revoke |
| 6d | The "Access tokens" Preferences provider (core #1162) and the user-menu entry (Next, by the Preferences agent) |
| 6e | The cluster-gitops role permissions and blueprint objects; kof22 adoption (draft) |
---
## Appendix A: Example API mapping (`authentik_user`)
| QQQ | Authentik |
+11 -5
View File
@@ -31,12 +31,12 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is
| `PolicyVersion` | string, semver | Must equal the supported version (`policyVersion`, default `1.0.0`) |
| `Statement` | array of statements | May be empty (grants nothing) |
| `Statement[].Effect` | string | `Allow` (the only effect in 1.0.0) |
| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*` |
| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`, or a table feature name registered for every listed resource |
| `Statement[].Resource` | array of strings | Each one a QQQ permission base name matching `[A-Za-z0-9_]+`: a table, process, app, report or widget name (or its custom `permissionBaseName`) |
## Meaning
Each statement grants `{resource}.{action}` for every resource and action it lists. `*` stands for all five actions. The result is the union over all statements, and over all of the user's groups.
Each statement grants `{resource}.{action}` for every resource and action it lists. `*` stands for the five standard actions only; it never includes table features. The result is the union over all statements, and over all of the user's groups.
| Action | QQQ permission | Applies to |
|---|---|---|
@@ -45,10 +45,14 @@ Each statement grants `{resource}.{action}` for every resource and action it lis
| `insert` | `{name}.insert` | tables |
| `edit` | `{name}.edit` | tables |
| `delete` | `{name}.delete` | tables |
| registered feature (e.g. `analyze`, `comment`) | `{name}.{feature}` | tables that register that feature, including `HAS_ACCESS` and `READ_WRITE` tables |
Examples:
- `{"Action": ["read"], "Resource": ["cms_page"]}` grants `cms_page.read`.
- `{"Action": ["*"], "Resource": ["cms_page"]}` grants `cms_page.hasAccess`, `cms_page.read`, `cms_page.insert`, `cms_page.edit` and `cms_page.delete`.
- `{"Action": ["analyze", "comment"], "Resource": ["person"]}` grants `person.analyze` and `person.comment` when both features are registered for `person`.
Features are generic strings, discovered by matching `PermissionsHelper.getAllAvailablePermissions(qInstance)` entries to the table's effective permission base name. Examples include `analyze` from qbit-data-analysis and `comment`, `attach`, `tag`, `share`, `task`, `assign` from qbit-collaboration. The feature label comes from `AvailablePermission.permissionType`. Every feature grant is explicit, so registering a new feature never broadens an existing `"*"` policy. This matching rule also exposes the available `write` permission of `READ_WRITE` tables as an explicit named action; it is not part of `*`.
A grant that QQQ never checks (for example `hasAccess` on a table with insert/edit/delete permissions) is harmless.
@@ -63,7 +67,7 @@ A document grants **nothing**, and the parser logs a warning, when any of these
- `Statement` is present but not an array, or a statement is not an object
- a statement's `Effect` is not `Allow` (including `Deny`, which is not supported yet)
- `Action` or `Resource` is missing or not an array of strings
- an action is not one of the six above
- an action is neither a standard action, `*`, nor a feature registered for every listed resource
- a resource does not match `[A-Za-z0-9_]+`
There is no partial credit: one bad statement voids the whole document. When the qbit reads policies from the group graph, each group's document is parsed on its own, so one bad group voids only that group. In the merged token claim, one bad statement voids the claim.
@@ -71,7 +75,7 @@ There is no partial credit: one bad statement voids the whole document. When the
## Canonical form
The qbit always writes the canonical form:
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`, then feature names sorted alphabetically (existing documents retain their byte-for-byte canonical form)
- each statement lists its resources sorted
- no `*`
- stored on the group as a compact JSON **string**
@@ -99,7 +103,9 @@ Authentik puts the merged document into the token through a scope mapping. Steps
4. Register the customizer in your app:
- `AuthentikPolicyClaimMapper` on its own, or
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat())`.
- 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 `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.
+3 -3
View File
@@ -11,14 +11,14 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated
| 2. Memberships and permission editing | done, except the webhook route, ESB trigger, inbox and session re-evaluation marking |
| 3. Full user lifecycle | done, except the optional snapshot export |
| 4. RBAC role management | post-v1, not started |
| 5. Policies and full CRUD (DESIGN.md section 12) | spec approved 2026-10-13; 5a policy model, 5b editor, 5c CRUD additions in progress |
| 5. Policies and full CRUD (DESIGN.md section 12) | 5a policy model (#14), 5b policy editor (#15), 5c user and group CRUD additions; the Next UI widget is a separate qqq-frontend-next PR |
### Built in this line (0.1)
| Area | What |
|---|---|
| People | Create, invite, edit, deactivate, reactivate, password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
| Groups | Create, edit (parents), add and remove members (bulk), edit permissions and app access |
| People | Create, invite, edit (username, name, email, path, other attributes), deactivate, reactivate, delete (feature `DELETE`), password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
| Groups | Create, edit (parents, other attributes), delete (feature `DELETE`), add and remove members (bulk), edit permissions and app access, edit the group policy (feature `POLICIES`) |
| Safety | Subset rule on live groups, protected accounts and superuser groups, no self-edits, last-admin protection, reasons, typed confirmations, bulk caps, stale checks, two-person approval for `authentik.*` |
| Audit | `authentik_admin_action` (before the call), Authentik `custom_qqq_admin_action` events, approval requests |
| Screens | Home dashboard, main tables in the nav, child lists on parents, every action as a table or record action |
+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)
);
+2 -2
View File
@@ -237,7 +237,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 +245,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.5</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,31 @@ 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.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.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 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;
@@ -90,6 +106,12 @@ public class AuthentikQBitConfig implements QBitConfig
private String policyAttribute = PolicyFormat.DEFAULT_ATTRIBUTE;
private String policyClaim = PolicyFormat.DEFAULT_CLAIM;
private Boolean policyTwoPersonApproval = false;
private Boolean mapperEnabled = false;
private Boolean mapperOverride = false;
private List<String> cutoverGuardGroups = new ArrayList<>();
private java.util.Map<String, List<String>> staticGroupPermissions = new java.util.LinkedHashMap<>();
@JsonIgnore
private com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource hostPermissionSource;
@@ -100,6 +122,19 @@ 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(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)");
@@ -179,6 +214,11 @@ public class AuthentikQBitConfig implements QBitConfig
errors.add("features USERS and CREDENTIALS need recoveryEmailStage");
}
if(requested.contains(AuthentikFeature.DELETE) && !requested.contains(AuthentikFeature.USERS) && !requested.contains(AuthentikFeature.GROUPS))
{
errors.add("feature DELETE needs USERS or GROUPS");
}
if(requested.contains(AuthentikFeature.POLICIES) && !requested.contains(AuthentikFeature.GROUPS))
{
errors.add("feature POLICIES needs GROUPS");
@@ -411,6 +451,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));
}
@@ -443,6 +488,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)") + "}");
}
@@ -1238,4 +1296,579 @@ public class AuthentikQBitConfig implements QBitConfig
return (new PolicyFormat(policySpec, policyVersion, policyAttribute, policyClaim));
}
/*******************************************************************************
** Getter for mapperEnabled: whether the host uses the qbit mapper for sign-in permissions (its own flag, e.g. AUTHENTIK_QBIT_MAPPER); false shows "policies are not enforced" everywhere.
*******************************************************************************/
public Boolean getMapperEnabled()
{
return (this.mapperEnabled);
}
/*******************************************************************************
** Fluent setter for mapperEnabled
*******************************************************************************/
public AuthentikQBitConfig withMapperEnabled(Boolean mapperEnabled)
{
this.mapperEnabled = mapperEnabled;
return (this);
}
/*******************************************************************************
** Getter for mapperOverride: activate the mapper even when the cut-over guard finds removed permissions (explicit owner override).
*******************************************************************************/
public Boolean getMapperOverride()
{
return (this.mapperOverride);
}
/*******************************************************************************
** Fluent setter for mapperOverride
*******************************************************************************/
public AuthentikQBitConfig withMapperOverride(Boolean mapperOverride)
{
this.mapperOverride = mapperOverride;
return (this);
}
/*******************************************************************************
** Getter for cutoverGuardGroups: groups whose members must lose no permission at the cut-over (DESIGN.md section 12.8).
*******************************************************************************/
public List<String> getCutoverGuardGroups()
{
return (this.cutoverGuardGroups);
}
/*******************************************************************************
** Fluent setter for cutoverGuardGroups
*******************************************************************************/
public AuthentikQBitConfig withCutoverGuardGroups(List<String> cutoverGuardGroups)
{
this.cutoverGuardGroups = cutoverGuardGroups;
return (this);
}
/*******************************************************************************
** Getter for staticGroupPermissions: permissions always granted to members of a group (the mapper floor, e.g. today's admin grant), so a cut-over cannot lock admins out.
*******************************************************************************/
public java.util.Map<String, List<String>> getStaticGroupPermissions()
{
return (this.staticGroupPermissions);
}
/*******************************************************************************
** Fluent setter for staticGroupPermissions
*******************************************************************************/
public AuthentikQBitConfig withStaticGroupPermissions(java.util.Map<String, List<String>> staticGroupPermissions)
{
this.staticGroupPermissions = staticGroupPermissions;
return (this);
}
/*******************************************************************************
** Getter for hostPermissionSource: the host's current (pre-policy)
** permission source, or null (DESIGN.md section 12.8).
*******************************************************************************/
@JsonIgnore
public com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource getHostPermissionSource()
{
return (this.hostPermissionSource);
}
/*******************************************************************************
** Fluent setter for hostPermissionSource
*******************************************************************************/
public AuthentikQBitConfig withHostPermissionSource(com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource hostPermissionSource)
{
this.hostPermissionSource = hostPermissionSource;
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);
}
}
@@ -23,6 +23,7 @@ import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
@@ -47,6 +48,8 @@ public class AuthentikRuntime
private final Memoization<String, GroupGraph> groupGraphCache;
private volatile java.util.Set<String> permissionCatalog;
private volatile PolicyCatalog policyCatalog;
private volatile AccessComparison.State mapperState;
private volatile boolean mapperInvoked;
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
@@ -233,4 +236,48 @@ public class AuthentikRuntime
return (responseCache);
}
/*******************************************************************************
** The cached mapper state (DESIGN.md section 12.8), or null.
*******************************************************************************/
public AccessComparison.State getMapperState()
{
return (mapperState);
}
/*******************************************************************************
** Cache a mapper state.
*******************************************************************************/
public void setMapperState(AccessComparison.State mapperState)
{
this.mapperState = mapperState;
}
/*******************************************************************************
** Whether the host has run the qbit mapper at sign-in (counts as "enabled"
** for hosts that call the mapper without setting mapperEnabled).
*******************************************************************************/
public boolean isMapperInvoked()
{
return (mapperInvoked);
}
/*******************************************************************************
** Record that the mapper ran.
*******************************************************************************/
public void markMapperInvoked()
{
if(!mapperInvoked)
{
mapperInvoked = true;
mapperState = null;
}
}
}
@@ -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),
@@ -39,6 +46,8 @@ public enum AdminAction
EDIT_GROUP("Edit group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_PERMISSIONS("Edit permissions and app access", "group", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
SAVE_POLICY("Save policy", "group", IdentityPermission.POLICIES_MANAGE, AuthentikFeature.POLICIES, Confirmation.CONFIRM),
DELETE_USER("Delete user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
DELETE_GROUP("Delete group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
SEND_RECOVERY_EMAIL("Send password reset email", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.CONFIRM),
RESET_MFA("Reset MFA", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
REVOKE_APP_PASSWORDS("Revoke app passwords", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
@@ -93,6 +93,12 @@ public class AuthentikAdmin
/** Process value / request parameter that carries a submitted policy document. */
public static final String POLICY_DOCUMENT = "policyDocument";
/** Request parameter: named permissions (one per line) to merge into qqq.io/permissions with a policy save. */
public static final String EXTRA_PERMISSIONS = "extraPermissions";
/** Request parameter with the non-reserved attributes (JSON object text) of Edit User and Edit Group. */
public static final String OTHER_ATTRIBUTES = "otherAttributes";
private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
private static final Pattern USERNAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._@+-]{0,149}");
private static final Pattern EMAIL = Pattern.compile("[^@\\s]+@[^@\\s]+\\.[^@\\s]+");
@@ -102,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;
@@ -135,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;
}
@@ -151,6 +300,7 @@ public class AuthentikAdmin
this.config = runtime.getConfig();
this.guard = runtime.getGuard();
this.auditor = new AdminActionAuditor(config);
this.tokens = new AuthentikTokens(this);
}
@@ -207,6 +357,21 @@ public class AuthentikAdmin
EffectivePermissionResolver.Resolution resolution = resolver().resolveGroups(groupPks);
permissions.addAll(resolution.sessionPermissions());
appAccess.addAll(resolution.appAccess());
//////////////////////////////////////////////////////////////////////
// what the operator holds today through the host (and the static //
// fallback) counts for the subset rule too (DESIGN.md 12.8) //
//////////////////////////////////////////////////////////////////////
List<String> groupNames = new ArrayList<>();
for(int i = 0; groups != null && i < groups.length(); i++)
{
groupNames.add(groups.getJSONObject(i).optString("name"));
}
permissions.addAll(com.kingsrook.qbits.authentik.permissions.AccessComparison.staticPermissions(config, groupNames));
if(config.getHostPermissionSource() != null)
{
permissions.addAll(config.getHostPermissionSource().userPermissions(email, groupNames));
}
}
else if(QContext.getQSession() != null && QContext.getQSession().getPermissions() != null)
{
@@ -251,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);
@@ -261,6 +427,8 @@ public class AuthentikAdmin
case EDIT_GROUP -> planEditGroup(plan, request);
case EDIT_PERMISSIONS -> planEditPermissions(plan, request);
case SAVE_POLICY -> planSavePolicy(plan, request);
case DELETE_USER -> planDeleteUser(plan, request);
case DELETE_GROUP -> planDeleteGroup(plan, request);
case SEND_RECOVERY_EMAIL -> planRecovery(plan, request);
case RESET_MFA -> planResetMfa(plan, request);
case REVOKE_APP_PASSWORDS -> planAppPasswords(plan, request);
@@ -276,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;
@@ -291,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();
@@ -312,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)
{
@@ -340,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);
}
@@ -665,6 +863,23 @@ public class AuthentikAdmin
{
return (plan);
}
String username = request.params().containsKey("username") && StringUtils.hasContent(request.str("username")) ? request.str("username") : user.optString("username");
if(!username.equals(user.optString("username")))
{
if(!USERNAME.matcher(username).matches())
{
return (plan.refuse("A username is letters, digits and . _ @ + - (up to 150)."));
}
if(findUserByUsername(username) != null)
{
return (plan.refuse("Another user already has the username " + username + "."));
}
}
String attributesProblem = otherAttributesProblem(request, user.optJSONObject("attributes"));
if(attributesProblem != null)
{
return (plan.refuse(attributesProblem));
}
String name = request.params().containsKey("name") ? request.str("name") : user.optString("name");
String email = request.params().containsKey("email") ? request.str("email") : user.optString("email");
String path = request.params().containsKey("path") ? request.str("path") : user.optString("path");
@@ -694,8 +909,14 @@ public class AuthentikAdmin
return (plan.refuse("This user was changed (for example in the Authentik admin UI) since you opened it. Review it again."));
}
String before = user.optString("name") + " <" + user.optString("email") + ">, " + user.optString("path");
String after = name + " <" + email + ">, " + path;
String before = user.optString("username") + ": " + user.optString("name") + " <" + user.optString("email") + ">, " + user.optString("path");
String after = username + ": " + name + " <" + email + ">, " + path;
String keys = changedAttributeKeys(request, user.optJSONObject("attributes"));
if(keys != null)
{
before = before + "; attributes";
after = after + "; attributes (" + keys + ")";
}
if(before.equals(after))
{
return (plan.noChange("Nothing to change."));
@@ -932,7 +1153,13 @@ public class AuthentikAdmin
{
return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again."));
}
if(newName.equals(group.optString("name")) && new LinkedHashSet<>(newParents).equals(new LinkedHashSet<>(currentParents)))
String attributesProblem = otherAttributesProblem(request, group.optJSONObject("attributes"));
if(attributesProblem != null)
{
return (plan.refuse(attributesProblem));
}
String keys = changedAttributeKeys(request, group.optJSONObject("attributes"));
if(newName.equals(group.optString("name")) && new LinkedHashSet<>(newParents).equals(new LinkedHashSet<>(currentParents)) && keys == null)
{
return (plan.noChange("Nothing to change."));
}
@@ -949,7 +1176,8 @@ public class AuthentikAdmin
}
plan.getRequest().params().put("resolvedParentPks", String.join(",", newParents));
return (plan.withChange(group.optString("name") + ", parents " + names(currentParents), newName + ", parents " + names(newParents)));
return (plan.withChange(group.optString("name") + ", parents " + names(currentParents) + (keys == null ? "" : "; attributes"),
newName + ", parents " + names(newParents) + (keys == null ? "" : "; attributes (" + keys + ")")));
}
@@ -1024,15 +1252,23 @@ public class AuthentikAdmin
{
return (plan);
}
if(StringUtils.hasContent(request.str("generateProblem")))
{
return (plan.refuse(request.str("generateProblem")));
}
PolicyFormat format = config.getPolicyFormat();
List<String> oldNamed = nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS));
Set<String> extrasNew = new TreeSet<>(AuthentikSafety.parseList(request.str(EXTRA_PERMISSIONS)));
extrasNew.removeAll(oldNamed);
Set<String> newPermissions;
JSONObject canonical;
try
{
String submitted = request.str(POLICY_DOCUMENT);
newPermissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(newPermissions, format);
newPermissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(newPermissions, format, runtime.getPolicyCatalog());
}
catch(PolicyParseException e)
{
@@ -1046,7 +1282,7 @@ public class AuthentikAdmin
{
try
{
oldPermissions.addAll(PolicyParser.parseStrict(stored, format).permissions());
oldPermissions.addAll(PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).permissions());
}
catch(PolicyParseException e)
{
@@ -1071,18 +1307,35 @@ public class AuthentikAdmin
return (plan.refuse("These are not permissions of this app: " + String.join(", ", unknownNew) + ". Pick them in the editor."));
}
if(added.isEmpty() && removed.isEmpty() && storedProblem == null)
if(added.isEmpty() && removed.isEmpty() && storedProblem == null && extrasNew.isEmpty())
{
return (plan.noChange("Nothing to change."));
}
Set<String> missing = AuthentikSafety.missing(added, operator().permissions());
Set<String> needed = new TreeSet<>(added);
for(String extra : extrasNew)
{
needed.addAll(resolver().expand(extra));
}
Set<String> missing = AuthentikSafety.missing(needed, operator().permissions());
if(!missing.isEmpty())
{
return (plan.refuse("You cannot grant permissions you do not hold yourself: " + String.join(", ", missing)));
}
plan.withChange(policySummary(oldPermissions), policySummary(newPermissions));
if(request.bool("generated"))
{
plan.getNotes().add("Generated from current access: " + PolicyGenerator.toCompact(canonical));
if(StringUtils.hasContent(request.str("skippedPermissions")))
{
plan.getNotes().add("Skipped (this app does not define them): " + request.str("skippedPermissions").replace("\n", ", "));
}
}
if(!extrasNew.isEmpty())
{
plan.getNotes().add("Also added to qqq.io/permissions (not expressible as a policy): " + abbreviate(extrasNew));
}
if(!added.isEmpty())
{
plan.getNotes().add("Adds: " + abbreviate(added));
@@ -1113,6 +1366,290 @@ public class AuthentikAdmin
/*******************************************************************************
** Fill a Save Policy request from what members of the group get TODAY from
** the host (DESIGN.md section 12.8): the policy-expressible permissions
** (joined with the group's current policy, so nothing is removed) become
** the document; named permissions this app defines but a policy cannot
** express go to qqq.io/permissions; the rest are listed as skipped.
*******************************************************************************/
public AdminRequest withGeneratedPolicy(AdminRequest request) throws QException
{
request.with("generated", true);
if(config.getHostPermissionSource() == null)
{
return (request.with("generateProblem", "This app has no host permission source, so there is no current access to generate from."));
}
JSONObject group = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false");
if(group == null)
{
return (request.with("generateProblem", "That group no longer exists."));
}
PolicyFormat format = config.getPolicyFormat();
Set<String> policy = new TreeSet<>();
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
policy.addAll(PolicyParser.parse(stored, format, runtime.getPolicyCatalog()));
Set<String> expressible = runtime.getPolicyCatalog().permissionNames();
Set<String> extras = new TreeSet<>();
Set<String> skipped = new TreeSet<>();
for(String permission : config.getHostPermissionSource().groupPermissions(group.optString("name")))
{
if(expressible.contains(permission))
{
policy.add(permission);
}
else if(!resolver().expand(permission).isEmpty())
{
extras.add(permission);
}
else
{
skipped.add(permission);
}
}
try
{
request.with(POLICY_DOCUMENT, PolicyGenerator.toCompact(PolicyGenerator.generate(policy, format, runtime.getPolicyCatalog())));
}
catch(PolicyParseException e)
{
return (request.with("generateProblem", "The current access cannot be written as a policy: " + e.getMessage()));
}
return (request.with(EXTRA_PERMISSIONS, String.join("\n", extras)).with("skippedPermissions", String.join("\n", skipped)));
}
/*******************************************************************************
** Delete a person (DESIGN.md section 12.5): explicit, typed, never yourself,
** a protected account or the last identity administrator.
*******************************************************************************/
private AdminPlan planDeleteUser(AdminPlan plan, AdminRequest request) throws QException
{
JSONObject user = targetUser(plan, request.targetKey(), false);
if(user == null)
{
return (plan);
}
String typed = typedProblem(request, user.optString("username"));
if(typed != null)
{
return (plan.refuse(typed));
}
Set<Integer> before = adminsNow();
if(before.contains(user.optInt("pk")))
{
List<AuthentikSafety.UserFacts> changed = new ArrayList<>();
for(AuthentikSafety.UserFacts facts : userFacts())
{
if(facts.pk() != user.optInt("pk"))
{
changed.add(facts);
}
}
if(AuthentikSafety.removesLastAdmin(before, AuthentikSafety.qualifyingAdmins(membersGraph(), new EffectivePermissionResolver(membersGraph(), runtime.getPermissionCatalog()), changed)))
{
return (plan.refuse("This is the last active person who can manage memberships and permissions. Add another administrator first."));
}
}
plan.getNotes().add("Deleting cannot be undone. Deactivate User keeps the account and its history.");
return (plan.withChange(user.optString("username") + " <" + user.optString("email") + ">", "deleted"));
}
/*******************************************************************************
** Delete a group (DESIGN.md section 12.5): explicit, typed, and only when it
** has no members and no child groups (remove them first, so nobody loses
** access by surprise).
*******************************************************************************/
private AdminPlan planDeleteGroup(AdminPlan plan, AdminRequest request) throws QException
{
JSONObject group = targetGroup(plan, request.targetKey());
if(group == null)
{
return (plan);
}
String name = group.optString("name");
if(!request.str("confirmText").equals(name) && !request.bool("approved"))
{
return (plan.refuse("Type the group name (" + name + ") to confirm."));
}
JSONArray users = group.optJSONArray("users");
if(users != null && !users.isEmpty())
{
return (plan.refuse("The group has " + users.length() + " member(s). Remove them first."));
}
List<String> children = new ArrayList<>();
for(GroupGraph.Node node : graph().nodes())
{
if(node.parentPks().contains(request.targetKey()))
{
children.add(node.name());
}
}
if(!children.isEmpty())
{
return (plan.refuse("The group has child groups (" + String.join(", ", children) + "). Change their parents first."));
}
plan.getNotes().add("Deleting cannot be undone. Its permissions, app access and policy go with it.");
return (plan.withChange(name, "deleted"));
}
/*******************************************************************************
** Why the submitted other-attributes JSON cannot be applied, or null. It must
** be a JSON object without reserved keys (qqq.io/ and goauthentik.io/ are
** set elsewhere), and editing attributes needs authentik.audit.read
** (attributes are sensitive, DESIGN.md section 5.3).
*******************************************************************************/
private String otherAttributesProblem(AdminRequest request, JSONObject current) throws QException
{
if(!StringUtils.hasContent(request.str(OTHER_ATTRIBUTES)))
{
return (null);
}
try
{
JSONObject submitted = parseOtherAttributes(request.str(OTHER_ATTRIBUTES));
for(String key : submitted.keySet())
{
if(isReservedAttribute(key))
{
return ("The attribute " + key + " is reserved; it is set by Edit Permissions, Edit Policy or Authentik itself.");
}
}
}
catch(QUserFacingException e)
{
return (e.getMessage());
}
if(changedAttributeKeys(request, current) != null && !operator().permissions().contains(IdentityPermission.AUDIT_READ.getSessionPermission()) && !operator().bootstrap())
{
return ("Editing attributes needs " + IdentityPermission.AUDIT_READ.getShortName() + ".");
}
return (null);
}
/*******************************************************************************
** The keys whose values the submitted attributes change (added, changed or
** removed), or null for no change. Blank means "leave them as they are"
** ({} removes them all). A null current compares against nothing.
*******************************************************************************/
private static String changedAttributeKeys(AdminRequest request, JSONObject current) throws QUserFacingException
{
if(!StringUtils.hasContent(request.str(OTHER_ATTRIBUTES)))
{
return (null);
}
JSONObject submitted = parseOtherAttributes(request.str(OTHER_ATTRIBUTES));
JSONObject others = otherAttributes(current);
Set<String> changed = new TreeSet<>();
for(String key : submitted.keySet())
{
if(!others.has(key) || !String.valueOf(others.get(key)).equals(String.valueOf(submitted.get(key))))
{
changed.add(key);
}
}
for(String key : others.keySet())
{
if(!submitted.has(key))
{
changed.add(key);
}
}
return (changed.isEmpty() ? null : String.join(", ", changed));
}
/*******************************************************************************
** The current attributes with the non-reserved part replaced by the
** submitted object (reserved keys are kept as they are).
*******************************************************************************/
static JSONObject mergeOtherAttributes(JSONObject current, String submittedText) throws QUserFacingException
{
JSONObject rs = new JSONObject();
if(current != null)
{
for(String key : current.keySet())
{
if(isReservedAttribute(key))
{
rs.put(key, current.get(key));
}
}
}
JSONObject submitted = parseOtherAttributes(submittedText);
for(String key : submitted.keySet())
{
if(!isReservedAttribute(key))
{
rs.put(key, submitted.get(key));
}
}
return (rs);
}
/*******************************************************************************
** The non-reserved attributes, as shown on Edit User and Edit Group.
*******************************************************************************/
public static JSONObject otherAttributes(JSONObject attributes)
{
JSONObject rs = new JSONObject();
if(attributes != null)
{
for(String key : attributes.keySet())
{
if(!isReservedAttribute(key))
{
rs.put(key, attributes.get(key));
}
}
}
return (rs);
}
/*******************************************************************************
** Keys QQQ manages elsewhere (qqq.io/) or Authentik owns (goauthentik.io/).
*******************************************************************************/
static boolean isReservedAttribute(String key)
{
return (key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX) || key.startsWith("goauthentik.io/"));
}
/*******************************************************************************
** A submitted attributes text as a JSON object (blank means none).
*******************************************************************************/
private static JSONObject parseOtherAttributes(String text) throws QUserFacingException
{
if(!StringUtils.hasContent(text))
{
return (new JSONObject());
}
try
{
return (new JSONObject(text));
}
catch(Exception e)
{
throw (new QUserFacingException("Other attributes must be a JSON object, e.g. {\"department\": \"sales\"}."));
}
}
/*******************************************************************************
** "n permission(s): a, b, ..." for the audit before/after.
*******************************************************************************/
@@ -1317,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()))
{
@@ -1489,13 +2026,18 @@ public class AuthentikAdmin
case EDIT_USER:
{
JSONObject body = new JSONObject();
for(String key : List.of("name", "email", "path"))
for(String key : List.of("username", "name", "email", "path"))
{
if(request.params().containsKey(key))
if(request.params().containsKey(key) && (!key.equals("username") || StringUtils.hasContent(request.str(key))))
{
body.put(key, request.str(key));
}
}
if(changedAttributeKeys(request, null) != null)
{
JSONObject fresh = util.getObject(AuthentikTables.get(AuthentikTables.USER), request.targetKey());
body.put("attributes", mergeOtherAttributes(fresh == null ? null : fresh.optJSONObject("attributes"), request.str(OTHER_ATTRIBUTES)));
}
util.write(auth, "PATCH", "/core/users/" + safeKey(request.targetKey()) + "/", body);
return ("User updated.");
}
@@ -1545,6 +2087,12 @@ public class AuthentikAdmin
{
body.put("name", request.str("name"));
}
if(changedAttributeKeys(request, null) != null)
{
runtime.invalidateCache();
JSONObject fresh = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false");
body.put("attributes", mergeOtherAttributes(fresh == null ? null : fresh.optJSONObject("attributes"), request.str(OTHER_ATTRIBUTES)));
}
if(request.params().containsKey("resolvedParentPks"))
{
JSONArray parents = new JSONArray();
@@ -1578,6 +2126,22 @@ public class AuthentikAdmin
}
case SAVE_POLICY:
return (performSavePolicy(plan, auth));
case DELETE_USER:
util.write(auth, "DELETE", "/core/users/" + safeKey(request.targetKey()) + "/", null);
return ("User deleted in Authentik.");
case DELETE_GROUP:
{
util.write(auth, "DELETE", "/core/groups/" + safeKey(request.targetKey()) + "/", null);
try
{
PolicyStore.delete(config, request.targetKey());
}
catch(Exception e)
{
LOG.warn("The group was deleted but its policy record could not be removed", logPair("group", request.targetKey()), logPair("error", e.getMessage()));
}
return ("Group deleted in Authentik.");
}
case SEND_RECOVERY_EMAIL:
sendRecovery(auth, request.targetKey());
return ("Password reset email sent by Authentik.");
@@ -1622,8 +2186,8 @@ public class AuthentikAdmin
try
{
String submitted = request.str(POLICY_DOCUMENT);
permissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(permissions, format);
permissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(permissions, format, runtime.getPolicyCatalog());
}
catch(PolicyParseException e)
{
@@ -1638,6 +2202,13 @@ public class AuthentikAdmin
}
JSONObject attributes = fresh.optJSONObject("attributes") == null ? new JSONObject() : new JSONObject(fresh.getJSONObject("attributes").toString());
String compact = PolicyGenerator.toCompact(canonical);
List<String> extras = AuthentikSafety.parseList(request.str(EXTRA_PERMISSIONS));
if(!extras.isEmpty())
{
Set<String> named = new java.util.LinkedHashSet<>(nonNull(AuthentikJson.stringList(attributes, AuthentikJson.ATTR_PERMISSIONS)));
named.addAll(extras);
putList(attributes, AuthentikJson.ATTR_PERMISSIONS, new ArrayList<>(named));
}
if(permissions.isEmpty())
{
attributes.remove(format.attribute());
@@ -1651,7 +2222,7 @@ public class AuthentikAdmin
try
{
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissions.size(), guard.operatorEmail(),
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format));
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format, runtime.getPolicyCatalog()));
}
catch(Exception e)
{
@@ -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,34 @@ 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", "lastReminder7d", "lastReminder1d"))
{
table.withField(ro(name, QFieldType.DATE_TIME));
}
return (table);
}
}
@@ -0,0 +1,659 @@
/*
* 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 || 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));
checkProtected(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()));
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 static void raceGuard(JSONObject before, JSONObject after) throws QUserFacingException
{
if(ownerPk(before) != ownerPk(after) || !before.getString("intent").equals(after.getString("intent")))
{
LOG.warn(TOKEN_MOVED);
throw (new QUserFacingException(TOKEN_MOVED));
}
}
/*******************************************************************************
** 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));
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);
checkProtected(owner);
JSONObject after = token(identifier);
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);
}
}
@@ -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,142 @@
/*
* 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.
*******************************************************************************/
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."));
}
}
}
@@ -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("'", "'\"'\"'") + "'");
}
}
@@ -212,6 +212,22 @@ public final class AuthentikHomeView
row(html, "Version", data.getVersion() == null ? "Not reachable" : data.getVersion());
row(html, "Service account", data.getConfig().getEffectiveServiceAccountUsername());
row(html, "Changes", Boolean.TRUE.equals(data.getConfig().getReadOnly()) ? "Off (read-only)" : "On, every change is audited here and in Authentik");
if(data.getConfig().hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.POLICIES))
{
String state;
try
{
com.kingsrook.qbits.authentik.permissions.AccessComparison.State mapper = com.kingsrook.qbits.authentik.permissions.AccessComparison.state(
com.kingsrook.qbits.authentik.AuthentikRuntime.forBackend(data.getConfig().getApiBackendName()),
com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(data.getConfig().getApiBackendName()));
state = (mapper.enforced() ? "Enforced. " : "Not enforced. ") + mapper.message();
}
catch(Exception e)
{
state = "Unknown (" + e.getMessage() + ")";
}
row(html, "Policies at sign-in", state);
}
html.append("</dl>");
close(html);
}
@@ -86,10 +86,22 @@ public final class AuthentikRelations
groupSections.add(new QFieldSection("permissions", "Permissions and app access", new QIcon("admin_panel_settings"), Tier.T2,
List.of("permissions", "effectivePermissions", "appAccess")));
relate(qInstance, groupSections, "members", "Members", "group", group, membership, "pk", "groupPk");
enforcement(qInstance, config, groupSections);
relate(qInstance, groupSections, "policy", "Policy", "policy", group, config.applyTablePrefix(AuthentikLocalTables.POLICY), "pk", "groupPk");
groupSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("attributes")));
applySections(qInstance, group, groupSections);
String policyTable = config.applyTablePrefix(AuthentikLocalTables.POLICY);
if(qInstance.getTable(policyTable) != null)
{
List<QFieldSection> policySections = new ArrayList<>();
enforcement(qInstance, config, policySections);
policySections.add(new QFieldSection("policy", "Policy", new QIcon("policy"), Tier.T1,
List.of("groupName", "statementCount", "permissionCount", "savedBy", "savedDate", "policyJson")));
policySections.add(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3, List.of("id", "groupPk", "authentikFingerprint", "createDate", "modifyDate")));
applySections(qInstance, policyTable, policySections);
}
List<QFieldSection> appSections = new ArrayList<>();
appSections.add(new QFieldSection("details", "Details", new QIcon("apps"), Tier.T1,
List.of("name", "slug", "group", "providerName", "policyEngineMode", "launchUrl", "publisher", "hiddenFromLibrary")));
@@ -99,6 +111,20 @@ public final class AuthentikRelations
/*******************************************************************************
** The policy enforcement banner as the first section (when produced).
*******************************************************************************/
private static void enforcement(QInstance qInstance, AuthentikQBitConfig config, List<QFieldSection> sections)
{
String widget = config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.ENFORCEMENT);
if(qInstance.getWidget(widget) != null)
{
sections.add(0, new QFieldSection("enforcement", new QIcon("verified_user"), Tier.T2).withLabel("Policy enforcement").withWidgetName(widget));
}
}
/*******************************************************************************
** One join plus its child-list widget and the section that shows it.
*******************************************************************************/
@@ -376,6 +376,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 +396,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 +412,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 +432,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.
@@ -765,7 +835,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
throw (new QUserFacingException("More than " + MAX_SWEEP_OBJECTS + " Authentik groups; the group graph is not supported at this size."));
}
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat());
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
if(cache)
{
runtime.getGroupGraphCache().storeResult("all", graph);
@@ -814,7 +884,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
}
List<JSONObject> groups = getAllPages("/core/groups/?include_users=true&include_parents=false&include_children=false&ordering=name", MAX_SWEEP_OBJECTS);
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat());
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
if(cache)
{
runtime.getGroupGraphCache().storeResult("members", graph);
@@ -1232,13 +1302,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 +1326,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 +1344,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 +1357,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 +1380,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,6 +58,12 @@ 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 //
@@ -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,10 @@ 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")));
return (spec);
}
@@ -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));
}
}
@@ -304,7 +304,7 @@ public class AuthentikDriftReport
void checkIdentityAdmins(List<DriftFinding> findings) throws QException
{
List<JSONObject> groups = client.getAllPages("/core/groups/?include_users=true&ordering=name", AuthentikActionUtil.MAX_SWEEP_OBJECTS);
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat());
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat(), client.getRuntime().getPolicyCatalog());
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
@@ -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,8 @@ 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(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,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
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;
/*******************************************************************************
** Compare Access (DESIGN.md section 12.8): today's access against what the policies would grant, per active person. Read only.
*******************************************************************************/
public class AuthentikPolicyCompareWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyCutoverWidgets.COMPARE))
.withLabel("Compare Access")
.withType(WidgetType.TABLE.getType())
.withGridColumns(12)
.withIsCard(true)
.withCodeReference(new QCodeReference(PolicyCutoverWidgets.CompareRenderer.class))
.withPermissionRules(IdentityPermission.ADMIN.rules())
.withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (470);
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
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;
/*******************************************************************************
** The policy enforcement banner (DESIGN.md section 12.8): whether saved policies take effect at sign-in on this instance.
*******************************************************************************/
public class AuthentikPolicyEnforcementWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyCutoverWidgets.ENFORCEMENT))
.withLabel("Policy Enforcement")
.withType(WidgetType.HTML.getType())
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(PolicyCutoverWidgets.EnforcementRenderer.class))
.withPermissionRules(IdentityPermission.USERS_READ.rules())
.withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (465);
}
}
@@ -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,254 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.permissions;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The cut-over from a host's current permission source to Authentik group
** policies (DESIGN.md section 12.8): for every active person, what they get
** today (the host source) against what the mapper would grant (their groups'
** policies and permissions, the static fallback and bootstrap subjects), and
** the guard that keeps the mapper inactive while a member of a guarded group
** would lose a permission.
*******************************************************************************/
public final class AccessComparison
{
private static final QLogger LOG = QLogger.getLogger(AccessComparison.class);
/** How long a computed mapper state is reused (sign-ins read it). */
public static final Duration STATE_TTL = Duration.ofSeconds(60);
/** Banner shown wherever policies are edited while they are not enforced. */
public static final String NOT_ENFORCED = "Policies are saved but NOT enforced at sign-in on this instance (mapper off).";
/*******************************************************************************
** One person.
*******************************************************************************/
public record Row(int pk, String username, String email, List<String> groups, Set<String> before, Set<String> after,
Set<String> added, Set<String> removed, boolean guarded)
{
}
/*******************************************************************************
** Whether the mapper is enforcing policies, and why not.
*******************************************************************************/
public enum Status
{
/** The host does not use the mapper: policies are saved, not enforced. */
OFF,
/** Enforced; the guard passed (or there is nothing to guard). */
ACTIVE,
/** Enforced because the owner overrode a failing guard. */
OVERRIDDEN,
/** Not enforced: the guard failed (or could not run); sign-in uses the host source. */
BLOCKED
}
/*******************************************************************************
** A mapper state with a plain message.
*******************************************************************************/
public record State(Status status, String message, Instant checkedAt)
{
/*******************************************************************************
** Whether sign-in permissions come from the policies.
*******************************************************************************/
public boolean enforced()
{
return (status == Status.ACTIVE || status == Status.OVERRIDDEN);
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AccessComparison()
{
}
/*******************************************************************************
** Compare every active person (internal and external users).
*******************************************************************************/
public static List<Row> run(AuthentikQBitConfig config, AuthentikActionUtil util, Collection<String> catalog) throws QException
{
GroupGraph graph = util.groupGraphWithMembers();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, catalog);
AuthentikHostPermissionSource source = config.getHostPermissionSource();
List<Row> rows = new ArrayList<>();
for(JSONObject user : util.allUsers())
{
String type = user.optString("type");
if(!user.optBoolean("is_active", true) || !("internal".equals(type) || "external".equals(type)))
{
continue;
}
int pk = user.optInt("pk");
List<String> groupPks = graph.groupsOfUser(pk);
List<String> names = new ArrayList<>();
for(String groupPk : groupPks)
{
names.add(graph.byPk(groupPk).name());
}
String email = user.optString("email", null);
Set<String> before = new TreeSet<>(source == null ? Set.of() : source.userPermissions(email, names));
Set<String> after = mapperPermissions(config, resolver, groupPks, names, String.valueOf(pk), user.optString("uuid"));
Set<String> added = new TreeSet<>(after);
added.removeAll(before);
Set<String> removed = new TreeSet<>(before);
removed.removeAll(after);
boolean guarded = names.stream().anyMatch(n -> config.getCutoverGuardGroups() != null && config.getCutoverGuardGroups().contains(n));
rows.add(new Row(pk, user.optString("username"), email, names, before, after, added, removed, guarded));
}
return (rows);
}
/*******************************************************************************
** What the mapper grants for a person's direct groups: the groups' (and
** ancestors') qqq.io/permissions and policies, the static fallback of the
** direct groups, and every authentik.* permission for a bootstrap subject.
*******************************************************************************/
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());
rs.addAll(staticPermissions(config, directGroupNames));
Set<String> subjects = config.getResolvedBootstrapSubjects();
if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid)))
{
for(IdentityPermission permission : IdentityPermission.values())
{
rs.add(permission.getSessionPermission());
}
}
return (rs);
}
/*******************************************************************************
** The static fallback permissions of some direct groups.
*******************************************************************************/
public static Set<String> staticPermissions(AuthentikQBitConfig config, Collection<String> directGroupNames)
{
Set<String> rs = new TreeSet<>();
if(config.getStaticGroupPermissions() != null)
{
for(String name : directGroupNames)
{
rs.addAll(config.getStaticGroupPermissions().getOrDefault(name, List.of()));
}
}
return (rs);
}
/*******************************************************************************
** The people in guarded groups who would lose a permission (the guard fails
** when this is not empty).
*******************************************************************************/
public static List<Row> guardFailures(List<Row> rows)
{
return (rows.stream().filter(r -> r.guarded() && !r.removed().isEmpty()).toList());
}
/*******************************************************************************
** The mapper state of a backend (cached for STATE_TTL; sign-ins read it).
*******************************************************************************/
public static State state(AuthentikRuntime runtime, AuthentikActionUtil util)
{
State cached = runtime.getMapperState();
if(cached != null && cached.checkedAt().plus(STATE_TTL).isAfter(Instant.now()))
{
return (cached);
}
State state = compute(runtime, util);
runtime.setMapperState(state);
return (state);
}
/*******************************************************************************
** Compute the mapper state now.
*******************************************************************************/
static State compute(AuthentikRuntime runtime, AuthentikActionUtil util)
{
AuthentikQBitConfig config = runtime.getConfig();
if(!Boolean.TRUE.equals(config.getMapperEnabled()) && !runtime.isMapperInvoked())
{
return (new State(Status.OFF, NOT_ENFORCED, Instant.now()));
}
if(config.getCutoverGuardGroups() == null || config.getCutoverGuardGroups().isEmpty())
{
return (new State(Status.ACTIVE, "Policies are enforced at sign-in.", Instant.now()));
}
if(Boolean.TRUE.equals(config.getMapperOverride()))
{
return (new State(Status.OVERRIDDEN, "Policies are enforced at sign-in; the cut-over guard is overridden by the owner.", Instant.now()));
}
try
{
List<Row> failures = guardFailures(run(config, util, runtime.getPermissionCatalog()));
if(failures.isEmpty())
{
return (new State(Status.ACTIVE, "Policies are enforced at sign-in; the cut-over guard passed (no member of "
+ String.join(", ", config.getCutoverGuardGroups()) + " loses a permission).", Instant.now()));
}
List<String> names = failures.stream().map(Row::username).limit(5).toList();
return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard found " + failures.size() + " member(s) of "
+ String.join(", ", config.getCutoverGuardGroups()) + " who would lose permissions (" + String.join(", ", names)
+ "). Sign-in keeps today's access until the policies cover it, or the owner overrides the guard.", Instant.now()));
}
catch(Exception e)
{
LOG.warn("The cut-over guard could not run; the mapper stays inactive", logPair("error", e.getMessage()));
return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard could not check Authentik ("
+ e.getMessage() + "). Sign-in keeps today's access.", Instant.now()));
}
}
}
@@ -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;
@@ -27,6 +30,7 @@ import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.security.BootstrapOperators;
@@ -116,7 +120,51 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
//////////////////////////////////////////////////////////////////////////
AuthentikRuntime runtime = runtime();
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format));
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format,
runtime == null ? (qInstance == null ? null : PolicyCatalog.of(qInstance)) : runtime.getPolicyCatalog()));
if(runtime != null)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups));
////////////////////////////////////////////////////////////////////////
// the cut-over guard (DESIGN.md section 12.8): while it blocks, the //
// session gets today's access from the host plus the floor (static //
// fallbacks, bootstrap subjects), never the policies alone //
////////////////////////////////////////////////////////////////////////
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())
{
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 at this sign-in; granting today's access", logPair("reason", state.message()));
permissions = floor;
}
}
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
@@ -126,6 +174,132 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
/*******************************************************************************
** 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);
}
/*******************************************************************************
** The permissions for a user's group names and token identity (sub, email,
** email_verified). Bootstrap operators are matched on immutable ids only.
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.permissions;
import java.util.Collection;
import java.util.List;
import java.util.Set;
/*******************************************************************************
** SPI: the permissions a host grants TODAY, before Authentik group policies
** take over (DESIGN.md section 12.8). Typically a hard-coded grant such as
** "members of the admins group get adminPermissions()". The qbit uses it to
** - generate a group's policy from current access,
** - compare current access with what the mapper would grant, and
** - fall back to it while the cut-over guard keeps the mapper inactive.
*******************************************************************************/
public interface AuthentikHostPermissionSource
{
/*******************************************************************************
** The QQQ session permissions a user gets today.
**
** @param email the user's email (may be null)
** @param groupNames the user's direct Authentik group names
*******************************************************************************/
Set<String> userPermissions(String email, Collection<String> groupNames);
/*******************************************************************************
** The permissions members of a group get today because of that group. The
** default asks for a user in only that group.
*******************************************************************************/
default Set<String> groupPermissions(String groupName)
{
return (userPermissions(null, List.of(groupName)));
}
}
@@ -27,6 +27,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
@@ -81,6 +82,16 @@ public class GroupGraph
** not parse grants nothing and is listed in the node's malformed entries.
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format)
{
return (fromGroups(groups, format, null));
}
/*******************************************************************************
** Read group policies with the catalog of registered table features.
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format, PolicyCatalog catalog)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
GroupGraph graph = new GroupGraph();
@@ -88,7 +99,7 @@ public class GroupGraph
{
JSONObject attributes = group.optJSONObject("attributes");
List<String> malformed = malformed(attributes);
List<String> policy = policyPermissions(attributes, f, malformed);
List<String> policy = policyPermissions(attributes, f, malformed, catalog);
graph.add(new Node(
group.getString("pk"),
group.optString("name", ""),
@@ -289,7 +300,7 @@ public class GroupGraph
** The permissions of a group's policy document; none (and a malformed entry)
** when it does not parse.
*******************************************************************************/
private static List<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed)
private static List<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed, PolicyCatalog catalog)
{
Object document = attributes == null ? null : attributes.opt(format.attribute());
if(document == null || JSONObject.NULL.equals(document) || "".equals(document))
@@ -298,7 +309,7 @@ public class GroupGraph
}
try
{
return (List.copyOf(PolicyParser.parseStrict(document, format).permissions()));
return (List.copyOf(PolicyParser.parseStrict(document, format, catalog).permissions()));
}
catch(PolicyParseException e)
{
@@ -95,7 +95,7 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
return;
}
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format));
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format, qInstance == null ? null : PolicyCatalog.of(qInstance)));
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
@@ -21,7 +21,8 @@ import java.util.Optional;
/*******************************************************************************
** The actions a policy statement can grant, in canonical statement order. Each
** The five standard actions, in canonical statement order. Table features use
** their registered string names separately. Each standard action
** maps to the QQQ permission suffix of the same name ({resource}.{action}).
*******************************************************************************/
public enum PolicyAction
@@ -18,6 +18,7 @@ package com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashMap;
@@ -25,7 +26,9 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
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.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -74,9 +77,26 @@ public final class PolicyCatalog
** @param label what operators see
** @param kind app, table, process, report or widget
** @param actions the actions QQQ checks, in canonical order
** @param features explicitly grantable table features, sorted by name
** @param note a plain note (e.g. why an action is missing), or null
*******************************************************************************/
public record Resource(String name, String label, Kind kind, List<PolicyAction> actions, String note)
public record Resource(String name, String label, Kind kind, List<PolicyAction> actions, List<Feature> features, String note)
{
/*******************************************************************************
** A resource with only standard actions.
*******************************************************************************/
public Resource(String name, String label, Kind kind, List<PolicyAction> actions, String note)
{
this(name, label, kind, actions, List.of(), note);
}
}
/*******************************************************************************
** A table feature's permission suffix and display label from QQQ.
*******************************************************************************/
public record Feature(String name, String label)
{
}
@@ -118,6 +138,17 @@ public final class PolicyCatalog
** Build the catalog of an instance.
*******************************************************************************/
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)
{
Map<String, String> appOfObject = appsOfObjects(qInstance);
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
@@ -155,7 +186,7 @@ public final class PolicyCatalog
}
if(rules.getLevel() == PermissionLevel.READ_WRITE_PERMISSIONS)
{
note = "This table uses one write permission, which policies cannot grant yet; only read is offered.";
note = "This table uses one write permission instead of separate insert, edit and delete actions.";
}
else
{
@@ -173,7 +204,20 @@ public final class PolicyCatalog
}
}
}
add(byApp, appOfObject.get("table:" + table.getName()), qInstance, table, Kind.TABLE, label(table.getLabel(), table.getName()), actions, note);
String baseName = StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : table.getName();
Map<String, Feature> features = new TreeMap<>();
for(AvailablePermission permission : availablePermissions)
{
if(permission.getName().startsWith(baseName + "."))
{
String suffix = permission.getName().substring(baseName.length() + 1);
if(!suffix.isEmpty() && PolicyAction.fromJsonName(suffix).isEmpty())
{
features.putIfAbsent(suffix, new Feature(suffix, permission.getPermissionType()));
}
}
}
add(byApp, appOfObject.get("table:" + table.getName()), qInstance, table, Kind.TABLE, label(table.getLabel(), table.getName()), actions, features, note);
}
List<QProcessMetaData> processes = new ArrayList<>(qInstance.getProcesses().values());
@@ -218,7 +262,7 @@ public final class PolicyCatalog
List<Resource> resources = new ArrayList<>();
for(ResourceDraft draft : entry.getValue().values())
{
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), draft.note));
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), List.copyOf(draft.features.values()), draft.note));
}
groups.add(new Group(entry.getKey(), groupLabel, resources));
}
@@ -237,6 +281,7 @@ public final class PolicyCatalog
private final Kind kind;
private final Set<String> labels = new LinkedHashSet<>();
private final Set<PolicyAction> actions = new TreeSet<>();
private final Map<String, Feature> features = new TreeMap<>();
private String note;
@@ -258,9 +303,20 @@ public final class PolicyCatalog
*******************************************************************************/
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
Set<PolicyAction> actions, String note)
{
add(byApp, app, qInstance, object, kind, label, actions, Map.of(), note);
}
/*******************************************************************************
** Add standard and feature actions, merging shared permission base names.
*******************************************************************************/
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
Set<PolicyAction> actions, Map<String, Feature> features, String note)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(object, qInstance);
if(rules == null || rules.getLevel() == null || rules.getLevel() == PermissionLevel.NOT_PROTECTED || rules.getCustomPermissionChecker() != null || actions.isEmpty())
if(rules == null || rules.getLevel() == null || rules.getLevel() == PermissionLevel.NOT_PROTECTED || rules.getCustomPermissionChecker() != null || (actions.isEmpty() && features.isEmpty()))
{
return;
}
@@ -281,6 +337,7 @@ public final class PolicyCatalog
{
existing.labels.add(label);
existing.actions.addAll(actions);
existing.features.putAll(features);
return;
}
}
@@ -288,6 +345,7 @@ public final class PolicyCatalog
ResourceDraft draft = new ResourceDraft(name, kind);
draft.labels.add(label);
draft.actions.addAll(actions);
draft.features.putAll(features);
draft.note = note;
byApp.computeIfAbsent(byApp.containsKey(app) ? app : null, k -> new LinkedHashMap<>()).put(name, draft);
}
@@ -399,6 +457,7 @@ public final class PolicyCatalog
{
rs.add(action.permission(resource.name()));
}
resource.features().forEach(feature -> rs.add(resource.name() + "." + feature.name()));
}
return (rs);
}
@@ -425,6 +484,17 @@ public final class PolicyCatalog
/*******************************************************************************
** Whether this resource has an explicitly registered feature action.
*******************************************************************************/
public boolean hasFeature(String resourceName, String actionName)
{
Resource resource = get(resourceName);
return (resource != null && resource.features().stream().anyMatch(feature -> feature.name().equals(actionName)));
}
/*******************************************************************************
** As JSON for the editor widget.
*******************************************************************************/
@@ -443,6 +513,12 @@ public final class PolicyCatalog
.put("label", resource.label())
.put("kind", resource.kind().name())
.put("actions", actions);
if(resource.kind() == Kind.TABLE || !resource.features().isEmpty())
{
JSONArray features = new JSONArray();
resource.features().forEach(feature -> features.put(new JSONObject().put("name", feature.name()).put("label", feature.label())));
json.put("features", features);
}
if(resource.note() != null)
{
json.put("note", resource.note());
@@ -0,0 +1,173 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
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.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** The cut-over widgets (DESIGN.md section 12.8): the enforcement banner shown
** on group and policy screens and on Edit Policy, and the Compare Access
** report (read only).
*******************************************************************************/
public final class PolicyCutoverWidgets
{
/** Widget default value naming the qbit's API backend. */
public static final String BACKEND_NAME_KEY = "backendName";
/** Short name of the banner widget. */
public static final String ENFORCEMENT = "policyEnforcement";
/** Short name of the report widget. */
public static final String COMPARE = "compareAccess";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyCutoverWidgets()
{
}
/*******************************************************************************
** The mapper state for a widget's backend.
*******************************************************************************/
static AccessComparison.State state(RenderWidgetInput input) throws QException
{
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
return (AccessComparison.state(AuthentikRuntime.forBackend(backendName), AuthentikActionUtil.forBackend(backendName)));
}
/*******************************************************************************
** The banner as HTML (also used by the home's health card).
*******************************************************************************/
public static String bannerHtml(AccessComparison.State state)
{
String style = state.enforced()
? "border:1px solid #16a34a;background:rgba(22,163,74,.10);"
: "border:1px solid #d97706;background:rgba(217,119,6,.12);";
return ("<div role=\"status\" data-qqq-id=\"authentik-policy-enforcement\" data-status=\"" + state.status().name() + "\" style=\"" + style
+ "border-radius:.375rem;padding:.5rem .75rem;font-size:.875rem;line-height:1.4;color:inherit;\"><strong>"
+ (state.enforced() ? "Enforced. " : "Not enforced. ") + "</strong>" + escape(state.message()) + "</div>");
}
/*******************************************************************************
**
*******************************************************************************/
static String escape(String text)
{
return (text == null ? "" : text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"));
}
/*******************************************************************************
** The enforcement banner (authentik.users.read).
*******************************************************************************/
public static class EnforcementRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(new RawHTML(null, bannerHtml(state(input)))));
}
}
/*******************************************************************************
** Compare Access (authentik.admin): every active person's access today
** against what the mapper would grant. Read only.
*******************************************************************************/
public static class CompareRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN))
{
throw (new QPermissionDeniedException("You do not have permission to compare access."));
}
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
AuthentikRuntime runtime = AuthentikRuntime.forBackend(backendName);
List<AccessComparison.Row> rows = AccessComparison.run(runtime.getConfig(), AuthentikActionUtil.forBackend(backendName), runtime.getPermissionCatalog());
List<Map<String, Object>> data = new ArrayList<>();
int removedPeople = 0;
for(AccessComparison.Row row : rows)
{
Map<String, Object> map = new LinkedHashMap<>();
map.put("user", escape(row.username()) + (row.guarded() ? " (guarded)" : ""));
map.put("groups", escape(String.join(", ", row.groups())));
map.put("added", row.added().isEmpty() ? "-" : escape(String.join(", ", row.added())));
map.put("removed", row.removed().isEmpty() ? "-" : escape(String.join(", ", row.removed())));
map.put("unchanged", row.before().size() - row.removed().size());
data.add(map);
if(!row.removed().isEmpty())
{
removedPeople++;
}
}
List<TableData.Column> columns = List.of(
new TableData.Column("html", "User", "user", "15%", "left"),
new TableData.Column("html", "Groups", "groups", "20%", "left"),
new TableData.Column("html", "Added", "added", "27%", "left"),
new TableData.Column("html", "Removed", "removed", "28%", "left"),
new TableData.Column("default", "Unchanged", "unchanged", "10%", "right"));
List<AccessComparison.Row> failures = AccessComparison.guardFailures(rows);
String summary = rows.size() + " active people; " + removedPeople + " would lose permissions; guard "
+ (runtime.getConfig().getCutoverGuardGroups().isEmpty() ? "not configured" : failures.isEmpty() ? "passes" : "FAILS for " + failures.size())
+ (runtime.getConfig().getHostPermissionSource() == null ? ". No host permission source is configured, so today's access is shown as empty." : ".");
TableData table = new TableData("Compare Access (today vs policies)", columns, data)
.withRowsPerPage(25)
.withNoRowsFoundHTML("No active people.");
table.setFooterHTML("<p style=\"font-size:.875rem;\">" + escape(summary) + "</p>");
return (new RenderWidgetOutput(table));
}
}
}
@@ -106,7 +106,7 @@ public final class PolicyEditorWidget
/////////////////////////////////////////////////////////////////
// canonical form of what is stored (unknown resources survive) //
/////////////////////////////////////////////////////////////////
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format)));
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format, runtime.getPolicyCatalog())));
}
catch(PolicyParseException e)
{
@@ -117,7 +117,7 @@ public final class PolicyEditorWidget
QRecord row = PolicyStore.find(config, groupPk);
if(row != null)
{
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format)));
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format, runtime.getPolicyCatalog())));
}
return (data);
}
@@ -21,6 +21,7 @@ import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -29,7 +30,8 @@ import org.json.JSONObject;
/*******************************************************************************
** Builds the canonical policy document for a set of QQQ permissions
** (docs/POLICY-FORMAT.md, "Canonical form"): one Allow statement per granted
** action in the order hasAccess, read, insert, edit, delete; resources sorted;
** action in the order hasAccess, read, insert, edit, delete, then features
** sorted by name; resources sorted;
** no "*". Every generated document is checked to round-trip through
** PolicyParser to exactly the permissions it came from; one that does not is
** never returned.
@@ -54,13 +56,24 @@ public final class PolicyGenerator
** round-trip
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format) throws PolicyParseException
{
return (generate(permissions, format, null));
}
/*******************************************************************************
** Generate with the catalog of explicitly grantable table features.
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
Map<PolicyAction, Set<String>> byAction = new LinkedHashMap<>();
Map<String, Set<String>> byAction = new LinkedHashMap<>();
Map<String, Set<String>> byFeature = new TreeMap<>();
for(PolicyAction action : PolicyAction.values())
{
byAction.put(action, new TreeSet<>());
byAction.put(action.getJsonName(), new TreeSet<>());
}
Set<String> expected = new TreeSet<>();
@@ -73,23 +86,28 @@ public final class PolicyGenerator
}
String resource = permission.substring(0, dot);
String name = permission.substring(dot + 1);
PolicyAction action = PolicyAction.fromJsonName(name).orElseThrow(() -> new PolicyParseException("\"" + permission + "\": unknown action \"" + name + "\""));
if(PolicyAction.fromJsonName(name).isEmpty() && (catalog == null || !catalog.hasFeature(resource, name)))
{
throw (new PolicyParseException("\"" + permission + "\": unknown action \"" + name + "\""));
}
if(!PolicyParser.RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException("\"" + permission + "\": resource must match [A-Za-z0-9_]+"));
}
byAction.get(action).add(resource);
Map<String, Set<String>> target = byAction.containsKey(name) ? byAction : byFeature;
target.computeIfAbsent(name, key -> new TreeSet<>()).add(resource);
expected.add(permission);
}
JSONArray statements = new JSONArray();
for(Map.Entry<PolicyAction, Set<String>> entry : byAction.entrySet())
byAction.putAll(byFeature);
for(Map.Entry<String, Set<String>> entry : byAction.entrySet())
{
if(!entry.getValue().isEmpty())
{
statements.put(new JSONObject()
.put("Effect", PolicyParser.ALLOW)
.put("Action", new JSONArray().put(entry.getKey().getJsonName()))
.put("Action", new JSONArray().put(entry.getKey()))
.put("Resource", new JSONArray(entry.getValue())));
}
}
@@ -99,7 +117,7 @@ public final class PolicyGenerator
.put("PolicyVersion", f.version())
.put("Statement", statements);
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f).permissions();
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f, catalog).permissions();
if(!parsed.equals(expected))
{
Set<String> missing = new TreeSet<>(expected);
@@ -118,7 +136,17 @@ public final class PolicyGenerator
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format) throws PolicyParseException
{
return (generate(PolicyParser.parseStrict(document, format).permissions(), format));
return (canonical(document, format, null));
}
/*******************************************************************************
** Canonicalize a document using the registered table features.
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
return (generate(PolicyParser.parseStrict(document, format, catalog).permissions(), format, catalog));
}
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.authentik.policy;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
@@ -80,6 +79,17 @@ public final class PolicyParser
** attribute or a token claim); null grants nothing
*******************************************************************************/
public static Set<String> parse(Object document, PolicyFormat format)
{
return (parse(document, format, null));
}
/*******************************************************************************
** Parse with the catalog of explicitly grantable table features; an invalid
** document still grants nothing.
*******************************************************************************/
public static Set<String> parse(Object document, PolicyFormat format, PolicyCatalog catalog)
{
if(document == null || JSONObject.NULL.equals(document))
{
@@ -87,7 +97,7 @@ public final class PolicyParser
}
try
{
return (parseStrict(document, format).permissions());
return (parseStrict(document, format, catalog).permissions());
}
catch(PolicyParseException e)
{
@@ -102,6 +112,17 @@ public final class PolicyParser
** Parse, throwing a PolicyParseException that names the first problem.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format) throws PolicyParseException
{
return (parseStrict(document, format, null));
}
/*******************************************************************************
** Parse standard actions and features registered for every listed resource.
** Without a catalog only the five standard actions are accepted.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
JSONObject json = asObject(document);
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
@@ -131,7 +152,6 @@ public final class PolicyParser
for(int i = 0; i < array.length(); i++)
{
final int number = i + 1;
if(!(array.opt(i) instanceof JSONObject statement))
{
throw (new PolicyParseException("Statement " + (i + 1) + " must be an object"));
@@ -142,29 +162,38 @@ public final class PolicyParser
throw (new PolicyParseException("Statement " + (i + 1) + ": Effect must be \"Allow\" (found " + describe(effect) + "); Deny is not supported yet"));
}
Set<PolicyAction> actions = EnumSet.noneOf(PolicyAction.class);
Set<String> statementResources = strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource");
Set<String> actions = new LinkedHashSet<>();
for(String name : strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action"))
{
if(PolicyAction.WILDCARD.equals(name))
{
actions.addAll(EnumSet.allOf(PolicyAction.class));
for(PolicyAction action : PolicyAction.values())
{
actions.add(action.getJsonName());
}
}
else
{
actions.add(PolicyAction.fromJsonName(name).orElseThrow(() -> new PolicyParseException("Statement " + number + ": unknown action \"" + name + "\"")));
if(PolicyAction.fromJsonName(name).isEmpty()
&& (catalog == null || statementResources.isEmpty() || !statementResources.stream().allMatch(resource -> catalog.hasFeature(resource, name))))
{
throw (new PolicyParseException("Statement " + (i + 1) + ": unknown action \"" + name + "\""));
}
actions.add(name);
}
}
for(String resource : strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource"))
for(String resource : statementResources)
{
if(!RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException("Statement " + (i + 1) + ": resource \"" + resource + "\" must match [A-Za-z0-9_]+"));
}
resources.add(resource);
for(PolicyAction action : actions)
for(String action : actions)
{
permissions.add(action.permission(resource));
permissions.add(resource + "." + action);
}
}
}
@@ -51,13 +51,23 @@ public final class PolicyPermissions
** when the claim is missing or invalid (fail closed).
*******************************************************************************/
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format)
{
return (fromClaims(claims, format, null));
}
/*******************************************************************************
** Read a policy claim with the catalog of registered table features.
*******************************************************************************/
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(claims == null || !claims.has(f.claim()))
{
return (Collections.emptySet());
}
return (PolicyParser.parse(claims.opt(f.claim()), f));
return (PolicyParser.parse(claims.opt(f.claim()), f, catalog));
}
@@ -25,11 +25,13 @@ import java.util.HexFormat;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.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.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -66,6 +68,16 @@ public final class PolicyStore
** text. Null for no policy.
*******************************************************************************/
public static String fingerprint(Object attributeValue, PolicyFormat format)
{
return (fingerprint(attributeValue, format, null));
}
/*******************************************************************************
** Fingerprint including registered table features in the canonical form.
*******************************************************************************/
public static String fingerprint(Object attributeValue, PolicyFormat format, PolicyCatalog catalog)
{
if(attributeValue == null || JSONObject.NULL.equals(attributeValue) || "".equals(attributeValue))
{
@@ -74,7 +86,7 @@ public final class PolicyStore
String text;
try
{
text = PolicyGenerator.toCompact(PolicyGenerator.canonical(attributeValue, format));
text = PolicyGenerator.toCompact(PolicyGenerator.canonical(attributeValue, format, catalog));
}
catch(PolicyParseException e)
{
@@ -111,6 +123,23 @@ public final class PolicyStore
/*******************************************************************************
** Remove the row of a deleted group (no-op without one).
*******************************************************************************/
public static void delete(AuthentikQBitConfig config, String groupPk) throws QException
{
QRecord existing = find(config, groupPk);
if(existing != null)
{
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.POLICY));
deleteInput.setPrimaryKeys(List.of(existing.getValue("id")));
new DeleteAction().execute(deleteInput);
}
}
/*******************************************************************************
** Insert or update the row of a group after a save to Authentik.
**
@@ -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));
createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT));
}
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),
@@ -150,14 +174,20 @@ public final class AuthentikProcesses
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email").withIsRequired(true),
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"),
reason), "Authentik emails a single-use sign-up invitation that expires in 7 days."));
QFieldMetaData otherAttributes = new QFieldMetaData(AuthentikAdmin.OTHER_ATTRIBUTES, QFieldType.TEXT).withLabel("Other attributes (JSON; leave blank to keep them)");
defs.add(new Def("editUser", "Edit User", AdminAction.EDIT_USER, Kind.USERS, users, "edit", true, List.of(
new QFieldMetaData("username", QFieldType.STRING).withLabel("Username"),
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"),
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email"),
new QFieldMetaData("path", QFieldType.STRING).withLabel("Path"),
hidden("expectedFingerprint"), reason), "Change a person's name, email or path."));
otherAttributes,
hidden("expectedFingerprint"), reason), "Change a person's username, name, email, path or other attributes. "
+ "Attributes starting with qqq.io/ or goauthentik.io/ are set elsewhere; editing attributes needs authentik.audit.read."));
defs.add(new Def("deactivateUser", "Deactivate User", AdminAction.DEACTIVATE_USER, Kind.USERS, users, "block", false, List.of(
new QFieldMetaData("revokeAppPasswords", QFieldType.BOOLEAN).withLabel("Also revoke their app passwords").withDefaultValue(false),
typed, reason), "Deactivating ends their sessions immediately and deletes nothing. Type the username to confirm (when you select several people, type the username of the first one)."));
defs.add(new Def("deleteUser", "Delete User", AdminAction.DELETE_USER, Kind.USERS, users, "delete_forever", false, List.of(typed, reason),
"Deletes the account in Authentik permanently. To keep the account and its history, use Deactivate User instead."));
defs.add(new Def("reactivateUser", "Reactivate User", AdminAction.REACTIVATE_USER, Kind.USERS, users, "check_circle", false, List.of(reason), null));
defs.add(new Def("addToGroup", "Add To Group", AdminAction.ADD_MEMBER, Kind.USERS, users, "group_add", false, List.of(group,
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason),
@@ -181,7 +211,11 @@ public final class AuthentikProcesses
defs.add(new Def("editGroup", "Edit Group", AdminAction.EDIT_GROUP, Kind.GROUPS, groups, "edit", true, List.of(
new QFieldMetaData("name", QFieldType.STRING).withLabel("Group Name"),
new QFieldMetaData("parents", QFieldType.TEXT).withLabel("Parent groups (names, one per line; none to clear)"),
hidden("expectedFingerprint"), reason), "Rename a group or change its parents."));
otherAttributes,
hidden("expectedFingerprint"), reason), "Rename a group, change its parents or its other attributes."));
defs.add(new Def("deleteGroup", "Delete Group", AdminAction.DELETE_GROUP, Kind.GROUPS, groups, "delete_forever", false, List.of(
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Type the group name to confirm").withIsRequired(true), reason),
"Deletes the group in Authentik permanently, with its permissions, app access and policy. Remove its members and child groups first."));
defs.add(new Def("editPermissions", "Edit Permissions And App Access", AdminAction.EDIT_PERMISSIONS, Kind.GROUPS, groups, "admin_panel_settings", true, List.of(
new QFieldMetaData("permissions", QFieldType.TEXT).withLabel("QQQ permissions (one per line)"),
new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"),
@@ -191,6 +225,9 @@ public final class AuthentikProcesses
+ "You can only grant permissions you hold yourself.";
List<QFieldMetaData> policyFields = List.of(hidden("groupPk"), hidden("expectedFingerprint"), reason);
defs.add(new Def("editPolicy", "Edit Policy", AdminAction.SAVE_POLICY, Kind.GROUPS, groups, "policy", true, policyFields, policyHelp));
defs.add(new Def("generatePolicy", "Generate Policy From Current Access", AdminAction.SAVE_POLICY, Kind.GROUPS, groups, "auto_fix_high", false, List.of(reason),
"Builds this group's policy from what its members get today from the app (joined with its current policy, so nothing is removed). "
+ "Review shows the generated document and what changes before anything is saved."));
defs.add(new Def("editPolicyRecord", "Edit Policy", AdminAction.SAVE_POLICY, Kind.POLICIES, AuthentikLocalTables.POLICY, "policy", true, policyFields, policyHelp));
defs.add(new Def("addMembers", "Add Members", AdminAction.ADD_MEMBER, Kind.GROUP_USERNAMES, groups, "person_add", false, List.of(
new QFieldMetaData("usernames", QFieldType.TEXT).withLabel("Usernames (one per line)").withIsRequired(true),
@@ -204,7 +241,10 @@ public final class AuthentikProcesses
defs.add(new Def("reject", "Reject", null, Kind.APPROVALS, AuthentikLocalTables.APPROVAL_REQUEST, "close", false, List.of(), null));
defs.removeIf(def -> def.action() != null && !config.hasFeature(def.action().getFeature()));
if(!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS) || !config.getTwoPersonApproval())
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())
&& !(config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.TOKENS) && Boolean.TRUE.equals(config.getTokenTwoPersonApproval())))
{
defs.removeIf(def -> def.kind() == Kind.APPROVALS);
}
@@ -276,15 +316,25 @@ 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);
process.setMaxInputRecords(def.action() == AdminAction.EDIT_USER || def.action() == AdminAction.EDIT_GROUP || def.action() == AdminAction.EDIT_PERMISSIONS
|| def.action() == AdminAction.SAVE_POLICY ? 1 : AuthentikAdmin.MAX_BULK);
|| def.action() == AdminAction.SAVE_POLICY || def.action() == AdminAction.DELETE_USER || def.action() == AdminAction.DELETE_GROUP ? 1 : AuthentikAdmin.MAX_BULK);
}
default ->
{
@@ -299,6 +349,10 @@ public final class AuthentikProcesses
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.HELP_TEXT).withValue("text", def.help()));
}
if(def.action() == AdminAction.SAVE_POLICY)
{
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.ENFORCEMENT)));
}
if(def.action() == AdminAction.SAVE_POLICY && !def.key().equals("generatePolicy"))
{
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", config.applyPrefix(PolicyEditorWidget.NAME)));
}
@@ -352,10 +406,17 @@ public final class AuthentikProcesses
case CREATE_GROUP -> w("group will be created", "groups will be created", "group was created", "groups were created");
case EDIT_GROUP -> w("group will be updated", "groups will be updated", "group was updated", "groups were updated");
case EDIT_PERMISSIONS -> w("group's permissions will be updated", "groups' permissions will be updated", "group's permissions were updated", "groups' permissions were updated");
case DELETE_USER -> w("user will be deleted", "users will be deleted", "user was deleted", "users were deleted");
case DELETE_GROUP -> w("group will be deleted", "groups will be deleted", "group was deleted", "groups were deleted");
case SAVE_POLICY -> w("group's policy will be saved to Authentik", "groups' policies will be saved to Authentik", "group's policy was saved to Authentik", "groups' policies were saved to Authentik");
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");
};
}
@@ -421,7 +482,12 @@ public final class AuthentikProcesses
rs.add(request);
}
}
case GROUPS, INVITATIONS -> rs.add(withParams(AdminRequest.of(def.action(), String.valueOf(record.getValue("pk") != null ? record.getValue("pk") : record.getValue("uuid")), reason), def, input));
case GROUPS, INVITATIONS ->
{
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 ->
{
@@ -441,10 +507,20 @@ public final class AuthentikProcesses
// the policy editor widget writes the document as a process value //
// (not a form field), as does an API client //
//////////////////////////////////////////////////////////////////////
if(def.action() == AdminAction.SAVE_POLICY && input.getValue(AuthentikAdmin.POLICY_DOCUMENT) != null)
if(def.action() == AdminAction.SAVE_POLICY && !"generatePolicy".equals(def.key()) && input.getValue(AuthentikAdmin.POLICY_DOCUMENT) != null)
{
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"))
@@ -494,6 +570,18 @@ public final class AuthentikProcesses
/*******************************************************************************
** Attributes are sensitive (DESIGN.md section 5.3): they are shown on the
** edit forms only with authentik.audit.read; left blank, they are unchanged.
*******************************************************************************/
static boolean canSeeAttributes()
{
return (QContext.getQSession() != null && QContext.getQSession().getPermissions() != null
&& QContext.getQSession().getPermissions().contains(IdentityPermission.AUDIT_READ.getSessionPermission()));
}
/*******************************************************************************
** The details screen is filled from the selected record (edit actions).
*******************************************************************************/
@@ -529,6 +617,11 @@ public final class AuthentikProcesses
JSONObject user = admin.getUtil().getObject(AuthentikTables.get(AuthentikTables.USER), pk);
if(user != null)
{
output.addValue("username", user.optString("username"));
if(canSeeAttributes())
{
output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(user.optJSONObject("attributes")).toString(2));
}
output.addValue("name", user.optString("name"));
output.addValue("email", user.optString("email"));
output.addValue("path", user.optString("path"));
@@ -543,6 +636,10 @@ public final class AuthentikProcesses
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
if(def.action() == AdminAction.EDIT_GROUP)
{
if(canSeeAttributes())
{
output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(group.optJSONObject("attributes")).toString(2));
}
output.addValue("name", group.optString("name"));
JSONArray parents = group.optJSONArray("parents_obj");
List<String> names = new ArrayList<>();
@@ -811,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;
@@ -832,6 +930,7 @@ public final class AuthentikProcesses
{
result = admin.execute(request);
}
result = deliver(output, result);
tally(result, w);
output.getRecords().add(record);
pace(admin);
@@ -841,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,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()));
}
@@ -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();
}
}
@@ -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));
@@ -297,6 +338,29 @@ public class FakeAuthentik
user.put("last_updated", "2026-10-12T01:00:00Z");
return (ok(200, render(user)));
}
if(path.matches("/core/users/[0-9]+/") && method.equals("DELETE"))
{
int pk = Integer.parseInt(path.split("/")[3]);
users.remove(pk);
for(JSONObject group : groups.values())
{
JSONArray kept = new JSONArray();
for(int i = 0; i < group.getJSONArray("users").length(); i++)
{
if(group.getJSONArray("users").getInt(i) != pk)
{
kept.put(group.getJSONArray("users").getInt(i));
}
}
group.put("users", kept);
}
return (ok(204, null));
}
if(path.matches("/core/groups/[^/]+/") && method.equals("DELETE"))
{
groups.remove(path.split("/")[3]);
return (ok(204, null));
}
if(path.matches("/core/groups/[^/]+/") && method.equals("PATCH"))
{
JSONObject group = groups.get(path.split("/")[3]);
@@ -426,7 +490,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)
{
@@ -457,7 +530,11 @@ 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/"))
{
return (page(loginEvents));
}
if(path.equals("/managed/blueprints/") || path.equals("/core/applications/"))
{
return (page(new ArrayList<>()));
}
@@ -466,6 +543,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));
}
}
@@ -0,0 +1,248 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
import com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource;
import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
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.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The cut-over to policies (DESIGN.md section 12.8): enforcement status, the
** compare report, the guard (and its fallback to today's access), and
** Generate Policy From Current Access.
*******************************************************************************/
class PolicyCutoverTest
{
/** Today's grant: publishers read pages and publish; analysts see analytics. */
static final AuthentikHostPermissionSource SOURCE = new AuthentikHostPermissionSource()
{
@Override
public Set<String> userPermissions(String email, Collection<String> groupNames)
{
Set<String> rs = new TreeSet<>();
if(groupNames.contains("kof-website-publishers") || groupNames.contains("identity-admins"))
{
rs.addAll(List.of("cms_page.read", "cms_page.edit", "cms.publish.hasAccess", "nothing.here.hasAccess"));
}
if(groupNames.contains("kof-website-analysts") || groupNames.contains("identity-admins"))
{
rs.addAll(List.of("kof22.analytics.hasAccess", "nothing.here.hasAccess"));
}
return (rs);
}
};
private AdminTestSupport t;
private AuthentikRuntime runtime;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, AuthentikFeature.POLICIES));
t.config.withHostPermissionSource(SOURCE).withCutoverGuardGroups(List.of("kof-website-publishers"));
runtime = AuthentikRuntime.forBackend(t.config.getApiBackendName());
}
/*******************************************************************************
**
*******************************************************************************/
private AccessComparison.State state() throws Exception
{
runtime.setMapperState(null);
return (AccessComparison.state(runtime, AuthentikActionUtil.forBackend(t.config.getApiBackendName())));
}
/*******************************************************************************
** OFF until the host turns the mapper on; then the guard decides; the
** owner can override.
*******************************************************************************/
@Test
void statusAndGuard() throws Exception
{
assertThat(state().status()).isEqualTo(AccessComparison.Status.OFF);
assertThat(state().message()).isEqualTo("Policies are saved but NOT enforced at sign-in on this instance (mapper off).");
t.config.withMapperEnabled(true);
AccessComparison.State blocked = state();
assertThat(blocked.status()).isEqualTo(AccessComparison.Status.BLOCKED);
assertThat(blocked.message()).contains("1 member(s) of kof-website-publishers").contains("operator");
t.config.withMapperOverride(true);
assertThat(state().status()).isEqualTo(AccessComparison.Status.OVERRIDDEN);
t.config.withMapperOverride(false);
////////////////////////////////////////////////////////////////////
// the static fallback for the guarded group covers today's grant //
////////////////////////////////////////////////////////////////////
t.config.withStaticGroupPermissions(Map.of("identity-admins", List.copyOf(SOURCE.userPermissions(null, List.of("identity-admins")))));
assertThat(state().status()).isEqualTo(AccessComparison.Status.ACTIVE);
assertThat(state().enforced()).isTrue();
}
/*******************************************************************************
** Compare Access: today against the mapper, per active person.
*******************************************************************************/
@Test
void compareReport() throws Exception
{
List<AccessComparison.Row> rows = AccessComparison.run(t.config, AuthentikActionUtil.forBackend(t.config.getApiBackendName()), runtime.getPermissionCatalog());
AccessComparison.Row operator = rows.stream().filter(r -> r.username().equals("operator")).findFirst().orElseThrow();
assertThat(operator.guarded()).isTrue();
assertThat(operator.removed()).contains("cms_page.read", "cms_page.edit", "kof22.analytics.hasAccess").doesNotContain("cms.publish.hasAccess");
assertThat(operator.added()).contains("authentik.users.read.hasAccess");
assertThat(rows).noneMatch(r -> r.username().equals("dormant") || r.username().equals("svc-publisher"));
assertThat(AccessComparison.guardFailures(rows)).extracting(AccessComparison.Row::username).containsExactly("operator");
RenderWidgetInput input = new RenderWidgetInput();
input.setWidgetMetaData(t.qInstance.getWidget("authentikCompareAccess"));
TableData table = (TableData) new RenderWidgetAction().execute(input).getWidgetData();
assertThat(table.getRows()).anyMatch(r -> String.valueOf(r.get("user")).equals("operator (guarded)") && String.valueOf(r.get("removed")).contains("cms_page.edit"));
assertThat(table.getFooterHTML()).contains("guard FAILS for 1");
assertThat(t.qInstance.getApp("authentikAdmin").getWidgets()).contains("authentikCompareAccess");
}
/*******************************************************************************
** The banner widget, on the group and policy screens and the Edit Policy
** step, and the home's health row.
*******************************************************************************/
@Test
void enforcementBanner() throws Exception
{
RenderWidgetInput input = new RenderWidgetInput();
input.setWidgetMetaData(t.qInstance.getWidget("authentikPolicyEnforcement"));
String html = ((RawHTML) new RenderWidgetAction().execute(input).getWidgetData()).getHtml();
assertThat(html).contains("Not enforced.").contains("NOT enforced at sign-in on this instance (mapper off)").contains("data-status=\"OFF\"");
assertThat(t.qInstance.getTable("authentik_group").getSections()).anyMatch(s -> "authentikPolicyEnforcement".equals(s.getWidgetName()));
assertThat(t.qInstance.getTable("authentik_policy").getSections().get(0).getWidgetName()).isEqualTo("authentikPolicyEnforcement");
assertThat(PolicyCutoverWidgets.bannerHtml(new AccessComparison.State(AccessComparison.Status.ACTIVE, "ok <b>", java.time.Instant.now()))).contains("Enforced.").contains("ok &lt;b&gt;");
String home = com.kingsrook.qbits.authentik.app.AuthentikHomeView.render(com.kingsrook.qbits.authentik.app.AuthentikHomeData.load(t.config));
assertThat(home).contains("Policies at sign-in").contains("mapper off");
}
/*******************************************************************************
** While the guard blocks, a sign-in through the mapper gets today's access
** (and the floor); once it passes, the policies.
*******************************************************************************/
@Test
void mapperFallsBackWhileBlocked() throws Exception
{
t.config.withMapperEnabled(true);
Map<String, Object> context = new HashMap<>();
context.put(AuthentikGroupPermissionMapper.CLAIMS_CONTEXT_KEY, new JSONObject().put("email", "sam@example.test").put("groups", new JSONArray().put("kof-website-publishers")));
runtime.setMapperState(null);
QSession blocked = new QSession();
new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, blocked, context);
assertThat(blocked.getPermissions()).contains("cms_page.read", "cms_page.edit", "cms.publish.hasAccess");
t.config.withStaticGroupPermissions(Map.of("kof-website-publishers", List.of("cms_page.read", "cms_page.edit", "cms.publish.hasAccess")));
t.config.withCutoverGuardGroups(List.of());
t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").put("qqq.io/policy",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"delete\"],\"Resource\":[\"cms_page\"]}]}");
t.config.withStaticGroupPermissions(Map.of());
runtime.invalidateCache();
runtime.setMapperState(null);
QSession active = new QSession();
new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, active, context);
assertThat(active.getPermissions()).contains("cms_page.delete", "cms.publish.hasAccess").doesNotContain("cms_page.edit");
}
/*******************************************************************************
** Generate Policy From Current Access: the expressible part becomes the
** policy, named permissions go to qqq.io/permissions, unknown ones are
** skipped; Review shows the document before anything is saved.
*******************************************************************************/
@Test
void generatePolicy() throws Exception
{
String analysts = t.authentik.pk("kof-website-analysts");
RunProcessInput input = new RunProcessInput();
input.setProcessName("authentikGeneratePolicy");
input.addValue("recordIds", t.authentik.pk("kof-website-publishers"));
RunProcessOutput output = new RunProcessAction().execute(input);
input.setProcessUUID(output.getProcessUUID());
input.addValue("reason", "seed from today's access");
input.setStartAfterStep("details");
output = new RunProcessAction().execute(input);
assertThat(output.getException()).isEmpty();
@SuppressWarnings("unchecked")
List<ProcessSummaryLine> review = (List<ProcessSummaryLine>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
assertThat(review).anyMatch(l -> l.getMessage().contains("Generated from current access: {\"PolicySpec\":\"qqq-iam\""));
assertThat(review).anyMatch(l -> l.getMessage().contains("Skipped (this app does not define them): nothing.here.hasAccess"));
assertThat(t.changes()).isEmpty();
AuthentikAdmin.AdminResult result = t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-publishers"), "seed")));
assertThat(result.ok()).as(result.message()).isTrue();
JSONObject attributes = t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes");
assertThat(PolicyParser.parse(attributes.get("qqq.io/policy"), PolicyFormat.DEFAULT)).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
assertThat(attributes.getJSONArray("qqq.io/permissions").toList()).contains("cms.publish.hasAccess");
assertThat(t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-publishers"), "again"))).noChange()).isTrue();
t.config.withHostPermissionSource(null);
assertThat(t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, analysts, "x"))).message()).contains("no host permission source");
}
}
@@ -18,6 +18,7 @@ package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
@@ -27,8 +28,12 @@ import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -42,10 +47,13 @@ import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
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;
@@ -98,6 +106,44 @@ class PolicyEditorTest
/*******************************************************************************
** Save, reload, resolve and compare a feature using simulated core metadata.
** The subset rule still requires the operator to hold the explicit grant.
*******************************************************************************/
@Test
void featurePolicyFlowsThroughEditorAndMapper() throws Exception
{
List<AvailablePermission> available = new ArrayList<>(PermissionsHelper.getAllAvailablePermissions(t.qInstance));
available.add(new AvailablePermission().withName("cms_page.analyze").withObjectType("Table").withObjectName("Pages").withPermissionType("Analyze"));
try(MockedStatic<PermissionsHelper> helper = Mockito.mockStatic(PermissionsHelper.class, Mockito.CALLS_REAL_METHODS))
{
helper.when(() -> PermissionsHelper.getAllAvailablePermissions(t.qInstance)).thenReturn(available);
String document = PAGE_READ_EDIT.replace("\"read\",\"edit\"", "\"analyze\"");
assertThat(save("kof-website-publishers", document).message()).contains("do not hold yourself");
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/policy", document);
t.admin().getUtil().getRuntime().invalidateCache();
AuthentikAdmin.AdminResult saved = save("kof-website-publishers", document);
assertThat(saved.ok()).as(saved.message()).isTrue();
assertThat(policyRows().get(0).getValueInteger("permissionCount")).isEqualTo(1);
PolicyEditorWidget.Data widget = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-publishers"));
assertThat(widget.getDocument()).contains("\"Action\": [\"analyze\"]");
assertThat(widget.getDocumentProblem()).isNull();
assertThat(widget.getChangedInAuthentik()).isFalse();
assertThat(save("kof-website-publishers", document).noChange()).isTrue();
assertThat(t.admin().getUtil().groupGraph().byName("kof-website-publishers").policyPermissions()).containsExactly("cms_page.analyze");
assertThat(AccessComparison.run(t.config, t.admin().getUtil(), t.admin().getUtil().getRuntime().getPermissionCatalog()))
.anyMatch(row -> row.username().equals("operator") && row.after().contains("cms_page.analyze"));
QSession session = new QSession();
JSONObject claims = new JSONObject().put("groups", List.of("kof-website-publishers")).put("sub", "1")
.put("qqq_policy", new JSONObject(document));
new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
assertThat(session.getPermissions()).contains("cms_page.analyze");
}
}
/*******************************************************************************
** Save writes the canonical compact string into the CURRENT attributes, and
** an authentik_policy row with the pretty document.
@@ -0,0 +1,532 @@
/*
* 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);
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,224 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.util.EnumSet;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.policy.PolicyStore;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
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 org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Full user and group CRUD (DESIGN.md section 12.5): username and attribute
** edits, delete user and delete group, and their gating.
*******************************************************************************/
class UserGroupCrudTest
{
private AdminTestSupport t;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.POLICIES, AuthentikFeature.DELETE));
}
/*******************************************************************************
**
*******************************************************************************/
private AuthentikAdmin.AdminResult run(AdminRequest request) throws QException
{
return (t.admin().execute(request));
}
/*******************************************************************************
** Username changes are validated and unique.
*******************************************************************************/
@Test
void editUsername() throws QException
{
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "rename").with("username", "second")).message()).contains("already has the username");
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "rename").with("username", "has space")).message()).contains("A username is");
AuthentikAdmin.AdminResult renamed = run(AdminRequest.of(AdminAction.EDIT_USER, "3", "rename").with("username", "samuel"));
assertThat(renamed.ok()).as(renamed.message()).isTrue();
assertThat(t.authentik.users.get(3).getString("username")).isEqualTo("samuel");
}
/*******************************************************************************
** Other attributes replace the non-reserved part; reserved keys survive and
** cannot be set; blank leaves them as they are; editing needs audit.read.
*******************************************************************************/
@Test
void editUserAttributes() throws QException
{
t.authentik.users.get(3).put("attributes", new JSONObject().put("qqq.io/protected", false).put("goauthentik.io/user/token-expires", true).put("team", "web").put("old", 1));
AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "{\"team\": \"ops\", \"desk\": 4}"));
assertThat(result.ok()).as(result.message()).isTrue();
JSONObject attributes = t.authentik.users.get(3).getJSONObject("attributes");
assertThat(attributes.getString("team")).isEqualTo("ops");
assertThat(attributes.getInt("desk")).isEqualTo(4);
assertThat(attributes.has("old")).isFalse();
assertThat(attributes.getBoolean("qqq.io/protected")).isFalse();
assertThat(attributes.getBoolean("goauthentik.io/user/token-expires")).isTrue();
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "{\"qqq.io/protected\": true}")).message()).contains("reserved");
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "[1]")).message()).contains("JSON object");
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "").with("name", "Sam B")).ok()).isTrue();
assertThat(t.authentik.users.get(3).getJSONObject("attributes").getString("team")).as("blank keeps them").isEqualTo("ops");
///////////////////////////////////////////////////////
// an operator without authentik.audit.read: refused //
///////////////////////////////////////////////////////
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/permissions", new JSONArray(List.of(
"authentik.users.read", "authentik.users.manage", "authentik.memberships.manage", "authentik.permissions.manage", "authentik.groups.manage")));
t.admin().getUtil().getRuntime().invalidateCache();
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "{\"team\": \"x\"}")).message()).contains("authentik.audit.read");
}
/*******************************************************************************
** Group attributes the same way.
*******************************************************************************/
@Test
void editGroupAttributes() throws QException
{
String pk = t.authentik.pk("kof-website-analysts");
AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_GROUP, pk, "update").with("otherAttributes", "{\"owner\": \"data team\"}"));
assertThat(result.ok()).as(result.message()).isTrue();
JSONObject attributes = t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes");
assertThat(attributes.getString("owner")).isEqualTo("data team");
assertThat(attributes.getJSONArray("qqq.io/permissions").toList()).containsExactly("kof22.analytics.hasAccess");
assertThat(run(AdminRequest.of(AdminAction.EDIT_GROUP, pk, "update").with("otherAttributes", "{\"owner\": \"data team\"}")).noChange()).isTrue();
}
/*******************************************************************************
** Delete user: typed confirmation, never protected accounts or yourself.
*******************************************************************************/
@Test
void deleteUser() throws QException
{
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "3", "left")).message()).contains("Type the username (sam)");
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "4", "left").with("confirmText", "breakglass")).message()).contains("Protected");
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "1", "left").with("confirmText", "operator")).message()).contains("your own account");
assertThat(t.changes()).isEmpty();
AuthentikAdmin.AdminResult deleted = run(AdminRequest.of(AdminAction.DELETE_USER, "3", "left the company").with("confirmText", "sam"));
assertThat(deleted.ok()).as(deleted.message()).isTrue();
assertThat(t.authentik.users).doesNotContainKey(3);
assertThat(t.changes()).containsExactly("DELETE /core/users/3/");
}
/*******************************************************************************
** Delete group: typed, and only empty groups without children; the policy
** record goes with it.
*******************************************************************************/
@Test
void deleteGroup() throws Exception
{
String publishers = t.authentik.pk("kof-website-publishers");
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, publishers, "cleanup").with("confirmText", "kof-website-publishers")).message()).contains("member(s)");
t.authentik.group("child-of-analysts", false, List.of(), List.of(), "kof-website-analysts");
t.admin().getUtil().getRuntime().invalidateCache();
String analysts = t.authentik.pk("kof-website-analysts");
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, analysts, "cleanup")).message()).contains("Type the group name");
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, analysts, "cleanup").with("confirmText", "kof-website-analysts")).message()).contains("child groups (child-of-analysts)");
String child = t.authentik.pk("child-of-analysts");
PolicyStore.save(t.config, child, "child-of-analysts", new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0").put("Statement", new JSONArray()), 0, "x", null);
AuthentikAdmin.AdminResult deleted = run(AdminRequest.of(AdminAction.DELETE_GROUP, child, "cleanup").with("confirmText", "child-of-analysts"));
assertThat(deleted.ok()).as(deleted.message()).isTrue();
assertThat(t.authentik.groups).doesNotContainKey(child);
assertThat(PolicyStore.find(t.config, child)).isNull();
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, t.authentik.pk("authentik Admins"), "x").with("confirmText", "authentik Admins")).message()).contains("Superuser groups");
}
/*******************************************************************************
** DELETE is off by default: no processes, and the action is refused; the
** permission is still required with it on.
*******************************************************************************/
@Test
void gating() throws QException
{
assertThat(t.qInstance.getProcess("authentikDeleteUser")).isNotNull();
assertThat(t.qInstance.getProcess("authentikDeleteGroup").getMaxInputRecords()).isEqualTo(1);
AdminTestSupport off = AdminTestSupport.create();
assertThat(off.qInstance.getProcess("authentikDeleteUser")).isNull();
assertThat(off.qInstance.getProcess("authentikDeleteGroup")).isNull();
assertThat(off.admin().execute(AdminRequest.of(AdminAction.DELETE_USER, "3", "x").with("confirmText", "sam")).ok()).isFalse();
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ, IdentityPermission.GROUPS_MANAGE);
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "3", "x").with("confirmText", "sam")).ok()).isFalse();
assertThat(t.authentik.users).containsKey(3);
}
/*******************************************************************************
** Edit User prefills username and (with audit.read) other attributes.
*******************************************************************************/
@Test
void editUserPrefill() throws QException
{
t.authentik.users.get(3).put("attributes", new JSONObject().put("team", "web").put("qqq.io/x", 1));
RunProcessInput input = new RunProcessInput();
input.setProcessName("authentikEditUser");
input.addValue("recordIds", "3");
RunProcessOutput output = new RunProcessAction().execute(input);
assertThat(output.getValues().get("username")).isEqualTo("sam");
assertThat(new JSONObject(String.valueOf(output.getValues().get("otherAttributes"))).toMap()).containsOnlyKeys("team");
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ, IdentityPermission.USERS_MANAGE);
RunProcessInput limited = new RunProcessInput();
limited.setProcessName("authentikEditUser");
limited.addValue("recordIds", "3");
assertThat(new RunProcessAction().execute(limited).getValues().get("otherAttributes")).isNull();
}
}
@@ -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());
@@ -78,7 +78,7 @@ class QbitWriteContractIT
/** The phase 2/3 permissions of DESIGN.md section 5.1 (plus view_flow to resolve the invitation flow by slug). */
private static final List<String> ROLE_PERMISSIONS = List.of(
"authentik_core.add_user", "authentik_core.change_user", "authentik_core.reset_user_password",
"authentik_core.add_user", "authentik_core.change_user", "authentik_core.reset_user_password", "authentik_core.delete_user", "authentik_core.delete_group",
"authentik_core.add_group", "authentik_core.change_group", "authentik_core.add_user_to_group", "authentik_core.remove_user_from_group",
"authentik_core.view_token", "authentik_core.delete_token", "authentik_core.delete_authenticatedsession",
"authentik_stages_invitation.view_invitation", "authentik_stages_invitation.add_invitation", "authentik_stages_invitation.delete_invitation",
@@ -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);
}
}
@@ -166,7 +165,7 @@ class QbitWriteContractIT
.withRecoveryEmailStage("kof22-recovery-email")
.withInvitationFlow("wr-enrollment")
.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES));
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE));
qInstance = new QInstance();
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
@@ -345,6 +344,35 @@ class QbitWriteContractIT
/*******************************************************************************
** Delete user and delete group on a real Authentik (delete_user and
** delete_group granted only for the DELETE feature); a group with members is
** refused by the qbit before any call.
*******************************************************************************/
@Test
void deleteUserAndGroup() throws Exception
{
admin.post("/core/users/", new JSONObject().put("username", "wr.leaver").put("name", "WR Leaver").put("email", "wr.leaver@example.test").put("type", "internal").put("path", "users"));
int leaver = admin.userPk("wr.leaver");
admin.post("/core/groups/", new JSONObject().put("name", "wr-doomed"));
String doomed = admin.groupPk("wr-doomed");
admin.post("/core/groups/" + doomed + "/add_user/", new JSONObject().put("pk", leaver));
AuthentikAdmin.AdminResult notEmpty = run(AdminRequest.of(AdminAction.DELETE_GROUP, doomed, "contract test").with("confirmText", "wr-doomed"));
assertThat(notEmpty.ok()).isFalse();
assertThat(notEmpty.message()).contains("member(s)");
AuthentikAdmin.AdminResult user = run(AdminRequest.of(AdminAction.DELETE_USER, String.valueOf(leaver), "contract test").with("confirmText", "wr.leaver"));
assertThat(user.ok()).as(user.message()).isTrue();
assertThat(admin.get("/core/users/" + leaver + "/").status()).isEqualTo(404);
AuthentikAdmin.AdminResult group = run(AdminRequest.of(AdminAction.DELETE_GROUP, doomed, "contract test").with("confirmText", "wr-doomed"));
assertThat(group.ok()).as(group.message()).isTrue();
assertThat(admin.get("/core/groups/" + doomed + "/").status()).isEqualTo(404);
}
/*******************************************************************************
** Group create and edit (parents), and the superuser parent refused by the
** guard.
@@ -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,731 @@
/*
* 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));
}
/*******************************************************************************
** 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
{
return (form(Map.of("grant_type", "client_credentials", "client_id", AuthentikTestStack.APP_SLUG,
"client_secret", "contract-test-oauth2-client-secret", "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
@@ -0,0 +1,261 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
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.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. The metadata hook is absent from the pinned core RC.5 release.
** STAND-IN(core=QRun-IO/qqq#1136, removeBy=4.1.0-RC.6)
** Replace the synthetic feature catalog with metadata registration when upgrading.
*******************************************************************************/
class PolicyFeaturePermissionsTest
{
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
/*******************************************************************************
** Use an overridden permission base name to catch matching by table name.
*******************************************************************************/
private static QInstance instance()
{
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")));
return (instance);
}
/*******************************************************************************
** The shape returned by PermissionsHelper when core registers a feature.
*******************************************************************************/
private static AvailablePermission feature(String name, String label)
{
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")));
}
/*******************************************************************************
** A statement granting these actions on these resources.
*******************************************************************************/
private static JSONObject document(List<String> actions, String... resources)
{
return (PolicyParserTest.document(PolicyParserTest.statement("Allow", actions, List.of(resources))));
}
/*******************************************************************************
** Labels, overridden/shared bases, permission levels and additive widget JSON.
*******************************************************************************/
@Test
void catalogListsFeaturesSeparately()
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
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("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")
.doesNotContain("person.share", "person_records.analyze");
assertThat(catalog.unknownPermissions(Set.of("person.analyze", "person.mystery"))).containsExactly("person.mystery");
List<JSONObject> resources = new ArrayList<>();
catalog.toJson().forEach(group -> ((JSONObject) group).getJSONArray("resources").forEach(resource -> resources.add((JSONObject) resource)));
JSONObject person = resources.stream().filter(resource -> resource.getString("name").equals("person")).findFirst().orElseThrow();
assertThat(person.getJSONArray("actions").toList()).containsExactly("read", "insert", "edit", "delete");
assertThat(person.getJSONArray("features").toList()).containsExactly(Map.of("name", "analyze", "label", "Analyze"), Map.of("name", "comment", "label", "Comment"));
assertThat(resources.stream().filter(resource -> resource.getString("kind").equals("TABLE"))).allMatch(resource -> resource.has("features"));
assertThat(resources.stream().filter(resource -> resource.getString("kind").equals("APP"))).noneMatch(resource -> resource.has("features"));
}
/*******************************************************************************
** Feature-only tables are still grantable when CRUD capabilities are absent.
*******************************************************************************/
@Test
void featuresDoNotRequireStandardActions()
{
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");
assertThat(resource.actions()).isEmpty();
assertThat(resource.features()).hasSize(2);
}
/*******************************************************************************
** Every resource must register a feature; one bad pair voids the document.
*******************************************************************************/
@Test
void parserValidatesFeaturesPerResource() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
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");
assertThat(PolicyParser.parse(document(List.of("comment", "write"), "legacy"), FORMAT, catalog)).containsExactly("legacy.comment", "legacy.write");
assertThatThrownBy(() -> PolicyParser.parseStrict(document(List.of("mystery"), "person"), FORMAT, catalog))
.hasMessage("Statement 1: unknown action \"mystery\"");
assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "legacy"), FORMAT, catalog)).isEmpty();
assertThat(PolicyParser.parse(document(List.of("analyze"), "gone"), FORMAT, catalog)).isEmpty();
assertThat(PolicyParser.parse(document(List.of("analyze")), FORMAT, catalog)).isEmpty();
assertThat(PolicyParser.parse(document(List.of("Analyze"), "person"), FORMAT, catalog)).isEmpty();
assertThat(PolicyParser.parse(document(List.of("analyze"), "person"), FORMAT)).isEmpty();
}
/*******************************************************************************
** Existing wildcard grants remain the five standard actions forever.
*******************************************************************************/
@Test
void wildcardDoesNotGrantFeatures()
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
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");
}
/*******************************************************************************
** Standard ordering stays byte-identical; sorted features follow it.
*******************************************************************************/
@Test
void generatorRoundTripsFeatures() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
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);
assertThat(compact).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":["
+ "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"person\"]},"
+ "{\"Effect\":\"Allow\",\"Action\":[\"analyze\"],\"Resource\":[\"gate\",\"person\"]},"
+ "{\"Effect\":\"Allow\",\"Action\":[\"comment\"],\"Resource\":[\"person\"]}]}");
assertThat(PolicyParser.parseStrict(compact, FORMAT, catalog).permissions()).containsExactlyInAnyOrderElementsOf(permissions);
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(generated), FORMAT, catalog).permissions()).containsExactlyInAnyOrderElementsOf(permissions);
assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(PolicyGenerator.toPretty(generated), FORMAT, catalog))).isEqualTo(compact);
assertThat(PolicyStore.fingerprint(compact, FORMAT, catalog)).isEqualTo(PolicyStore.fingerprint(PolicyGenerator.toPretty(generated), FORMAT, catalog));
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("person.mystery"), FORMAT, catalog)).hasMessageContaining("unknown action \"mystery\"");
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("legacy.analyze"), FORMAT, catalog)).hasMessageContaining("unknown action \"analyze\"");
List<String> standard = List.of("person.read", "person.edit", "gate.hasAccess");
assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(standard, FORMAT, catalog))).isEqualTo(PolicyGenerator.toCompact(PolicyGenerator.generate(standard, FORMAT)));
}
/*******************************************************************************
** The real mapper discovers simulated core entries 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");
}
}
/*******************************************************************************
** Inherited feature grants reach resolution, attribution and access comparison.
*******************************************************************************/
@Test
void comparisonIncludesInheritedFeatures() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
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")))
.put("users", new JSONArray(List.of(1)));
GroupGraph graph = GroupGraph.fromGroups(List.of(parent, child), FORMAT, catalog);
EffectivePermissionResolver.Resolution resolution = new EffectivePermissionResolver(graph, catalog.permissionNames()).resolveUser(1);
assertThat(resolution.sessionPermissions()).containsExactly("person.analyze");
assertThat(resolution.grantedBy().get("person.analyze")).containsExactly("Analysts");
assertThat(resolution.unknown()).isEmpty();
AuthentikActionUtil util = Mockito.mock(AuthentikActionUtil.class);
Mockito.when(util.groupGraphWithMembers()).thenReturn(graph);
Mockito.when(util.allUsers()).thenReturn(List.of(new JSONObject().put("pk", 1).put("username", "person").put("type", "internal")));
AuthentikQBitConfig config = new AuthentikQBitConfig().withCutoverGuardGroups(List.of("Staff"));
AccessComparison.Row added = AccessComparison.run(config, util, catalog.permissionNames()).get(0);
assertThat(added.after()).containsExactly("person.analyze");
assertThat(added.added()).containsExactly("person.analyze");
config.withHostPermissionSource((email, groups) -> Set.of("person.analyze"));
AccessComparison.Row unchanged = AccessComparison.run(config, util, catalog.permissionNames()).get(0);
assertThat(unchanged.before()).isEqualTo(unchanged.after());
assertThat(unchanged.removed()).isEmpty();
assertThat(AccessComparison.guardFailures(List.of(unchanged))).isEmpty();
}
}
@@ -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,195 @@
# 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.
- 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 {}
# 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