Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 99a302c7ce refactor(tokens): token notifications call core QAlerts directly (RC.6)
QQQ 4.1.0-RC.6 (now the qbit's baseline) ships the notification SPI
(qqq#1125), so the reflection stand-in is gone: guard incidents use
QAlerts.raise, owner reminders QAlerts.notify.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:26:53 -05:00
james.maes 38d9776f25 Merge remote-tracking branch 'origin/develop' into feature/arch-rc5
# Conflicts:
#	CHANGELOG.md
2026-10-13 20:18:53 -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
41 changed files with 2163 additions and 92 deletions

No files matched your search

+2 -2
View File
@@ -45,7 +45,7 @@ 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[]=`.
@@ -61,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
+12 -1
View File
@@ -7,11 +7,22 @@ All notable changes to this project are documented here. Format: [Keep a Changel
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
### Added (access tokens 6c)
- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153.
- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked.
- Daily expiry reminders with persistent per-window deduplication and optional Alerts delivery; high-severity guard-bypass alerts.
- A standard Access tokens TABLE widget with owner counts, age buckets and cleanup links, plus the existing record/list bulk revoke action.
### Added (access tokens, DESIGN.md section 13)
- Default-on `TOKENS` with personal create/rotate/revoke/extend, service-account creation and rotation, protected-owner guardrails and optional two-person approval.
- Action-scoped OAuth2 token exchange, response-only one-time key delivery (fail closed on older QQQ), generated usage instructions and non-secret token scope records.
- Token-manager read access, expanded token metadata, secret redaction and in-memory process/persistence tests.
### Changed (core baseline, #20 / F19)
- Build against the released QQQ BOM `4.1.0-RC.6` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. Adopt `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
- Core actions now enforce permissions for non-HTTP callers. Authentik-specific guards remain for direct util calls and domain rules. See [UPGRADE.md](UPGRADE.md) for host impact and feature registration requirements.
- Replace the expired feature-catalog stand-in with real `TableFeaturePermissionsMetaData` registrations and remove the test-only `PolicyCatalog` permission-source overload. Feature discovery uses the core catalog without static mocking.
### Added (automated sessions)
- `AuthentikGroupPermissionMapper` resolves tokenless automated sessions by a unique active Authentik email match, applying direct and inherited group permissions, static fallbacks and the sign-in cut-over guard while preserving existing session permissions.
@@ -62,7 +73,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel
### Changed
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
- **Table names are lowercase snake_case with the `authentik_` prefix** (owner naming rule, 2026-10-12): `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event` (were `authentikUser`, ...). New `AuthentikQBitConfig.applyTablePrefix` and `toSnakeCase`; apps, widgets, the backend and processes keep camelCase names (`authentikAdmin`, `authentikApi`). The old names are kept as `@Deprecated` `AuthentikTables.LEGACY_*_TABLE_NAME` constants. See [UPGRADE.md](UPGRADE.md).
### Documentation
+2 -2
View File
@@ -45,7 +45,7 @@ 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[]=`.
@@ -61,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
+16 -2
View File
@@ -68,7 +68,7 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
The editor discovers features and labels through `PermissionsHelper.getAllAvailablePermissions`; they appear when the host's core version exposes them. This qbit does not compile against the unreleased metadata hook. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
The editor discovers registered features and labels through core RC.6's `PermissionsHelper.getAllAvailablePermissions`. The build uses `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
### Authentik side
@@ -83,7 +83,7 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
| Item | What to do |
|---|---|
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
| QQQ version | QQQ 4.1 (see `pom.xml` for the exact RC). Java 21. |
| QQQ version | QQQ 4.1.0-RC.6 (released BOM, no core SNAPSHOT). Java 21. |
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. |
| Tables | Create `authentik_admin_action`, `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)`. |
@@ -110,6 +110,20 @@ Apache 2.0
Access-token metadata is readable with `authentik.audit.read` or `authentik.tokens.manage`. Personal actions have no additional process permission; ownership is checked live. Managing service-account tokens needs `authentik.tokens.manage`, and creating a service account also needs `authentik.users.manage`. Configure `tokenBrokerClientId`, `tokenBrokerClientSecret` (environment references supported), `appClientId`, `appPublicUrl` and `authentikPublicUrl` for exchange and usage instructions. Scope fields remain unavailable until `tokenScopesEnabled=true`.
| Access-token setting | Default | Purpose |
|---|---|---|
| `tokenScopesEnabled` | `false` | Offer read-only, apps and tables fields after the signed claim mapping is installed. Names must exist and the operator must currently have read/hasAccess permission. |
| `tokenScopeClaim` | `qqq_token` | Signed JWT claim containing the Authentik token identifier. Claims are enforced even when offering new scopes is disabled. |
| `tokenUnusedDays` | `60` | Inactivity threshold for cleanup suggestions. With no login found, a known creation date and a threshold at most 90 days are required. |
| `tokenReminderSchedulerName` | First registered scheduler | Run `authentikTokenReminders` daily. Without a scheduler the hidden process is available for a host to invoke. Register the scheduler before producing the qbit. |
The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The temporary session-permission intersection is marked `STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7)`; a reflection bridge also attaches `QCredentialScope` when available. It does not add permissions.
The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`.
Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added.
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
+22
View File
@@ -1,5 +1,27 @@
# Upgrading qbit-authentik
## QQQ 4.1.0-RC.6 baseline (#20 / F19)
Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.6`
and use Java 21. The historical `qqq-snapshot` profile and `qqq.snapshot.version`
property names remain, but the imported core version is a release, not a SNAPSHOT.
Core actions now enforce permissions and field security locks for non-HTTP callers
(QRun-IO/qqq#1061, #1062); callers must supply an appropriately authorized session.
The qbit keeps its guards, scope checks and sensitive-field stripping because they
also protect direct backend calls and implement Authentik-specific rules.
No qbit configuration, stored policy format or schema changes are introduced.
The build adopts `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
The expired #1136 test stand-in is removed: tests now register features through
`TableFeaturePermissionsMetaData` and discover them through the real
`PermissionsHelper.getAllAvailablePermissions`, as production hosts do. The
unused explicit permission-source overload in `PolicyCatalog` is removed.
Duplicate shared-base entries, replacement registrations and malformed suffixes
remain covered. Hosts should register feature suffixes, labels and table
applicability through the core metadata API; no synthetic catalog is needed.
Client-channel attribution and identity/credential channel restrictions remain
separate follow-up work.
## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release)
The qbit's permissions follow the same owner naming rule (2026-10-12) and now
+13 -7
View File
@@ -1,10 +1,10 @@
# Using access tokens
An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6).
An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6).
## Create a token
1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**.
1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**.
2. Choose **Create token** and fill in:
- a description, for example "nightly export script";
- an expiry. It's required: at most 90 days for people, and 365 for service accounts;
@@ -36,7 +36,7 @@ curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData
## Manage your tokens
From **Preferences → Access tokens**:
From **Preferences → Access tokens**:
| Action | What happens |
|---|---|
@@ -44,14 +44,20 @@ From **Preferences → Access tokens**:
| **Extend** | Moves the expiry. The limit counts from today. |
| **Revoke** | Deletes the token in Authentik. Anything using it stops working at once. |
You get a reminder in the notification bell, and by email, 7 days and 1 day before a token expires.
When the host has configured a scheduler and the optional Alerts delivery adapter, the daily job sends owner reminders in the 7-day and 1-day expiry windows. Delivery channels (bell/email) follow the host’s Alerts configuration. Each window sends once; if first seen within one day, only the urgent window sends. Extending a token through the qbit resets its reminder windows. Without delivery support, the job logs and leaves the reminder pending.
## For administrators
People with `authentik.tokens.manage` see **all** tokens in the Identity app (**Tokens**). From there they can:
- filter by person, intent and expiry, and sort by age and last use;
People with `authentik.tokens.manage` see the **Access tokens** TABLE widget on the Identity home page, with a link to **all access tokens**. From there they can:
- filter by person, intent, expiring status or token identifier; view computed creation time, scope and last use (these computed columns cannot be filtered or sorted by the Authentik API);
- revoke tokens one at a time or in bulk, for example the tokens the home page lists as unused for 60 days, or as belonging to deactivated people;
- create a **service account** for an integration (**Users → Create Service Account**). The account gets one app password, shown once, and the groups you choose. Rotate and extend it from its token record.
- create a **service account** for an integration (**Users → Create Service Account**). The account gets one app password, shown once, and the groups you choose. Rotate and extend it from its token record.
The widget shows the top ten owners plus a total, age buckets (`<30d`, `30–90d`, `90–365d`, `>365d`, and unknown), unused tokens and tokens with deactivated owners. Cleanup rows include owner and token links; age buckets link to the explicit token identifiers. Creation dates are known only for tokens created by the qbit. External tokens can receive reminders without acquiring a scope or an invented creation date.
Last use comes from app-password login events over the last 90 days, cached for five minutes. The pinned Authentik schema has no date-range parameter for this endpoint, so the qbit pages newest-first to the window boundary. API-intent tokens show **not tracked** and never appear as unused. A token with no recorded login is suggested as unused only if its known creation date is older than `tokenUnusedDays` and that threshold is at most 90 days.
Create Token offers read-only, apps and tables scopes only when `tokenScopesEnabled` is enabled after installing the signed claim mapping. Enter one metadata name per line. Review checks existence and the operator’s current read/hasAccess permission and shows the scope. The claims mappers intersect permissions after resolving them; scopes never grant access the owner lacks. A deleted token receives no permissions even if its local scope row remains, subject to the 60-second verification cache.
Administrators can never create, rotate or extend a token for **another person**. Each person manages their own.
+13 -7
View File
@@ -24,7 +24,7 @@ Seven design rules shape everything below:
- A group's QQQ permissions live in the group attribute `qqq.io/permissions`.
- The reusable `AuthentikGroupPermissionMapper` turns a user's groups, including inherited parents, into QQQ session permissions.
- Who can operate the qbit is decided by those same permissions (`authentik.*`). No operator group is hard-coded.
4. **The qbit checks permissions itself.** QQQ core actions do not enforce permissions (QRun-IO/qqq#1061), and `FieldSecurityLock` is not enforced (#1062). An `AuthentikGuard` in the backend action layer checks every read and write.
4. **The qbit checks permissions itself.** The original core gaps (QRun-IO/qqq#1061, #1062) are fixed in the RC.5 baseline. Direct backend calls and Authentik-specific rules still require local checks. An `AuthentikGuard` in the backend action layer checks every read and write.
5. **No escalation.**
- Operators can only grant permissions they hold themselves, whether through memberships or permission attributes.
- Operators cannot edit themselves.
@@ -74,6 +74,12 @@ Seven design rules shape everything below:
| 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. |
**Owner decision (2026-10-14): `add_token` (option A).** The contract tests showed that Authentik 2026.8.3 requires `add_token` for `POST /core/users/service_account/` (with `add_user`) and for `POST /core/tokens/{id}/set_key/` (the POST object permission, with `set_token_key`).
| # | Decision |
|---|---|
| 31 | **Grant `add_token` to the qbit role, so service-account creation and rotation stay in the app.** It is safe: for a non-superuser, `POST /core/tokens/` always creates the token for the caller, so it can never mint a token for anyone else. `view_token_key` stays **not** granted. `TokenContractIT` pins 403 without `add_token` and 200/204 with it. The detective alarm (§13.4 e) also notifies `identity-admins` whenever the qbit's account creates a service account. The blueprint explains this next to the permission (cluster-gitops#103). |
### 0.2 Phase 0 findings (2026-10-11, verified on Authentik 2026.8.3)
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.
@@ -668,7 +674,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost
- `AuthentikActionUtil.doQuery/doGet/doInsert/doUpdate/doDelete` call `AuthentikGuard.check(table, op, session)` first. The guard checks the QQQ permission, the feature flag, `readOnly` and scope.
- A session with no user is refused, except `MirrorSync`'s internal read-only session.
- Every qbit process's first backend step re-checks `checkProcessPermissionThrowing` and the per-record rules (§5.4). It does not trust that the middleware ran.
- Field-level protection does not use `FieldSecurityLock` (#1062):
- Field-level protection retains explicit stripping for direct backend calls; core also enforces `FieldSecurityLock` in the RC.5 baseline (#1062):
- Sensitive columns are removed in `jsonObjectToRecord` without `authentik.audit.read`. These are `attributes` (except the `qqq.io/*` keys, shown under `users.read`), `last_ip`, `user_agent` and `geo_ip`.
- Filters on those fields are rejected for that session.
- Tables also declare `QPermissionRules`, so the UI hides what the user can't use.
@@ -955,7 +961,7 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh
| A blueprint re-apply reverts QQQ edits | No `present` identity entries, no member lists (CI lint); bootstrap as `created` (verified, §4.2); reversion detector |
| Lost update between QQQ and the Authentik UI | Stale check at execute; attribute merge-patch after a fresh read |
| Account takeover through a recovery link | Links are never shown; email only |
| A non-HTTP caller bypasses permissions (#1061) | Guard in the backend util, plus ArchUnit |
| A direct backend caller bypasses core actions | Guard in the backend util, plus ArchUnit |
| A field leaks because `FieldSecurityLock` isn't enforced (#1062) | Sensitive fields stripped in the util |
| A forged webhook | Shared secret, constant-time compare; it only triggers re-reads |
| Stale session permissions after a demotion | Re-evaluation on token refresh plus webhook-triggered marking (§5.6); live groups used for guard checks |
@@ -1033,7 +1039,7 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a
| The service account can lock administrators out (remove members from `authentik Admins`, deactivate break-glass, strip `qqq.io/protected`, §0.2 ii-b). Re-parenting under a superuser group **is** refused by Authentik (§0.2 ii) | Guard refusal of every write that targets a protected account or a superuser group; drift report; recovery through cluster access |
| Load on the shared Postgres | Throttle, caps, mirror |
| Webhook loss | Inbox, 15-minute reconcile, nightly sweep |
| QQQ core gaps (#1061, #1062) | Guard in the backend util, ArchUnit, field stripping |
| Direct backend calls outside core enforcement | Guard in the backend util, ArchUnit, field stripping |
| Token leakage | Redaction tests; no body logging on sensitive paths |
| An Authentik upgrade changes fields | Pinned schema, fixtures per version, version check |
@@ -1289,7 +1295,7 @@ A QQQ access token is an Authentik **app password** (`intent: app_password`) own
- `password={token}`
- `scope=openid profile email`
Authentik checks that the owner is active, that the token exists with intent `app_password`, and that the app's policies let the owner in. It records a `login` event with `method: token` and the token's identifier.
No client secret: Authentik 2026.8.3 authenticates this grant by the username and app password, even for a confidential client (client authentication is required only for the code, refresh, device and token-exchange grants; pinned by `TokenContractIT`). Authentik checks that the owner is active, that the token exists with intent `app_password`, and that the app's policies let the owner in. It records a `login` event with `method: token` and the token's identifier.
2. **Call the app** (`/qqq/v1/...` and the published APIs) with `Authorization: Bearer {jwt}`. When the JWT expires (the provider's access-token validity), exchange the token again.
The qbit generates these instructions from the instance's real URLs (§13.7).
@@ -1362,7 +1368,7 @@ Without a broker, the self-service actions are hidden, and Review says why.
| (b) | **Self-service is self-only.** A person without `authentik.tokens.manage` may target only themselves. An admin may also target service accounts, but **never another human**; this covers create, rotate and extend. Revoke is the exception: for incident response, an admin may revoke any token whose owner isn't protected. | The qbit guard; Authentik itself for the as-user calls |
| (c) | **Expiry is required.** There's no non-expiring option. The maximum is `tokenMaxDaysUser` (default 90) for people and `tokenMaxDaysServiceAccount` (default 365) for service accounts. Extend counts from now. Authentik's group attribute is a second cap. | The qbit (Review refuses); Authentik (`token-maximum-lifetime`) |
| (d) | **Two-person approval** for service-account tokens (create service account, rotate): `tokenTwoPersonApproval`, default **off** while the team is one person. When on, the change is queued in `authentik_approval_request`, and the key is shown to the approver who applies it. | The qbit |
| (e) | **Detective control.** An Authentik notification rule and an expression policy fire whenever the qbit's service account creates or updates a token whose owner is protected, reads its key, or sets its key (`model_created`, `model_updated`, `secret_view` or `secret_rotate` on `authentik_core.token`). They notify `identity-admins` through Authentik notifications. The rule should never fire; if it does, guard (a) was bypassed. | The Authentik blueprint (§13.9). The qbit also raises an `authentik_token_guard_bypass` alert through the Alerts producer when its own re-read finds a protected owner. |
| (e) | **Detective control.** An Authentik notification rule and an expression policy fire whenever the qbit's service account creates or updates a token whose owner is protected, reads its key, or sets its key (`model_created`, `model_updated`, `secret_view` or `secret_rotate` on `authentik_core.token`). They notify `identity-admins` through Authentik notifications. The rule should never fire; if it does, guard (a) was bypassed. It also notifies `identity-admins` whenever the qbit's account creates a service account (decision 31). | The Authentik blueprint (§13.9). The qbit also raises an `authentik_token_guard_bypass` alert through the Alerts producer when its own re-read finds a protected owner. |
| (f) | **Everything is audited.** Before each call, every action writes an `authentik_admin_action` row with the client channel (`audit.clientChannel`, for example `web`, `api` or `token:{identifier}`), plus an Authentik `custom_qqq_admin_action` event. The assistant can't reach these tables or actions, because the assistant wall denies `authentik_*`. | The qbit; core audit |
### 13.5 Scopes
@@ -1408,7 +1414,7 @@ STAND-IN(core=QRun-IO/qqq#1153, removeBy=the RC that ships #1153, RC.7 at the ea
**Record actions.** These use core's one-time process output values: `RunBackendStepOutput.addOneTimeSecret(...)`, in the QRun-IO/qqq PR `feature/process-one-time-values`.
- The value travels only in the step's HTTP response.
- `ProcessState`, traces and lifecycle listeners (ESB) see only the masked form: `••••` plus the last 4 characters.
- If the run finishes asynchronously, the key is lost by design, and the result says "Rotate the token to get a new key".
- If the step outlives the middleware's step timeout (3 s) and finishes as a job, core parks the secrets in memory (`OneTimeSecretHandoff`, 5 minutes, never in state) and returns them once, to the first status poll that sees the job complete. A poll that reaches another server instance or comes too late finds nothing: the key is lost by design, and the result says "Rotate the token to get a new key". (Found in the demo: a create through the broker can take longer than 3 s.)
**Where the key never goes.** The qbit never passes it to the audit row, the Authentik event context, `AdminResult.message`, notifications, logs or exceptions. `AuthentikRedactor` also masks any `key` or `token` JSON value in error text.
+1 -1
View File
@@ -105,7 +105,7 @@ Authentik puts the merged document into the token through a scope mapping. Steps
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog continue to accept only standard actions. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the existing available-permission API without compiling against the unreleased `TableFeaturePermissionsMetaData` hook; features become available when the host's core supplies those entries.
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog continue to accept only standard actions. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the existing available-permission API without compiling against the `TableFeaturePermissionsMetaData` hook (absent from the pinned RC.5 release); features become available when the host's core supplies those entries.
The mapping merges the statements of every group in `request.user.all_groups()` (direct groups and their ancestors) whose document has the right spec and version. It always emits a document, with an empty `Statement` array when the user has no policies.
+5 -3
View File
@@ -18,6 +18,8 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -36,7 +38,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -237,7 +239,7 @@
<profiles>
<!-- Needs qqq 4.1 (QBit producer API). Until qbit-build-parent imports the -->
<!-- released 4.1 BOM, this profile is active by default and imports -->
<!-- qqq-bom-pom 4.1.0-RC.2 ahead of the parent 4.0.0 BOM, as in -->
<!-- qqq-bom-pom 4.1.0-RC.5 ahead of the parent 4.0.0 BOM, as in -->
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
@@ -245,7 +247,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -61,6 +61,8 @@ public class AuthentikQBitConfig implements QBitConfig
private List<String> tokenProtectedGroups = new ArrayList<>(List.of("authentik Admins", "identity-admins"));
private Boolean tokenTwoPersonApproval = false;
private Boolean tokenScopesEnabled = false;
private String tokenScopeClaim = "qqq_token";
private String tokenReminderSchedulerName;
private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}";
private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}";
private String tokenEndpointUrl = null;
@@ -130,6 +132,10 @@ public class AuthentikQBitConfig implements QBitConfig
{
errors.add("tokenMaxDaysServiceAccount must be at least 1");
}
if(tokenScopeClaim == null || tokenScopeClaim.isBlank())
{
errors.add("tokenScopeClaim must not be blank");
}
if(tokenUnusedDays == null || tokenUnusedDays < 1)
{
errors.add("tokenUnusedDays must be at least 1");
@@ -1871,4 +1877,54 @@ public class AuthentikQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** Signed JWT claim identifying the Authentik token.
*******************************************************************************/
public String getTokenScopeClaim()
{
return (tokenScopeClaim);
}
/*******************************************************************************
** Set the host's signed token claim name.
*******************************************************************************/
public void setTokenScopeClaim(String value)
{
tokenScopeClaim = value;
}
/*******************************************************************************
** Fluent signed token claim name.
*******************************************************************************/
public AuthentikQBitConfig withTokenScopeClaim(String value)
{
tokenScopeClaim = value;
return (this);
}
/*******************************************************************************
** Optional scheduler; otherwise use the instance's first scheduler.
*******************************************************************************/
public String getTokenReminderSchedulerName()
{
return (tokenReminderSchedulerName);
}
/*******************************************************************************
** Select a scheduler when the host defines several.
*******************************************************************************/
public void setTokenReminderSchedulerName(String value)
{
tokenReminderSchedulerName = value;
}
/*******************************************************************************
** Fluent scheduler selection.
*******************************************************************************/
public AuthentikQBitConfig withTokenReminderSchedulerName(String value)
{
tokenReminderSchedulerName = value;
return (this);
}
}
@@ -46,10 +46,13 @@ public class AuthentikRuntime
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
private final Memoization<String, String> responseCache;
private final Memoization<String, GroupGraph> groupGraphCache;
private final Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> tokenScopeCache = new Memoization<>(Duration.ofSeconds(60));
private final Memoization<String, Map<String, java.time.Instant>> tokenActivityCache = new Memoization<>(Duration.ofMinutes(5));
private volatile java.util.Set<String> permissionCatalog;
private volatile PolicyCatalog policyCatalog;
private volatile AccessComparison.State mapperState;
private volatile boolean mapperInvoked;
private com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink tokenAlerts = new com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts()::send;
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
@@ -130,6 +133,8 @@ public class AuthentikRuntime
public void invalidateCache()
{
responseCache.clear();
tokenScopeCache.clear();
tokenActivityCache.clear();
groupGraphCache.clear();
}
@@ -280,4 +285,37 @@ public class AuthentikRuntime
mapperState = null;
}
}
/*******************************************************************************
** Token claims are cached independently of the general list TTL.
*******************************************************************************/
public Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> getTokenScopeCache()
{
return (tokenScopeCache);
}
/*******************************************************************************
** Login-event aggregation is shared by every token record in this runtime.
*******************************************************************************/
public Memoization<String, Map<String, java.time.Instant>> getTokenActivityCache()
{
return (tokenActivityCache);
}
/*******************************************************************************
** Optional Alerts delivery, replaceable by a host or a capturing test sink.
*******************************************************************************/
public com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink getTokenAlerts()
{
return (tokenAlerts);
}
/*******************************************************************************
** Install a sink for this runtime only.
*******************************************************************************/
public AuthentikRuntime withTokenAlerts(com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink sink)
{
tokenAlerts = java.util.Objects.requireNonNull(sink);
return (this);
}
}
@@ -293,10 +293,16 @@ public final class AuthentikLocalTables
{
table.withField(ro(name, QFieldType.TEXT));
}
for(String name : List.of("createdAt", "rotatedAt", "lastReminder7d", "lastReminder1d"))
for(String name : List.of("createdAt", "rotatedAt"))
{
table.withField(ro(name, QFieldType.DATE_TIME));
}
////////////////////////////////////////////////////////////////////////
// explicit column names: a camel-to-snake mapping would give //
// last_reminder7d, while docs/schema.sql (and hosts) use _7d / _1d //
////////////////////////////////////////////////////////////////////////
table.withField(ro("lastReminder7d", QFieldType.DATE_TIME).withBackendName("last_reminder_7d"));
table.withField(ro("lastReminder1d", QFieldType.DATE_TIME).withBackendName("last_reminder_1d"));
return (table);
}
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.logging.QLogger;
/*******************************************************************************
** Optional Alerts bridge. The qbit compiles on RC.2 without either Alerts API.
** No credentials enter this interface. Hosts and tests can supply a sink.
*******************************************************************************/
public class AuthentikTokenAlerts
{
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenAlerts.class);
/*******************************************************************************
** Safe notification envelope. A null recipient denotes the admin audience.
*******************************************************************************/
public record Notice(String key, String severity, String recipient, String message)
{
}
/*******************************************************************************
** Return true only when delivery was accepted, so absence never dedupes.
*******************************************************************************/
@FunctionalInterface
public interface Sink
{
/*******************************************************************************
** Accept a safe notification, returning false when delivery is unavailable.
*******************************************************************************/
boolean send(Notice notice);
}
/*******************************************************************************
** Constructor for the reflection adapter.
*******************************************************************************/
public AuthentikTokenAlerts()
{
}
/*******************************************************************************
** Guard incidents are raised through core QAlerts; owner reminders through
** core QAlerts.notify (qqq#1125, QQQ 4.1.0-RC.6+). Never another qbit's API.
*******************************************************************************/
public boolean send(Notice notice)
{
try
{
if("high".equals(notice.severity()))
{
QAlerts.raise(new AlertSignal("authentik.token.guard-bypass")
.withDedupKey(notice.key())
.withTitle("Access token guard bypass")
.withSummary(notice.message())
.withSeverity(AlertSeverity.CRITICAL));
return (true);
}
///////////////////////////////////////////////////////////////////
// owner reminders: core's notification SPI (bell and email via //
// whichever alerts producer the host installed; qqq#1125) //
///////////////////////////////////////////////////////////////////
QAlerts.notify(new NotificationSignal("Access token expiry reminder")
.withBody(notice.message())
.withSourceName("authentik")
.withSourceKey(notice.key())
.withAudience(NotificationAudience.user(notice.recipient())));
return (true);
}
catch(RuntimeException e)
{
///////////////////////////////////////////////////////////
// never fail the caller; never log the notice payload //
///////////////////////////////////////////////////////////
LOG.info("Access-token notification delivery failed; notification skipped.");
return (false);
}
}
}
@@ -167,6 +167,13 @@ public class AuthentikTokens
return (plan.refuse("Scopes are not enabled on this app yet."));
}
}
if(action == AdminAction.CREATE_TOKEN && Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.validate(request);
QRecord scope = new QRecord();
request.params().forEach(scope::setValue);
plan.withChange(null, TokenScopeStore.summary(scope));
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
{
if(!TokenKeyDelivery.get().available())
@@ -412,7 +419,7 @@ public class AuthentikTokens
}
JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before));
checkProtected(owner);
checkProtectedAfterRead(owner);
boolean service = "service_account".equals(owner.optString("type"));
boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
@@ -433,6 +440,7 @@ public class AuthentikTokens
util.writeAs(bearer, auth, "PATCH", path(request.targetKey()), new JSONObject().put("intent", before.getString("intent"))
.put("user", owner.getInt("pk")).put("expires", expiry(request, service).toString()).put("expiring", true));
raceGuard(before, token(request.targetKey()));
scopes.resetReminders(request.targetKey());
return (success("Token expiry updated."));
case ROTATE_TOKEN:
if(!service)
@@ -462,13 +470,15 @@ public class AuthentikTokens
/*******************************************************************************
** Guard owner and intent after every PATCH or set_key, with a safe warning.
*******************************************************************************/
private static void raceGuard(JSONObject before, JSONObject after) throws QUserFacingException
private void raceGuard(JSONObject before, JSONObject after) throws QException
{
if(ownerPk(before) != ownerPk(after) || !before.getString("intent").equals(after.getString("intent")))
{
alertGuardBypass();
LOG.warn(TOKEN_MOVED);
throw (new QUserFacingException(TOKEN_MOVED));
}
checkProtectedAfterRead(owner(ownerPk(after)));
}
@@ -487,6 +497,7 @@ public class AuthentikTokens
try
{
JSONObject created = util.getAs(bearer, auth, path(identifier));
checkProtectedAfterRead(owner(ownerPk(created)));
if(ownerPk(created) != owner.getInt("pk") || !"app_password".equals(created.optString("intent")))
{
throw (new QUserFacingException(TOKEN_MOVED));
@@ -569,8 +580,9 @@ public class AuthentikTokens
util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk));
}
JSONObject owner = owner(pk);
checkProtected(owner);
checkProtectedAfterRead(owner);
JSONObject after = token(identifier);
checkProtectedAfterRead(owner(ownerPk(after)));
scopes.create(identifier, owner, request, admin.operator().email());
return (success("Service account created. " + String.join(" ", plan.getNotes()))
.withSecret(oneTime(identifier, created.getString("token"), owner, checkExpiry(after, true), "Owner permissions"),
@@ -656,4 +668,36 @@ public class AuthentikTokens
String slug = username.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-]+", "-");
return ("qqq-pat-" + slug + "-" + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC).format(Instant.now()) + "-" + suffix);
}
/*******************************************************************************
** A protected owner observed after Review or a write is a guard incident.
*******************************************************************************/
private void checkProtectedAfterRead(JSONObject owner) throws QException
{
try
{
checkProtected(owner);
}
catch(QUserFacingException e)
{
alertGuardBypass();
throw (e);
}
}
/*******************************************************************************
** Safe incident payload: no response bodies, credentials or owner details.
*******************************************************************************/
private void alertGuardBypass()
{
try
{
util.getRuntime().getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null,
"An access token changed owner or intent, or a protected owner was observed after Review. Investigate in Authentik."));
}
catch(RuntimeException e)
{
LOG.warn("Access-token guard alert delivery failed.");
}
}
}
@@ -65,7 +65,7 @@ public class TokenScopeStore
/*******************************************************************************
** Fetch the unique scope row.
** Fetch the unique scope row as a trusted SYSTEM read (QueryInput default).
*******************************************************************************/
public QRecord find(String identifier) throws QException
{
@@ -139,4 +139,54 @@ public class TokenScopeStore
throw (new QException("Token scope metadata could not be saved."));
}
}
/*******************************************************************************
** Human-readable, non-secret scope for lists and Review.
*******************************************************************************/
public static String summary(QRecord row)
{
java.util.ArrayList<String> parts = new java.util.ArrayList<>();
for(String field : SCOPE_FIELDS)
{
if(row != null && row.getValue(field) != null && !row.getValueString(field).isBlank())
{
parts.add(field + ": " + row.getValueString(field));
}
}
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
}
/*******************************************************************************
** Persist dedupe metadata. External tokens get no invented creation date or
** restrictions; their token row remains unscoped.
*******************************************************************************/
public void reminded(String identifier, JSONObject owner, String field, Instant at) throws QException
{
QRecord row = find(identifier);
if(row == null)
{
row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
.withValue("ownerUsername", owner.getString("username")).withValue(field, at);
check(new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
else
{
row.setValue(field, at);
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
/*******************************************************************************
** A new expiry starts a new set of reminder windows.
*******************************************************************************/
public void resetReminders(String identifier) throws QException
{
QRecord row = find(identifier);
if(row != null)
{
row.setValue("lastReminder7d", null);
row.setValue("lastReminder1d", null);
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
}
@@ -0,0 +1,180 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Standard TABLE widget: inventory, age and actionable cleanup suggestions.
** Computed fields never become API filters; links use owner or token keys.
*******************************************************************************/
public class AuthentikTokenWidget extends AbstractWidgetRenderer
{
/*******************************************************************************
** Check the permission here as well as in metadata (non-HTTP callers).
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backend = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get("backendName"));
return (new RenderWidgetOutput(data(AuthentikActionUtil.forBackend(backend), Instant.now())));
}
/*******************************************************************************
** Report complete inventory and explicit unknown ages. An API token's
** activity is untracked and never qualifies as unused.
*******************************************************************************/
public static TableData data(AuthentikActionUtil util, Instant now) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
{
throw (new QPermissionDeniedException("Access token widgets require authentik.tokens.manage."));
}
AuthentikQBitConfig config = util.getRuntime().getConfig();
List<JSONObject> tokens = util.getAllPages("/core/tokens/", Integer.MAX_VALUE);
Map<String, Instant> activity = AuthentikTokenActivity.lastUsed(util);
Map<String, List<String>> owners = new LinkedHashMap<>();
Map<String, List<String>> ages = new LinkedHashMap<>();
for(String bucket : List.of("<30d", "30-90d", "90-365d", ">365d", "unknown"))
{
ages.put(bucket, new ArrayList<>());
}
Map<Integer, JSONObject> users = new LinkedHashMap<>();
List<Map<String, Object>> cleanup = new ArrayList<>();
TokenScopeStore scopes = new TokenScopeStore(config);
for(JSONObject token : tokens)
{
String id = token.getString("identifier");
JSONObject owner = token.getJSONObject("user_obj");
String username = owner.getString("username");
owners.computeIfAbsent(username, k -> new ArrayList<>()).add(id);
QRecord scope = scopes.find(id);
Instant created = scope == null ? null : scope.getValueInstant("createdAt");
long days = created == null ? -1 : ChronoUnit.DAYS.between(created, now);
ages.get(days < 0 ? "unknown" : days < 30 ? "<30d" : days < 90 ? "30-90d" : days <= 365 ? "90-365d" : ">365d").add(id);
int pk = owner.getInt("pk");
if(!users.containsKey(pk))
{
users.put(pk, util.getApiJsonOrNull("/core/users/" + pk + "/"));
}
JSONObject live = users.get(pk);
String links = link(list(config, "username", List.of(username)), username) + " / " + link(list(config, "identifier", List.of(id)), id);
if(live != null && !live.optBoolean("is_active", true))
{
cleanup.add(row("Owner deactivated", 1, links));
}
Instant last = activity.get(id);
Instant cutoff = now.minus(config.getTokenUnusedDays(), ChronoUnit.DAYS);
// Without a creation time or an event we cannot prove age. A 90-day
// event window cannot prove inactivity beyond 90 days either.
boolean unused = last != null ? last.isBefore(cutoff)
: config.getTokenUnusedDays() <= 90 && created != null && created.isBefore(cutoff);
if("app_password".equals(token.optString("intent")) && unused)
{
cleanup.add(row("Unused for " + config.getTokenUnusedDays() + " days", 1, links));
}
}
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(row("Total", tokens.size(), link(list(config, null, List.of()), "All access tokens")));
owners.entrySet().stream().sorted(Comparator.<Map.Entry<String, List<String>>>comparingInt(e -> e.getValue().size()).reversed().thenComparing(Map.Entry::getKey))
.limit(10).forEach(e -> rows.add(row("Owner: " + e.getKey(), e.getValue().size(), link(list(config, "username", List.of(e.getKey())), "Tokens"))));
ages.forEach((bucket, ids) -> rows.add(row("Age: " + bucket, ids.size(), ids.isEmpty() ? "" : link(list(config, "identifier", ids), "Tokens"))));
rows.addAll(cleanup);
return (new TableData("Access tokens", List.of(
new TableData.Column("default", "Category", "category", "35%", "left"),
new TableData.Column("default", "Count", "count", "10%", "right"),
new TableData.Column("html", "Access tokens", "tokens", "55%", "left")), rows)
.withRowsPerPage(25).withLinkText("All access tokens / bulk revoke").withLinkURL(list(config, null, List.of())));
}
/*******************************************************************************
** A standard table row; only the link cell contains escaped anchor markup.
*******************************************************************************/
private static Map<String, Object> row(String category, int count, String tokens)
{
return (Map.of("category", category, "count", count, "tokens", tokens));
}
/*******************************************************************************
** Native list filter format shared by Material and Next. Primary-key IN is
** supported by the API adapter as individual GETs; no computed filtering.
*******************************************************************************/
private static String list(AuthentikQBitConfig config, String field, List<String> values)
{
String url = (config.getLinkPrefix() == null ? "" : config.getLinkPrefix()) + "/" + config.applyTablePrefix("token");
if(field != null)
{
JSONObject criteria = new JSONObject().put("fieldName", field).put("operator", values.size() == 1 ? "EQUALS" : "IN").put("values", values);
String filter = new JSONObject().put("booleanOperator", "AND").put("criteria", new JSONArray().put(criteria)).toString();
url += "?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8);
}
return (url);
}
/*******************************************************************************
** Escape both the link target and text; descriptions are never HTML.
*******************************************************************************/
private static String link(String url, String text)
{
return ("<a href=\"" + escape(url) + "\">" + escape(text) + "</a>");
}
/*******************************************************************************
** Minimal HTML escaping for a standard table link cell.
*******************************************************************************/
private static String escape(String value)
{
return (value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
}
@@ -803,6 +803,10 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
record.setDisplayValue("isSuperuserInherited", UNAVAILABLE);
}
}
else if(AuthentikTables.TOKEN.equals(spec.getName()))
{
AuthentikTokenActivity.enrich(this, record);
}
return (record);
}
catch(JSONException | IllegalArgumentException e)
@@ -74,7 +74,7 @@ public final class AuthentikApiSurface
rs.add(new Operation("get", "/core/users/{key}/", Set.of()));
rs.add(new Operation("get", "/managed/blueprints/", Set.of("page", "page_size")));
rs.add(new Operation("get", "/core/tokens/", Set.of("user__username", "intent", "page", "page_size")));
rs.add(new Operation("get", "/events/events/", Set.of("username", "ordering", "page", "page_size")));
rs.add(new Operation("get", "/events/events/", Set.of("username", "action", "ordering", "page", "page_size")));
return (rs);
}
@@ -419,6 +419,10 @@ public final class AuthentikTables
spec.withField(new AuthentikFieldSpec("ownerType", "Owner Type", QFieldType.STRING, at("user_obj", "type")));
spec.withField(new AuthentikFieldSpec("ownerName", "Owner", QFieldType.STRING, at("user_obj", "name")));
spec.withField(new AuthentikFieldSpec("managed", "Managed", QFieldType.STRING, string("managed")));
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, o -> null));
spec.withField(new AuthentikFieldSpec("lastUsedNote", "Last Used Note", QFieldType.STRING, o -> null));
spec.withField(new AuthentikFieldSpec("createdAt", "Created", QFieldType.DATE_TIME, o -> null));
spec.withField(new AuthentikFieldSpec("scopeSummary", "Scope", QFieldType.STRING, o -> null));
return (spec);
}
@@ -0,0 +1,112 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Non-secret local metadata and the most recent app-password login per token.
*******************************************************************************/
public final class AuthentikTokenActivity
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikTokenActivity()
{
}
/*******************************************************************************
** The pinned schema has no created__gt. Walk descending pages until the
** 90-day boundary instead of sending a silently ignored query parameter.
*******************************************************************************/
public static Map<String, Instant> lastUsed(AuthentikActionUtil util) throws QException
{
return (util.getRuntime().getTokenActivityCache().getResultThrowing("login", key ->
{
Map<String, Instant> latest = new LinkedHashMap<>();
Instant cutoff = Instant.now().minus(90, ChronoUnit.DAYS);
for(int page = 1; ; page++)
{
String path = "/events/events/?action=login&ordering=-created&page=" + page + "&page_size=100";
util.getRuntime().getResponseCache().clearKey(util.getRuntime().getConfig().getApiRoot() + path);
JSONObject response = util.getApiJson(path);
JSONArray events = response.getJSONArray("results");
for(int i = 0; i < events.length(); i++)
{
JSONObject event = events.getJSONObject(i);
Instant at = Instant.parse(event.getString("created"));
if(at.isBefore(cutoff))
{
return (Map.copyOf(latest));
}
JSONObject context = event.optJSONObject("context");
if("login".equals(event.optString("action")) && context != null && "token".equals(context.optString("method")))
{
JSONObject args = context.optJSONObject("method_args");
String identifier = args == null ? "" : args.optString("identifier");
if(!identifier.isBlank())
{
latest.merge(identifier, at, (a, b) -> a.isAfter(b) ? a : b);
}
}
}
if(events.isEmpty() || page >= response.getJSONObject("pagination").getInt("total_pages"))
{
return (Map.copyOf(latest));
}
}
}).orElseThrow());
}
/*******************************************************************************
** Enrich only token records, never leaking token keys into the table.
*******************************************************************************/
public static void enrich(AuthentikActionUtil util, QRecord record) throws QException
{
var config = util.getRuntime().getConfig();
QRecord scope = QContext.getQInstance().getTable(config.applyTablePrefix("token_scope")) == null ? null
: new TokenScopeStore(config).find(record.getValueString("identifier"));
record.setValue("createdAt", scope == null ? null : scope.getValue("createdAt"));
record.setValue("scopeSummary", TokenScopeStore.summary(scope));
if("api".equals(record.getValueString("intent")))
{
record.setValue("lastUsedNote", "not tracked");
record.setDisplayValue("lastUsed", "not tracked");
}
else
{
record.setValue("lastUsed", lastUsed(util).get(record.getValueString("identifier")));
record.setValue("lastUsedNote", record.getValue("lastUsed") == null ? "No login in the last 90 days" : null);
}
}
}
@@ -74,7 +74,7 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION);
List<String> widgets = new ArrayList<>();
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME),
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikTokenWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME),
config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.COMPARE)))
{
if(produced.get(QWidgetMetaData.class, widgetName) != null)
@@ -39,7 +39,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Renders the drift report as a TableData. Re-checks authentik.admin itself
** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
** for direct renderer calls as well as calls through core RenderWidgetAction.
*******************************************************************************/
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
{
@@ -0,0 +1,72 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
/*******************************************************************************
** Daily background task, hidden from navigation; no scheduler dependency.
*******************************************************************************/
public class AuthentikTokenReminderProcessProducer extends QBitComponentMetaDataProducer<QProcessMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Reminders require local dedupe storage and the TOKENS feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
** Use the configured scheduler or the instance's first registered scheduler.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance instance)
{
AuthentikQBitConfig config = getQBitConfig();
QProcessMetaData process = new QProcessMetaData().withName(config.applyPrefix("tokenReminders"))
.withLabel("Access token reminders").withIsHidden(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules())
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(AuthentikTokenReminders.class))
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("backendName", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING)
.withDefaultValue(config.getApiBackendName()))));
String scheduler = config.getTokenReminderSchedulerName();
if(scheduler == null && !instance.getSchedulers().isEmpty())
{
scheduler = instance.getSchedulers().keySet().iterator().next();
}
if(scheduler != null)
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(scheduler).withRepeatSeconds(86400).withInitialDelaySeconds(3600));
}
return (process);
}
}
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** Access token administration uses a standard TABLE widget (guardrail G5).
*******************************************************************************/
public class AuthentikTokenWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
public static final String NAME = "accessTokens";
/*******************************************************************************
** Token inventory needs the local metadata table.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
** Register before the app, alongside the existing home widgets.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (500);
}
/*******************************************************************************
** Permission is checked again in the renderer.
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance instance)
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData().withName(config.applyPrefix(NAME)).withLabel("Access tokens").withType(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType())
.withGridColumns(12).withIsCard(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules())
.withCodeReference(new QCodeReference(AuthentikTokenWidget.class)).withDefaultValue("backendName", config.getApiBackendName()));
}
}
@@ -170,6 +170,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims);
}
@@ -92,6 +92,9 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
if(claims == null)
{
LOG.info("No policy claim in the token; no policy permissions added", logPair("claim", format.claim()));
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession,
context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY)));
return;
}
@@ -101,6 +104,9 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession,
context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY)));
}
@@ -139,17 +139,7 @@ public final class PolicyCatalog
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance)
{
return (of(qInstance, PermissionsHelper.getAllAvailablePermissions(qInstance)));
}
/*******************************************************************************
** Build using an explicit permission source, including features registered
** by core. This needs no dependency on the feature metadata hook itself.
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance, Collection<AvailablePermission> availablePermissions)
{
Collection<AvailablePermission> availablePermissions = PermissionsHelper.getAllAvailablePermissions(qInstance);
Map<String, String> appOfObject = appsOfObjects(qInstance);
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
@@ -147,8 +147,8 @@ public final class AuthentikProcesses
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(new QFieldMetaData("apps", QFieldType.TEXT).withLabel("Apps (names, one per line)"));
createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT).withLabel("Tables (names, one per line)"));
}
createTokenFields.add(reason);
defs.add(new Def("createToken", "Create Token", AdminAction.CREATE_TOKEN, Kind.CREATE, AuthentikTables.TOKEN, "key", false,
@@ -0,0 +1,105 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.processes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Daily app-password expiry reminders, deduplicated in local token metadata.
*******************************************************************************/
public class AuthentikTokenReminders implements BackendStep
{
/*******************************************************************************
** The scheduler invokes the same guarded path as manual process execution.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
run(AuthentikActionUtil.forBackend(input.getValueString("backendName")), Instant.now());
}
/*******************************************************************************
** Two non-overlapping windows avoid two reminders when first seen on day 1.
** Synchronization prevents overlapping local scheduled runs from duplicating.
*******************************************************************************/
public static void run(AuthentikActionUtil util, Instant now) throws QException
{
if(!(QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession)
&& !AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
{
throw (new QPermissionDeniedException("Token reminders require authentik.tokens.manage."));
}
AuthentikRuntime runtime = util.getRuntime();
synchronized(runtime)
{
TokenScopeStore store = new TokenScopeStore(runtime.getConfig());
for(JSONObject token : util.getAllPages("/core/tokens/?intent=app_password", Integer.MAX_VALUE))
{
if(!"app_password".equals(token.optString("intent")) || !token.optBoolean("expiring") || token.isNull("expires"))
{
continue;
}
Instant expires = Instant.parse(token.getString("expires"));
if(!expires.isAfter(now) || expires.isAfter(now.plus(7, ChronoUnit.DAYS)))
{
continue;
}
String field = expires.isAfter(now.plus(1, ChronoUnit.DAYS)) ? "lastReminder7d" : "lastReminder1d";
String id = token.getString("identifier");
QRecord scope = store.find(id);
Instant remindedAt = scope == null ? null : scope.getValueInstant(field);
Instant windowStart = expires.minus(field.equals("lastReminder7d") ? 7 : 1, ChronoUnit.DAYS);
if(remindedAt != null && !remindedAt.isBefore(windowStart))
{
continue;
}
int pk = token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk");
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
if(owner == null || owner.optString("email").isBlank())
{
continue;
}
String message = "Your access token " + token.optString("description") + " (" + id + ") expires on " + expires
+ ". Extend or rotate it in Preferences > Access tokens.";
if(runtime.getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_expiry:" + id + ":" + field + ":" + expires,
"info", owner.getString("email"), message)))
{
store.reminded(id, owner, field, now);
}
}
}
}
}
@@ -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;
@@ -0,0 +1,475 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.security;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.admin.AdminRequest;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.json.JSONObject;
/*******************************************************************************
** Signed token claims can only narrow the permissions computed by a host.
*******************************************************************************/
public final class AuthentikTokenScopes
{
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenScopes.class);
/*******************************************************************************
** Distinguish unrestricted credentials from revoked credentials.
*******************************************************************************/
public enum Mode
{
SCOPED,
UNSCOPED,
DENY_ALL
}
/*******************************************************************************
** Immutable, non-secret credential restrictions, including advanced scopes.
*******************************************************************************/
public record TokenScope(String identifier, Mode mode, boolean readOnly, Set<String> apps, Set<String> tables,
Set<String> processes, Set<String> reports, Set<String> widgets, Set<String> operationIds)
{
/*******************************************************************************
** A missing row is unrestricted; a missing token is denied.
*******************************************************************************/
public static TokenScope marker(String identifier, Mode mode)
{
return (new TokenScope(identifier, mode, false, Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), Set.of()));
}
/*******************************************************************************
** Decode a local metadata row without retaining the mutable record.
*******************************************************************************/
public static TokenScope from(String identifier, QRecord row)
{
return (new TokenScope(identifier, Mode.SCOPED, Boolean.TRUE.equals(row.getValueBoolean("readOnly")),
names(row.getValueString("apps")), names(row.getValueString("tables")), names(row.getValueString("processes")),
names(row.getValueString("reports")), names(row.getValueString("widgets")), names(row.getValueString("operationIds"))));
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikTokenScopes()
{
}
/*******************************************************************************
** One name per line; immutable sets keep cached scopes safe to share.
*******************************************************************************/
public static Set<String> names(String value)
{
if(value == null || value.isBlank())
{
return (Set.of());
}
return (Set.copyOf(Arrays.stream(value.split("\\R")).map(String::trim).filter(s -> !s.isEmpty()).toList()));
}
/*******************************************************************************
** Resolve only qbit backends present in this instance. Cache by identifier
** within each runtime, so tenants never share a scope or token existence.
*******************************************************************************/
public static Optional<TokenScope> forClaims(QInstance instance, JSONObject claims) throws QException
{
if(claims == null)
{
return (Optional.empty());
}
if(instance != null)
{
for(var backend : instance.getBackends().values())
{
AuthentikRuntime runtime;
try
{
runtime = AuthentikRuntime.forBackend(backend.getName());
}
catch(IllegalStateException ignored)
{
continue;
}
String claim = runtime.getConfig().getTokenScopeClaim();
if(!claims.has(claim))
{
continue;
}
Object value = claims.opt(claim);
if(!(value instanceof String identifier) || !identifier.matches("[A-Za-z0-9._-]+"))
{
return (Optional.of(TokenScope.marker("invalid", Mode.DENY_ALL)));
}
return (runtime.getTokenScopeCache().getResultThrowing(identifier, id -> lookupAsSystem(instance, () ->
{
APIBackendMetaData api = (APIBackendMetaData) backend;
AuthentikActionUtil util = (AuthentikActionUtil) QCodeLoader.getAdHoc(BaseAPIActionUtil.class, api.getActionUtil());
util.setBackendMetaData(api);
// Check existence even when a local row remains after an external revoke.
// Do not let a longer general response TTL extend the 60-second
// revocation window of this dedicated cache.
runtime.getResponseCache().clearKey(runtime.getConfig().getApiRoot() + "/core/tokens/" + id + "/");
if(util.getApiJsonOrNull("/core/tokens/" + id + "/") == null)
{
return (TokenScope.marker(id, Mode.DENY_ALL));
}
if(instance.getTable(runtime.getConfig().applyTablePrefix("token_scope")) == null)
{
throw (new QException("Token scope metadata is unavailable."));
}
QRecord row = new TokenScopeStore(runtime.getConfig()).find(id);
return (row == null ? TokenScope.marker(id, Mode.UNSCOPED) : TokenScope.from(id, row));
})));
}
}
// A default token claim must never silently lose enforcement if the qbit is missing.
return (claims.has("qqq_token") ? Optional.of(TokenScope.marker(claims.optString("qqq_token"), Mode.DENY_ALL)) : Optional.empty());
}
/*******************************************************************************
** A lookup step that may throw.
*******************************************************************************/
@FunctionalInterface
private interface Lookup
{
/***************************************************************************
** Run the lookup.
***************************************************************************/
TokenScope get() throws Exception;
}
/*******************************************************************************
** The scope lookup runs while a session is still being built (no user yet),
** so it reads Authentik and the scope row as the internal system session,
** then restores the caller's context.
*******************************************************************************/
private static TokenScope lookupAsSystem(QInstance instance, Lookup lookup) throws QException
{
QInstance previousInstance = QContext.getQInstance();
com.kingsrook.qqq.backend.core.model.session.QSession previousSession = QContext.getQSession();
try
{
if(previousInstance == null)
{
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession());
}
else
{
QContext.setQSession(new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession());
}
return (lookup.get());
}
catch(QException e)
{
throw (e);
}
catch(Exception e)
{
throw (new QException("Token scope lookup failed", e));
}
finally
{
if(previousInstance == null)
{
QContext.clear();
}
else
{
QContext.setQSession(previousSession);
}
}
}
/*******************************************************************************
** Hosts with their own customizer call this AFTER computing permissions.
*******************************************************************************/
public static void apply(QSession session, JSONObject claims)
{
apply(QContext.getQInstance(), session, claims);
}
/*******************************************************************************
** Explicit instance variant for authentication, before QContext is installed.
*******************************************************************************/
public static void apply(QInstance instance, QSession session, JSONObject claims)
{
try
{
forClaims(instance, claims).ifPresent(scope -> narrow(instance, session, scope));
}
catch(Exception e)
{
session.setPermissions(new TreeSet<>());
LOG.warn("Token scope could not be verified; denying credential permissions.", e);
}
}
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7): intersect, never grant.
*******************************************************************************/
public static void narrow(QSession session, TokenScope scope)
{
narrow(QContext.getQInstance(), session, scope);
}
/*******************************************************************************
** Keep only permitted bases and read operations, then attach core's scope
** when available. Management hasAccess permissions are not read operations.
*******************************************************************************/
private static void narrow(QInstance instance, QSession session, TokenScope scope)
{
Set<String> permissions = new TreeSet<>(session.getPermissions() == null ? Set.of() : session.getPermissions());
if(scope.mode() == Mode.DENY_ALL)
{
permissions.clear();
}
else if(scope.mode() == Mode.SCOPED)
{
if(scope.readOnly())
{
permissions.removeIf(p -> p.startsWith("authentik.") || !(p.endsWith(".read") || p.endsWith(".hasAccess")));
}
if(!scope.apps().isEmpty() || !scope.tables().isEmpty() || !scope.processes().isEmpty() || !scope.reports().isEmpty() || !scope.widgets().isEmpty())
{
Set<String> bases = new LinkedHashSet<>();
if(instance != null)
{
for(String name : scope.tables())
{
if(instance.getTable(name) != null)
{
bases.add(base(instance, instance.getTable(name), name));
}
}
// Policy-format names exclude dots; host permission aliases need
// direct metadata traversal as well as the policy catalog.
for(String appName : scope.apps())
{
var app = instance.getApp(appName);
if(app == null)
{
continue;
}
bases.add(base(instance, app, appName));
Set<String> appTables = new LinkedHashSet<>();
if(app.getChildren() != null)
{
for(var child : app.getChildren())
{
if(child instanceof MetaDataWithPermissionRules secured)
{
bases.add(base(instance, secured, child.getName()));
}
if(instance.getTable(child.getName()) != null)
{
appTables.add(child.getName());
}
}
}
for(var process : instance.getProcesses().values())
{
if(process.getTableName() != null && appTables.contains(process.getTableName()))
{
bases.add(base(instance, process, process.getName()));
}
}
if(app.getWidgets() != null)
{
for(String name : app.getWidgets())
{
if(instance.getWidget(name) != null)
{
bases.add(base(instance, instance.getWidget(name), name));
}
}
}
}
for(PolicyCatalog.Group group : PolicyCatalog.of(instance).getGroups())
{
for(PolicyCatalog.Resource resource : group.resources())
{
if((group.app() != null && scope.apps().contains(group.app())) || scope.processes().contains(resource.name())
|| scope.reports().contains(resource.name()) || scope.widgets().contains(resource.name()))
{
bases.add(resource.name());
}
}
}
}
permissions.removeIf(p -> p.lastIndexOf('.') < 0 || !bases.contains(p.substring(0, p.lastIndexOf('.'))));
}
}
session.setPermissions(permissions);
session.setValue("qqq.credentialScope", scope.identifier());
attachCoreScope(session, scope);
}
/*******************************************************************************
** Use the permission base, which may differ from a table's metadata name.
*******************************************************************************/
private static String base(QInstance instance, MetaDataWithPermissionRules object, String name)
{
String base = PermissionsHelper.getEffectivePermissionRules(object, instance).getPermissionBaseName();
return (base == null || base.isBlank() ? name : base);
}
/*******************************************************************************
** Validate against current operator permissions at Review and execution.
*******************************************************************************/
public static void validate(AdminRequest request) throws QUserFacingException
{
QInstance instance = QContext.getQInstance();
for(String field : List.of("apps", "tables"))
{
for(String name : names(request.str(field)))
{
MetaDataWithPermissionRules object = field.equals("apps") ? instance.getApp(name) : instance.getTable(name);
if(object == null)
{
throw (new QUserFacingException("Unknown " + field + " scope: " + name));
}
String base = base(instance, object, name);
QSession session = QContext.getQSession();
if(!session.hasPermission(base + ".hasAccess") && !session.hasPermission(base + ".read"))
{
throw (new QUserFacingException("You do not currently have read access to scope: " + name));
}
}
}
for(String advanced : List.of("processes", "reports", "widgets", "operationIds"))
{
if(!request.str(advanced).isBlank())
{
throw (new QUserFacingException("Advanced token scopes are not offered by Create Token."));
}
}
}
/*******************************************************************************
** Optional core bridge. An incompatible installed contract fails closed;
** an absent class uses the permission intersection above.
*******************************************************************************/
/*******************************************************************************
** An empty scope dimension means "not restricted" (null for core).
*******************************************************************************/
private static Set<String> nullIfEmpty(Set<String> set)
{
return (set == null || set.isEmpty() ? null : set);
}
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1168, removeBy=4.1.0-RC.7): until the qbit's QQQ
** version has QCredentialScope (qqq#1153), narrow() above enforces the scope
** by intersecting the session's permissions; this attaches the core scope by
** reflection when it exists. Replace both with a direct withCredentialScope.
*******************************************************************************/
private static void attachCoreScope(QSession session, TokenScope scope)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.session.QCredentialScope");
Object value = type.getConstructor().newInstance();
///////////////////////////////////////////////////////////////////////
// core treats a null set as unrestricted and an empty set as //
// "nothing": an unlisted dimension must therefore be passed as null //
///////////////////////////////////////////////////////////////////////
Map<String, Object> fields = new java.util.LinkedHashMap<>();
fields.put("CredentialId", scope.identifier());
fields.put("ReadOnly", scope.readOnly());
fields.put("Apps", nullIfEmpty(scope.apps()));
fields.put("Tables", nullIfEmpty(scope.tables()));
fields.put("Processes", nullIfEmpty(scope.processes()));
fields.put("Reports", nullIfEmpty(scope.reports()));
fields.put("Widgets", nullIfEmpty(scope.widgets()));
fields.put("OperationIds", nullIfEmpty(scope.operationIds()));
for(var field : fields.entrySet())
{
Method setter = Arrays.stream(type.getMethods()).filter(m -> m.getName().equals("with" + field.getKey()) && m.getParameterCount() == 1)
.findFirst().orElseThrow(NoSuchMethodException::new);
Object arg = field.getValue();
if(arg instanceof Set<?> set && List.class.isAssignableFrom(setter.getParameterTypes()[0]))
{
arg = List.copyOf(set);
}
setter.invoke(value, arg);
}
session.getClass().getMethod("withCredentialScope", type).invoke(session, value);
}
catch(ClassNotFoundException ignored)
{
// RC.2 has no credential scope; the stand-in above is active.
}
catch(ReflectiveOperationException | IllegalArgumentException e)
{
session.setPermissions(new TreeSet<>());
LOG.warn("The installed credential-scope contract is incompatible; denying credential permissions.");
}
}
}
@@ -174,4 +174,18 @@ public class SchemaDriftTest
return (null);
}
/*******************************************************************************
** Login aggregation uses only parameters documented by the pinned schema.
*******************************************************************************/
@Test
void tokenActivitySurfacePinsLoginOrderingWithoutUnsupportedDateFilter()
{
assertThat(AuthentikApiSurface.operations()).anySatisfy(operation ->
{
assertThat(operation.path()).isEqualTo("/events/events/");
assertThat(operation.method()).isEqualTo("get");
assertThat(operation.params()).contains("action", "ordering", "page", "page_size").doesNotContain("created__gt");
});
}
}
@@ -532,7 +532,22 @@ public class FakeAuthentik
}
if(path.equals("/events/events/"))
{
return (page(loginEvents));
List<JSONObject> selected = new ArrayList<>(loginEvents);
String action = first(query, "action");
if(action != null)
{
selected.removeIf(event -> !action.equals(event.optString("action")));
}
if("-created".equals(first(query, "ordering")))
{
selected.sort(java.util.Comparator.comparing((JSONObject event) -> java.time.Instant.parse(event.getString("created"))).reversed());
}
int size = query.containsKey("page_size") ? Integer.parseInt(first(query, "page_size")) : 100;
int number = query.containsKey("page") ? Integer.parseInt(first(query, "page")) : 1;
int from = Math.min(selected.size(), (number - 1) * size);
int to = Math.min(selected.size(), from + size);
return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", selected.size()).put("current", number)
.put("total_pages", Math.max(1, (selected.size() + size - 1) / size))).put("results", new JSONArray(selected.subList(from, to)))));
}
if(path.equals("/managed/blueprints/") || path.equals("/core/applications/"))
{
@@ -135,6 +135,7 @@ class TokenManagementTest
void createOwnAndRotateScopeThenRevoke() throws QException
{
t.config.withTokenScopesEnabled(true);
QContext.getQSession().withPermission("cms.publish.hasAccess");
AuthentikAdmin.AdminResult result = t.admin().execute(create().with("readOnly", true).with("tables", "post"));
assertThat(result.ok()).as(result.message()).isTrue();
OneTimeKey key = result.takeSecret();
@@ -0,0 +1,300 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.metadata.AuthentikTokenReminderProcessProducer;
import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Event aggregation, scheduling, delivery dedupe and standard admin widgets.
*******************************************************************************/
class TokenOperationsTest
{
private AdminTestSupport t;
private AuthentikActionUtil util;
private final List<AuthentikTokenAlerts.Notice> notices = new ArrayList<>();
private final Instant now = Instant.now();
/*******************************************************************************
** Capture delivery without a dependency on Alerts or a real mail service.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
t = AdminTestSupport.create();
util = AuthentikActionUtil.forBackend(t.config.getApiBackendName());
util.getRuntime().withTokenAlerts(notice -> { notices.add(notice); return (true); });
}
/*******************************************************************************
** Seed an expiring token for the chosen owner.
*******************************************************************************/
private JSONObject token(String id, int owner, int days)
{
return (t.authentik.seedToken(id, owner, "app_password", now.plus(days, ChronoUnit.DAYS).toString()));
}
/*******************************************************************************
** A login event using the pinned context.method_args.identifier contract.
*******************************************************************************/
private JSONObject event(String id, Instant at)
{
return (new JSONObject().put("action", "login").put("created", at.toString())
.put("context", new JSONObject().put("method", "token").put("method_args", new JSONObject().put("identifier", id))));
}
/*******************************************************************************
** Create non-secret bookkeeping with a known age for widget assertions.
*******************************************************************************/
private void created(String id, int days) throws QException
{
TokenScopeStore store = new TokenScopeStore(t.config);
store.create(id, t.authentik.users.get(3), AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test"), "sam@example.test");
QRecord row = store.find(id).withValue("createdAt", now.minus(days, ChronoUnit.DAYS));
new UpdateAction().execute(new UpdateInput().withTableName("authentik_token_scope").withRecords(List.of(row)));
}
/*******************************************************************************
** Page, stop at 90 days, exclude non-token events, and reuse the 5-minute map.
*******************************************************************************/
@Test
void eventPagingWindowAndCache() throws QException
{
for(int i = 0; i < 101; i++)
{
t.authentik.loginEvents.add(event("token-" + i, now.minus(i, ChronoUnit.HOURS)));
}
t.authentik.loginEvents.add(event("token-0", now.minus(20, ChronoUnit.DAYS)));
t.authentik.loginEvents.add(event("password", now).put("context", new JSONObject().put("method", "password")));
t.authentik.loginEvents.add(event("wrong-action", now).put("action", "logout"));
t.authentik.loginEvents.add(event("old", now.minus(91, ChronoUnit.DAYS)));
Map<String, Instant> used = AuthentikTokenActivity.lastUsed(util);
assertThat(used).hasSize(101).containsEntry("token-0", now).doesNotContainKeys("password", "wrong-action", "old");
long requests = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count();
assertThat(requests).isEqualTo(2);
t.authentik.loginEvents.add(event("later", now));
assertThat(AuthentikTokenActivity.lastUsed(util)).isEqualTo(used);
assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count()).isEqualTo(requests);
assertThat(util.getRuntime().getTokenActivityCache().getTimeout()).isEqualTo(java.time.Duration.ofMinutes(5));
}
/*******************************************************************************
** API activity is explicitly not tracked, and local creation/scope is shown.
*******************************************************************************/
@Test
void computedFields() throws QException
{
token("personal", 3, 5);
created("personal", 31);
t.authentik.loginEvents.add(event("personal", now));
QRecord record = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), util.getApiJson("/core/tokens/personal/"), true);
assertThat(record.getValueInstant("lastUsed")).isEqualTo(now);
assertThat(record.getValueInstant("createdAt")).isEqualTo(now.minus(31, ChronoUnit.DAYS));
assertThat(record.getValueString("scopeSummary")).isEqualTo("Owner permissions");
JSONObject api = token("api", 3, 5).put("intent", "api");
QRecord untracked = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), api, true);
assertThat(untracked.getValue("lastUsed")).isNull();
assertThat(untracked.getValueString("lastUsedNote")).isEqualTo("not tracked");
}
/*******************************************************************************
** Each window sends once to the owner's email; a minimal external row does
** not invent restrictions or a creation date. Extending resets the windows.
*******************************************************************************/
@Test
void remindersDedupeAcrossBothWindows() throws QException
{
token("external", 3, 5);
token("tomorrow", 3, 1);
token("api", 3, 1).put("intent", "api");
token("expired", 3, -1);
token("later", 3, 20);
token("no-email", 6, 2);
AuthentikTokenReminders.run(util, now);
AuthentikTokenReminders.run(util, now);
assertThat(notices).hasSize(2).allMatch(n -> "sam@example.test".equals(n.recipient()));
assertThat(notices.getFirst().message()).contains("Your access token Test access (external)", "Preferences > Access tokens.");
TokenScopeStore store = new TokenScopeStore(t.config);
QRecord row = store.find("external");
assertThat(row.getValue("createdAt")).isNull();
assertThat(row.getValue("readOnly")).isNull();
assertThat(row.getValue("lastReminder7d")).isNotNull();
assertThat(row.getValue("lastReminder1d")).isNull();
AuthentikTokenReminders.run(util, now.plus(4, ChronoUnit.DAYS));
assertThat(notices).hasSize(3);
assertThat(store.find("external").getValue("lastReminder1d")).isNotNull();
store.resetReminders("external");
assertThat(store.find("external").getValue("lastReminder7d")).isNull();
AuthentikTokenScopes.apply(QContext.getQSession(), new JSONObject().put("qqq_token", "external"));
assertThat(QContext.getQSession().getPermissions()).isNotEmpty();
}
/*******************************************************************************
** Delivery absence leaves dedupe unset so installing Alerts permits retry.
*******************************************************************************/
@Test
void unavailableDeliveryAndUnauthorizedRun() throws QException
{
token("external", 3, 5);
util.getRuntime().withTokenAlerts(new AuthentikTokenAlerts()::send);
AuthentikTokenReminders.run(util, now);
//////////////////////////////////////////////////////////////////////
// RC.6 core has the notification SPI: the reminder goes out through //
// QAlerts.notify and is recorded for dedupe //
//////////////////////////////////////////////////////////////////////
assertThat(new TokenScopeStore(t.config).find("external")).isNotNull();
//////////////////////////////////////////////////////////////////////
// core QAlerts exists from RC.5: guard incidents always have a path //
//////////////////////////////////////////////////////////////////////
assertThat(new AuthentikTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null, "safe"))).isTrue();
t.loginAs("sam@example.test");
assertThatThrownBy(() -> AuthentikTokenReminders.run(util, now)).hasMessageContaining("tokens.manage");
assertThatThrownBy(() -> AuthentikTokenWidget.data(util, now)).hasMessageContaining("tokens.manage");
}
/*******************************************************************************
** The hidden daily process runs with core's system scheduler session.
*******************************************************************************/
@Test
void scheduledProcessAndBulkRevokeMetadata() throws QException
{
t.qInstance.addScheduler(new SimpleSchedulerMetaData().withName("test"));
AuthentikTokenReminderProcessProducer producer = new AuthentikTokenReminderProcessProducer();
producer.setQBitConfig(t.config);
var process = producer.produce(t.qInstance);
assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(86400);
assertThat(process.getSchedule().getSchedulerName()).isEqualTo("test");
assertThat(process.getIsHidden()).isTrue();
token("external", 3, 5);
QContext.init(t.qInstance, new QSystemUserSession());
new RunProcessAction().execute(new RunProcessInput().withProcessName("authentikTokenReminders"));
assertThat(notices).hasSize(1);
var revoke = t.qInstance.getProcess("authentikRevokeToken");
assertThat(revoke.getTableName()).isEqualTo("authentik_token");
assertThat(revoke.getMinInputRecords()).isEqualTo(1);
assertThat(revoke.getMaxInputRecords()).isEqualTo(AuthentikAdmin.MAX_BULK);
assertThat(Boolean.TRUE.equals(revoke.getIsHidden())).isFalse();
}
/*******************************************************************************
** Widget counts, known/unknown ages, links, deactivated owners and inactivity.
*******************************************************************************/
@Test
void adminWidgetUsesStandardTableAndSafeFilters() throws QException
{
token("new", 3, 5); created("new", 10);
token("middle", 3, 5); created("middle", 45);
token("unused", 3, 5); created("unused", 100);
token("ancient", 3, 5); created("ancient", 400);
token("unknown", 7, 5);
token("api", 3, 5).put("intent", "api"); created("api", 100);
t.authentik.loginEvents.add(event("middle", now));
TableData data = AuthentikTokenWidget.data(util, now);
assertThat(data.getRows()).contains(Map.of("category", "Age: unknown", "count", 1, "tokens", data.getRows().stream()
.filter(row -> "Age: unknown".equals(row.get("category"))).findFirst().orElseThrow().get("tokens")));
assertThat(data.getRows()).filteredOn(row -> "Unused for 60 days".equals(row.get("category"))).hasSize(2);
assertThat(data.getRows()).filteredOn(row -> "Owner deactivated".equals(row.get("category"))).hasSize(1);
assertThat(data.getRows().toString()).contains("?filter=", "identifier", "username").doesNotContain("lastUsed%22");
QWidgetMetaData widget = (QWidgetMetaData) t.qInstance.getWidget("authentikAccessTokens");
assertThat(widget.getType()).isEqualTo(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType());
assertThat(new AuthentikTokenWidget().render(new RenderWidgetInput().withWidgetMetaData(widget))).isNotNull();
t.config.withTokenUnusedDays(120);
assertThat(AuthentikTokenWidget.data(util, now).getRows()).noneMatch(row -> "Unused for 120 days".equals(row.get("category")));
}
/*******************************************************************************
** A post-write owner move is a high-severity incident without a token key.
*******************************************************************************/
@Test
void protectedOwnerRaceRaisesAlert() throws QException
{
t.authentik.users.get(6).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365");
token("service", 6, 5);
t.authentik.moveTokenAfterUpdate = true;
var result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "test").with("expiresInDays", 20));
assertThat(result.ok()).isFalse();
assertThat(notices).hasSize(1);
assertThat(notices.getFirst().key()).isEqualTo("authentik_token_guard_bypass");
assertThat(notices.getFirst().severity()).isEqualTo("high");
assertThat(notices.toString()).doesNotContain(t.authentik.tokenKeys.get("service"));
}
/*******************************************************************************
** External extension opens a new reminder window without rewriting scope.
*******************************************************************************/
@Test
void externalExtensionStartsNewReminderWindow() throws QException
{
JSONObject token = token("external", 3, 5);
AuthentikTokenReminders.run(util, now);
token.put("expires", now.plus(20, ChronoUnit.DAYS).toString());
util.getRuntime().invalidateCache();
AuthentikTokenReminders.run(util, now.plus(15, ChronoUnit.DAYS));
assertThat(notices).hasSize(2);
}
}
@@ -0,0 +1,275 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
import com.kingsrook.qbits.authentik.policy.AuthentikPolicyClaimMapper;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.Mode;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Scope enforcement intersects existing permissions without Docker or OAuth.
*******************************************************************************/
class TokenScopesTest
{
private AdminTestSupport t;
/*******************************************************************************
** Instance includes a table whose permission base differs from its name.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
t = AdminTestSupport.create();
t.config.withTokenScopesEnabled(true).withTokenBrokerClientId("broker").withTokenBrokerClientSecret("secret")
.withSubjectTokenSource(session -> "subject:3");
TokenKeyDelivery.set((output, key) -> {});
}
/*******************************************************************************
** Reset the response-only test adapter.
*******************************************************************************/
@AfterEach
void tearDown()
{
TokenKeyDelivery.set(null);
}
/*******************************************************************************
** Seed a credential and optional local metadata.
*******************************************************************************/
private void seed(String id, AdminRequest scope) throws QException
{
t.authentik.seedToken(id, 3, "app_password", Instant.now().plusSeconds(86400).toString());
if(scope != null)
{
new TokenScopeStore(t.config).create(id, t.authentik.users.get(3), scope, "sam@example.test");
}
}
/*******************************************************************************
** No claim means no scope lookup and no changes to the permission set.
*******************************************************************************/
@Test
void absentAndExternalClaims() throws QException
{
QSession session = QContext.getQSession().withPermissions("post.read", "post.edit");
Set<String> before = Set.copyOf(session.getPermissions());
AuthentikTokenScopes.apply(session, new JSONObject());
assertThat(session.getPermissions()).isEqualTo(before);
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, null)).isEmpty();
seed("external", null);
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, new JSONObject().put("qqq_token", "external")).orElseThrow().mode()).isEqualTo(Mode.UNSCOPED);
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "external"));
assertThat(session.getPermissions()).isEqualTo(before);
assertThat(session.getValue("qqq.credentialScope")).isEqualTo("external");
}
/*******************************************************************************
** Read-only strips writes and management hasAccess permissions.
*******************************************************************************/
@Test
void readOnlyAndDeletedToken() throws QException
{
seed("limited", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("readOnly", true));
QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "post.read", "post.edit", "authentik.tokens.manage.hasAccess");
JSONObject claims = new JSONObject().put("qqq_token", "limited");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactlyInAnyOrder("cms.publish.hasAccess", "post.read");
t.authentik.tokens.get("sam").clear();
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, claims).orElseThrow().mode()).isEqualTo(Mode.DENY_ALL);
assertThat(new TokenScopeStore(t.config).find("limited")).isNotNull();
}
/*******************************************************************************
** A scope cannot grant missing read permissions, even with a valid table.
*******************************************************************************/
@Test
void tableNamesResolvePermissionAliasesAndNeverWiden() throws QException
{
seed("table", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("tables", "post"));
QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "kof22.analytics.hasAccess");
JSONObject claims = new JSONObject().put("qqq_token", "table");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactly("cms.publish.hasAccess");
session.setPermissions(Set.of("kof22.analytics.hasAccess"));
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** An app includes its table actions, reports and widgets, not other apps.
*******************************************************************************/
@Test
void appChildrenAndExplicitTableUnion() throws QException
{
var rules = new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION);
QProcessMetaData process = new QProcessMetaData().withName("publish").withTableName("post").withPermissionRules(rules);
QReportMetaData report = new QReportMetaData().withName("report").withPermissionRules(rules);
QWidgetMetaData widget = new QWidgetMetaData().withName("widget").withType("TABLE").withPermissionRules(rules);
t.qInstance.addProcess(process);
t.qInstance.addReport(report);
t.qInstance.addWidget(widget);
t.qInstance.addApp(new QAppMetaData().withName("content").withPermissionRules(rules).withChild(t.qInstance.getTable("post"))
.withChild(report).withWidgets(List.of("widget")));
seed("app", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("apps", "content").with("tables", "analytics"));
QSession session = t.loginAs("sam@example.test").withPermissions("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess",
"widget.hasAccess", "kof22.analytics.hasAccess", "unrelated.read");
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "app"));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess",
"widget.hasAccess", "kof22.analytics.hasAccess");
}
/*******************************************************************************
** The configured claim is enforced even if offering new scopes is disabled.
*******************************************************************************/
@Test
void configuredClaimCacheAndMalformedClaim() throws QException
{
t.config.withTokenScopesEnabled(false).withTokenScopeClaim("credential");
seed("external", null);
QSession session = t.loginAs("sam@example.test").withPermissions("post.read");
JSONObject claims = new JSONObject().put("credential", "external");
AuthentikTokenScopes.apply(session, claims);
t.authentik.tokens.get("sam").clear();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactly("post.read");
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
session.withPermission("post.read");
AuthentikTokenScopes.apply(session, new JSONObject().put("credential", new JSONObject()));
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** Both claims customizers narrow after computing their permission unions.
*******************************************************************************/
@Test
void mappersCannotAddPermissionsAfterDenyAll()
{
JSONObject claims = new JSONObject().put("qqq_token", "deleted");
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
assertThat(session.getPermissions()).isEmpty();
session.withPermission("post.read");
new AuthentikGroupPermissionMapper().withGroupPermissions("test", List.of("post.edit"))
.customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims.put("groups", List.of("test"))));
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** Review validates current access and displays the scope; execution repeats.
*******************************************************************************/
@Test
void reviewRejectsUnknownOrUnheldAndExecutionRechecks() throws QException
{
t.loginAs("sam@example.test");
AdminRequest request = AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("description", "CI").with("expiresInDays", 30).with("tables", "missing");
assertThat(t.admin().plan(request).getMessage()).contains("Unknown");
request.with("tables", "post");
assertThat(t.admin().plan(request).getMessage()).contains("read access");
QContext.getQSession().withPermission("cms.publish.hasAccess");
assertThat(t.admin().plan(request).describe()).contains("tables: post");
QContext.getQSession().setPermissions(Set.of());
assertThat(t.admin().execute(request).ok()).isFalse();
assertThat(t.changes()).isEmpty();
}
/*******************************************************************************
** A long general response cache cannot extend the scope revocation window.
** Lookup outages deny, rather than falling back to unrestricted access.
*******************************************************************************/
@Test
void verificationCacheIsIndependentAndApiFailureDenies() throws QException
{
t.config.withListCacheTtlSeconds(3600);
seed("cached", null);
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
JSONObject claims = new JSONObject().put("qqq_token", "cached");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactly("post.read");
t.authentik.tokens.get("sam").clear();
AuthentikRuntime.forBackend(t.config.getApiBackendName()).getTokenScopeCache().clear();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
session.withPermission("post.read");
com.kingsrook.qbits.authentik.client.FixtureActionUtil.handler = request ->
com.kingsrook.qbits.authentik.client.FixtureActionUtil.response(403, "{}");
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "unavailable"));
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** The ID token may supply policy context while the access token binds scope.
*******************************************************************************/
@Test
void idTokenContextCannotHideAccessTokenScope()
{
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of(
"idToken", new JSONObject().put("email", "sam@example.test"),
"jwtPayloadJsonObject", new JSONObject().put("qqq_token", "deleted")));
assertThat(session.getPermissions()).isEmpty();
}
}
@@ -61,7 +61,7 @@ class AuthentikAppTest
assertThat(app.getChildren()).extracting(c -> c.getName())
.containsExactly("authentik_user", "authentik_invitation", "authentik_group", "authentik_application", "authentik_approval_request", "authentik_admin_action");
assertThat(app.getSections()).extracting(s -> s.getLabel()).containsExactly("People", "Access", "Activity");
assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikDriftReport");
assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikAccessTokens", "authentikDriftReport");
for(String subTable : List.of("authentik_group_member", "authentik_session", "authentik_token", "authentik_event", "authentik_policy_binding", "authentik_role", "authentik_blueprint"))
{
@@ -334,6 +334,79 @@ class TokenContractIT
/*******************************************************************************
** Owner decision A (2026-10-14): the role holds add_token because 2026.8.3
** needs it for service_account and for set_key (POST object permission).
** Pin both: 403 without add_token, 200/204 with it (restored in finally).
*******************************************************************************/
@Test
void serviceAccountActionsNeedAddToken() throws Exception
{
JSONObject seed = expect(admin.post("/core/users/service_account/", new JSONObject().put("name", unique("needs-add"))
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
users.add(seed.getInt("user_pk"));
String seedToken = "service-account-" + seed.getString("username") + "-password";
tokens.add(seedToken);
String role = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
JSONObject permissions = new JSONObject().put("permissions", new org.json.JSONArray().put("authentik_core.add_token"));
String name = unique("no-add-token");
assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", permissions).status()).isEqualTo(204);
try
{
expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name)
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 403);
assertThat(admin.first("/core/users/?username=" + name)).isNull();
expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 403);
}
finally
{
assertThat(admin.post("/rbac/permissions/assigned_by_roles/" + role + "/assign/", permissions).status()).isEqualTo(200);
}
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name)
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
users.add(created.getInt("user_pk"));
tokens.add("service-account-" + name + "-password");
expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 204);
}
/*******************************************************************************
** The alarm also notifies identity-admins whenever the qbit's account
** creates a service account (owner decision 2026-10-14).
*******************************************************************************/
@Test
void detectiveRuleNotifiesOnServiceAccountCreation() throws Exception
{
int member = user("alarm-sa");
expect(admin.post("/core/groups/" + admin.groupPk("identity-admins") + "/add_user/", new JSONObject().put("pk", member)), 204);
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", unique("alarmed"))
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
int servicePk = created.getInt("user_pk");
users.add(servicePk);
tokens.add("service-account-" + created.getString("username") + "-password");
String[] eventPk = { null };
AuthentikTestStack.waitFor(Duration.ofSeconds(30), "service-account model_created event", () ->
{
JSONArray events = admin.getOk("/events/events/?action=model_created&ordering=-created&page_size=50&username="
+ TestApi.encode(AuthentikTestStack.SERVICE_ACCOUNT)).getJSONArray("results");
for(int i = 0; i < events.length(); i++)
{
JSONObject model = events.getJSONObject(i).getJSONObject("context").optJSONObject("model");
if(model != null && "user".equals(model.optString("model_name")) && String.valueOf(servicePk).equals(model.optString("pk")))
{
eventPk[0] = events.getJSONObject(i).getString("pk");
return (true);
}
}
return (false);
});
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "service-account creation notification", () ->
notifications(member, eventPk[0]).length() > 0);
}
/*******************************************************************************
** Real qbit calls, local audit/scope persistence and the response-only SPI.
*******************************************************************************/
@@ -542,8 +615,13 @@ class TokenContractIT
*******************************************************************************/
private TestApi.Response credentials(String username, String key, String scope) throws Exception
{
//////////////////////////////////////////////////////////////////////
// no client_secret: exactly what a PAT holder sends (the generated //
// usage text). 2026.8.3 authenticates client_credentials by the //
// username and app password even for a confidential client //
//////////////////////////////////////////////////////////////////////
return (form(Map.of("grant_type", "client_credentials", "client_id", AuthentikTestStack.APP_SLUG,
"client_secret", "contract-test-oauth2-client-secret", "username", username, "password", key, "scope", scope)));
"username", username, "password", key, "scope", scope)));
}
@@ -26,26 +26,25 @@ import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermission;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Table features use generic permission suffixes from core's existing catalog
** API. No test or production code depends on the unreleased metadata hook.
** Table features are discovered through core's registered metadata and catalog.
*******************************************************************************/
class PolicyFeaturePermissionsTest
{
@@ -61,31 +60,27 @@ class PolicyFeaturePermissionsTest
QInstance instance = PolicyCatalogTest.instance();
instance.addTable(new QTableMetaData().withName("person_records").withLabel("People").withBackendName("memory")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withPermissionBaseName("person")));
instance.addTable(new QTableMetaData().withName("person_extra").withLabel("Extra People").withBackendName("memory")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.ofOrWithNew(instance);
features.withPermission(feature("comment", "Comment", "person_records", "legacy"))
.withPermission(feature("analyze", "Old label", "person_records"))
.withPermission(feature("analyze", "Analyze", "person_records", "gate", "open_table"))
.withPermission(feature("tag", "Tag", "shared_one", "shared_two"))
.withPermission(feature("share", "Share", "person_extra"))
.withPermission(feature("", "Empty", "person_records"));
return (instance);
}
/*******************************************************************************
** The shape returned by PermissionsHelper when core registers a feature.
** Register a feature only for the named tables; core supplies permission names.
*******************************************************************************/
private static AvailablePermission feature(String name, String label)
private static TableFeaturePermission feature(String suffix, String label, String... tables)
{
return (new AvailablePermission().withName(name).withObjectName("People").withObjectType("Table").withPermissionType(label));
}
/*******************************************************************************
** Deliberately unsorted; duplicates and standard suffixes are not features.
*******************************************************************************/
private static List<AvailablePermission> available()
{
return (List.of(feature("person.comment", "Comment"), feature("person.analyze", "Analyze"), feature("person.analyze", "Analyze"),
feature("person.read", "Read"), feature("person.insert", "Insert"), feature("person.edit", "Edit"), feature("person.delete", "Delete"),
feature("person.hasAccess", "Access"), feature("person_extra.share", "Share"), feature("person.", "Empty"),
feature("gate.analyze", "Analyze"), feature("legacy.comment", "Comment"), feature("legacy.write", "Write"),
feature("shared_base.tag", "Tag"), feature("open_table.analyze", "Analyze")));
return (new TableFeaturePermission().withSuffix(suffix).withLabel(label)
.withAppliesTo((instance, table) -> List.of(tables).contains(table.getName())));
}
@@ -106,11 +101,13 @@ class PolicyFeaturePermissionsTest
@Test
void catalogListsFeaturesSeparately()
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
QInstance instance = instance();
assertThat(PermissionsHelper.getAllAvailablePermissions(instance)).filteredOn(permission -> permission.getName().equals("shared_base.tag")).hasSize(2);
PolicyCatalog catalog = PolicyCatalog.of(instance);
assertThat(catalog.get("person").actions()).containsExactly(PolicyAction.READ, PolicyAction.INSERT, PolicyAction.EDIT, PolicyAction.DELETE);
assertThat(catalog.get("person").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"), new PolicyCatalog.Feature("comment", "Comment"));
assertThat(catalog.get("gate").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"));
assertThat(catalog.get("legacy").features()).containsExactly(new PolicyCatalog.Feature("comment", "Comment"), new PolicyCatalog.Feature("write", "Write"));
assertThat(catalog.get("legacy").features()).containsExactly(new PolicyCatalog.Feature("comment", "Comment"), new PolicyCatalog.Feature("write", "WRITE"));
assertThat(catalog.get("shared_base").features()).containsExactly(new PolicyCatalog.Feature("tag", "Tag"));
assertThat(catalog.get("open_table")).isNull();
assertThat(catalog.permissionNames()).contains("person.analyze", "person.comment", "gate.analyze", "legacy.comment", "legacy.write", "shared_base.tag")
@@ -136,7 +133,7 @@ class PolicyFeaturePermissionsTest
{
QInstance instance = instance();
instance.getTable("person_records").withoutCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
PolicyCatalog.Resource resource = PolicyCatalog.of(instance, available()).get("person");
PolicyCatalog.Resource resource = PolicyCatalog.of(instance).get("person");
assertThat(resource.actions()).isEmpty();
assertThat(resource.features()).hasSize(2);
}
@@ -149,7 +146,7 @@ class PolicyFeaturePermissionsTest
@Test
void parserValidatesFeaturesPerResource() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
assertThat(PolicyParser.parseStrict(document(List.of("read", "analyze"), "person"), FORMAT, catalog).permissions())
.containsExactly("person.analyze", "person.read");
assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "gate"), FORMAT, catalog)).containsExactly("gate.analyze", "person.analyze");
@@ -171,7 +168,7 @@ class PolicyFeaturePermissionsTest
@Test
void wildcardDoesNotGrantFeatures()
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
assertThat(PolicyParser.parse(document(List.of("*"), "person"), FORMAT, catalog))
.containsExactlyInAnyOrder("person.hasAccess", "person.read", "person.insert", "person.edit", "person.delete");
assertThat(PolicyParser.parse(document(List.of("*", "analyze"), "person"), FORMAT, catalog)).hasSize(6).contains("person.analyze").doesNotContain("person.comment");
@@ -185,7 +182,7 @@ class PolicyFeaturePermissionsTest
@Test
void generatorRoundTripsFeatures() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
List<String> permissions = List.of("person.comment", "person.read", "person.analyze", "gate.analyze");
JSONObject generated = PolicyGenerator.generate(permissions, FORMAT, catalog);
String compact = PolicyGenerator.toCompact(generated);
@@ -206,22 +203,18 @@ class PolicyFeaturePermissionsTest
/*******************************************************************************
** The real mapper discovers simulated core entries and adds their exact names.
** The real mapper discovers registered core features and adds their exact names.
*******************************************************************************/
@Test
void claimMapperUsesCoreCatalog()
{
QInstance instance = instance();
try(MockedStatic<PermissionsHelper> helper = Mockito.mockStatic(PermissionsHelper.class, Mockito.CALLS_REAL_METHODS))
{
helper.when(() -> PermissionsHelper.getAllAvailablePermissions(instance)).thenReturn(available());
QSession session = new QSession();
session.setPermissions(Set.of("existing.read"));
JSONObject claims = new JSONObject().put(FORMAT.claim(), document(List.of("analyze"), "person"));
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.read", "person.analyze");
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance))).containsExactly("person.analyze");
}
QSession session = new QSession();
session.setPermissions(Set.of("existing.read"));
JSONObject claims = new JSONObject().put(FORMAT.claim(), document(List.of("analyze"), "person"));
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.read", "person.analyze");
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance))).containsExactly("person.analyze");
}
@@ -232,7 +225,7 @@ class PolicyFeaturePermissionsTest
@Test
void comparisonIncludesInheritedFeatures() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
JSONObject parent = new JSONObject().put("pk", "parent").put("name", "Analysts")
.put("attributes", new JSONObject().put(FORMAT.attribute(), document(List.of("analyze"), "person")));
JSONObject child = new JSONObject().put("pk", "child").put("name", "Staff").put("parents", new JSONArray(List.of("parent")))
@@ -131,7 +131,14 @@ class AuthentikProcessesTest
for(QProcessMetaData process : t.qInstance.getProcesses().values())
{
assertThat(process.getTableName()).as(process.getName()).isNotBlank();
if(!process.getName().equals("authentikTokenReminders"))
{
assertThat(process.getTableName()).as(process.getName()).isNotBlank();
}
else
{
assertThat(process.getIsHidden()).isTrue();
}
assertThat(t.qInstance.getApp("authentikAdmin").getChildren()).extracting(c -> c.getName()).doesNotContain(process.getName());
}
assertThat(t.qInstance.getProcess("authentikCreateUser").getMaxInputRecords()).isEqualTo(0);
@@ -104,6 +104,9 @@ entries:
# superuser, a member of authentik Admins or identity-admins, or
# qqq.io/protected). If it ever happens, guard (a) was bypassed:
# identity-admins get an Authentik notification and an email.
# It also notifies whenever that account creates a service account (each
# comes with an app password; owner decision 2026-10-14), so in-app
# service-account creation is always visible to identity-admins.
- model: authentik_policies_expression.expressionpolicy
id: qqq-token-alarm-policy
state: present
@@ -121,6 +124,11 @@ entries:
if (event.user or {}).get("username") != "qqq-kof22-admin-idadmin":
return False
model = event.context.get("model") or event.context.get("secret") or {}
# Also notify (owner decision 2026-10-14) whenever the qbit's account
# creates a service account: each one comes with an app password.
if event.action == "model_created" and model.get("app") == "authentik_core" and model.get("model_name") == "user":
created = User.objects.filter(pk=model.get("pk")).first()
return created is not None and created.type == "service_account"
# Two shapes: the usual model reference (app, model_name, pk), and set_key's
# model_to_dict(token) (token_uuid, identifier, intent, user; key masked).
is_reference = model.get("app") == "authentik_core" and model.get("model_name") == "token"