Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99a302c7ce
|
||
|
|
65aa816660
|
||
|
|
3722380db0
|
||
|
|
89fcdec2ab | ||
|
|
f78aaa3626 | ||
|
|
02c18de8c1
|
||
|
|
eb74cd12db
|
||
|
|
9dfef79929
|
||
|
|
eb5d019fe7 | ||
|
|
6c56a24960
|
||
|
|
3e18458fb3
|
||
|
|
5a3802193f
|
||
|
|
8698d627a9
|
||
|
|
32741508e8
|
||
|
|
2061677e16
|
||
|
|
7f8e742120
|
||
|
|
26a449c9e6
|
||
|
|
de7c015081
|
||
|
|
38d9776f25
|
||
|
|
9eece9cde5 | ||
|
|
b1d9df5370 | ||
|
|
d138c5c911
|
||
|
|
f151a7344a
|
No files matched your search
@@ -1,10 +1,10 @@
|
||||
# AGENTS.md
|
||||
# AGENTS.md / CLAUDE.md
|
||||
|
||||
This file provides guidance to Codex and other agents when working with code in this repository. It mirrors CLAUDE.md; keep them in sync.
|
||||
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md in sync.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and (from phase 2) writes through the Authentik API. Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = read-only (phases 0-1).
|
||||
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and writes through the Authentik API (never its DB). Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = the full user lifecycle and group/permission management (phases 0-3), read-only by default.
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -12,6 +12,7 @@ This file provides guidance to Codex and other agents when working with code in
|
||||
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
|
||||
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
|
||||
./mvnw test -Dtest=ClassName
|
||||
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
|
||||
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
|
||||
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
|
||||
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
|
||||
@@ -29,24 +30,30 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
|
||||
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
|
||||
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
|
||||
- Writes go to the guard, which refuses them in 0.1.
|
||||
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
|
||||
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
|
||||
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
|
||||
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
|
||||
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
|
||||
- `security/AuthentikGuard` handles read and write permission checks, the user scope, and protected-account detection. `security/IdentityPermission` is the `identity.*` catalog (session form `<name>.hasAccess`).
|
||||
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
|
||||
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
|
||||
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
|
||||
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
|
||||
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
|
||||
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
|
||||
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
|
||||
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `authentik.admin`.
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
|
||||
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
|
||||
- Never filter client-side; reject what Authentik cannot evaluate.
|
||||
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
|
||||
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
|
||||
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
|
||||
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions.
|
||||
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
|
||||
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
|
||||
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
|
||||
|
||||
## Code Style
|
||||
|
||||
@@ -54,7 +61,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Dependencies
|
||||
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
|
||||
## Release
|
||||
|
||||
|
||||
+23
-1
@@ -4,6 +4,28 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
|
||||
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
|
||||
|
||||
### Added (access tokens 6c)
|
||||
- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153.
|
||||
- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked.
|
||||
- Daily expiry reminders with persistent per-window deduplication and optional Alerts delivery; high-severity guard-bypass alerts.
|
||||
- A standard Access tokens TABLE widget with owner counts, age buckets and cleanup links, plus the existing record/list bulk revoke action.
|
||||
|
||||
### Added (access tokens, DESIGN.md section 13)
|
||||
- Default-on `TOKENS` with personal create/rotate/revoke/extend, service-account creation and rotation, protected-owner guardrails and optional two-person approval.
|
||||
- Action-scoped OAuth2 token exchange, response-only one-time key delivery (fail closed on older QQQ), generated usage instructions and non-secret token scope records.
|
||||
- Token-manager read access, expanded token metadata, secret redaction and in-memory process/persistence tests.
|
||||
|
||||
### Changed (core baseline, #20 / F19)
|
||||
- Build against the released QQQ BOM `4.1.0-RC.6` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. Adopt `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
|
||||
- Core actions now enforce permissions for non-HTTP callers. Authentik-specific guards remain for direct util calls and domain rules. See [UPGRADE.md](UPGRADE.md) for host impact and feature registration requirements.
|
||||
- Replace the expired feature-catalog stand-in with real `TableFeaturePermissionsMetaData` registrations and remove the test-only `PolicyCatalog` permission-source overload. Feature discovery uses the core catalog without static mocking.
|
||||
|
||||
### Added (automated sessions)
|
||||
- `AuthentikGroupPermissionMapper` resolves tokenless automated sessions by a unique active Authentik email match, applying direct and inherited group permissions, static fallbacks and the sign-in cut-over guard while preserving existing session permissions.
|
||||
|
||||
### Added (table feature permissions)
|
||||
- Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData
|
||||
|
||||
@@ -51,7 +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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
# AGENTS.md / CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md in sync.
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -12,6 +12,7 @@ This file provides guidance to Claude Code when working with code in this reposi
|
||||
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
|
||||
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
|
||||
./mvnw test -Dtest=ClassName
|
||||
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
|
||||
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
|
||||
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
|
||||
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
|
||||
@@ -29,13 +30,14 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
|
||||
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
|
||||
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
|
||||
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. The only way to change Authentik is `write(WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins that only `AuthentikAdmin` calls it).
|
||||
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
|
||||
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
|
||||
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
|
||||
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
|
||||
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
|
||||
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
|
||||
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
|
||||
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
|
||||
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
|
||||
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
|
||||
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
|
||||
@@ -43,15 +45,15 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
|
||||
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
|
||||
- Never filter client-side; reject what Authentik cannot evaluate.
|
||||
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
|
||||
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
|
||||
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
|
||||
- Nothing but `AuthentikAdmin` may call `AuthentikActionUtil.write`. Every write needs a reason, passes the guard, and is audited first.
|
||||
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
|
||||
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
|
||||
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key or impersonate permissions; `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
|
||||
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
|
||||
|
||||
## Code Style
|
||||
|
||||
@@ -59,7 +61,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Dependencies
|
||||
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
|
||||
## Release
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Access tokens** | Create and rotate your personal access tokens, revoke (including bulk), extend expiry, and create service accounts. Enabled with any write feature; opt out with `tokensEnabled=false` or `AUTHENTIK_QBIT_TOKENS=false`. Keys are response-only and shown once; key-producing actions require QQQ 4.1.0-RC.7+ one-time output support. See [Access tokens](docs/ACCESS-TOKENS.md). |
|
||||
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. Edit covers username, name, email, path and other attributes. Delete is a separate, explicit action behind the `DELETE` feature (off by default); deactivate stays the default. |
|
||||
| **Groups and permissions** | Create, edit (parents, other attributes) and delete groups, add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
|
||||
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
|
||||
@@ -67,13 +68,13 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
|
||||
|
||||
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
|
||||
|
||||
The editor discovers features and labels through `PermissionsHelper.getAllAvailablePermissions`; they appear when the host's core version exposes them. This qbit does not compile against the unreleased metadata hook. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
|
||||
The editor discovers registered features and labels through core RC.6's `PermissionsHelper.getAllAvailablePermissions`. The build uses `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
|
||||
|
||||
### Authentik side
|
||||
|
||||
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, token-key or impersonate permissions, and holds delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
|
||||
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, `view_token_key` or impersonate permissions, and holds user/group delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
|
||||
|
||||
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. Saving a policy uses `change_group`. The `DELETE` feature also needs `delete_user` and `delete_group`. The contract tests grant exactly this list.
|
||||
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. Saving a policy uses `change_group`. Token management additionally needs `change_token`, `set_token_key` and `add_token` (Authentik requires `add_token` for `service_account` and `set_key`; for a non-superuser it can only create the caller's own token); never grant `view_token_key`. Personal token operations use an exchanged user bearer from the confidential token broker, never the qbit bearer. The `DELETE` feature also needs `delete_user` and `delete_group`. The contract fixtures include the token broker, claim mapping, detective rule and token write permissions.
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
@@ -82,9 +83,9 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
|
||||
| Item | What to do |
|
||||
|---|---|
|
||||
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
|
||||
| QQQ version | QQQ 4.1 (see `pom.xml` for the exact RC). Java 21. |
|
||||
| QQQ version | QQQ 4.1.0-RC.6 (released BOM, no core SNAPSHOT). Java 21. |
|
||||
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. |
|
||||
| Tables | Create `authentik_admin_action` and `authentik_approval_request` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
|
||||
| Tables | Create `authentik_admin_action`, `authentik_approval_request` and `authentik_token_scope` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
|
||||
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. |
|
||||
| Required config | `baseUrl`, `apiToken`, `appKey`. For writes: `features`, `localBackendName`, `recoveryEmailStage` (users and credentials). Optional: `invitationFlow`, `twoPersonApproval` (default on), `userScopePathStartsWith`, `protectedUsernames`, `applicationsInScope`, `linkPrefix` (default `/app`), `throttleRequestsPerSecond` (default 10), `bulkPacingMillis`. |
|
||||
| Secrets / env | `apiToken` as a `${env.X}` reference; `bootstrapOperatorSubjects` (the lockout escape hatch) defaults to `${env.AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS}`: immutable ids only (the Authentik user pk or uuid, or the token `sub`), never emails, because users can change their own email. Empty it once real group grants exist. Keep secrets in the host's secret store. |
|
||||
@@ -106,3 +107,23 @@ Contract tests start Authentik (server and worker), Postgres and Mailpit, and ta
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
|
||||
Access-token metadata is readable with `authentik.audit.read` or `authentik.tokens.manage`. Personal actions have no additional process permission; ownership is checked live. Managing service-account tokens needs `authentik.tokens.manage`, and creating a service account also needs `authentik.users.manage`. Configure `tokenBrokerClientId`, `tokenBrokerClientSecret` (environment references supported), `appClientId`, `appPublicUrl` and `authentikPublicUrl` for exchange and usage instructions. Scope fields remain unavailable until `tokenScopesEnabled=true`.
|
||||
|
||||
| Access-token setting | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `tokenScopesEnabled` | `false` | Offer read-only, apps and tables fields after the signed claim mapping is installed. Names must exist and the operator must currently have read/hasAccess permission. |
|
||||
| `tokenScopeClaim` | `qqq_token` | Signed JWT claim containing the Authentik token identifier. Claims are enforced even when offering new scopes is disabled. |
|
||||
| `tokenUnusedDays` | `60` | Inactivity threshold for cleanup suggestions. With no login found, a known creation date and a threshold at most 90 days are required. |
|
||||
| `tokenReminderSchedulerName` | First registered scheduler | Run `authentikTokenReminders` daily. Without a scheduler the hidden process is available for a host to invoke. Register the scheduler before producing the qbit. |
|
||||
|
||||
The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list.
|
||||
|
||||
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The temporary session-permission intersection is marked `STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7)`; a reflection bridge also attaches `QCredentialScope` when available. It does not add permissions.
|
||||
|
||||
The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`.
|
||||
|
||||
|
||||
Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added.
|
||||
|
||||
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
|
||||
+22
@@ -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
@@ -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.
|
||||
|
||||
|
||||
+26
-11
@@ -24,7 +24,7 @@ Seven design rules shape everything below:
|
||||
- A group's QQQ permissions live in the group attribute `qqq.io/permissions`.
|
||||
- The reusable `AuthentikGroupPermissionMapper` turns a user's groups, including inherited parents, into QQQ session permissions.
|
||||
- Who can operate the qbit is decided by those same permissions (`authentik.*`). No operator group is hard-coded.
|
||||
4. **The qbit checks permissions itself.** QQQ core actions do not enforce permissions (QRun-IO/qqq#1061), and `FieldSecurityLock` is not enforced (#1062). An `AuthentikGuard` in the backend action layer checks every read and write.
|
||||
4. **The qbit checks permissions itself.** The original core gaps (QRun-IO/qqq#1061, #1062) are fixed in the RC.5 baseline. Direct backend calls and Authentik-specific rules still require local checks. An `AuthentikGuard` in the backend action layer checks every read and write.
|
||||
5. **No escalation.**
|
||||
- Operators can only grant permissions they hold themselves, whether through memberships or permission attributes.
|
||||
- Operators cannot edit themselves.
|
||||
@@ -72,7 +72,13 @@ Seven design rules shape everything below:
|
||||
| 27 | **Guardrails** (§13.4): (a) no minting or rotating for privileged accounts, in the qbit and again in Authentik; (b) self-service is self-only, admins may also target service accounts, never another human; (c) expiry is required, at most 90 days for people and 365 for service accounts (configurable); (d) optional two-person approval for service-account tokens (off by default); (e) an Authentik notification rule fires if the qbit's service account ever touches a protected user's token; (f) everything audited with the client channel. |
|
||||
| 28 | **Optional per-token scopes** (read only; specific apps and tables), narrower than the user, stored in `authentik_token_scope` and enforced by core scoped credentials (QRun-IO/qqq#1153, `QCredentialScope`) through a signed `qqq_token` claim (§13.5). |
|
||||
| 29 | **First class:** usage instructions generated from the instance's real URLs; expiry reminders 7 days and 1 day before (bell and email); last used; an unused-token cleanup suggestion; admin views by user and by age with bulk revoke; the doc page [`ACCESS-TOKENS.md`](ACCESS-TOKENS.md). |
|
||||
| 30 | **Owner-approved revision of the mechanism (2026-10-13, coordinator-approved under the owner's direction).** Authentik 2026.8.3 always creates a token for the *caller* unless the caller is a superuser (`TokenViewSet.perform_create`), so `add_token` cannot mint for anyone else (§13.2). The qbit therefore creates a person's token **as that person**, through RFC 8693 token exchange with a 2-minute API token from the `qqq-<appKey>-token-broker` provider (with a deny policy for protected users). Service accounts use `POST /core/users/service_account/` (create), `set_key` (rotate) and `PATCH` (extend). The qbit role gains `change_token` and `set_token_key`, keeps `delete_token` and `add_user`, and **never** holds `add_token` or `view_token_key`. Limitation: one app password per service account. |
|
||||
| 30 | **Owner-approved revision of the mechanism (2026-10-13, coordinator-approved under the owner's direction).** Authentik 2026.8.3 always creates a token for the *caller* unless the caller is a superuser (`TokenViewSet.perform_create`), so `add_token` cannot mint for anyone else (§13.2). The qbit therefore creates a person's token **as that person**, through RFC 8693 token exchange with a 2-minute API token from the `qqq-<appKey>-token-broker` provider (with a deny policy for protected users). Service accounts use `POST /core/users/service_account/` (create), `set_key` (rotate) and `PATCH` (extend). The qbit role gains `change_token`, `set_token_key` and `add_token`, keeps `delete_token` and `add_user`, and **never** holds `view_token_key`. `add_token` was found necessary by the contract tests (`service_account` needs `add_user` + `add_token`; `set_key` needs the POST object permission `add_token` + `set_token_key`) and is safe because a non-superuser's create is always the caller's (§13.9). Limitation: one app password per service account. |
|
||||
|
||||
**Owner decision (2026-10-14): `add_token` (option A).** The contract tests showed that Authentik 2026.8.3 requires `add_token` for `POST /core/users/service_account/` (with `add_user`) and for `POST /core/tokens/{id}/set_key/` (the POST object permission, with `set_token_key`).
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| 31 | **Grant `add_token` to the qbit role, so service-account creation and rotation stay in the app.** It is safe: for a non-superuser, `POST /core/tokens/` always creates the token for the caller, so it can never mint a token for anyone else. `view_token_key` stays **not** granted. `TokenContractIT` pins 403 without `add_token` and 200/204 with it. The detective alarm (§13.4 e) also notifies `identity-admins` whenever the qbit's account creates a service account. The blueprint explains this next to the permission (cluster-gitops#103). |
|
||||
|
||||
### 0.2 Phase 0 findings (2026-10-11, verified on Authentik 2026.8.3)
|
||||
|
||||
@@ -624,7 +630,7 @@ The role `qqq-<appKey>-idadmin` is assigned directly to the service account (boo
|
||||
|
||||
**Never granted:**
|
||||
- `enable_group_superuser` / `disable_group_superuser`. Authentik then refuses every route into superuser ancestry: membership (direct, bulk, by user or by group), `parents` under a superuser group or any descendant, creating a group under one, and flipping `is_superuser` (verified, §0.2 ii).
|
||||
- `view_token_key` and `add_token` (MHP APPS-1462). `set_token_key` is granted since revision 6 for service-account rotation only, behind §13.4 (§13.2 explains why `add_token` would not help anyway)
|
||||
- `view_token_key` (MHP APPS-1462). Since revision 6, `set_token_key` and `add_token` are granted for service-account creation and rotation only, behind §13.4; `add_token` cannot mint for others (§13.2, §13.9)
|
||||
- `impersonate`; `delete_user` and `delete_group` unless the `DELETE` feature is on (§12.5)
|
||||
- any `change_*` on flows, stages, providers, applications, policies, bindings, brands or blueprints
|
||||
- any change on its own role. Role permissions are object permissions excluding `qqq-*` roles, and the guard also refuses.
|
||||
@@ -668,7 +674,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost
|
||||
- `AuthentikActionUtil.doQuery/doGet/doInsert/doUpdate/doDelete` call `AuthentikGuard.check(table, op, session)` first. The guard checks the QQQ permission, the feature flag, `readOnly` and scope.
|
||||
- A session with no user is refused, except `MirrorSync`'s internal read-only session.
|
||||
- Every qbit process's first backend step re-checks `checkProcessPermissionThrowing` and the per-record rules (§5.4). It does not trust that the middleware ran.
|
||||
- Field-level protection does not use `FieldSecurityLock` (#1062):
|
||||
- Field-level protection retains explicit stripping for direct backend calls; core also enforces `FieldSecurityLock` in the RC.5 baseline (#1062):
|
||||
- Sensitive columns are removed in `jsonObjectToRecord` without `authentik.audit.read`. These are `attributes` (except the `qqq.io/*` keys, shown under `users.read`), `last_ip`, `user_agent` and `geo_ip`.
|
||||
- Filters on those fields are rejected for that session.
|
||||
- Tables also declare `QPermissionRules`, so the UI hides what the user can't use.
|
||||
@@ -720,6 +726,10 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace
|
||||
|
||||
**Fallback.** If Authentik is unreachable at sign-in, the mapper uses the mirror. If the mirror is empty, it grants only what the static config provides. It never grants everything.
|
||||
|
||||
#### Automated sessions
|
||||
|
||||
Server-created sessions (scheduled reports and SSH-key users of the SFTP gateway) have no token. `customizeAutomatedSessionForUser` treats `userId` as the email idReference used by `user_email` mode and requires exactly one Authentik user with that email (trimmed, case-insensitive), who must be active. It resolves the API user's direct groups and their ancestors through the same permission resolver, static fallbacks and cut-over guard as sign-in, including the host permission source while the guard blocks. Service accounts follow the same rule. Bootstrap grants match only the resolved user's immutable pk or uuid; email verification is never assumed. Unknown, duplicate, inactive, unreadable or truncated lookups grant only empty-group static fallbacks. Existing session permissions are always preserved; an API failure never grants all permissions.
|
||||
|
||||
---
|
||||
## 6. Data model
|
||||
|
||||
@@ -951,7 +961,7 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh
|
||||
| A blueprint re-apply reverts QQQ edits | No `present` identity entries, no member lists (CI lint); bootstrap as `created` (verified, §4.2); reversion detector |
|
||||
| Lost update between QQQ and the Authentik UI | Stale check at execute; attribute merge-patch after a fresh read |
|
||||
| Account takeover through a recovery link | Links are never shown; email only |
|
||||
| A non-HTTP caller bypasses permissions (#1061) | Guard in the backend util, plus ArchUnit |
|
||||
| A direct backend caller bypasses core actions | Guard in the backend util, plus ArchUnit |
|
||||
| A field leaks because `FieldSecurityLock` isn't enforced (#1062) | Sensitive fields stripped in the util |
|
||||
| A forged webhook | Shared secret, constant-time compare; it only triggers re-reads |
|
||||
| Stale session permissions after a demotion | Re-evaluation on token refresh plus webhook-triggered marking (§5.6); live groups used for guard checks |
|
||||
@@ -1029,7 +1039,7 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a
|
||||
| The service account can lock administrators out (remove members from `authentik Admins`, deactivate break-glass, strip `qqq.io/protected`, §0.2 ii-b). Re-parenting under a superuser group **is** refused by Authentik (§0.2 ii) | Guard refusal of every write that targets a protected account or a superuser group; drift report; recovery through cluster access |
|
||||
| Load on the shared Postgres | Throttle, caps, mirror |
|
||||
| Webhook loss | Inbox, 15-minute reconcile, nightly sweep |
|
||||
| QQQ core gaps (#1061, #1062) | Guard in the backend util, ArchUnit, field stripping |
|
||||
| Direct backend calls outside core enforcement | Guard in the backend util, ArchUnit, field stripping |
|
||||
| Token leakage | Redaction tests; no body logging on sensitive paths |
|
||||
| An Authentik upgrade changes fields | Pinned schema, fixtures per version, version check |
|
||||
|
||||
@@ -1285,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).
|
||||
@@ -1315,6 +1325,8 @@ API-intent tokens (`intent: api`) call Authentik's own API, not the app. QQQ lis
|
||||
|
||||
**Limitation (decision 30).** A service account has exactly one app password: the one `service_account` creates. Only a superuser could add a second. To run two credentials side by side, create a second service account.
|
||||
|
||||
**Contract finding (2026-10-13, verified on 2026.8.3).** `UserViewSet.service_account` requires `add_user` **and** `add_token`, and `POST …/set_key/` requires `add_token` (Authentik's `ObjectPermissions` maps a detail POST to `add_*`) as well as `set_token_key`. Without `add_token` both return 403. The role therefore holds `add_token` (§13.9); `TokenContractIT` pins both behaviours.
|
||||
|
||||
**Race guard.** Before an extend or a rotate, the qbit reads the token (owner pk and intent), and it reads the token again after the `PATCH` or `set_key`. If the owner or the intent changed:
|
||||
- the run fails;
|
||||
- the audit row is `FAILED` with "token moved";
|
||||
@@ -1356,7 +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
|
||||
@@ -1402,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.
|
||||
|
||||
@@ -1434,9 +1446,11 @@ Reminders are deduplicated through `lastReminder7d` and `lastReminder1d`. Tokens
|
||||
### 13.9 Authentik changes (cluster-gitops, kof22)
|
||||
|
||||
**The qbit role** `qqq-kof22-admin-idadmin`:
|
||||
- add `authentik_core.change_token` and `authentik_core.set_token_key`;
|
||||
- add `authentik_core.change_token`, `authentik_core.set_token_key` and `authentik_core.add_token`;
|
||||
- it already has `delete_token`, `view_token` and `add_user`;
|
||||
- **not** `add_token` or `view_token_key`.
|
||||
- **not** `view_token_key`.
|
||||
|
||||
`add_token` is safe here: for a non-superuser, `POST /core/tokens/` always creates the token for the **caller**, so it can never mint a token for someone else. It is required by `service_account` and by `set_key` (the POST object permission).
|
||||
|
||||
**New Authentik objects:**
|
||||
- the broker provider and application, with the deny policy (§13.3.1);
|
||||
@@ -1459,6 +1473,7 @@ Reminders are deduplicated through `lastReminder7d` and `lastReminder1d`. Tokens
|
||||
|---|---|---|
|
||||
| `tokensEnabled` | `true` when any write feature is on | Opt-out (`AUTHENTIK_QBIT_TOKENS=false`) |
|
||||
| `tokenMaxDaysUser` / `tokenMaxDaysServiceAccount` | 90 / 365 | Guardrail (c) |
|
||||
| `serviceAccountTokenLifetime` | `days=365` | Authentik lifetime cap merged into each newly created service account's USER attributes |
|
||||
| `tokenProtectedGroups` | `authentik Admins`, `identity-admins` | Added to the hard-coded classes of guardrail (a) |
|
||||
| `tokenTwoPersonApproval` | `false` | Guardrail (d) |
|
||||
| `tokenScopesEnabled` | `false` | Offer scopes once the claim mapping is installed |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -68,3 +68,26 @@ CREATE TABLE authentik_policy
|
||||
authentik_fingerprint VARCHAR(64),
|
||||
CONSTRAINT uk_authentik_policy_group_pk UNIQUE (group_pk)
|
||||
);
|
||||
|
||||
|
||||
-- Token metadata only (DESIGN section 13.5). No key or key hash is stored.
|
||||
CREATE TABLE authentik_token_scope
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
token_identifier VARCHAR(255) NOT NULL,
|
||||
owner_username VARCHAR(255),
|
||||
owner_pk INTEGER,
|
||||
read_only BOOLEAN,
|
||||
apps TEXT,
|
||||
tables TEXT,
|
||||
processes TEXT,
|
||||
reports TEXT,
|
||||
widgets TEXT,
|
||||
operation_ids TEXT,
|
||||
created_by VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE,
|
||||
rotated_at TIMESTAMP WITH TIME ZONE,
|
||||
last_reminder_7d TIMESTAMP WITH TIME ZONE,
|
||||
last_reminder_1d TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT uk_authentik_token_scope_identifier UNIQUE (token_identifier)
|
||||
);
|
||||
@@ -18,6 +18,8 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -36,7 +38,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -237,7 +239,7 @@
|
||||
<profiles>
|
||||
<!-- Needs qqq 4.1 (QBit producer API). Until qbit-build-parent imports the -->
|
||||
<!-- released 4.1 BOM, this profile is active by default and imports -->
|
||||
<!-- qqq-bom-pom 4.1.0-RC.2 ahead of the parent 4.0.0 BOM, as in -->
|
||||
<!-- qqq-bom-pom 4.1.0-RC.5 ahead of the parent 4.0.0 BOM, as in -->
|
||||
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
@@ -245,7 +247,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -26,6 +26,9 @@ public enum AuthentikFeature
|
||||
/** Read-only tables: users, groups, memberships, roles, applications, bindings, blueprints, sessions, events. */
|
||||
READ,
|
||||
|
||||
/** Access tokens (DESIGN section 13). */
|
||||
TOKENS,
|
||||
|
||||
/** Create, invite, edit, deactivate users (phase 3). */
|
||||
USERS,
|
||||
|
||||
|
||||
@@ -46,15 +46,33 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
{
|
||||
/** Features implemented in this release. Others are refused by validate(). */
|
||||
public static final Set<AuthentikFeature> AVAILABLE_FEATURES = EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE);
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE, AuthentikFeature.TOKENS);
|
||||
|
||||
/** Features that change Authentik data; they need a local backend for the audit trail. */
|
||||
public static final Set<AuthentikFeature> WRITE_FEATURES = EnumSet.of(AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE);
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE, AuthentikFeature.TOKENS);
|
||||
|
||||
private static final Pattern APP_KEY_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,48}[a-z0-9]");
|
||||
private static final Pattern PREFIX_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9]{0,30}");
|
||||
|
||||
private Boolean tokensEnabled = null;
|
||||
private Integer tokenMaxDaysUser = 90;
|
||||
private Integer tokenMaxDaysServiceAccount = 365;
|
||||
private List<String> tokenProtectedGroups = new ArrayList<>(List.of("authentik Admins", "identity-admins"));
|
||||
private Boolean tokenTwoPersonApproval = false;
|
||||
private Boolean tokenScopesEnabled = false;
|
||||
private String tokenScopeClaim = "qqq_token";
|
||||
private String tokenReminderSchedulerName;
|
||||
private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}";
|
||||
private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}";
|
||||
private String tokenEndpointUrl = null;
|
||||
private String appPublicUrl = null;
|
||||
private String authentikPublicUrl = null;
|
||||
private String appClientId = null;
|
||||
private Integer tokenUnusedDays = 60;
|
||||
private String serviceAccountTokenLifetime = "days=365";
|
||||
private com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource subjectTokenSource = new com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource();
|
||||
|
||||
private String baseUrl;
|
||||
private String apiToken;
|
||||
private String appKey;
|
||||
@@ -106,6 +124,23 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
@Override
|
||||
public void validate(QInstance qInstance, List<String> errors)
|
||||
{
|
||||
if(tokenMaxDaysUser == null || tokenMaxDaysUser < 1)
|
||||
{
|
||||
errors.add("tokenMaxDaysUser must be at least 1");
|
||||
}
|
||||
if(tokenMaxDaysServiceAccount == null || tokenMaxDaysServiceAccount < 1)
|
||||
{
|
||||
errors.add("tokenMaxDaysServiceAccount must be at least 1");
|
||||
}
|
||||
if(tokenScopeClaim == null || tokenScopeClaim.isBlank())
|
||||
{
|
||||
errors.add("tokenScopeClaim must not be blank");
|
||||
}
|
||||
if(tokenUnusedDays == null || tokenUnusedDays < 1)
|
||||
{
|
||||
errors.add("tokenUnusedDays must be at least 1");
|
||||
}
|
||||
|
||||
if(!StringUtils.hasContent(baseUrl))
|
||||
{
|
||||
errors.add("baseUrl is required (the Authentik host root, e.g. https://auth.example.com)");
|
||||
@@ -422,6 +457,11 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
*******************************************************************************/
|
||||
public boolean hasFeature(AuthentikFeature feature)
|
||||
{
|
||||
if(feature == AuthentikFeature.TOKENS)
|
||||
{
|
||||
return (!Boolean.TRUE.equals(readOnly) && Boolean.TRUE.equals(getTokensEnabled()) && features != null
|
||||
&& features.stream().anyMatch(WRITE_FEATURES::contains));
|
||||
}
|
||||
return (features != null && features.contains(feature));
|
||||
}
|
||||
|
||||
@@ -454,6 +494,19 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
public String toString()
|
||||
{
|
||||
return ("AuthentikQBitConfig{baseUrl=" + baseUrl + ", appKey=" + appKey + ", tableNamePrefix=" + tableNamePrefix
|
||||
+ ", tokensEnabled=" + tokensEnabled
|
||||
+ ", tokenMaxDaysUser=" + tokenMaxDaysUser
|
||||
+ ", tokenMaxDaysServiceAccount=" + tokenMaxDaysServiceAccount
|
||||
+ ", tokenProtectedGroups=" + tokenProtectedGroups
|
||||
+ ", tokenTwoPersonApproval=" + tokenTwoPersonApproval
|
||||
+ ", tokenScopesEnabled=" + tokenScopesEnabled
|
||||
+ ", tokenBrokerClientId=" + tokenBrokerClientId
|
||||
+ ", tokenEndpointUrl=" + tokenEndpointUrl
|
||||
+ ", appPublicUrl=" + appPublicUrl
|
||||
+ ", authentikPublicUrl=" + authentikPublicUrl
|
||||
+ ", appClientId=" + appClientId
|
||||
+ ", tokenUnusedDays=" + tokenUnusedDays
|
||||
+ ", serviceAccountTokenLifetime=" + serviceAccountTokenLifetime
|
||||
+ ", features=" + features + ", readOnly=" + readOnly + ", apiToken=" + (StringUtils.hasContent(apiToken) ? "******" : "(unset)") + "}");
|
||||
}
|
||||
|
||||
@@ -1356,4 +1409,522 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokensEnabled.
|
||||
*******************************************************************************/
|
||||
public Boolean getTokensEnabled()
|
||||
{
|
||||
return (tokensEnabled == null ? !"false".equalsIgnoreCase(String.valueOf(resolve("${env.AUTHENTIK_QBIT_TOKENS}")).trim()) : tokensEnabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tokensEnabled.
|
||||
*******************************************************************************/
|
||||
public void setTokensEnabled(Boolean tokensEnabled)
|
||||
{
|
||||
this.tokensEnabled = tokensEnabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tokensEnabled.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokensEnabled(Boolean tokensEnabled)
|
||||
{
|
||||
this.tokensEnabled = tokensEnabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenMaxDaysUser.
|
||||
*******************************************************************************/
|
||||
public Integer getTokenMaxDaysUser()
|
||||
{
|
||||
return (tokenMaxDaysUser);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tokenMaxDaysUser.
|
||||
*******************************************************************************/
|
||||
public void setTokenMaxDaysUser(Integer tokenMaxDaysUser)
|
||||
{
|
||||
this.tokenMaxDaysUser = tokenMaxDaysUser;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tokenMaxDaysUser.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenMaxDaysUser(Integer tokenMaxDaysUser)
|
||||
{
|
||||
this.tokenMaxDaysUser = tokenMaxDaysUser;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenMaxDaysServiceAccount.
|
||||
*******************************************************************************/
|
||||
public Integer getTokenMaxDaysServiceAccount()
|
||||
{
|
||||
return (tokenMaxDaysServiceAccount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tokenMaxDaysServiceAccount.
|
||||
*******************************************************************************/
|
||||
public void setTokenMaxDaysServiceAccount(Integer tokenMaxDaysServiceAccount)
|
||||
{
|
||||
this.tokenMaxDaysServiceAccount = tokenMaxDaysServiceAccount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tokenMaxDaysServiceAccount.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenMaxDaysServiceAccount(Integer tokenMaxDaysServiceAccount)
|
||||
{
|
||||
this.tokenMaxDaysServiceAccount = tokenMaxDaysServiceAccount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenProtectedGroups.
|
||||
*******************************************************************************/
|
||||
public List<String> getTokenProtectedGroups()
|
||||
{
|
||||
return (tokenProtectedGroups);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tokenProtectedGroups.
|
||||
*******************************************************************************/
|
||||
public void setTokenProtectedGroups(List<String> tokenProtectedGroups)
|
||||
{
|
||||
this.tokenProtectedGroups = tokenProtectedGroups;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tokenProtectedGroups.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenProtectedGroups(List<String> tokenProtectedGroups)
|
||||
{
|
||||
this.tokenProtectedGroups = tokenProtectedGroups;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenTwoPersonApproval.
|
||||
*******************************************************************************/
|
||||
public Boolean getTokenTwoPersonApproval()
|
||||
{
|
||||
return (tokenTwoPersonApproval);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tokenTwoPersonApproval.
|
||||
*******************************************************************************/
|
||||
public void setTokenTwoPersonApproval(Boolean tokenTwoPersonApproval)
|
||||
{
|
||||
this.tokenTwoPersonApproval = tokenTwoPersonApproval;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tokenTwoPersonApproval.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenTwoPersonApproval(Boolean tokenTwoPersonApproval)
|
||||
{
|
||||
this.tokenTwoPersonApproval = tokenTwoPersonApproval;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenScopesEnabled.
|
||||
*******************************************************************************/
|
||||
public Boolean getTokenScopesEnabled()
|
||||
{
|
||||
return (tokenScopesEnabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tokenScopesEnabled.
|
||||
*******************************************************************************/
|
||||
public void setTokenScopesEnabled(Boolean tokenScopesEnabled)
|
||||
{
|
||||
this.tokenScopesEnabled = tokenScopesEnabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tokenScopesEnabled.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenScopesEnabled(Boolean tokenScopesEnabled)
|
||||
{
|
||||
this.tokenScopesEnabled = tokenScopesEnabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenBrokerClientId.
|
||||
*******************************************************************************/
|
||||
public String getTokenBrokerClientId()
|
||||
{
|
||||
return (resolve(tokenBrokerClientId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tokenBrokerClientId.
|
||||
*******************************************************************************/
|
||||
public void setTokenBrokerClientId(String tokenBrokerClientId)
|
||||
{
|
||||
this.tokenBrokerClientId = tokenBrokerClientId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tokenBrokerClientId.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenBrokerClientId(String tokenBrokerClientId)
|
||||
{
|
||||
this.tokenBrokerClientId = tokenBrokerClientId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenBrokerClientSecret.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getTokenBrokerClientSecret()
|
||||
{
|
||||
return (resolve(tokenBrokerClientSecret));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tokenBrokerClientSecret.
|
||||
*******************************************************************************/
|
||||
public void setTokenBrokerClientSecret(String tokenBrokerClientSecret)
|
||||
{
|
||||
this.tokenBrokerClientSecret = tokenBrokerClientSecret;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tokenBrokerClientSecret.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenBrokerClientSecret(String tokenBrokerClientSecret)
|
||||
{
|
||||
this.tokenBrokerClientSecret = tokenBrokerClientSecret;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenEndpointUrl.
|
||||
*******************************************************************************/
|
||||
public String getTokenEndpointUrl()
|
||||
{
|
||||
return (tokenEndpointUrl == null ? getBaseUrl().replaceAll("/+$", "") + "/application/o/token/" : resolve(tokenEndpointUrl));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tokenEndpointUrl.
|
||||
*******************************************************************************/
|
||||
public void setTokenEndpointUrl(String tokenEndpointUrl)
|
||||
{
|
||||
this.tokenEndpointUrl = tokenEndpointUrl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tokenEndpointUrl.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenEndpointUrl(String tokenEndpointUrl)
|
||||
{
|
||||
this.tokenEndpointUrl = tokenEndpointUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appPublicUrl.
|
||||
*******************************************************************************/
|
||||
public String getAppPublicUrl()
|
||||
{
|
||||
return (resolve(appPublicUrl));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for appPublicUrl.
|
||||
*******************************************************************************/
|
||||
public void setAppPublicUrl(String appPublicUrl)
|
||||
{
|
||||
this.appPublicUrl = appPublicUrl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appPublicUrl.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withAppPublicUrl(String appPublicUrl)
|
||||
{
|
||||
this.appPublicUrl = appPublicUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for authentikPublicUrl.
|
||||
*******************************************************************************/
|
||||
public String getAuthentikPublicUrl()
|
||||
{
|
||||
return (authentikPublicUrl == null ? getBaseUrl() : resolve(authentikPublicUrl));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for authentikPublicUrl.
|
||||
*******************************************************************************/
|
||||
public void setAuthentikPublicUrl(String authentikPublicUrl)
|
||||
{
|
||||
this.authentikPublicUrl = authentikPublicUrl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for authentikPublicUrl.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withAuthentikPublicUrl(String authentikPublicUrl)
|
||||
{
|
||||
this.authentikPublicUrl = authentikPublicUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appClientId.
|
||||
*******************************************************************************/
|
||||
public String getAppClientId()
|
||||
{
|
||||
return (resolve(appClientId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for appClientId.
|
||||
*******************************************************************************/
|
||||
public void setAppClientId(String appClientId)
|
||||
{
|
||||
this.appClientId = appClientId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appClientId.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withAppClientId(String appClientId)
|
||||
{
|
||||
this.appClientId = appClientId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenUnusedDays.
|
||||
*******************************************************************************/
|
||||
public Integer getTokenUnusedDays()
|
||||
{
|
||||
return (tokenUnusedDays);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tokenUnusedDays.
|
||||
*******************************************************************************/
|
||||
public void setTokenUnusedDays(Integer tokenUnusedDays)
|
||||
{
|
||||
this.tokenUnusedDays = tokenUnusedDays;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tokenUnusedDays.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenUnusedDays(Integer tokenUnusedDays)
|
||||
{
|
||||
this.tokenUnusedDays = tokenUnusedDays;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for serviceAccountTokenLifetime.
|
||||
*******************************************************************************/
|
||||
public String getServiceAccountTokenLifetime()
|
||||
{
|
||||
return (serviceAccountTokenLifetime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for serviceAccountTokenLifetime.
|
||||
*******************************************************************************/
|
||||
public void setServiceAccountTokenLifetime(String serviceAccountTokenLifetime)
|
||||
{
|
||||
this.serviceAccountTokenLifetime = serviceAccountTokenLifetime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for serviceAccountTokenLifetime.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withServiceAccountTokenLifetime(String serviceAccountTokenLifetime)
|
||||
{
|
||||
this.serviceAccountTokenLifetime = serviceAccountTokenLifetime;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for subjectTokenSource.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource getSubjectTokenSource()
|
||||
{
|
||||
return (subjectTokenSource);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for subjectTokenSource.
|
||||
*******************************************************************************/
|
||||
public void setSubjectTokenSource(com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource subjectTokenSource)
|
||||
{
|
||||
this.subjectTokenSource = subjectTokenSource;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for subjectTokenSource.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withSubjectTokenSource(com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource subjectTokenSource)
|
||||
{
|
||||
this.subjectTokenSource = subjectTokenSource;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Signed JWT claim identifying the Authentik token.
|
||||
*******************************************************************************/
|
||||
public String getTokenScopeClaim()
|
||||
{
|
||||
return (tokenScopeClaim);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the host's signed token claim name.
|
||||
*******************************************************************************/
|
||||
public void setTokenScopeClaim(String value)
|
||||
{
|
||||
tokenScopeClaim = value;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent signed token claim name.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenScopeClaim(String value)
|
||||
{
|
||||
tokenScopeClaim = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional scheduler; otherwise use the instance's first scheduler.
|
||||
*******************************************************************************/
|
||||
public String getTokenReminderSchedulerName()
|
||||
{
|
||||
return (tokenReminderSchedulerName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Select a scheduler when the host defines several.
|
||||
*******************************************************************************/
|
||||
public void setTokenReminderSchedulerName(String value)
|
||||
{
|
||||
tokenReminderSchedulerName = value;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent scheduler selection.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenReminderSchedulerName(String value)
|
||||
{
|
||||
tokenReminderSchedulerName = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -46,10 +46,13 @@ public class AuthentikRuntime
|
||||
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
|
||||
private final Memoization<String, String> responseCache;
|
||||
private final Memoization<String, GroupGraph> groupGraphCache;
|
||||
private final Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> tokenScopeCache = new Memoization<>(Duration.ofSeconds(60));
|
||||
private final Memoization<String, Map<String, java.time.Instant>> tokenActivityCache = new Memoization<>(Duration.ofMinutes(5));
|
||||
private volatile java.util.Set<String> permissionCatalog;
|
||||
private volatile PolicyCatalog policyCatalog;
|
||||
private volatile AccessComparison.State mapperState;
|
||||
private volatile boolean mapperInvoked;
|
||||
private com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink tokenAlerts = new com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts()::send;
|
||||
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
@@ -130,6 +133,8 @@ public class AuthentikRuntime
|
||||
public void invalidateCache()
|
||||
{
|
||||
responseCache.clear();
|
||||
tokenScopeCache.clear();
|
||||
tokenActivityCache.clear();
|
||||
groupGraphCache.clear();
|
||||
}
|
||||
|
||||
@@ -280,4 +285,37 @@ public class AuthentikRuntime
|
||||
mapperState = null;
|
||||
}
|
||||
}
|
||||
/*******************************************************************************
|
||||
** Token claims are cached independently of the general list TTL.
|
||||
*******************************************************************************/
|
||||
public Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> getTokenScopeCache()
|
||||
{
|
||||
return (tokenScopeCache);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Login-event aggregation is shared by every token record in this runtime.
|
||||
*******************************************************************************/
|
||||
public Memoization<String, Map<String, java.time.Instant>> getTokenActivityCache()
|
||||
{
|
||||
return (tokenActivityCache);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional Alerts delivery, replaceable by a host or a capturing test sink.
|
||||
*******************************************************************************/
|
||||
public com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink getTokenAlerts()
|
||||
{
|
||||
return (tokenAlerts);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Install a sink for this runtime only.
|
||||
*******************************************************************************/
|
||||
public AuthentikRuntime withTokenAlerts(com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink sink)
|
||||
{
|
||||
tokenAlerts = java.util.Objects.requireNonNull(sink);
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,6 +27,13 @@ import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
*******************************************************************************/
|
||||
public enum AdminAction
|
||||
{
|
||||
// Null permission means self-service: AuthentikTokens verifies the live owner.
|
||||
// Processes add no permission beyond their table; cross-owner actions require TOKENS_MANAGE.
|
||||
CREATE_TOKEN("Create token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
|
||||
ROTATE_TOKEN("Rotate token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
|
||||
REVOKE_TOKEN("Revoke token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
|
||||
EXTEND_TOKEN("Extend expiry", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
|
||||
CREATE_SERVICE_ACCOUNT("Create service account", "user", IdentityPermission.TOKENS_MANAGE, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
|
||||
CREATE_USER("Create user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
|
||||
INVITE_USER("Invite user", "invitation", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
|
||||
EDIT_USER("Edit user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
|
||||
|
||||
@@ -108,6 +108,7 @@ public class AuthentikAdmin
|
||||
private final AuthentikQBitConfig config;
|
||||
private final AuthentikGuard guard;
|
||||
private final AdminActionAuditor auditor;
|
||||
private final AuthentikTokens tokens;
|
||||
|
||||
private Operator operator;
|
||||
private GroupGraph graph;
|
||||
@@ -141,8 +142,150 @@ public class AuthentikAdmin
|
||||
** @param message what happened, for the operator
|
||||
** @param eventPk the Authentik audit event, when posted
|
||||
*******************************************************************************/
|
||||
public record AdminResult(boolean ok, boolean pending, boolean noChange, String message, String eventPk)
|
||||
public static final class AdminResult
|
||||
{
|
||||
private final boolean ok;
|
||||
private final boolean pending;
|
||||
private final boolean noChange;
|
||||
private final String message;
|
||||
private final String eventPk;
|
||||
private transient OneTimeKey secret;
|
||||
private transient SecretCleanup cleanup;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a result without a secret.
|
||||
*******************************************************************************/
|
||||
public AdminResult(boolean ok, boolean pending, boolean noChange, String message, String eventPk)
|
||||
{
|
||||
this.ok = ok;
|
||||
this.pending = pending;
|
||||
this.noChange = noChange;
|
||||
this.message = message;
|
||||
this.eventPk = eventPk;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for ok.
|
||||
*******************************************************************************/
|
||||
public boolean ok()
|
||||
{
|
||||
return (ok);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for pending.
|
||||
*******************************************************************************/
|
||||
public boolean pending()
|
||||
{
|
||||
return (pending);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for noChange.
|
||||
*******************************************************************************/
|
||||
public boolean noChange()
|
||||
{
|
||||
return (noChange);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for message.
|
||||
*******************************************************************************/
|
||||
public String message()
|
||||
{
|
||||
return (message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for eventPk.
|
||||
*******************************************************************************/
|
||||
public String eventPk()
|
||||
{
|
||||
return (eventPk);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Attach a response-only secret and its emergency cleanup.
|
||||
*******************************************************************************/
|
||||
public AdminResult withSecret(OneTimeKey key, SecretCleanup cleanup)
|
||||
{
|
||||
this.secret = key;
|
||||
this.cleanup = cleanup;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Take the secret exactly once; no serializer getter is exposed.
|
||||
*******************************************************************************/
|
||||
public OneTimeKey takeSecret()
|
||||
{
|
||||
OneTimeKey key = secret;
|
||||
secret = null;
|
||||
return (key);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Revoke a newly issued credential when response delivery fails.
|
||||
*******************************************************************************/
|
||||
public void discardSecret() throws QException
|
||||
{
|
||||
if(cleanup != null)
|
||||
{
|
||||
cleanup.run();
|
||||
cleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop the action-scoped cleanup (which may hold an exchanged bearer).
|
||||
*******************************************************************************/
|
||||
public void delivered()
|
||||
{
|
||||
cleanup = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No secret or usage text in diagnostics.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("AdminResult{ok=" + ok + ", pending=" + pending + ", message=" + message + "}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Action-local cleanup, never serialized or retained in process state.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface SecretCleanup
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Discard the credential after a delivery failure.
|
||||
*******************************************************************************/
|
||||
void run() throws QException;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +300,7 @@ public class AuthentikAdmin
|
||||
this.config = runtime.getConfig();
|
||||
this.guard = runtime.getGuard();
|
||||
this.auditor = new AdminActionAuditor(config);
|
||||
this.tokens = new AuthentikTokens(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -272,6 +416,7 @@ public class AuthentikAdmin
|
||||
|
||||
return switch(action)
|
||||
{
|
||||
case CREATE_TOKEN, ROTATE_TOKEN, REVOKE_TOKEN, EXTEND_TOKEN, CREATE_SERVICE_ACCOUNT -> tokens.plan(plan);
|
||||
case CREATE_USER -> planCreateUser(plan, request);
|
||||
case INVITE_USER -> planInvite(plan, request);
|
||||
case CANCEL_INVITATION -> planCancelInvitation(plan, request);
|
||||
@@ -299,7 +444,12 @@ public class AuthentikAdmin
|
||||
*******************************************************************************/
|
||||
public AdminResult execute(AdminRequest request) throws QException
|
||||
{
|
||||
if(request.action().getFeature() == com.kingsrook.qbits.authentik.AuthentikFeature.TOKENS)
|
||||
{
|
||||
request.params().remove("approved");
|
||||
}
|
||||
runtime.invalidateCache();
|
||||
operator = null;
|
||||
graph = null;
|
||||
membersGraph = null;
|
||||
userFacts = null;
|
||||
@@ -314,6 +464,16 @@ public class AuthentikAdmin
|
||||
*******************************************************************************/
|
||||
public AdminResult execute(AdminPlan plan, String approver) throws QException
|
||||
{
|
||||
if(plan.getRequest().action().getFeature() == com.kingsrook.qbits.authentik.AuthentikFeature.TOKENS)
|
||||
{
|
||||
if(approver == null)
|
||||
{
|
||||
plan.getRequest().params().remove("approved");
|
||||
}
|
||||
operator = null;
|
||||
graph = null;
|
||||
plan = plan(plan.getRequest());
|
||||
}
|
||||
String correlationId = UUID.randomUUID().toString();
|
||||
String operatorName = guard.operatorEmail();
|
||||
AdminAction action = plan.getRequest().action();
|
||||
@@ -335,9 +495,18 @@ public class AuthentikAdmin
|
||||
Serializable auditId = auditor.begin(plan, correlationId, operatorName, AuthentikLocalTables.ActionStatus.RUNNING, approver);
|
||||
|
||||
String message;
|
||||
AdminResult tokenResult = null;
|
||||
try
|
||||
{
|
||||
message = perform(plan, authorization, correlationId);
|
||||
if(action.getFeature() == com.kingsrook.qbits.authentik.AuthentikFeature.TOKENS)
|
||||
{
|
||||
tokenResult = tokens.perform(plan, authorization);
|
||||
message = tokenResult.message();
|
||||
}
|
||||
else
|
||||
{
|
||||
message = perform(plan, authorization, correlationId);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -363,7 +532,13 @@ public class AuthentikAdmin
|
||||
{
|
||||
LOG.warn("Could not mark the audit row succeeded", logPair("id", auditId));
|
||||
}
|
||||
return (new AdminResult(true, false, false, message, eventPk));
|
||||
AdminResult result = new AdminResult(true, false, false, message, eventPk);
|
||||
if(tokenResult != null)
|
||||
{
|
||||
result.withSecret(tokenResult.takeSecret(), tokenResult.cleanup);
|
||||
tokenResult.delivered();
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
@@ -1679,7 +1854,7 @@ public class AuthentikAdmin
|
||||
** group, and the operator holds everything it confers (the subset rule),
|
||||
** including app access. Returns the refusal text, or null.
|
||||
*******************************************************************************/
|
||||
private String checkGroupGrantable(GroupGraph.Node node) throws QException
|
||||
String checkGroupGrantable(GroupGraph.Node node) throws QException
|
||||
{
|
||||
if(graph().isSuperuserInherited(node.pk()))
|
||||
{
|
||||
|
||||
@@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
*******************************************************************************/
|
||||
public final class AuthentikLocalTables
|
||||
{
|
||||
public static final String TOKEN_SCOPE = "token_scope";
|
||||
public static final String ADMIN_ACTION = "admin_action";
|
||||
public static final String APPROVAL_REQUEST = "approval_request";
|
||||
public static final String POLICY = "policy";
|
||||
@@ -269,4 +270,40 @@ public final class AuthentikLocalTables
|
||||
return (new QFieldMetaData(name, type).withIsEditable(false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Non-secret scope metadata only (DESIGN section 13.5).
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData tokenScope(AuthentikQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, TOKEN_SCOPE, "Token Scopes", "key", IdentityPermission.TOKENS_MANAGE)
|
||||
.withPermissionRules(com.kingsrook.qbits.authentik.security.TokenReadPermission.rules())
|
||||
.withRecordLabelFormatAndFields("%s", "tokenIdentifier")
|
||||
.withUniqueKey(new UniqueKey("tokenIdentifier"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
|
||||
.withField(ro("tokenIdentifier", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(ro("ownerUsername", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(ro("ownerPk", QFieldType.INTEGER))
|
||||
.withField(ro("readOnly", QFieldType.BOOLEAN))
|
||||
.withField(ro("createdBy", QFieldType.STRING).withMaxLength(255));
|
||||
table.getFields().remove("createDate");
|
||||
table.getFields().remove("modifyDate");
|
||||
for(String name : List.of("apps", "tables", "processes", "reports", "widgets", "operationIds"))
|
||||
{
|
||||
table.withField(ro(name, QFieldType.TEXT));
|
||||
}
|
||||
for(String name : List.of("createdAt", "rotatedAt"))
|
||||
{
|
||||
table.withField(ro(name, QFieldType.DATE_TIME));
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// explicit column names: a camel-to-snake mapping would give //
|
||||
// last_reminder7d, while docs/schema.sql (and hosts) use _7d / _1d //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
table.withField(ro("lastReminder7d", QFieldType.DATE_TIME).withBackendName("last_reminder_7d"));
|
||||
table.withField(ro("lastReminder1d", QFieldType.DATE_TIME).withBackendName("last_reminder_1d"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional Alerts bridge. The qbit compiles on RC.2 without either Alerts API.
|
||||
** No credentials enter this interface. Hosts and tests can supply a sink.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokenAlerts
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenAlerts.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** Safe notification envelope. A null recipient denotes the admin audience.
|
||||
*******************************************************************************/
|
||||
public record Notice(String key, String severity, String recipient, String message)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return true only when delivery was accepted, so absence never dedupes.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Sink
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Accept a safe notification, returning false when delivery is unavailable.
|
||||
*******************************************************************************/
|
||||
boolean send(Notice notice);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor for the reflection adapter.
|
||||
*******************************************************************************/
|
||||
public AuthentikTokenAlerts()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard incidents are raised through core QAlerts; owner reminders through
|
||||
** core QAlerts.notify (qqq#1125, QQQ 4.1.0-RC.6+). Never another qbit's API.
|
||||
*******************************************************************************/
|
||||
public boolean send(Notice notice)
|
||||
{
|
||||
try
|
||||
{
|
||||
if("high".equals(notice.severity()))
|
||||
{
|
||||
QAlerts.raise(new AlertSignal("authentik.token.guard-bypass")
|
||||
.withDedupKey(notice.key())
|
||||
.withTitle("Access token guard bypass")
|
||||
.withSummary(notice.message())
|
||||
.withSeverity(AlertSeverity.CRITICAL));
|
||||
return (true);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// owner reminders: core's notification SPI (bell and email via //
|
||||
// whichever alerts producer the host installed; qqq#1125) //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
QAlerts.notify(new NotificationSignal("Access token expiry reminder")
|
||||
.withBody(notice.message())
|
||||
.withSourceName("authentik")
|
||||
.withSourceKey(notice.key())
|
||||
.withAudience(NotificationAudience.user(notice.recipient())));
|
||||
return (true);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// never fail the caller; never log the notice payload //
|
||||
///////////////////////////////////////////////////////////
|
||||
LOG.info("Access-token notification delivery failed; notification skipped.");
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker;
|
||||
import com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access-token actions (DESIGN section 13). All target checks run at Review and
|
||||
** again at execution; credentials exist only in the action and one-time result.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokens
|
||||
{
|
||||
public static final String DELIVERY_UNAVAILABLE = "This QQQ version cannot show a key once; upgrade QQQ (4.1.0-RC.7+)";
|
||||
public static final String TOKEN_MOVED = "Token moved: owner or intent changed after the update; investigate in Authentik.";
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikTokens.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private final AuthentikAdmin admin;
|
||||
private final AuthentikActionUtil util;
|
||||
private final AuthentikQBitConfig config;
|
||||
private final TokenScopeStore scopes;
|
||||
|
||||
/*******************************************************************************
|
||||
** Delegate with the existing admin's live operator and subset checks.
|
||||
*******************************************************************************/
|
||||
public AuthentikTokens(AuthentikAdmin admin)
|
||||
{
|
||||
this.admin = admin;
|
||||
util = admin.getUtil();
|
||||
config = util.getRuntime().getConfig();
|
||||
scopes = new TokenScopeStore(config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review all constraints, without exchanging or creating a credential.
|
||||
*******************************************************************************/
|
||||
public AdminPlan plan(AdminPlan plan) throws QException
|
||||
{
|
||||
AdminRequest request = plan.getRequest();
|
||||
AdminAction action = request.action();
|
||||
util.getRuntime().invalidateCache();
|
||||
try
|
||||
{
|
||||
if(action == AdminAction.CREATE_SERVICE_ACCOUNT)
|
||||
{
|
||||
if(!request.str("name").matches("[A-Za-z0-9][A-Za-z0-9._-]{0,99}"))
|
||||
{
|
||||
return (plan.refuse("A service-account name (letters, numbers, dot, dash or underscore) is required."));
|
||||
}
|
||||
if(admin.userPkByUsername(request.str("name")) != null)
|
||||
{
|
||||
return (plan.refuse("That username already exists."));
|
||||
}
|
||||
JSONObject candidate = new JSONObject().put("username", request.str("name")).put("type", "service_account");
|
||||
checkProtected(candidate);
|
||||
expiry(request, true);
|
||||
if(!TokenKeyDelivery.get().available())
|
||||
{
|
||||
return (plan.refuse(DELIVERY_UNAVAILABLE));
|
||||
}
|
||||
for(GroupGraph.Node group : serviceGroups(request))
|
||||
{
|
||||
String problem = admin.checkGroupGrantable(group);
|
||||
if(problem != null)
|
||||
{
|
||||
return (plan.refuse(problem));
|
||||
}
|
||||
checkProtectedGroup(group);
|
||||
}
|
||||
plan.withTargetLabel(request.str("name"));
|
||||
return (approval(plan));
|
||||
}
|
||||
|
||||
JSONObject token = action == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
|
||||
Integer operatorPk = admin.operator().pk();
|
||||
int ownerPk = token == null ? (operatorPk == null ? -1 : operatorPk) : ownerPk(token);
|
||||
if(action == AdminAction.CREATE_TOKEN && (!request.targetKey().isBlank() && !request.targetKey().equals(String.valueOf(operatorPk))))
|
||||
{
|
||||
return (plan.refuse("Create Token only creates a token for you."));
|
||||
}
|
||||
if(ownerPk < 0)
|
||||
{
|
||||
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
|
||||
}
|
||||
JSONObject owner = owner(ownerPk);
|
||||
boolean own = Objects.equals(operatorPk, ownerPk);
|
||||
boolean service = "service_account".equals(owner.optString("type"));
|
||||
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
|
||||
if(service && action == AdminAction.CREATE_TOKEN)
|
||||
{
|
||||
return (plan.refuse("Service accounts have one app password. Use Rotate Token or Create Service Account."));
|
||||
}
|
||||
if(!own && !manager || service && !manager)
|
||||
{
|
||||
return (plan.refuse("You may only manage your own tokens; service accounts need authentik.tokens.manage."));
|
||||
}
|
||||
if(!own && !service && action != AdminAction.REVOKE_TOKEN)
|
||||
{
|
||||
return (plan.refuse("You cannot create, rotate or extend another person's token."));
|
||||
}
|
||||
checkProtected(owner);
|
||||
plan.withTargetLabel(token == null ? owner.getString("username") : request.targetKey());
|
||||
if(action != AdminAction.REVOKE_TOKEN && token != null && !"app_password".equals(token.optString("intent")))
|
||||
{
|
||||
return (plan.refuse("Only app-password tokens can be rotated or extended here. API tokens can be revoked."));
|
||||
}
|
||||
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.EXTEND_TOKEN)
|
||||
{
|
||||
expiry(request, service);
|
||||
}
|
||||
if(action == AdminAction.ROTATE_TOKEN)
|
||||
{
|
||||
checkExpiry(token, service);
|
||||
}
|
||||
if(action == AdminAction.CREATE_TOKEN)
|
||||
{
|
||||
if(request.str("description").isBlank())
|
||||
{
|
||||
return (plan.refuse("A description is required."));
|
||||
}
|
||||
if(!Boolean.TRUE.equals(config.getTokenScopesEnabled()) && TokenScopeStore.SCOPE_FIELDS.stream().anyMatch(request.params()::containsKey))
|
||||
{
|
||||
return (plan.refuse("Scopes are not enabled on this app yet."));
|
||||
}
|
||||
}
|
||||
if(action == AdminAction.CREATE_TOKEN && Boolean.TRUE.equals(config.getTokenScopesEnabled()))
|
||||
{
|
||||
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.validate(request);
|
||||
QRecord scope = new QRecord();
|
||||
request.params().forEach(scope::setValue);
|
||||
plan.withChange(null, TokenScopeStore.summary(scope));
|
||||
}
|
||||
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
|
||||
{
|
||||
if(!TokenKeyDelivery.get().available())
|
||||
{
|
||||
return (plan.refuse(DELIVERY_UNAVAILABLE));
|
||||
}
|
||||
}
|
||||
if(own && !service)
|
||||
{
|
||||
if(blank(config.getTokenBrokerClientId()) || blank(config.getTokenBrokerClientSecret()))
|
||||
{
|
||||
return (plan.refuse("Self-service tokens need a configured Authentik token broker."));
|
||||
}
|
||||
if(config.getSubjectTokenSource() == null || blank(config.getSubjectTokenSource().subjectToken(QContext.getQSession())))
|
||||
{
|
||||
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
|
||||
}
|
||||
}
|
||||
return (service && action == AdminAction.ROTATE_TOKEN ? approval(plan) : plan);
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
return (plan.refuse(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional approval for new service accounts and service-account rotation.
|
||||
*******************************************************************************/
|
||||
private AdminPlan approval(AdminPlan plan)
|
||||
{
|
||||
if(Boolean.TRUE.equals(config.getTokenTwoPersonApproval()) && !plan.getRequest().bool("approved"))
|
||||
{
|
||||
plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("Service-account tokens require a second person's approval.");
|
||||
}
|
||||
return (plan);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve requested groups with the same subset rules used for membership.
|
||||
*******************************************************************************/
|
||||
private List<GroupGraph.Node> serviceGroups(AdminRequest request) throws QException
|
||||
{
|
||||
Set<String> names = new LinkedHashSet<>(AuthentikSafety.parseList(request.str("groups")));
|
||||
GroupGraph graph = util.groupGraph();
|
||||
List<GroupGraph.Node> groups = new ArrayList<>();
|
||||
for(String name : names)
|
||||
{
|
||||
GroupGraph.Node node = graph.byName(name);
|
||||
if(node == null)
|
||||
{
|
||||
throw (new QUserFacingException("Group not found: " + name));
|
||||
}
|
||||
groups.add(node);
|
||||
}
|
||||
return (groups);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Required expiry measured from now, with separate human and service caps.
|
||||
*******************************************************************************/
|
||||
private Instant expiry(AdminRequest request, boolean service) throws QUserFacingException
|
||||
{
|
||||
int maximum = service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser();
|
||||
try
|
||||
{
|
||||
int days = Integer.parseInt(request.str("expiresInDays"));
|
||||
if(days >= 1 && days <= maximum)
|
||||
{
|
||||
return (Instant.now().plus(days, ChronoUnit.DAYS));
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException ignored)
|
||||
{
|
||||
// Missing, fractional and malformed values are all refused.
|
||||
}
|
||||
throw (new QUserFacingException("Expiry is required: expiresInDays must be between 1 and " + maximum + "."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rotation preserves a valid existing expiry, never a non-expiring token.
|
||||
*******************************************************************************/
|
||||
private Instant checkExpiry(JSONObject token, boolean service) throws QUserFacingException
|
||||
{
|
||||
try
|
||||
{
|
||||
Instant expires = Instant.parse(token.getString("expires"));
|
||||
if(token.optBoolean("expiring") && expires.isAfter(Instant.now())
|
||||
&& !expires.isAfter(Instant.now().plus(service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser(), ChronoUnit.DAYS)))
|
||||
{
|
||||
return (expires);
|
||||
}
|
||||
}
|
||||
catch(RuntimeException ignored)
|
||||
{
|
||||
// No expiry is never acceptable for a new credential.
|
||||
}
|
||||
throw (new QUserFacingException("Token expiry is missing, expired or exceeds the maximum lifetime."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Built-in protected groups cannot be removed by configuration.
|
||||
*******************************************************************************/
|
||||
private void checkProtectedGroup(GroupGraph.Node group) throws QException
|
||||
{
|
||||
Set<String> protectedNames = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
|
||||
protectedNames.addAll(List.of("authentik Admins", "identity-admins"));
|
||||
if(config.getTokenProtectedGroups() != null)
|
||||
{
|
||||
protectedNames.addAll(config.getTokenProtectedGroups());
|
||||
}
|
||||
for(GroupGraph.Node ancestor : util.groupGraph().selfAndAncestors(group.pk()))
|
||||
{
|
||||
if(ancestor.ownSuperuser() || protectedNames.contains(ancestor.name()))
|
||||
{
|
||||
throw (protectedAccount("member of " + ancestor.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse account protection, adding inherited protected-group membership.
|
||||
*******************************************************************************/
|
||||
private void checkProtected(JSONObject owner) throws QException
|
||||
{
|
||||
AuthentikGuard.ProtectionResult protection = util.getRuntime().getGuard().protection(owner);
|
||||
if(protection.isProtected())
|
||||
{
|
||||
throw (protectedAccount(protection.reason()));
|
||||
}
|
||||
JSONArray groups = owner.optJSONArray("groups_obj");
|
||||
for(int i = 0; groups != null && i < groups.length(); i++)
|
||||
{
|
||||
GroupGraph.Node node = util.groupGraph().byPk(groups.getJSONObject(i).getString("pk"));
|
||||
if(node == null)
|
||||
{
|
||||
throw (protectedAccount("group protection could not be verified"));
|
||||
}
|
||||
checkProtectedGroup(node);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Consistent protected-owner refusal, including service-account set_key.
|
||||
*******************************************************************************/
|
||||
private static QUserFacingException protectedAccount(String reason)
|
||||
{
|
||||
return (new QUserFacingException("Protected account (" + reason + "): tokens for it are managed in the Authentik admin UI."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fetch token metadata without retaining a bearer or key.
|
||||
*******************************************************************************/
|
||||
private JSONObject token(String identifier) throws QException
|
||||
{
|
||||
util.getRuntime().invalidateCache();
|
||||
JSONObject token = util.getApiJsonOrNull(path(identifier));
|
||||
if(token == null)
|
||||
{
|
||||
throw (new QUserFacingException("That token no longer exists."));
|
||||
}
|
||||
return (token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Token owner pk from the pinned schema.
|
||||
*******************************************************************************/
|
||||
private static int ownerPk(JSONObject token)
|
||||
{
|
||||
return (token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fetch live owner facts, refusing missing owners.
|
||||
*******************************************************************************/
|
||||
private JSONObject owner(int pk) throws QException
|
||||
{
|
||||
util.getRuntime().invalidateCache();
|
||||
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
|
||||
if(owner == null)
|
||||
{
|
||||
throw (new QUserFacingException("The token's owner no longer exists."));
|
||||
}
|
||||
return (owner);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validated token URL component.
|
||||
*******************************************************************************/
|
||||
private static String path(String identifier) throws QException
|
||||
{
|
||||
return ("/core/tokens/" + AuthentikAdmin.safeKey(identifier) + "/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a resolved config string is absent.
|
||||
*******************************************************************************/
|
||||
private static boolean blank(String value)
|
||||
{
|
||||
return (value == null || value.isBlank());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute only after auditing has begun; return the key outside audit data.
|
||||
*******************************************************************************/
|
||||
public AuthentikAdmin.AdminResult perform(AdminPlan plan, WriteAuthorization auth) throws QException
|
||||
{
|
||||
AdminRequest request = plan.getRequest();
|
||||
if((request.action() == AdminAction.CREATE_TOKEN || request.action() == AdminAction.ROTATE_TOKEN || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
|
||||
&& !TokenKeyDelivery.get().available())
|
||||
{
|
||||
throw (new QUserFacingException(DELIVERY_UNAVAILABLE));
|
||||
}
|
||||
if(request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
|
||||
{
|
||||
return (createServiceAccount(plan, auth));
|
||||
}
|
||||
JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
|
||||
JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before));
|
||||
checkProtectedAfterRead(owner);
|
||||
boolean service = "service_account".equals(owner.optString("type"));
|
||||
boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk"));
|
||||
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
|
||||
if((!own || service) && !manager || !own && !service && request.action() != AdminAction.REVOKE_TOKEN)
|
||||
{
|
||||
throw (new QUserFacingException("Token ownership changed; review again."));
|
||||
}
|
||||
String bearer = own && !service ? new AuthentikTokenBroker().exchange(util, auth) : config.getResolvedApiToken();
|
||||
switch(request.action())
|
||||
{
|
||||
case CREATE_TOKEN:
|
||||
return (createPersonal(request, owner, expiry(request, false), null, bearer, auth));
|
||||
case REVOKE_TOKEN:
|
||||
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
|
||||
scopes.delete(request.targetKey());
|
||||
return (success("Token revoked."));
|
||||
case EXTEND_TOKEN:
|
||||
util.writeAs(bearer, auth, "PATCH", path(request.targetKey()), new JSONObject().put("intent", before.getString("intent"))
|
||||
.put("user", owner.getInt("pk")).put("expires", expiry(request, service).toString()).put("expiring", true));
|
||||
raceGuard(before, token(request.targetKey()));
|
||||
scopes.resetReminders(request.targetKey());
|
||||
return (success("Token expiry updated."));
|
||||
case ROTATE_TOKEN:
|
||||
if(!service)
|
||||
{
|
||||
return (createPersonal(request, owner, checkExpiry(before, false), before, bearer, auth));
|
||||
}
|
||||
String scope = scopeSummary(request.targetKey());
|
||||
Instant expires = checkExpiry(before, true);
|
||||
byte[] random = new byte[48];
|
||||
RANDOM.nextBytes(random);
|
||||
String key = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
util.writeAs(bearer, auth, "POST", path(request.targetKey()) + "set_key/", new JSONObject().put("key", key));
|
||||
JSONObject after = token(request.targetKey());
|
||||
raceGuard(before, after);
|
||||
return (success("Service-account token rotated. The old key no longer works.")
|
||||
.withSecret(oneTime(request.targetKey(), key, owner, expires, scope), () ->
|
||||
{
|
||||
throw (new QUserFacingException("The old key no longer works and the new key is lost. Rotate it in the Authentik admin UI."));
|
||||
}));
|
||||
default:
|
||||
throw (new QException("Unsupported token action"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard owner and intent after every PATCH or set_key, with a safe warning.
|
||||
*******************************************************************************/
|
||||
private void raceGuard(JSONObject before, JSONObject after) throws QException
|
||||
{
|
||||
if(ownerPk(before) != ownerPk(after) || !before.getString("intent").equals(after.getString("intent")))
|
||||
{
|
||||
alertGuardBypass();
|
||||
LOG.warn(TOKEN_MOVED);
|
||||
throw (new QUserFacingException(TOKEN_MOVED));
|
||||
}
|
||||
checkProtectedAfterRead(owner(ownerPk(after)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create as the operator, fetch their key, and optionally replace the old id.
|
||||
** On partial failure discard the new credential; the old one stays usable.
|
||||
*******************************************************************************/
|
||||
private AuthentikAdmin.AdminResult createPersonal(AdminRequest request, JSONObject owner, Instant expires, JSONObject old,
|
||||
String bearer, WriteAuthorization auth) throws QException
|
||||
{
|
||||
String identifier = identifier(owner.getString("username"));
|
||||
String description = old == null ? request.str("description") : old.optString("description");
|
||||
util.writeAs(bearer, auth, "POST", "/core/tokens/", new JSONObject().put("identifier", identifier).put("intent", "app_password")
|
||||
.put("description", description).put("expiring", true).put("expires", expires.toString()));
|
||||
try
|
||||
{
|
||||
JSONObject created = util.getAs(bearer, auth, path(identifier));
|
||||
checkProtectedAfterRead(owner(ownerPk(created)));
|
||||
if(ownerPk(created) != owner.getInt("pk") || !"app_password".equals(created.optString("intent")))
|
||||
{
|
||||
throw (new QUserFacingException(TOKEN_MOVED));
|
||||
}
|
||||
String key = util.getAs(bearer, auth, path(identifier) + "view_key/").getString("key");
|
||||
Instant actualExpiry = checkExpiry(created, false);
|
||||
if(old == null)
|
||||
{
|
||||
scopes.create(identifier, owner, request, admin.operator().email());
|
||||
}
|
||||
else
|
||||
{
|
||||
QRecord previous = scopes.find(request.targetKey());
|
||||
if(previous == null)
|
||||
{
|
||||
scopes.create(identifier, owner, AdminRequest.of(request.action(), identifier, request.reason()), admin.operator().email());
|
||||
}
|
||||
else
|
||||
{
|
||||
scopes.move(request.targetKey(), identifier);
|
||||
}
|
||||
}
|
||||
OneTimeKey secret = oneTime(identifier, key, owner, actualExpiry, scopeSummary(identifier));
|
||||
if(old != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
scopes.move(identifier, request.targetKey());
|
||||
throw (new QUserFacingException("Rotation failed: the old token could not be deleted and remains listed."));
|
||||
}
|
||||
}
|
||||
return (success(old == null ? "Token created. The key is shown once." : "Token rotated. The old token was deleted.")
|
||||
.withSecret(secret, () -> revokeIssued(identifier, bearer, auth)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
revokeIssued(identifier, bearer, auth);
|
||||
if(e instanceof QUserFacingException facing)
|
||||
{
|
||||
throw (facing);
|
||||
}
|
||||
throw (new QUserFacingException("The new token could not be completed and was revoked."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the single service-account app password and grant checked groups.
|
||||
*******************************************************************************/
|
||||
private AuthentikAdmin.AdminResult createServiceAccount(AdminPlan plan, WriteAuthorization auth) throws QException
|
||||
{
|
||||
AdminRequest request = plan.getRequest();
|
||||
String identifier = "service-account-" + request.str("name") + "-password";
|
||||
JSONObject created = util.write(auth, "POST", "/core/users/service_account/", new JSONObject().put("name", request.str("name"))
|
||||
.put("create_group", false).put("expiring", true).put("expires", expiry(request, true).toString()));
|
||||
try
|
||||
{
|
||||
Integer pk = created.has("user_pk") ? created.getInt("user_pk") : admin.userPkByUsername(created.optString("username", request.str("name")));
|
||||
if(pk == null)
|
||||
{
|
||||
throw (new QException("The service account could not be found."));
|
||||
}
|
||||
JSONObject current = owner(pk);
|
||||
JSONObject attributes = new JSONObject(current.optJSONObject("attributes") == null ? "{}" : current.getJSONObject("attributes").toString());
|
||||
attributes.put("goauthentik.io/user/token-maximum-lifetime", config.getServiceAccountTokenLifetime());
|
||||
util.write(auth, "PATCH", "/core/users/" + pk + "/", new JSONObject().put("attributes", attributes));
|
||||
for(GroupGraph.Node group : serviceGroups(request))
|
||||
{
|
||||
checkProtectedGroup(group);
|
||||
String problem = admin.checkGroupGrantable(group);
|
||||
if(problem != null)
|
||||
{
|
||||
throw (new QUserFacingException(problem));
|
||||
}
|
||||
util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk));
|
||||
}
|
||||
JSONObject owner = owner(pk);
|
||||
checkProtectedAfterRead(owner);
|
||||
JSONObject after = token(identifier);
|
||||
checkProtectedAfterRead(owner(ownerPk(after)));
|
||||
scopes.create(identifier, owner, request, admin.operator().email());
|
||||
return (success("Service account created. " + String.join(" ", plan.getNotes()))
|
||||
.withSecret(oneTime(identifier, created.getString("token"), owner, checkExpiry(after, true), "Owner permissions"),
|
||||
() -> revokeIssued(identifier, config.getResolvedApiToken(), auth)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
revokeIssued(identifier, config.getResolvedApiToken(), auth);
|
||||
throw (new QUserFacingException("The service account was created, but setup failed. Its token was revoked; finish setup in Authentik."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Emergency revocation for a newly created token whose key cannot be shown.
|
||||
*******************************************************************************/
|
||||
private void revokeIssued(String identifier, String bearer, WriteAuthorization auth) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
util.writeAs(bearer, auth, "DELETE", path(identifier), null);
|
||||
scopes.delete(identifier);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new QUserFacingException("The new key could not be delivered and token revocation failed. Revoke it in the Authentik admin UI."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scope summary contains metadata only.
|
||||
*******************************************************************************/
|
||||
private String scopeSummary(String identifier) throws QException
|
||||
{
|
||||
QRecord row = scopes.find(identifier);
|
||||
List<String> parts = new ArrayList<>();
|
||||
for(String field : TokenScopeStore.SCOPE_FIELDS)
|
||||
{
|
||||
if(row != null && row.getValue(field) != null)
|
||||
{
|
||||
parts.add(field + ": " + row.getValueString(field));
|
||||
}
|
||||
}
|
||||
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the isolated one-time object, including its response-only usage text.
|
||||
*******************************************************************************/
|
||||
private OneTimeKey oneTime(String identifier, String key, JSONObject owner, Instant expires, String scope)
|
||||
{
|
||||
return (new OneTimeKey(identifier, key, TokenUsageText.build(config, owner.getString("username"), key, expires, scope)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Non-secret result text.
|
||||
*******************************************************************************/
|
||||
private static AuthentikAdmin.AdminResult success(String message)
|
||||
{
|
||||
return (new AuthentikAdmin.AdminResult(true, false, false, message, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Collision-resistant identifier, independent of the secret value.
|
||||
*******************************************************************************/
|
||||
private static String identifier(String username)
|
||||
{
|
||||
String alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
StringBuilder suffix = new StringBuilder();
|
||||
for(int i = 0; i < 6; i++)
|
||||
{
|
||||
suffix.append(alphabet.charAt(RANDOM.nextInt(alphabet.length())));
|
||||
}
|
||||
String slug = username.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-]+", "-");
|
||||
return ("qqq-pat-" + slug + "-" + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC).format(Instant.now()) + "-" + suffix);
|
||||
}
|
||||
/*******************************************************************************
|
||||
** A protected owner observed after Review or a write is a guard incident.
|
||||
*******************************************************************************/
|
||||
private void checkProtectedAfterRead(JSONObject owner) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
checkProtected(owner);
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
alertGuardBypass();
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Safe incident payload: no response bodies, credentials or owner details.
|
||||
*******************************************************************************/
|
||||
private void alertGuardBypass()
|
||||
{
|
||||
try
|
||||
{
|
||||
util.getRuntime().getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null,
|
||||
"An access token changed owner or intent, or a protected owner was observed after Review. Investigate in Authentik."));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Access-token guard alert delivery failed.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
/*******************************************************************************
|
||||
** A response-only value. Deliberately not Serializable; never a process value.
|
||||
*******************************************************************************/
|
||||
public final class OneTimeKey
|
||||
{
|
||||
private final String label;
|
||||
private final String value;
|
||||
private final String usageText;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct the one-time response.
|
||||
*******************************************************************************/
|
||||
public OneTimeKey(String label, String value, String usageText)
|
||||
{
|
||||
this.label = label;
|
||||
this.value = value;
|
||||
this.usageText = usageText;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label.
|
||||
*******************************************************************************/
|
||||
public String label()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for value.
|
||||
*******************************************************************************/
|
||||
public String value()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for masked.
|
||||
*******************************************************************************/
|
||||
public String masked()
|
||||
{
|
||||
return ("\u2022\u2022\u2022\u2022" + value.substring(Math.max(0, value.length() - 4)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for usageText.
|
||||
*******************************************************************************/
|
||||
public String usageText()
|
||||
{
|
||||
return (usageText);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never render the key or its usage text.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("OneTimeKey{label=" + label + ", value=[one-time]}" );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Non-secret bookkeeping. No method accepts a key or a bearer.
|
||||
*******************************************************************************/
|
||||
public class TokenScopeStore
|
||||
{
|
||||
public static final List<String> SCOPE_FIELDS = List.of("readOnly", "apps", "tables", "processes", "reports", "widgets", "operationIds");
|
||||
private final AuthentikQBitConfig config;
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind to one qbit's local backend.
|
||||
*******************************************************************************/
|
||||
public TokenScopeStore(AuthentikQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Local table name.
|
||||
*******************************************************************************/
|
||||
private String table()
|
||||
{
|
||||
return (config.applyTablePrefix(AuthentikLocalTables.TOKEN_SCOPE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fetch the unique scope row as a trusted SYSTEM read (QueryInput default).
|
||||
*******************************************************************************/
|
||||
public QRecord find(String identifier) throws QException
|
||||
{
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput().withTableName(table())
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("tokenIdentifier", QCriteriaOperator.EQUALS, identifier)))).getRecords();
|
||||
return (rows.isEmpty() ? null : rows.get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record a new identifier, even when scopes are disabled.
|
||||
*******************************************************************************/
|
||||
public void create(String identifier, JSONObject owner, AdminRequest request, String operator) throws QException
|
||||
{
|
||||
QRecord row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
|
||||
.withValue("ownerUsername", owner.getString("username")).withValue("createdBy", operator).withValue("createdAt", Instant.now());
|
||||
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
|
||||
{
|
||||
for(String field : SCOPE_FIELDS)
|
||||
{
|
||||
if(request.params().containsKey(field))
|
||||
{
|
||||
row.setValue(field, request.params().get(field));
|
||||
}
|
||||
}
|
||||
}
|
||||
List<QRecord> rows = new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords();
|
||||
check(rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep all scope and reminder fields when rotating a personal credential.
|
||||
*******************************************************************************/
|
||||
public void move(String oldIdentifier, String newIdentifier) throws QException
|
||||
{
|
||||
QRecord row = find(oldIdentifier);
|
||||
if(row != null)
|
||||
{
|
||||
row.setValue("tokenIdentifier", newIdentifier);
|
||||
row.setValue("rotatedAt", Instant.now());
|
||||
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Revoke removes only the corresponding metadata row.
|
||||
*******************************************************************************/
|
||||
public void delete(String identifier) throws QException
|
||||
{
|
||||
QRecord row = find(identifier);
|
||||
if(row != null)
|
||||
{
|
||||
new DeleteAction().execute(new DeleteInput().withTableName(table()).withPrimaryKeys(List.of(row.getValue("id"))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail closed if local storage rejects the write.
|
||||
*******************************************************************************/
|
||||
private static void check(List<QRecord> rows) throws QException
|
||||
{
|
||||
if(rows.isEmpty() || !rows.get(0).getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Token scope metadata could not be saved."));
|
||||
}
|
||||
}
|
||||
/*******************************************************************************
|
||||
** Human-readable, non-secret scope for lists and Review.
|
||||
*******************************************************************************/
|
||||
public static String summary(QRecord row)
|
||||
{
|
||||
java.util.ArrayList<String> parts = new java.util.ArrayList<>();
|
||||
for(String field : SCOPE_FIELDS)
|
||||
{
|
||||
if(row != null && row.getValue(field) != null && !row.getValueString(field).isBlank())
|
||||
{
|
||||
parts.add(field + ": " + row.getValueString(field));
|
||||
}
|
||||
}
|
||||
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist dedupe metadata. External tokens get no invented creation date or
|
||||
** restrictions; their token row remains unscoped.
|
||||
*******************************************************************************/
|
||||
public void reminded(String identifier, JSONObject owner, String field, Instant at) throws QException
|
||||
{
|
||||
QRecord row = find(identifier);
|
||||
if(row == null)
|
||||
{
|
||||
row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
|
||||
.withValue("ownerUsername", owner.getString("username")).withValue(field, at);
|
||||
check(new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords());
|
||||
}
|
||||
else
|
||||
{
|
||||
row.setValue(field, at);
|
||||
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A new expiry starts a new set of reminder windows.
|
||||
*******************************************************************************/
|
||||
public void resetReminders(String identifier) throws QException
|
||||
{
|
||||
QRecord row = find(identifier);
|
||||
if(row != null)
|
||||
{
|
||||
row.setValue("lastReminder7d", null);
|
||||
row.setValue("lastReminder1d", null);
|
||||
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Usage instructions held exclusively inside the one-time response.
|
||||
*******************************************************************************/
|
||||
public final class TokenUsageText
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private TokenUsageText()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Generate shell-quoted exchange and application requests from public URLs.
|
||||
*******************************************************************************/
|
||||
public static String build(AuthentikQBitConfig config, String username, String key, Instant expires, String scope)
|
||||
{
|
||||
String clientId = config.getAppClientId();
|
||||
if(clientId == null && QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth)
|
||||
{
|
||||
clientId = oauth.getClientId();
|
||||
}
|
||||
String endpoint = config.getAuthentikPublicUrl().replaceAll("/+$", "") + "/application/o/token/";
|
||||
String app = config.getAppPublicUrl() == null ? "{appPublicUrl}" : config.getAppPublicUrl().replaceAll("/+$", "");
|
||||
return ("Expires: " + expires + "\nScope: " + scope + "\n\n"
|
||||
+ "curl -X POST " + quote(endpoint) + " --data-urlencode 'grant_type=client_credentials'"
|
||||
+ " --data-urlencode " + quote("client_id=" + (clientId == null ? "{appClientId}" : clientId))
|
||||
+ " --data-urlencode " + quote("username=" + username) + " --data-urlencode " + quote("password=" + key)
|
||||
+ " --data-urlencode 'scope=openid profile email'\n\n"
|
||||
+ "curl -H 'Authorization: Bearer {jwt}' " + quote(app + "/qqq/v1/metaData"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Quote literal shell arguments, including usernames containing apostrophes.
|
||||
*******************************************************************************/
|
||||
private static String quote(String value)
|
||||
{
|
||||
return ("'" + value.replace("'", "'\"'\"'") + "'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.app;
|
||||
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard TABLE widget: inventory, age and actionable cleanup suggestions.
|
||||
** Computed fields never become API filters; links use owner or token keys.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokenWidget extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check the permission here as well as in metadata (non-HTTP callers).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String backend = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get("backendName"));
|
||||
return (new RenderWidgetOutput(data(AuthentikActionUtil.forBackend(backend), Instant.now())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Report complete inventory and explicit unknown ages. An API token's
|
||||
** activity is untracked and never qualifies as unused.
|
||||
*******************************************************************************/
|
||||
public static TableData data(AuthentikActionUtil util, Instant now) throws QException
|
||||
{
|
||||
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Access token widgets require authentik.tokens.manage."));
|
||||
}
|
||||
AuthentikQBitConfig config = util.getRuntime().getConfig();
|
||||
List<JSONObject> tokens = util.getAllPages("/core/tokens/", Integer.MAX_VALUE);
|
||||
Map<String, Instant> activity = AuthentikTokenActivity.lastUsed(util);
|
||||
Map<String, List<String>> owners = new LinkedHashMap<>();
|
||||
Map<String, List<String>> ages = new LinkedHashMap<>();
|
||||
for(String bucket : List.of("<30d", "30-90d", "90-365d", ">365d", "unknown"))
|
||||
{
|
||||
ages.put(bucket, new ArrayList<>());
|
||||
}
|
||||
Map<Integer, JSONObject> users = new LinkedHashMap<>();
|
||||
List<Map<String, Object>> cleanup = new ArrayList<>();
|
||||
TokenScopeStore scopes = new TokenScopeStore(config);
|
||||
for(JSONObject token : tokens)
|
||||
{
|
||||
String id = token.getString("identifier");
|
||||
JSONObject owner = token.getJSONObject("user_obj");
|
||||
String username = owner.getString("username");
|
||||
owners.computeIfAbsent(username, k -> new ArrayList<>()).add(id);
|
||||
QRecord scope = scopes.find(id);
|
||||
Instant created = scope == null ? null : scope.getValueInstant("createdAt");
|
||||
long days = created == null ? -1 : ChronoUnit.DAYS.between(created, now);
|
||||
ages.get(days < 0 ? "unknown" : days < 30 ? "<30d" : days < 90 ? "30-90d" : days <= 365 ? "90-365d" : ">365d").add(id);
|
||||
int pk = owner.getInt("pk");
|
||||
if(!users.containsKey(pk))
|
||||
{
|
||||
users.put(pk, util.getApiJsonOrNull("/core/users/" + pk + "/"));
|
||||
}
|
||||
JSONObject live = users.get(pk);
|
||||
String links = link(list(config, "username", List.of(username)), username) + " / " + link(list(config, "identifier", List.of(id)), id);
|
||||
if(live != null && !live.optBoolean("is_active", true))
|
||||
{
|
||||
cleanup.add(row("Owner deactivated", 1, links));
|
||||
}
|
||||
Instant last = activity.get(id);
|
||||
Instant cutoff = now.minus(config.getTokenUnusedDays(), ChronoUnit.DAYS);
|
||||
// Without a creation time or an event we cannot prove age. A 90-day
|
||||
// event window cannot prove inactivity beyond 90 days either.
|
||||
boolean unused = last != null ? last.isBefore(cutoff)
|
||||
: config.getTokenUnusedDays() <= 90 && created != null && created.isBefore(cutoff);
|
||||
if("app_password".equals(token.optString("intent")) && unused)
|
||||
{
|
||||
cleanup.add(row("Unused for " + config.getTokenUnusedDays() + " days", 1, links));
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(row("Total", tokens.size(), link(list(config, null, List.of()), "All access tokens")));
|
||||
owners.entrySet().stream().sorted(Comparator.<Map.Entry<String, List<String>>>comparingInt(e -> e.getValue().size()).reversed().thenComparing(Map.Entry::getKey))
|
||||
.limit(10).forEach(e -> rows.add(row("Owner: " + e.getKey(), e.getValue().size(), link(list(config, "username", List.of(e.getKey())), "Tokens"))));
|
||||
ages.forEach((bucket, ids) -> rows.add(row("Age: " + bucket, ids.size(), ids.isEmpty() ? "" : link(list(config, "identifier", ids), "Tokens"))));
|
||||
rows.addAll(cleanup);
|
||||
return (new TableData("Access tokens", List.of(
|
||||
new TableData.Column("default", "Category", "category", "35%", "left"),
|
||||
new TableData.Column("default", "Count", "count", "10%", "right"),
|
||||
new TableData.Column("html", "Access tokens", "tokens", "55%", "left")), rows)
|
||||
.withRowsPerPage(25).withLinkText("All access tokens / bulk revoke").withLinkURL(list(config, null, List.of())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A standard table row; only the link cell contains escaped anchor markup.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> row(String category, int count, String tokens)
|
||||
{
|
||||
return (Map.of("category", category, "count", count, "tokens", tokens));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Native list filter format shared by Material and Next. Primary-key IN is
|
||||
** supported by the API adapter as individual GETs; no computed filtering.
|
||||
*******************************************************************************/
|
||||
private static String list(AuthentikQBitConfig config, String field, List<String> values)
|
||||
{
|
||||
String url = (config.getLinkPrefix() == null ? "" : config.getLinkPrefix()) + "/" + config.applyTablePrefix("token");
|
||||
if(field != null)
|
||||
{
|
||||
JSONObject criteria = new JSONObject().put("fieldName", field).put("operator", values.size() == 1 ? "EQUALS" : "IN").put("values", values);
|
||||
String filter = new JSONObject().put("booleanOperator", "AND").put("criteria", new JSONArray().put(criteria)).toString();
|
||||
url += "?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8);
|
||||
}
|
||||
return (url);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escape both the link target and text; descriptions are never HTML.
|
||||
*******************************************************************************/
|
||||
private static String link(String url, String text)
|
||||
{
|
||||
return ("<a href=\"" + escape(url) + "\">" + escape(text) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal HTML escaping for a standard table link cell.
|
||||
*******************************************************************************/
|
||||
private static String escape(String value)
|
||||
{
|
||||
return (value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"));
|
||||
}
|
||||
}
|
||||
@@ -376,6 +376,16 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
** @param body request body (null for none)
|
||||
*******************************************************************************/
|
||||
public JSONObject write(WriteAuthorization authorization, String method, String pathAndQuery, JSONObject body) throws QException
|
||||
{
|
||||
return (writeAs(token(), authorization, method, pathAndQuery, body));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit bearer for an authorized token action, through the same send seam.
|
||||
*******************************************************************************/
|
||||
public JSONObject writeAs(String bearer, WriteAuthorization authorization, String method, String pathAndQuery, JSONObject body) throws QException
|
||||
{
|
||||
if(authorization == null)
|
||||
{
|
||||
@@ -386,6 +396,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
throw (new QException("Bad Authentik path"));
|
||||
}
|
||||
|
||||
checkAuthorization(authorization);
|
||||
String url = runtime().getConfig().getApiRoot() + pathAndQuery;
|
||||
HttpRequestBase request = switch(method)
|
||||
{
|
||||
@@ -401,7 +412,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
|
||||
try
|
||||
{
|
||||
QHttpResponse response = execute(request);
|
||||
QHttpResponse response = execute(request, bearer, false);
|
||||
if(response.getStatusCode() != null && response.getStatusCode() == 404)
|
||||
{
|
||||
throw (new QUserFacingException("Authentik no longer has that record (it may have been removed in the Authentik admin UI)."));
|
||||
@@ -421,6 +432,65 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read token metadata or its new key as its owner; never use the read cache.
|
||||
*******************************************************************************/
|
||||
public JSONObject getAs(String bearer, WriteAuthorization auth, String path) throws QException
|
||||
{
|
||||
checkAuthorization(auth);
|
||||
if(path == null || !path.startsWith("/core/tokens/") || path.contains("..") || path.contains("?"))
|
||||
{
|
||||
throw (new QException("Bad token path"));
|
||||
}
|
||||
QHttpResponse response = execute(new HttpGet(runtime().getConfig().getApiRoot() + path), bearer, false);
|
||||
try
|
||||
{
|
||||
return (response.getStatusCode() == 404 ? null : new JSONObject(response.getContent()));
|
||||
}
|
||||
catch(JSONException e)
|
||||
{
|
||||
throw (new QUserFacingException("Authentik returned an invalid token response."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exchange through the shared throttle/send seam with no service bearer.
|
||||
*******************************************************************************/
|
||||
public JSONObject exchangeToken(WriteAuthorization auth, HttpPost request) throws QException
|
||||
{
|
||||
checkAuthorization(auth);
|
||||
if(!request.getURI().toString().equals(runtime().getConfig().getTokenEndpointUrl()))
|
||||
{
|
||||
throw (new QException("Bad token endpoint"));
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new JSONObject(execute(request, null, true).getContent()));
|
||||
}
|
||||
catch(JSONException e)
|
||||
{
|
||||
throw (new QUserFacingException("Authentik refused to act as you (invalid_response)"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Revalidate the authorization against the current session and config.
|
||||
*******************************************************************************/
|
||||
private void checkAuthorization(WriteAuthorization auth) throws QException
|
||||
{
|
||||
if(auth == null || !java.util.Objects.equals(auth.getOperator(), runtime().getGuard().operatorEmail()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Authentik data can only be changed through the guard."));
|
||||
}
|
||||
runtime().getGuard().authorize(com.kingsrook.qbits.authentik.admin.AdminAction.valueOf(auth.getAction()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fetch a single Authentik object by key (null when it doesn't exist).
|
||||
** No permission check: callers check first.
|
||||
@@ -733,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)
|
||||
@@ -1232,13 +1306,22 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
** error mapping, logging. Returns 2xx and 404 responses.
|
||||
*******************************************************************************/
|
||||
public QHttpResponse execute(HttpRequestBase request) throws QException
|
||||
{
|
||||
return (execute(request, token(), false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared request loop; exchange responses and all token traffic stay secret.
|
||||
*******************************************************************************/
|
||||
private QHttpResponse execute(HttpRequestBase request, String token, boolean exchange) throws QException
|
||||
{
|
||||
String path = pathOf(request.getURI().toString());
|
||||
int attempts = 0;
|
||||
int backoffMs = INITIAL_BACKOFF_MS;
|
||||
String token = token();
|
||||
|
||||
if(!StringUtils.hasContent(token))
|
||||
if(!exchange && !StringUtils.hasContent(token))
|
||||
{
|
||||
throw (new QException("The Authentik API token is not configured (apiToken resolved to an empty value)."));
|
||||
}
|
||||
@@ -1247,11 +1330,14 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
attempts++;
|
||||
runtime().getThrottle().acquire();
|
||||
request.setHeader("Authorization", "Bearer " + token);
|
||||
if(!exchange)
|
||||
{
|
||||
request.setHeader("Authorization", "Bearer " + token);
|
||||
}
|
||||
request.setHeader("Accept", "application/json");
|
||||
if(request instanceof HttpEntityEnclosingRequest)
|
||||
{
|
||||
request.setHeader("Content-Type", "application/json");
|
||||
request.setHeader("Content-Type", exchange ? "application/x-www-form-urlencoded" : "application/json");
|
||||
}
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
@@ -1262,7 +1348,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
String message = AuthentikRedactor.redactText(e.getMessage(), token);
|
||||
String message = exchange || AuthentikRedactor.isSensitivePath(path) ? "Sensitive request failed" : AuthentikRedactor.redactText(e.getMessage(), token);
|
||||
LOG.warn("Authentik API call failed", logPair("method", request.getMethod()), logPair("path", path), logPair("error", message));
|
||||
if(attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
|
||||
{
|
||||
@@ -1275,7 +1361,10 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
|
||||
int status = response.getStatusCode() == null ? 0 : response.getStatusCode();
|
||||
LOG.info("Authentik API call", logPair("method", request.getMethod()), logPair("path", path), logPair("status", status), logPair("millis", System.currentTimeMillis() - start));
|
||||
logOutboundCall(request, path, response, token);
|
||||
if(!exchange)
|
||||
{
|
||||
logOutboundCall(request, path, response, token);
|
||||
}
|
||||
checkVersionOnce(status);
|
||||
|
||||
if(status == 429 && attempts <= MAX_RETRIES)
|
||||
@@ -1295,6 +1384,23 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
return (response);
|
||||
}
|
||||
if(exchange)
|
||||
{
|
||||
String error = "exchange_failed";
|
||||
try
|
||||
{
|
||||
String code = new JSONObject(response.getContent()).optString("error");
|
||||
if(List.of("invalid_grant", "access_denied", "invalid_client", "invalid_request", "unsupported_grant_type", "invalid_scope").contains(code))
|
||||
{
|
||||
error = code;
|
||||
}
|
||||
}
|
||||
catch(JSONException ignored)
|
||||
{
|
||||
// Never echo an untrusted broker error body.
|
||||
}
|
||||
throw (new QUserFacingException("Authentik refused to act as you (" + error + ")"));
|
||||
}
|
||||
throw (mapError(request, path, status, response.getContent(), token));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,12 @@ public final class AuthentikApiSurface
|
||||
{
|
||||
List<Operation> rs = new ArrayList<>();
|
||||
rs.add(new Operation("get", "/admin/version/", Set.of()));
|
||||
rs.add(new Operation("post", "/core/tokens/", Set.of()));
|
||||
rs.add(new Operation("patch", "/core/tokens/{key}/", Set.of()));
|
||||
rs.add(new Operation("delete", "/core/tokens/{key}/", Set.of()));
|
||||
rs.add(new Operation("get", "/core/tokens/{key}/view_key/", Set.of()));
|
||||
rs.add(new Operation("post", "/core/tokens/{key}/set_key/", Set.of()));
|
||||
rs.add(new Operation("post", "/core/users/service_account/", Set.of()));
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// virtual membership table, group graph, drift report //
|
||||
@@ -68,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,10 +37,10 @@ public final class AuthentikRedactor
|
||||
|
||||
/** Path fragments whose request and response bodies are never logged. */
|
||||
private static final List<String> SENSITIVE_PATH_FRAGMENTS = List.of(
|
||||
"/recovery", "/recovery_email", "/set_password", "/set_password_hash", "/core/tokens/", "/service_account", "/impersonate", "/view_key", "/set_key");
|
||||
"/application/o/token/", "/recovery", "/recovery_email", "/set_password", "/set_password_hash", "/core/tokens/", "/service_account", "/impersonate", "/view_key", "/set_key");
|
||||
|
||||
private static final Pattern SECRET_JSON_VALUE = Pattern.compile(
|
||||
"(\"(?:key|token|link|password|secret|client_secret|password_hash|recovery_link)\"\\s*:\\s*)\"((?:[^\"\\\\]|\\\\.)*)\"", Pattern.CASE_INSENSITIVE);
|
||||
"(\"(?:key|token|access_token|subject_token|link|password|secret|client_secret|password_hash|recovery_link)\"\\s*:\\s*)\"((?:[^\"\\\\]|\\\\.)*)\"", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final Pattern BEARER = Pattern.compile("(?i)(bearer\\s+)[A-Za-z0-9._~+/=-]+");
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supplies the current operator's subject JWT for one token action.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface AuthentikSubjectTokenSource
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Return a subject JWT without retaining or logging it.
|
||||
*******************************************************************************/
|
||||
String subjectToken(QSession session) throws QException;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public class AuthentikTableSpec
|
||||
private final Map<String, AuthentikFieldSpec> fields = new LinkedHashMap<>();
|
||||
private final List<NameValuePair> defaultListParams = new ArrayList<>();
|
||||
private final List<NameValuePair> defaultGetParams = new ArrayList<>();
|
||||
private IdentityPermission alternativeReadPermission;
|
||||
private IdentityPermission readPermission = IdentityPermission.USERS_READ;
|
||||
private String defaultOrdering;
|
||||
private List<String> recordLabelFields = new ArrayList<>();
|
||||
@@ -298,4 +299,34 @@ public class AuthentikTableSpec
|
||||
return (requiredFilterField);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a second permission that also permits reads.
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec withAlternativeReadPermission(IdentityPermission permission)
|
||||
{
|
||||
alternativeReadPermission = permission;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the optional alternative permission.
|
||||
*******************************************************************************/
|
||||
public IdentityPermission getAlternativeReadPermission()
|
||||
{
|
||||
return (alternativeReadPermission);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Token tables need OR rules in core as well as in the API guard.
|
||||
*******************************************************************************/
|
||||
public com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules readRules()
|
||||
{
|
||||
return (alternativeReadPermission == null ? readPermission.rules() : com.kingsrook.qbits.authentik.security.TokenReadPermission.rules());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -400,12 +400,13 @@ public final class AuthentikTables
|
||||
|
||||
/*******************************************************************************
|
||||
** /core/tokens/ (identifier). Metadata only: there is NO key field, and the
|
||||
** service account is never given token-key permissions. Needs audit.read.
|
||||
** service account never reads token keys. Auditors and token managers may read.
|
||||
*******************************************************************************/
|
||||
static AuthentikTableSpec tokens()
|
||||
{
|
||||
AuthentikTableSpec spec = new AuthentikTableSpec(TOKEN, "Tokens", "key", "/core/tokens/", "identifier")
|
||||
.withReadPermission(IdentityPermission.AUDIT_READ)
|
||||
.withAlternativeReadPermission(IdentityPermission.TOKENS_MANAGE)
|
||||
.withDefaultOrdering("identifier")
|
||||
.withRecordLabelFields("identifier");
|
||||
spec.withField(new AuthentikFieldSpec("identifier", "Identifier", QFieldType.STRING, string("identifier")).withSearchable().withOrdering("identifier"));
|
||||
@@ -414,6 +415,14 @@ public final class AuthentikTables
|
||||
spec.withField(new AuthentikFieldSpec("description", "Description", QFieldType.STRING, string("description")));
|
||||
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
|
||||
spec.withField(new AuthentikFieldSpec("expiring", "Expiring", QFieldType.BOOLEAN, bool("expiring")).withFilter(QCriteriaOperator.EQUALS, "expiring"));
|
||||
spec.withField(new AuthentikFieldSpec("ownerPk", "Owner Id", QFieldType.INTEGER, intAt("user_obj", "pk")));
|
||||
spec.withField(new AuthentikFieldSpec("ownerType", "Owner Type", QFieldType.STRING, at("user_obj", "type")));
|
||||
spec.withField(new AuthentikFieldSpec("ownerName", "Owner", QFieldType.STRING, at("user_obj", "name")));
|
||||
spec.withField(new AuthentikFieldSpec("managed", "Managed", QFieldType.STRING, string("managed")));
|
||||
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("lastUsedNote", "Last Used Note", QFieldType.STRING, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("createdAt", "Created", QFieldType.DATE_TIME, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("scopeSummary", "Scope", QFieldType.STRING, o -> null));
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Non-secret local metadata and the most recent app-password login per token.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikTokenActivity
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AuthentikTokenActivity()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The pinned schema has no created__gt. Walk descending pages until the
|
||||
** 90-day boundary instead of sending a silently ignored query parameter.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Instant> lastUsed(AuthentikActionUtil util) throws QException
|
||||
{
|
||||
return (util.getRuntime().getTokenActivityCache().getResultThrowing("login", key ->
|
||||
{
|
||||
Map<String, Instant> latest = new LinkedHashMap<>();
|
||||
Instant cutoff = Instant.now().minus(90, ChronoUnit.DAYS);
|
||||
for(int page = 1; ; page++)
|
||||
{
|
||||
String path = "/events/events/?action=login&ordering=-created&page=" + page + "&page_size=100";
|
||||
util.getRuntime().getResponseCache().clearKey(util.getRuntime().getConfig().getApiRoot() + path);
|
||||
JSONObject response = util.getApiJson(path);
|
||||
JSONArray events = response.getJSONArray("results");
|
||||
for(int i = 0; i < events.length(); i++)
|
||||
{
|
||||
JSONObject event = events.getJSONObject(i);
|
||||
Instant at = Instant.parse(event.getString("created"));
|
||||
if(at.isBefore(cutoff))
|
||||
{
|
||||
return (Map.copyOf(latest));
|
||||
}
|
||||
JSONObject context = event.optJSONObject("context");
|
||||
if("login".equals(event.optString("action")) && context != null && "token".equals(context.optString("method")))
|
||||
{
|
||||
JSONObject args = context.optJSONObject("method_args");
|
||||
String identifier = args == null ? "" : args.optString("identifier");
|
||||
if(!identifier.isBlank())
|
||||
{
|
||||
latest.merge(identifier, at, (a, b) -> a.isAfter(b) ? a : b);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(events.isEmpty() || page >= response.getJSONObject("pagination").getInt("total_pages"))
|
||||
{
|
||||
return (Map.copyOf(latest));
|
||||
}
|
||||
}
|
||||
}).orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enrich only token records, never leaking token keys into the table.
|
||||
*******************************************************************************/
|
||||
public static void enrich(AuthentikActionUtil util, QRecord record) throws QException
|
||||
{
|
||||
var config = util.getRuntime().getConfig();
|
||||
QRecord scope = QContext.getQInstance().getTable(config.applyTablePrefix("token_scope")) == null ? null
|
||||
: new TokenScopeStore(config).find(record.getValueString("identifier"));
|
||||
record.setValue("createdAt", scope == null ? null : scope.getValue("createdAt"));
|
||||
record.setValue("scopeSummary", TokenScopeStore.summary(scope));
|
||||
if("api".equals(record.getValueString("intent")))
|
||||
{
|
||||
record.setValue("lastUsedNote", "not tracked");
|
||||
record.setDisplayValue("lastUsed", "not tracked");
|
||||
}
|
||||
else
|
||||
{
|
||||
record.setValue("lastUsed", lastUsed(util).get(record.getValueString("identifier")));
|
||||
record.setValue("lastUsedNote", record.getValue("lastUsed") == null ? "No login in the last 90 days" : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import org.apache.http.client.entity.UrlEncodedFormEntity;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.message.BasicNameValuePair;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RFC 8693 exchange for a two-minute API bearer, never cached or logged.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokenBroker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Exchange the operator's JWT through the configured protected-user broker.
|
||||
*******************************************************************************/
|
||||
public String exchange(AuthentikActionUtil util, WriteAuthorization auth) throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = util.getRuntime().getConfig();
|
||||
String subject = config.getSubjectTokenSource().subjectToken(QContext.getQSession());
|
||||
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
|
||||
request.setEntity(new UrlEncodedFormEntity(List.of(
|
||||
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
|
||||
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
|
||||
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret()),
|
||||
new BasicNameValuePair("subject_token", subject),
|
||||
new BasicNameValuePair("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"),
|
||||
new BasicNameValuePair("scope", "goauthentik.io/api")), StandardCharsets.UTF_8));
|
||||
JSONObject result = util.exchangeToken(auth, request);
|
||||
String bearer = result.optString("access_token", "");
|
||||
if(bearer.isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("Authentik refused to act as you (invalid_response)"));
|
||||
}
|
||||
return (bearer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reads the OAuth2 module's user-session row (uuid and accessToken in RC.2).
|
||||
** No memoization: the credential lives only for the current action.
|
||||
*******************************************************************************/
|
||||
public class OAuth2SubjectTokenSource implements AuthentikSubjectTokenSource
|
||||
{
|
||||
public static final String UNAVAILABLE = "Self-service tokens need a signed-in Authentik session.";
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up the exact current session, including its hidden access token.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String subjectToken(QSession session) throws QException
|
||||
{
|
||||
if(session != null && session == QContext.getQSession() && !(session instanceof QSystemUserSession) && session.getUser() != null && session.getUuid() != null && QContext.getQInstance() != null
|
||||
&& QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth
|
||||
&& oauth.getUserSessionTableName() != null)
|
||||
{
|
||||
QRecord row;
|
||||
QSession previous = QContext.getQSession();
|
||||
try
|
||||
{
|
||||
// Core uses a system read here too, avoiding recursive OAuth validation.
|
||||
// The UUID is taken only from the already authorized current session.
|
||||
QContext.setQSession(new QSystemUserSession());
|
||||
row = new GetAction().executeForRecord(new GetInput(oauth.getUserSessionTableName())
|
||||
.withUniqueKey(Map.of("uuid", session.getUuid())).withShouldMaskPasswords(false).withShouldOmitHiddenFields(false));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new QUserFacingException(UNAVAILABLE));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setQSession(previous);
|
||||
}
|
||||
if(row != null && row.getValueString("accessToken") != null && !row.getValueString("accessToken").isBlank())
|
||||
{
|
||||
return (row.getValueString("accessToken"));
|
||||
}
|
||||
}
|
||||
throw (new QUserFacingException(UNAVAILABLE));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -83,7 +83,7 @@ public abstract class AbstractAuthentikTableProducer extends QBitComponentMetaDa
|
||||
.withBackendName(config.getApiBackendName())
|
||||
.withPrimaryKeyField(spec.getKeyFieldName())
|
||||
.withBackendDetails(new APITableBackendDetails().withTablePath(spec.getPath()))
|
||||
.withPermissionRules(spec.getReadPermission().rules())
|
||||
.withPermissionRules(spec.readRules())
|
||||
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_EXPORT)
|
||||
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE, Capability.QUERY_STATS);
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+72
@@ -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);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Token scope metadata, with no credential material.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokenScopeTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Needs the local backend and the TOKENS feature.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (AuthentikLocalTables.tokenScope(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** After the backend.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (300);
|
||||
}
|
||||
|
||||
}
|
||||
+70
@@ -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()));
|
||||
}
|
||||
}
|
||||
+130
@@ -17,6 +17,9 @@
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -167,6 +170,133 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
}
|
||||
qSession.setPermissions(permissions);
|
||||
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Automated sessions have no claims. Resolve their email idReference to one
|
||||
** active API user before trusting any groups or immutable bootstrap id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeAutomatedSessionForUser(QInstance qInstance, QSession session, Serializable userId)
|
||||
{
|
||||
AuthentikRuntime runtime = runtime();
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
String email = userId == null ? "" : userId.toString().trim();
|
||||
try
|
||||
{
|
||||
JSONObject found = null;
|
||||
int matches = 0;
|
||||
List<JSONObject> users = email.isEmpty() ? List.of() : client(qInstance).getAllPages(
|
||||
"/core/users/?email=" + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&include_groups=true&include_roles=false", 10);
|
||||
for(JSONObject user : users)
|
||||
{
|
||||
if(email.equalsIgnoreCase(user.optString("email", "").trim()))
|
||||
{
|
||||
found = user;
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
// A bounded, possibly truncated lookup cannot prove uniqueness.
|
||||
if(users.size() < 10 && matches == 1 && found.optBoolean("is_active", false))
|
||||
{
|
||||
List<String> groups = new ArrayList<>();
|
||||
JSONArray groupObjects = found.optJSONArray("groups_obj");
|
||||
if(groupObjects != null)
|
||||
{
|
||||
for(int i = 0; i < groupObjects.length(); i++)
|
||||
{
|
||||
groups.add(groupObjects.getJSONObject(i).getString("name"));
|
||||
}
|
||||
}
|
||||
else if(found.opt("groups") instanceof JSONArray groupPks && !groupPks.isEmpty())
|
||||
{
|
||||
GroupGraph graph = client(qInstance).groupGraph();
|
||||
for(int i = 0; i < groupPks.length(); i++)
|
||||
{
|
||||
GroupGraph.Node group = graph.byPk(groupPks.getString(i));
|
||||
if(group != null)
|
||||
{
|
||||
groups.add(group.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
String pk = found.optString("pk", "");
|
||||
String sub = runtime != null && runtime.getConfig().getResolvedBootstrapSubjects().contains(pk) ? pk : found.optString("uuid", pk);
|
||||
String userEmail = found.getString("email").trim();
|
||||
boolean verified = found.optBoolean("email_verified", false);
|
||||
permissions.addAll(resolve(qInstance, groups, sub, userEmail, verified));
|
||||
if(runtime != null)
|
||||
{
|
||||
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups));
|
||||
permissions = automatedSessionPermissions(qInstance, runtime, permissions, groups, sub, userEmail, verified);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG.info("Automated session has no unique active Authentik user; granting only the static fallbacks");
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
permissions.clear();
|
||||
// Exception messages can contain the lookup email; log only the type.
|
||||
LOG.warn("Authentik could not be read for an automated session; granting only the static fallbacks", logPair("errorType", e.getClass().getSimpleName()));
|
||||
}
|
||||
if(runtime != null)
|
||||
{
|
||||
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), List.of()));
|
||||
}
|
||||
if(session.getPermissions() != null)
|
||||
{
|
||||
permissions.addAll(session.getPermissions());
|
||||
}
|
||||
session.setPermissions(permissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mirror the sign-in cut-over floor without changing the claims path.
|
||||
*******************************************************************************/
|
||||
private Set<String> automatedSessionPermissions(QInstance qInstance, AuthentikRuntime runtime, Set<String> permissions,
|
||||
List<String> groups, String sub, String email, boolean verified)
|
||||
{
|
||||
runtime.markMapperInvoked();
|
||||
AccessComparison.State state;
|
||||
try
|
||||
{
|
||||
state = AccessComparison.state(runtime, client(qInstance));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now());
|
||||
}
|
||||
if(state.enforced())
|
||||
{
|
||||
return (permissions);
|
||||
}
|
||||
Set<String> floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups));
|
||||
for(String group : groups)
|
||||
{
|
||||
floor.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
|
||||
}
|
||||
if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified))
|
||||
{
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
floor.add(permission.getSessionPermission());
|
||||
}
|
||||
}
|
||||
AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource();
|
||||
if(source != null)
|
||||
{
|
||||
floor.addAll(source.userPermissions(email, groups));
|
||||
}
|
||||
LOG.warn("Policies are not enforced for this automated session; granting today's access", logPair("status", state.status()));
|
||||
return (floor);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
|
||||
@@ -91,7 +91,9 @@ public final class AuthentikProcesses
|
||||
/** Selected approval requests. */
|
||||
APPROVALS,
|
||||
/** Selected policy records (authentik_policy); the target is the record's group. */
|
||||
POLICIES
|
||||
POLICIES,
|
||||
/** Tokens, keyed by identifier. */
|
||||
TOKENS
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -139,6 +141,28 @@ public final class AuthentikProcesses
|
||||
String users = AuthentikTables.USER;
|
||||
String groups = AuthentikTables.GROUP;
|
||||
|
||||
List<QFieldMetaData> createTokenFields = new ArrayList<>(List.of(
|
||||
new QFieldMetaData("description", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(30)));
|
||||
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
|
||||
{
|
||||
createTokenFields.add(new QFieldMetaData("readOnly", QFieldType.BOOLEAN));
|
||||
createTokenFields.add(new QFieldMetaData("apps", QFieldType.TEXT).withLabel("Apps (names, one per line)"));
|
||||
createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT).withLabel("Tables (names, one per line)"));
|
||||
}
|
||||
createTokenFields.add(reason);
|
||||
defs.add(new Def("createToken", "Create Token", AdminAction.CREATE_TOKEN, Kind.CREATE, AuthentikTables.TOKEN, "key", false,
|
||||
createTokenFields, "Creates a token for you. The key is shown once, on the next screen."));
|
||||
defs.add(new Def("rotateToken", "Rotate Token", AdminAction.ROTATE_TOKEN, Kind.TOKENS, AuthentikTables.TOKEN, "key", false, List.of(reason), null));
|
||||
defs.add(new Def("revokeToken", "Revoke Token", AdminAction.REVOKE_TOKEN, Kind.TOKENS, AuthentikTables.TOKEN, "key_off", false, List.of(reason), null));
|
||||
defs.add(new Def("extendToken", "Extend Expiry", AdminAction.EXTEND_TOKEN, Kind.TOKENS, AuthentikTables.TOKEN, "event", false, List.of(
|
||||
new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true), reason), null));
|
||||
defs.add(new Def("createServiceAccount", "Create Service Account", AdminAction.CREATE_SERVICE_ACCOUNT, Kind.CREATE, users, "person_add", false, List.of(
|
||||
new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("groups", QFieldType.TEXT).withLabel("Groups (names, one per line)"),
|
||||
new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(365), reason),
|
||||
"Creates a service account with one app password, shown once."));
|
||||
|
||||
defs.add(new Def("createUser", "Create User", AdminAction.CREATE_USER, Kind.CREATE, users, "person_add", false, List.of(
|
||||
new QFieldMetaData("username", QFieldType.STRING).withLabel("Username").withIsRequired(true),
|
||||
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name").withIsRequired(true),
|
||||
@@ -219,7 +243,8 @@ public final class AuthentikProcesses
|
||||
defs.removeIf(def -> def.action() != null && !config.hasFeature(def.action().getFeature()));
|
||||
defs.removeIf(def -> (def.action() == AdminAction.DELETE_USER && !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.USERS))
|
||||
|| (def.action() == AdminAction.DELETE_GROUP && !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS)));
|
||||
if(!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS) || !config.getTwoPersonApproval())
|
||||
if((!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS) || !config.getTwoPersonApproval())
|
||||
&& !(config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.TOKENS) && Boolean.TRUE.equals(config.getTokenTwoPersonApproval())))
|
||||
{
|
||||
defs.removeIf(def -> def.kind() == Kind.APPROVALS);
|
||||
}
|
||||
@@ -291,10 +316,20 @@ public final class AuthentikProcesses
|
||||
process.setLabel(def.label());
|
||||
process.setTableName(table);
|
||||
process.setIcon(new QIcon(def.icon()));
|
||||
process.setPermissionRules((def.action() == null ? IdentityPermission.APPROVE : def.action().getPermission()).rules());
|
||||
IdentityPermission permission = def.action() == null ? IdentityPermission.APPROVE : def.action().getPermission();
|
||||
// Null means self-service; the table still controls the record-action entry.
|
||||
if(permission != null)
|
||||
{
|
||||
process.setPermissionRules(permission.rules());
|
||||
}
|
||||
switch(def.kind())
|
||||
{
|
||||
case CREATE -> process.setMaxInputRecords(0);
|
||||
case TOKENS ->
|
||||
{
|
||||
process.setMinInputRecords(1);
|
||||
process.setMaxInputRecords(def.action() == AdminAction.REVOKE_TOKEN ? AuthentikAdmin.MAX_BULK : 1);
|
||||
}
|
||||
case USERS, GROUP_USERNAMES, GROUPS, INVITATIONS, APPROVALS, POLICIES ->
|
||||
{
|
||||
process.setMinInputRecords(1);
|
||||
@@ -377,6 +412,11 @@ public final class AuthentikProcesses
|
||||
case SEND_RECOVERY_EMAIL -> w("password reset email will be sent", "password reset emails will be sent", "password reset email was sent", "password reset emails were sent");
|
||||
case RESET_MFA -> w("user's MFA will be reset", "users' MFA will be reset", "user's MFA was reset", "users' MFA were reset");
|
||||
case REVOKE_APP_PASSWORDS -> w("user's app passwords will be revoked", "users' app passwords will be revoked", "user's app passwords were revoked", "users' app passwords were revoked");
|
||||
case CREATE_TOKEN -> w("token will be created", "tokens will be created", "token was created", "tokens were created");
|
||||
case ROTATE_TOKEN -> w("token will be rotated", "tokens will be rotated", "token was rotated", "tokens were rotated");
|
||||
case REVOKE_TOKEN -> w("token will be revoked", "tokens will be revoked", "token was revoked", "tokens were revoked");
|
||||
case EXTEND_TOKEN -> w("expiry will be extended", "expiries will be extended", "expiry was extended", "expiries were extended");
|
||||
case CREATE_SERVICE_ACCOUNT -> w("service account will be created", "service accounts will be created", "service account was created", "service accounts were created");
|
||||
case REVOKE_SESSIONS -> w("user's sessions will be revoked", "users' sessions will be revoked", "user's sessions were revoked", "users' sessions were revoked");
|
||||
};
|
||||
}
|
||||
@@ -447,6 +487,7 @@ public final class AuthentikProcesses
|
||||
AdminRequest request = withParams(AdminRequest.of(def.action(), String.valueOf(record.getValue("pk") != null ? record.getValue("pk") : record.getValue("uuid")), reason), def, input);
|
||||
rs.add("generatePolicy".equals(def.key()) ? admin.withGeneratedPolicy(request) : request);
|
||||
}
|
||||
case TOKENS -> rs.add(withParams(AdminRequest.of(def.action(), record.getValueString("identifier"), reason), def, input));
|
||||
case POLICIES -> rs.add(withParams(AdminRequest.of(def.action(), record.getValueString("groupPk"), reason), def, input));
|
||||
default ->
|
||||
{
|
||||
@@ -470,6 +511,16 @@ public final class AuthentikProcesses
|
||||
{
|
||||
request.with(AuthentikAdmin.POLICY_DOCUMENT, input.getValueString(AuthentikAdmin.POLICY_DOCUMENT));
|
||||
}
|
||||
if(def.action() == AdminAction.CREATE_TOKEN)
|
||||
{
|
||||
for(String field : com.kingsrook.qbits.authentik.admin.TokenScopeStore.SCOPE_FIELDS)
|
||||
{
|
||||
if(input.getValue(field) != null)
|
||||
{
|
||||
request.with(field, input.getValue(field));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QFieldMetaData field : def.fields())
|
||||
{
|
||||
if(field.getName().equals("reason"))
|
||||
@@ -857,6 +908,7 @@ public final class AuthentikProcesses
|
||||
if(def.kind() == Kind.APPROVALS)
|
||||
{
|
||||
AuthentikAdmin.AdminResult result = "approve".equals(def.key()) ? admin.approve(record.getValue("id")) : admin.reject(record.getValue("id"));
|
||||
result = deliver(output, result);
|
||||
tally(result, w);
|
||||
output.getRecords().add(record);
|
||||
continue;
|
||||
@@ -878,6 +930,7 @@ public final class AuthentikProcesses
|
||||
{
|
||||
result = admin.execute(request);
|
||||
}
|
||||
result = deliver(output, result);
|
||||
tally(result, w);
|
||||
output.getRecords().add(record);
|
||||
pace(admin);
|
||||
@@ -887,6 +940,36 @@ public final class AuthentikProcesses
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send the one-time key only through the response-only adapter.
|
||||
*******************************************************************************/
|
||||
private AuthentikAdmin.AdminResult deliver(RunBackendStepOutput output, AuthentikAdmin.AdminResult result)
|
||||
{
|
||||
com.kingsrook.qbits.authentik.admin.OneTimeKey key = result.takeSecret();
|
||||
if(key == null)
|
||||
{
|
||||
return (result);
|
||||
}
|
||||
if(TokenKeyDelivery.tryDeliver(output, key))
|
||||
{
|
||||
result.delivered();
|
||||
return (result);
|
||||
}
|
||||
try
|
||||
{
|
||||
result.discardSecret();
|
||||
return (new AuthentikAdmin.AdminResult(false, false, false,
|
||||
"The key could not be shown: this QQQ version cannot return one-time values. The token was revoked; nothing was kept.", null));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
return (new AuthentikAdmin.AdminResult(false, false, false,
|
||||
"The key could not be shown. Cleanup failed: the old key may no longer work and the new key is lost. Revoke or rotate the token in Authentik.", null));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pace bulk work (Authentik shares its database with the app).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts;
|
||||
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Daily app-password expiry reminders, deduplicated in local token metadata.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokenReminders implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The scheduler invokes the same guarded path as manual process execution.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
run(AuthentikActionUtil.forBackend(input.getValueString("backendName")), Instant.now());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Two non-overlapping windows avoid two reminders when first seen on day 1.
|
||||
** Synchronization prevents overlapping local scheduled runs from duplicating.
|
||||
*******************************************************************************/
|
||||
public static void run(AuthentikActionUtil util, Instant now) throws QException
|
||||
{
|
||||
if(!(QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession)
|
||||
&& !AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Token reminders require authentik.tokens.manage."));
|
||||
}
|
||||
AuthentikRuntime runtime = util.getRuntime();
|
||||
synchronized(runtime)
|
||||
{
|
||||
TokenScopeStore store = new TokenScopeStore(runtime.getConfig());
|
||||
for(JSONObject token : util.getAllPages("/core/tokens/?intent=app_password", Integer.MAX_VALUE))
|
||||
{
|
||||
if(!"app_password".equals(token.optString("intent")) || !token.optBoolean("expiring") || token.isNull("expires"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Instant expires = Instant.parse(token.getString("expires"));
|
||||
if(!expires.isAfter(now) || expires.isAfter(now.plus(7, ChronoUnit.DAYS)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String field = expires.isAfter(now.plus(1, ChronoUnit.DAYS)) ? "lastReminder7d" : "lastReminder1d";
|
||||
String id = token.getString("identifier");
|
||||
QRecord scope = store.find(id);
|
||||
Instant remindedAt = scope == null ? null : scope.getValueInstant(field);
|
||||
Instant windowStart = expires.minus(field.equals("lastReminder7d") ? 7 : 1, ChronoUnit.DAYS);
|
||||
if(remindedAt != null && !remindedAt.isBefore(windowStart))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int pk = token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk");
|
||||
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
|
||||
if(owner == null || owner.optString("email").isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String message = "Your access token " + token.optString("description") + " (" + id + ") expires on " + expires
|
||||
+ ". Extend or rotate it in Preferences > Access tokens.";
|
||||
if(runtime.getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_expiry:" + id + ":" + field + ":" + expires,
|
||||
"info", owner.getString("email"), message)))
|
||||
{
|
||||
store.reminded(id, owner, field, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.processes;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Response-only delivery SPI. Hosts may install their core-compatible adapter.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface TokenKeyDelivery
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Deliver only into a response-only core channel, never process values.
|
||||
*******************************************************************************/
|
||||
void deliver(RunBackendStepOutput output, OneTimeKey key);
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this implementation can show a key once.
|
||||
*******************************************************************************/
|
||||
default boolean available()
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current adapter; null restores the safe reflection adapter.
|
||||
*******************************************************************************/
|
||||
static void set(TokenKeyDelivery delivery)
|
||||
{
|
||||
Holder.delivery = delivery == null ? new ReflectiveDelivery() : delivery;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current adapter.
|
||||
*******************************************************************************/
|
||||
static TokenKeyDelivery get()
|
||||
{
|
||||
return (Holder.delivery);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail closed if runtime delivery becomes unavailable after Review.
|
||||
*******************************************************************************/
|
||||
static boolean tryDeliver(RunBackendStepOutput output, OneTimeKey key)
|
||||
{
|
||||
try
|
||||
{
|
||||
TokenKeyDelivery delivery = get();
|
||||
if(!delivery.available())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
delivery.deliver(output, key);
|
||||
return (true);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mutable holder, without putting a credential in static state.
|
||||
*******************************************************************************/
|
||||
final class Holder
|
||||
{
|
||||
private static volatile TokenKeyDelivery delivery = new ReflectiveDelivery();
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility holder.
|
||||
*******************************************************************************/
|
||||
private Holder()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7)
|
||||
** Reflection keeps RC.2 binary-compatible and never falls back to values.
|
||||
*******************************************************************************/
|
||||
final class ReflectiveDelivery implements TokenKeyDelivery
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check the complete core contract before any irreversible key mutation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean available()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
|
||||
type.getConstructor();
|
||||
for(String field : new String[] { "Label", "Value", "Masked", "UsageText" })
|
||||
{
|
||||
type.getMethod("with" + field, String.class);
|
||||
}
|
||||
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type);
|
||||
return (true);
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add to core's response-only channel; never include failures' contents.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void deliver(RunBackendStepOutput output, OneTimeKey key)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
|
||||
Object secret = type.getConstructor().newInstance();
|
||||
type.getMethod("withLabel", String.class).invoke(secret, key.label());
|
||||
type.getMethod("withValue", String.class).invoke(secret, key.value());
|
||||
type.getMethod("withMasked", String.class).invoke(secret, key.masked());
|
||||
type.getMethod("withUsageText", String.class).invoke(secret, key.usageText());
|
||||
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type).invoke(output, secret);
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("One-time output unavailable"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,9 @@ import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's own enforcement point (DESIGN.md section 5.3). QQQ core actions
|
||||
** do not enforce permissions for non-HTTP callers (QRun-IO/qqq#1061), so every
|
||||
** call into AuthentikActionUtil is checked here first:
|
||||
** The qbit's own enforcement point (DESIGN.md section 5.3). Core actions now
|
||||
** enforce permissions (QRun-IO/qqq#1061); direct AuthentikActionUtil calls and
|
||||
** Authentik-specific rules still need this guard:
|
||||
** - reads need the table's authentik.* permission (system sessions may read,
|
||||
** for the mirror sync);
|
||||
** - sensitive fields need authentik.audit.read;
|
||||
@@ -80,7 +80,7 @@ public class AuthentikGuard
|
||||
return;
|
||||
}
|
||||
requireUser(session);
|
||||
if(!has(session, spec.getReadPermission()))
|
||||
if(!has(session, spec.getReadPermission()) && !(spec.getAlternativeReadPermission() != null && has(session, spec.getAlternativeReadPermission())))
|
||||
{
|
||||
throw (new QPermissionDeniedException("You do not have permission to view " + spec.getLabel() + "."));
|
||||
}
|
||||
@@ -130,10 +130,14 @@ public class AuthentikGuard
|
||||
{
|
||||
throw (new QPermissionDeniedException(action.getLabel() + " is not enabled (feature " + action.getFeature() + ")."));
|
||||
}
|
||||
if(!has(session, action.getPermission()))
|
||||
if(action.getPermission() != null && !has(session, action.getPermission()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("You do not have permission to do this (" + action.getPermission().getShortName() + ")."));
|
||||
}
|
||||
if(action == AdminAction.CREATE_SERVICE_ACCOUNT && !has(session, IdentityPermission.USERS_MANAGE))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Creating a service account also needs authentik.users.manage."));
|
||||
}
|
||||
return (new WriteAuthorization(action.name(), operatorEmail()));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.security;
|
||||
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminRequest;
|
||||
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Signed token claims can only narrow the permissions computed by a host.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikTokenScopes
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenScopes.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** Distinguish unrestricted credentials from revoked credentials.
|
||||
*******************************************************************************/
|
||||
public enum Mode
|
||||
{
|
||||
SCOPED,
|
||||
UNSCOPED,
|
||||
DENY_ALL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable, non-secret credential restrictions, including advanced scopes.
|
||||
*******************************************************************************/
|
||||
public record TokenScope(String identifier, Mode mode, boolean readOnly, Set<String> apps, Set<String> tables,
|
||||
Set<String> processes, Set<String> reports, Set<String> widgets, Set<String> operationIds)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A missing row is unrestricted; a missing token is denied.
|
||||
*******************************************************************************/
|
||||
public static TokenScope marker(String identifier, Mode mode)
|
||||
{
|
||||
return (new TokenScope(identifier, mode, false, Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), Set.of()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Decode a local metadata row without retaining the mutable record.
|
||||
*******************************************************************************/
|
||||
public static TokenScope from(String identifier, QRecord row)
|
||||
{
|
||||
return (new TokenScope(identifier, Mode.SCOPED, Boolean.TRUE.equals(row.getValueBoolean("readOnly")),
|
||||
names(row.getValueString("apps")), names(row.getValueString("tables")), names(row.getValueString("processes")),
|
||||
names(row.getValueString("reports")), names(row.getValueString("widgets")), names(row.getValueString("operationIds"))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AuthentikTokenScopes()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One name per line; immutable sets keep cached scopes safe to share.
|
||||
*******************************************************************************/
|
||||
public static Set<String> names(String value)
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return (Set.of());
|
||||
}
|
||||
return (Set.copyOf(Arrays.stream(value.split("\\R")).map(String::trim).filter(s -> !s.isEmpty()).toList()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve only qbit backends present in this instance. Cache by identifier
|
||||
** within each runtime, so tenants never share a scope or token existence.
|
||||
*******************************************************************************/
|
||||
public static Optional<TokenScope> forClaims(QInstance instance, JSONObject claims) throws QException
|
||||
{
|
||||
if(claims == null)
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
if(instance != null)
|
||||
{
|
||||
for(var backend : instance.getBackends().values())
|
||||
{
|
||||
AuthentikRuntime runtime;
|
||||
try
|
||||
{
|
||||
runtime = AuthentikRuntime.forBackend(backend.getName());
|
||||
}
|
||||
catch(IllegalStateException ignored)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String claim = runtime.getConfig().getTokenScopeClaim();
|
||||
if(!claims.has(claim))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Object value = claims.opt(claim);
|
||||
if(!(value instanceof String identifier) || !identifier.matches("[A-Za-z0-9._-]+"))
|
||||
{
|
||||
return (Optional.of(TokenScope.marker("invalid", Mode.DENY_ALL)));
|
||||
}
|
||||
return (runtime.getTokenScopeCache().getResultThrowing(identifier, id -> lookupAsSystem(instance, () ->
|
||||
{
|
||||
APIBackendMetaData api = (APIBackendMetaData) backend;
|
||||
AuthentikActionUtil util = (AuthentikActionUtil) QCodeLoader.getAdHoc(BaseAPIActionUtil.class, api.getActionUtil());
|
||||
util.setBackendMetaData(api);
|
||||
// Check existence even when a local row remains after an external revoke.
|
||||
// Do not let a longer general response TTL extend the 60-second
|
||||
// revocation window of this dedicated cache.
|
||||
runtime.getResponseCache().clearKey(runtime.getConfig().getApiRoot() + "/core/tokens/" + id + "/");
|
||||
if(util.getApiJsonOrNull("/core/tokens/" + id + "/") == null)
|
||||
{
|
||||
return (TokenScope.marker(id, Mode.DENY_ALL));
|
||||
}
|
||||
if(instance.getTable(runtime.getConfig().applyTablePrefix("token_scope")) == null)
|
||||
{
|
||||
throw (new QException("Token scope metadata is unavailable."));
|
||||
}
|
||||
QRecord row = new TokenScopeStore(runtime.getConfig()).find(id);
|
||||
return (row == null ? TokenScope.marker(id, Mode.UNSCOPED) : TokenScope.from(id, row));
|
||||
})));
|
||||
}
|
||||
}
|
||||
// A default token claim must never silently lose enforcement if the qbit is missing.
|
||||
return (claims.has("qqq_token") ? Optional.of(TokenScope.marker(claims.optString("qqq_token"), Mode.DENY_ALL)) : Optional.empty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A lookup step that may throw.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Lookup
|
||||
{
|
||||
/***************************************************************************
|
||||
** Run the lookup.
|
||||
***************************************************************************/
|
||||
TokenScope get() throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scope lookup runs while a session is still being built (no user yet),
|
||||
** so it reads Authentik and the scope row as the internal system session,
|
||||
** then restores the caller's context.
|
||||
*******************************************************************************/
|
||||
private static TokenScope lookupAsSystem(QInstance instance, Lookup lookup) throws QException
|
||||
{
|
||||
QInstance previousInstance = QContext.getQInstance();
|
||||
com.kingsrook.qqq.backend.core.model.session.QSession previousSession = QContext.getQSession();
|
||||
try
|
||||
{
|
||||
if(previousInstance == null)
|
||||
{
|
||||
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession());
|
||||
}
|
||||
else
|
||||
{
|
||||
QContext.setQSession(new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession());
|
||||
}
|
||||
return (lookup.get());
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Token scope lookup failed", e));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(previousInstance == null)
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
QContext.setQSession(previousSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hosts with their own customizer call this AFTER computing permissions.
|
||||
*******************************************************************************/
|
||||
public static void apply(QSession session, JSONObject claims)
|
||||
{
|
||||
apply(QContext.getQInstance(), session, claims);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit instance variant for authentication, before QContext is installed.
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance instance, QSession session, JSONObject claims)
|
||||
{
|
||||
try
|
||||
{
|
||||
forClaims(instance, claims).ifPresent(scope -> narrow(instance, session, scope));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
session.setPermissions(new TreeSet<>());
|
||||
LOG.warn("Token scope could not be verified; denying credential permissions.", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7): intersect, never grant.
|
||||
*******************************************************************************/
|
||||
public static void narrow(QSession session, TokenScope scope)
|
||||
{
|
||||
narrow(QContext.getQInstance(), session, scope);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep only permitted bases and read operations, then attach core's scope
|
||||
** when available. Management hasAccess permissions are not read operations.
|
||||
*******************************************************************************/
|
||||
private static void narrow(QInstance instance, QSession session, TokenScope scope)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>(session.getPermissions() == null ? Set.of() : session.getPermissions());
|
||||
if(scope.mode() == Mode.DENY_ALL)
|
||||
{
|
||||
permissions.clear();
|
||||
}
|
||||
else if(scope.mode() == Mode.SCOPED)
|
||||
{
|
||||
if(scope.readOnly())
|
||||
{
|
||||
permissions.removeIf(p -> p.startsWith("authentik.") || !(p.endsWith(".read") || p.endsWith(".hasAccess")));
|
||||
}
|
||||
if(!scope.apps().isEmpty() || !scope.tables().isEmpty() || !scope.processes().isEmpty() || !scope.reports().isEmpty() || !scope.widgets().isEmpty())
|
||||
{
|
||||
Set<String> bases = new LinkedHashSet<>();
|
||||
if(instance != null)
|
||||
{
|
||||
for(String name : scope.tables())
|
||||
{
|
||||
if(instance.getTable(name) != null)
|
||||
{
|
||||
bases.add(base(instance, instance.getTable(name), name));
|
||||
}
|
||||
}
|
||||
// Policy-format names exclude dots; host permission aliases need
|
||||
// direct metadata traversal as well as the policy catalog.
|
||||
for(String appName : scope.apps())
|
||||
{
|
||||
var app = instance.getApp(appName);
|
||||
if(app == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
bases.add(base(instance, app, appName));
|
||||
Set<String> appTables = new LinkedHashSet<>();
|
||||
if(app.getChildren() != null)
|
||||
{
|
||||
for(var child : app.getChildren())
|
||||
{
|
||||
if(child instanceof MetaDataWithPermissionRules secured)
|
||||
{
|
||||
bases.add(base(instance, secured, child.getName()));
|
||||
}
|
||||
if(instance.getTable(child.getName()) != null)
|
||||
{
|
||||
appTables.add(child.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var process : instance.getProcesses().values())
|
||||
{
|
||||
if(process.getTableName() != null && appTables.contains(process.getTableName()))
|
||||
{
|
||||
bases.add(base(instance, process, process.getName()));
|
||||
}
|
||||
}
|
||||
if(app.getWidgets() != null)
|
||||
{
|
||||
for(String name : app.getWidgets())
|
||||
{
|
||||
if(instance.getWidget(name) != null)
|
||||
{
|
||||
bases.add(base(instance, instance.getWidget(name), name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for(PolicyCatalog.Group group : PolicyCatalog.of(instance).getGroups())
|
||||
{
|
||||
for(PolicyCatalog.Resource resource : group.resources())
|
||||
{
|
||||
if((group.app() != null && scope.apps().contains(group.app())) || scope.processes().contains(resource.name())
|
||||
|| scope.reports().contains(resource.name()) || scope.widgets().contains(resource.name()))
|
||||
{
|
||||
bases.add(resource.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
permissions.removeIf(p -> p.lastIndexOf('.') < 0 || !bases.contains(p.substring(0, p.lastIndexOf('.'))));
|
||||
}
|
||||
}
|
||||
session.setPermissions(permissions);
|
||||
session.setValue("qqq.credentialScope", scope.identifier());
|
||||
attachCoreScope(session, scope);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the permission base, which may differ from a table's metadata name.
|
||||
*******************************************************************************/
|
||||
private static String base(QInstance instance, MetaDataWithPermissionRules object, String name)
|
||||
{
|
||||
String base = PermissionsHelper.getEffectivePermissionRules(object, instance).getPermissionBaseName();
|
||||
return (base == null || base.isBlank() ? name : base);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate against current operator permissions at Review and execution.
|
||||
*******************************************************************************/
|
||||
public static void validate(AdminRequest request) throws QUserFacingException
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
for(String field : List.of("apps", "tables"))
|
||||
{
|
||||
for(String name : names(request.str(field)))
|
||||
{
|
||||
MetaDataWithPermissionRules object = field.equals("apps") ? instance.getApp(name) : instance.getTable(name);
|
||||
if(object == null)
|
||||
{
|
||||
throw (new QUserFacingException("Unknown " + field + " scope: " + name));
|
||||
}
|
||||
String base = base(instance, object, name);
|
||||
QSession session = QContext.getQSession();
|
||||
if(!session.hasPermission(base + ".hasAccess") && !session.hasPermission(base + ".read"))
|
||||
{
|
||||
throw (new QUserFacingException("You do not currently have read access to scope: " + name));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(String advanced : List.of("processes", "reports", "widgets", "operationIds"))
|
||||
{
|
||||
if(!request.str(advanced).isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("Advanced token scopes are not offered by Create Token."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional core bridge. An incompatible installed contract fails closed;
|
||||
** an absent class uses the permission intersection above.
|
||||
*******************************************************************************/
|
||||
/*******************************************************************************
|
||||
** An empty scope dimension means "not restricted" (null for core).
|
||||
*******************************************************************************/
|
||||
private static Set<String> nullIfEmpty(Set<String> set)
|
||||
{
|
||||
return (set == null || set.isEmpty() ? null : set);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1168, removeBy=4.1.0-RC.7): until the qbit's QQQ
|
||||
** version has QCredentialScope (qqq#1153), narrow() above enforces the scope
|
||||
** by intersecting the session's permissions; this attaches the core scope by
|
||||
** reflection when it exists. Replace both with a direct withCredentialScope.
|
||||
*******************************************************************************/
|
||||
private static void attachCoreScope(QSession session, TokenScope scope)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.session.QCredentialScope");
|
||||
Object value = type.getConstructor().newInstance();
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// core treats a null set as unrestricted and an empty set as //
|
||||
// "nothing": an unlisted dimension must therefore be passed as null //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
Map<String, Object> fields = new java.util.LinkedHashMap<>();
|
||||
fields.put("CredentialId", scope.identifier());
|
||||
fields.put("ReadOnly", scope.readOnly());
|
||||
fields.put("Apps", nullIfEmpty(scope.apps()));
|
||||
fields.put("Tables", nullIfEmpty(scope.tables()));
|
||||
fields.put("Processes", nullIfEmpty(scope.processes()));
|
||||
fields.put("Reports", nullIfEmpty(scope.reports()));
|
||||
fields.put("Widgets", nullIfEmpty(scope.widgets()));
|
||||
fields.put("OperationIds", nullIfEmpty(scope.operationIds()));
|
||||
for(var field : fields.entrySet())
|
||||
{
|
||||
Method setter = Arrays.stream(type.getMethods()).filter(m -> m.getName().equals("with" + field.getKey()) && m.getParameterCount() == 1)
|
||||
.findFirst().orElseThrow(NoSuchMethodException::new);
|
||||
Object arg = field.getValue();
|
||||
if(arg instanceof Set<?> set && List.class.isAssignableFrom(setter.getParameterTypes()[0]))
|
||||
{
|
||||
arg = List.copyOf(set);
|
||||
}
|
||||
setter.invoke(value, arg);
|
||||
}
|
||||
session.getClass().getMethod("withCredentialScope", type).invoke(session, value);
|
||||
}
|
||||
catch(ClassNotFoundException ignored)
|
||||
{
|
||||
// RC.2 has no credential scope; the stand-in above is active.
|
||||
}
|
||||
catch(ReflectiveOperationException | IllegalArgumentException e)
|
||||
{
|
||||
session.setPermissions(new TreeSet<>());
|
||||
LOG.warn("The installed credential-scope contract is incompatible; denying credential permissions.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public enum IdentityPermission
|
||||
ROLES_MANAGE("authentik.roles.manage"),
|
||||
CREDENTIALS_RESET("authentik.credentials.reset"),
|
||||
SESSIONS_REVOKE("authentik.sessions.revoke"),
|
||||
TOKENS_MANAGE("authentik.tokens.manage"),
|
||||
AUDIT_READ("authentik.audit.read"),
|
||||
APPROVE("authentik.approve"),
|
||||
ADMIN("authentik.admin");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.security;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Either auditors or token managers may read token metadata.
|
||||
*******************************************************************************/
|
||||
public class TokenReadPermission implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Shared metadata rules for the API and local token tables.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules rules()
|
||||
{
|
||||
return (IdentityPermission.AUDIT_READ.rules().withCustomPermissionChecker(new QCodeReference(TokenReadPermission.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Require either permission; core invokes this for table-level access.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.AUDIT_READ)
|
||||
&& !AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Token metadata requires authentik.audit.read or authentik.tokens.manage."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,4 +202,38 @@ class AuthentikQBitConfigTest
|
||||
assertThat(AuthentikQBitConfig.toSnakeCase("auth2Factor")).isEqualTo("auth2_factor");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Token defaults, minimum lifetimes and secret-safe diagnostics.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tokenConfig()
|
||||
{
|
||||
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig();
|
||||
assertThat(config.getTokenMaxDaysUser()).isEqualTo(90);
|
||||
assertThat(config.getTokenMaxDaysServiceAccount()).isEqualTo(365);
|
||||
assertThat(config.getTokenUnusedDays()).isEqualTo(60);
|
||||
assertThat(config.getTokenProtectedGroups()).containsExactly("authentik Admins", "identity-admins");
|
||||
assertThat(config.getTokenScopesEnabled()).isFalse();
|
||||
assertThat(config.getTokenTwoPersonApproval()).isFalse();
|
||||
assertThat(config.getTokenEndpointUrl()).isEqualTo(config.getBaseUrl() + "/application/o/token/");
|
||||
assertThat(config.getAuthentikPublicUrl()).isEqualTo(config.getBaseUrl());
|
||||
assertThat(config.getServiceAccountTokenLifetime()).isEqualTo("days=365");
|
||||
config.withServiceAccountTokenLifetime("days=200");
|
||||
assertThat(config.getServiceAccountTokenLifetime()).isEqualTo("days=200");
|
||||
config.setServiceAccountTokenLifetime("days=365");
|
||||
config.withTokenBrokerClientSecret("do-not-serialize-this");
|
||||
assertThat(config.toString()).doesNotContain("do-not-serialize-this");
|
||||
assertThat(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(config)).doesNotContain("do-not-serialize-this");
|
||||
config.setTokenMaxDaysUser(0);
|
||||
config.setTokenMaxDaysServiceAccount(-1);
|
||||
config.setTokenUnusedDays(null);
|
||||
assertThat(errors(config)).contains("tokenMaxDaysUser must be at least 1", "tokenMaxDaysServiceAccount must be at least 1", "tokenUnusedDays must be at least 1");
|
||||
config.withTokenMaxDaysUser(1).withTokenMaxDaysServiceAccount(1).withTokenUnusedDays(1);
|
||||
assertThat(errors(config)).isEmpty();
|
||||
config.withTokenBrokerClientId("${env.QBIT_TEST_UNSET_BROKER_ID}").withTokenBrokerClientSecret("${env.QBIT_TEST_UNSET_BROKER_SECRET}");
|
||||
assertThat(config.getTokenBrokerClientId()).isNull();
|
||||
assertThat(config.getTokenBrokerClientSecret()).isNull();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -174,4 +174,18 @@ public class SchemaDriftTest
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Login aggregation uses only parameters documented by the pinned schema.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tokenActivitySurfacePinsLoginOrderingWithoutUnsupportedDateFilter()
|
||||
{
|
||||
assertThat(AuthentikApiSurface.operations()).anySatisfy(operation ->
|
||||
{
|
||||
assertThat(operation.path()).isEqualTo("/events/events/");
|
||||
assertThat(operation.method()).isEqualTo("get");
|
||||
assertThat(operation.params()).contains("action", "ordering", "page", "page_size").doesNotContain("created__gt");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import org.json.JSONObject;
|
||||
*******************************************************************************/
|
||||
public class FakeAuthentik
|
||||
{
|
||||
public final JSONObject brandAttributes = new JSONObject();
|
||||
public final Map<Integer, JSONObject> users = new LinkedHashMap<>();
|
||||
public final Map<String, JSONObject> groups = new LinkedHashMap<>();
|
||||
public final Map<String, List<JSONObject>> tokens = new LinkedHashMap<>();
|
||||
@@ -54,6 +55,12 @@ public class FakeAuthentik
|
||||
public final List<JSONObject> events = new ArrayList<>();
|
||||
public final List<JSONObject> writeBodies = new ArrayList<>();
|
||||
|
||||
public final Map<String, String> tokenKeys = new LinkedHashMap<>();
|
||||
public final java.util.Set<Integer> deniedSubjects = new java.util.HashSet<>();
|
||||
public final List<Integer> tokenCallers = new ArrayList<>();
|
||||
public final List<JSONObject> loginEvents = new ArrayList<>();
|
||||
public boolean moveTokenAfterUpdate;
|
||||
public boolean failTokenDelete;
|
||||
private int nextUserPk = 100;
|
||||
|
||||
|
||||
@@ -204,7 +211,7 @@ public class FakeAuthentik
|
||||
{
|
||||
JSONObject rs = new JSONObject(user.toString());
|
||||
JSONArray groupsObj = new JSONArray();
|
||||
boolean superuser = false;
|
||||
boolean superuser = user.optBoolean("is_superuser");
|
||||
for(JSONObject group : groups.values())
|
||||
{
|
||||
if(contains(group.getJSONArray("users"), user.getInt("pk")))
|
||||
@@ -251,7 +258,27 @@ public class FakeAuthentik
|
||||
String path = uri.getPath().replace("/api/v3", "");
|
||||
String method = request.getMethod();
|
||||
Map<String, List<String>> query = parseQuery(uri.getRawQuery());
|
||||
if(path.equals("/application/o/token/"))
|
||||
{
|
||||
try
|
||||
{
|
||||
Map<String, List<String>> form = parseQuery(EntityUtils.toString(((HttpEntityEnclosingRequest) request).getEntity()));
|
||||
String subject = first(form, "subject_token");
|
||||
int pk = subject != null && subject.startsWith("subject:") ? Integer.parseInt(subject.substring(8)) : -1;
|
||||
if(!users.containsKey(pk) || deniedSubjects.contains(pk))
|
||||
{
|
||||
return (ok(400, new JSONObject().put("error", "invalid_grant")));
|
||||
}
|
||||
return (ok(200, new JSONObject().put("access_token", "user-bearer:" + pk).put("expires_in", 120)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (ok(400, new JSONObject().put("error", "invalid_request")));
|
||||
}
|
||||
}
|
||||
JSONObject body = body(request);
|
||||
String authorization = request.getFirstHeader("Authorization") == null ? "" : request.getFirstHeader("Authorization").getValue();
|
||||
int caller = authorization.startsWith("Bearer user-bearer:") ? Integer.parseInt(authorization.substring("Bearer user-bearer:".length())) : 5;
|
||||
|
||||
if(!method.equals("GET"))
|
||||
{
|
||||
@@ -259,6 +286,20 @@ public class FakeAuthentik
|
||||
writeBodies.add(body == null ? new JSONObject() : body);
|
||||
}
|
||||
|
||||
if(path.startsWith("/core/tokens/") && !path.equals("/core/tokens/") || path.equals("/core/tokens/") && method.equals("POST"))
|
||||
{
|
||||
return (tokenRequest(method, path, body, caller));
|
||||
}
|
||||
if(path.equals("/core/users/service_account/"))
|
||||
{
|
||||
int pk = nextUserPk++;
|
||||
String name = body.getString("name");
|
||||
user(pk, name, "", "service_account", true);
|
||||
users.get(pk).getJSONObject("attributes").put("goauthentik.io/user/token-expires", body.optBoolean("expiring", true));
|
||||
String identifier = "service-account-" + name + "-password";
|
||||
seedToken(identifier, pk, "app_password", body.getString("expires"));
|
||||
return (ok(200, new JSONObject().put("username", name).put("user_pk", pk).put("user_uid", UUID.randomUUID().toString()).put("token", tokenKeys.get(identifier))));
|
||||
}
|
||||
if(method.equals("GET"))
|
||||
{
|
||||
return (get(path, query));
|
||||
@@ -449,7 +490,16 @@ public class FakeAuthentik
|
||||
}
|
||||
if(path.equals("/core/tokens/"))
|
||||
{
|
||||
List<JSONObject> rs = new ArrayList<>(tokens.getOrDefault(first(query, "user__username"), List.of()));
|
||||
List<JSONObject> rs = new ArrayList<>();
|
||||
if(query.containsKey("user__username"))
|
||||
{
|
||||
rs.addAll(tokens.getOrDefault(first(query, "user__username"), List.of()));
|
||||
}
|
||||
else
|
||||
{
|
||||
tokens.values().forEach(rs::addAll);
|
||||
}
|
||||
rs.replaceAll(this::renderToken);
|
||||
String intent = first(query, "intent");
|
||||
if(intent != null)
|
||||
{
|
||||
@@ -480,7 +530,26 @@ public class FakeAuthentik
|
||||
JSONObject invitation = invitations.get(path.split("/")[4]);
|
||||
return (invitation == null ? FixtureActionUtil.response(404, "{}") : ok(200, invitation));
|
||||
}
|
||||
if(path.equals("/events/events/") || path.equals("/managed/blueprints/") || path.equals("/core/applications/"))
|
||||
if(path.equals("/events/events/"))
|
||||
{
|
||||
List<JSONObject> selected = new ArrayList<>(loginEvents);
|
||||
String action = first(query, "action");
|
||||
if(action != null)
|
||||
{
|
||||
selected.removeIf(event -> !action.equals(event.optString("action")));
|
||||
}
|
||||
if("-created".equals(first(query, "ordering")))
|
||||
{
|
||||
selected.sort(java.util.Comparator.comparing((JSONObject event) -> java.time.Instant.parse(event.getString("created"))).reversed());
|
||||
}
|
||||
int size = query.containsKey("page_size") ? Integer.parseInt(first(query, "page_size")) : 100;
|
||||
int number = query.containsKey("page") ? Integer.parseInt(first(query, "page")) : 1;
|
||||
int from = Math.min(selected.size(), (number - 1) * size);
|
||||
int to = Math.min(selected.size(), from + size);
|
||||
return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", selected.size()).put("current", number)
|
||||
.put("total_pages", Math.max(1, (selected.size() + size - 1) / size))).put("results", new JSONArray(selected.subList(from, to)))));
|
||||
}
|
||||
if(path.equals("/managed/blueprints/") || path.equals("/core/applications/"))
|
||||
{
|
||||
return (page(new ArrayList<>()));
|
||||
}
|
||||
@@ -489,6 +558,152 @@ public class FakeAuthentik
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed an app password or API token with an independent fake key.
|
||||
*******************************************************************************/
|
||||
public JSONObject seedToken(String identifier, int owner, String intent, String expires)
|
||||
{
|
||||
JSONObject token = new JSONObject().put("identifier", identifier).put("user", owner).put("intent", intent)
|
||||
.put("description", "Test access").put("expiring", true).put("expires", expires).put("managed", JSONObject.NULL);
|
||||
tokens.computeIfAbsent(users.get(owner).getString("username"), k -> new ArrayList<>()).add(token);
|
||||
tokenKeys.put(identifier, UUID.randomUUID().toString() + UUID.randomUUID());
|
||||
return (token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata never contains a key, including list and detail responses.
|
||||
*******************************************************************************/
|
||||
private JSONObject renderToken(JSONObject token)
|
||||
{
|
||||
JSONObject result = new JSONObject(token.toString());
|
||||
if(token.has("user") && users.containsKey(token.getInt("user")))
|
||||
{
|
||||
result.put("user_obj", render(users.get(token.getInt("user"))));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentik 2026.8.3 caller ownership, PATCH defaults, caps and key access.
|
||||
*******************************************************************************/
|
||||
private QHttpResponse tokenRequest(String method, String path, JSONObject body, int caller)
|
||||
{
|
||||
tokenCallers.add(caller);
|
||||
if(path.equals("/core/tokens/") && method.equals("POST"))
|
||||
{
|
||||
int owner = render(users.get(caller)).optBoolean("is_superuser") ? body.optInt("user", caller) : caller;
|
||||
JSONObject token = seedToken(body.getString("identifier"), owner, body.optString("intent", "api"), body.optString("expires", java.time.Instant.now().plusSeconds(1800).toString()));
|
||||
token.put("description", body.optString("description"));
|
||||
if(!capExpiry(token))
|
||||
{
|
||||
tokens.values().forEach(list -> list.remove(token));
|
||||
tokenKeys.remove(token.getString("identifier"));
|
||||
return (ok(400, new JSONObject().put("expires", "Token expires exceeds maximum lifetime")));
|
||||
}
|
||||
return (ok(201, renderToken(token)));
|
||||
}
|
||||
String identifier = path.split("/")[3];
|
||||
JSONObject token = tokens.values().stream().flatMap(List::stream).filter(t -> identifier.equals(t.optString("identifier"))).findFirst().orElse(null);
|
||||
if(token == null)
|
||||
{
|
||||
return (ok(404, new JSONObject()));
|
||||
}
|
||||
if(path.endsWith("/view_key/"))
|
||||
{
|
||||
return (token.optInt("user") == caller ? ok(200, new JSONObject().put("key", tokenKeys.get(identifier))) : ok(403, new JSONObject()));
|
||||
}
|
||||
if(method.equals("GET"))
|
||||
{
|
||||
return (ok(200, renderToken(token)));
|
||||
}
|
||||
if(caller != 5 && token.optInt("user") != caller)
|
||||
{
|
||||
return (ok(403, new JSONObject()));
|
||||
}
|
||||
if(method.equals("DELETE"))
|
||||
{
|
||||
if(failTokenDelete)
|
||||
{
|
||||
return (ok(400, new JSONObject().put("detail", "delete refused")));
|
||||
}
|
||||
tokens.values().forEach(list -> list.removeIf(t -> identifier.equals(t.optString("identifier"))));
|
||||
tokenKeys.remove(identifier);
|
||||
return (ok(204, null));
|
||||
}
|
||||
if(path.endsWith("/set_key/"))
|
||||
{
|
||||
tokenKeys.put(identifier, body.getString("key"));
|
||||
}
|
||||
else if(method.equals("PATCH"))
|
||||
{
|
||||
// validate_user runs for explicitly supplied user; setdefault is later.
|
||||
if(body.has("user") && body.getInt("user") != token.getInt("user"))
|
||||
{
|
||||
return (ok(400, new JSONObject().put("user", "User cannot be changed")));
|
||||
}
|
||||
JSONObject original = new JSONObject(token.toString());
|
||||
token.put("intent", body.optString("intent", "api")).put("user", body.optInt("user", caller));
|
||||
if(body.has("expires"))
|
||||
{
|
||||
token.put("expires", body.getString("expires"));
|
||||
}
|
||||
token.put("expiring", body.optBoolean("expiring", token.optBoolean("expiring")));
|
||||
if(!capExpiry(token))
|
||||
{
|
||||
original.keySet().forEach(key -> token.put(key, original.get(key)));
|
||||
return (ok(400, new JSONObject().put("expires", "Token expires exceeds maximum lifetime")));
|
||||
}
|
||||
}
|
||||
if(moveTokenAfterUpdate)
|
||||
{
|
||||
token.put("user", 4).put("intent", "api");
|
||||
}
|
||||
return (ok(200, renderToken(token)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Brand attributes, then group attributes, then user attributes set the cap.
|
||||
*******************************************************************************/
|
||||
private boolean capExpiry(JSONObject token)
|
||||
{
|
||||
java.time.Instant now = java.time.Instant.now();
|
||||
long seconds = 1800;
|
||||
if("app_password".equals(token.optString("intent")))
|
||||
{
|
||||
JSONObject attributes = new JSONObject(brandAttributes.toString());
|
||||
com.kingsrook.qbits.authentik.permissions.GroupGraph graph = com.kingsrook.qbits.authentik.permissions.GroupGraph.fromGroups(groups.values());
|
||||
graph.groupsOfUser(token.getInt("user")).stream().flatMap(pk -> graph.selfAndAncestors(pk).stream())
|
||||
.distinct().sorted(java.util.Comparator.comparing(com.kingsrook.qbits.authentik.permissions.GroupGraph.Node::name)).forEach(group ->
|
||||
{
|
||||
JSONObject values = groups.get(group.pk()).getJSONObject("attributes");
|
||||
values.keySet().forEach(key -> attributes.put(key, values.get(key)));
|
||||
});
|
||||
JSONObject values = users.get(token.getInt("user")).getJSONObject("attributes");
|
||||
values.keySet().forEach(key -> attributes.put(key, values.get(key)));
|
||||
String lifetime = attributes.optString("goauthentik.io/user/token-maximum-lifetime");
|
||||
if(lifetime.matches("days=[0-9]+"))
|
||||
{
|
||||
seconds = Long.parseLong(lifetime.substring(5)) * 86400;
|
||||
}
|
||||
}
|
||||
java.time.Instant cap = now.plusSeconds(seconds);
|
||||
java.time.Instant requested = java.time.Instant.parse(token.getString("expires"));
|
||||
if("api".equals(token.optString("intent")))
|
||||
{
|
||||
token.put("expires", cap.toString());
|
||||
return (true);
|
||||
}
|
||||
return (!requested.isAfter(cap));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.client.methods.HttpPatch;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.entity.ContentType;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pin the fake's dangerous defaults independently of the qbit's safe requests.
|
||||
*******************************************************************************/
|
||||
class FakeAuthentikTokenTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Non-superuser create ignores an explicit other owner; keys are owner-only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void callerOwnsCreateAndKey()
|
||||
{
|
||||
FakeAuthentik fake = directory();
|
||||
HttpPost request = new HttpPost("https://example.test/api/v3/core/tokens/");
|
||||
request.setHeader("Authorization", "Bearer user-bearer:3");
|
||||
request.setEntity(new StringEntity(new JSONObject().put("identifier", "caller-owned").put("user", 6)
|
||||
.put("intent", "app_password").put("expires", Instant.now().plusSeconds(600).toString()).toString(), ContentType.APPLICATION_JSON));
|
||||
JSONObject token = new JSONObject(fake.handle(request).getContent());
|
||||
assertThat(token.getInt("user")).isEqualTo(3);
|
||||
assertThat(Instant.parse(token.getString("expires"))).isBefore(Instant.now().plusSeconds(1801));
|
||||
HttpGet key = new HttpGet("https://example.test/api/v3/core/tokens/caller-owned/view_key/");
|
||||
assertThat(fake.handle(key).getStatusCode()).isEqualTo(403);
|
||||
key.setHeader("Authorization", "Bearer user-bearer:3");
|
||||
assertThat(fake.handle(key).getStatusCode()).isEqualTo(200);
|
||||
assertThat(token.has("key")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Omitted PATCH intent/user really do cause a dangerous default transition.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void patchDefaultsAndGroupLifetime()
|
||||
{
|
||||
FakeAuthentik fake = directory();
|
||||
fake.group("lifetime", false, List.of(), List.of());
|
||||
fake.groupByName("lifetime").getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=90");
|
||||
fake.groupByName("lifetime").getJSONArray("users").put(3);
|
||||
fake.seedToken("app-password", 3, "app_password", Instant.now().plusSeconds(86400).toString());
|
||||
HttpPatch patch = new HttpPatch("https://example.test/api/v3/core/tokens/app-password/");
|
||||
patch.setEntity(new StringEntity(new JSONObject().put("intent", "app_password").put("user", 3)
|
||||
.put("expires", Instant.now().plusSeconds(30 * 86400L).toString()).toString(), ContentType.APPLICATION_JSON));
|
||||
JSONObject updated = new JSONObject(fake.handle(patch).getContent());
|
||||
assertThat(updated.getInt("user")).isEqualTo(3);
|
||||
assertThat(updated.getString("intent")).isEqualTo("app_password");
|
||||
assertThat(Instant.parse(updated.getString("expires"))).isBefore(Instant.now().plusSeconds(90 * 86400L + 1));
|
||||
patch.setEntity(new StringEntity(new JSONObject().put("intent", "app_password").put("user", 3)
|
||||
.put("expires", Instant.now().plusSeconds(100 * 86400L).toString()).toString(), ContentType.APPLICATION_JSON));
|
||||
assertThat(fake.handle(patch).getStatusCode()).isEqualTo(400);
|
||||
fake.brandAttributes.put("goauthentik.io/user/token-maximum-lifetime", "days=10");
|
||||
fake.users.get(3).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365");
|
||||
assertThat(fake.handle(patch).getStatusCode()).isEqualTo(200);
|
||||
fake.users.get(3).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=5");
|
||||
assertThat(fake.handle(patch).getStatusCode()).isEqualTo(400);
|
||||
patch.setEntity(new StringEntity(new JSONObject().put("user", 6).toString(), ContentType.APPLICATION_JSON));
|
||||
assertThat(fake.handle(patch).getContent()).contains("User cannot be changed");
|
||||
patch.setEntity(new StringEntity("{}", ContentType.APPLICATION_JSON));
|
||||
updated = new JSONObject(fake.handle(patch).getContent());
|
||||
assertThat(updated.getInt("user")).isEqualTo(5);
|
||||
assertThat(updated.getString("intent")).isEqualTo("api");
|
||||
assertThat(Instant.parse(updated.getString("expires"))).isBefore(Instant.now().plusSeconds(1801));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal directory including the qbit's caller.
|
||||
*******************************************************************************/
|
||||
private FakeAuthentik directory()
|
||||
{
|
||||
return (new FakeAuthentik().user(3, "sam", "sam@example.test", "internal", true)
|
||||
.user(5, "qbit", "", "service_account", true).user(6, "service", "", "service_account", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.core.LogEvent;
|
||||
import org.apache.logging.log4j.core.Logger;
|
||||
import org.apache.logging.log4j.core.appender.AbstractAppender;
|
||||
import org.apache.logging.log4j.core.config.Property;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Token actions against the in-memory Authentik 2026.8.3 model. No Docker.
|
||||
*******************************************************************************/
|
||||
class TokenManagementTest
|
||||
{
|
||||
private AdminTestSupport t;
|
||||
private final List<OneTimeKey> delivered = new ArrayList<>();
|
||||
private final List<String> logLines = new ArrayList<>();
|
||||
private AbstractAppender appender;
|
||||
|
||||
/*******************************************************************************
|
||||
** A non-protected person, broker, lifetime groups and capturing response SPI.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
t = AdminTestSupport.create();
|
||||
t.config.withTokenBrokerClientId("test-broker").withTokenBrokerClientSecret("test-broker-secret")
|
||||
.withSubjectTokenSource(session -> "subject:3").withAppClientId("test-app").withAppPublicUrl("https://app.example.test")
|
||||
.withAuthentikPublicUrl("https://auth.example.test");
|
||||
t.authentik.group("token-users", false, List.of(), List.of());
|
||||
t.authentik.groupByName("token-users").getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=90");
|
||||
t.authentik.groupByName("token-users").getJSONArray("users").put(3);
|
||||
t.authentik.users.get(6).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365");
|
||||
t.loginAs("sam@example.test");
|
||||
TokenKeyDelivery.set((output, key) -> delivered.add(key));
|
||||
appender = new AbstractAppender("token-test-capture", null, null, false, Property.EMPTY_ARRAY)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Capture formatted log messages without writing them elsewhere.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void append(LogEvent event)
|
||||
{
|
||||
logLines.add(event.getMessage().getFormattedMessage());
|
||||
}
|
||||
};
|
||||
appender.start();
|
||||
((Logger) LogManager.getRootLogger()).addAppender(appender);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not leak a test adapter into other tests.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
TokenKeyDelivery.set(null);
|
||||
((Logger) LogManager.getRootLogger()).removeAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A valid personal create request.
|
||||
*******************************************************************************/
|
||||
private AdminRequest create()
|
||||
{
|
||||
return (AdminRequest.of(AdminAction.CREATE_TOKEN, "", "automation").with("description", "CI access").with("expiresInDays", 30));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A seeded expiring token.
|
||||
*******************************************************************************/
|
||||
private JSONObject seed(String id, int owner)
|
||||
{
|
||||
return (t.authentik.seedToken(id, owner, "app_password", Instant.now().plusSeconds(86400).toString()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assert a Review refusal before Authentik mutates anything.
|
||||
*******************************************************************************/
|
||||
private void refused(AdminRequest request, String message) throws QException
|
||||
{
|
||||
int before = t.changes().size();
|
||||
AdminPlan plan = t.admin().plan(request);
|
||||
assertThat(plan.getState()).isEqualTo(AdminPlan.State.REFUSED);
|
||||
assertThat(plan.getMessage()).containsIgnoringCase(message);
|
||||
assertThat(t.changes()).hasSize(before);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Personal create belongs to the exchanged caller and carries a one-time key.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void createOwnAndRotateScopeThenRevoke() throws QException
|
||||
{
|
||||
t.config.withTokenScopesEnabled(true);
|
||||
QContext.getQSession().withPermission("cms.publish.hasAccess");
|
||||
AuthentikAdmin.AdminResult result = t.admin().execute(create().with("readOnly", true).with("tables", "post"));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
OneTimeKey key = result.takeSecret();
|
||||
assertThat(result.takeSecret()).isNull();
|
||||
assertThat(key.label()).matches("qqq-pat-sam-[0-9]{8}-[a-z0-9]{6}");
|
||||
assertThat(key.usageText()).contains("https://auth.example.test/application/o/token/", "username=sam", "test-app", "https://app.example.test/qqq/v1/metaData");
|
||||
assertThat(key.masked()).endsWith(key.value().substring(key.value().length() - 4));
|
||||
assertThat(key.toString()).doesNotContain(key.value());
|
||||
assertThat(t.authentik.tokenCallers).containsOnly(3);
|
||||
assertThat(t.authentik.tokens.get("sam").get(0).getInt("user")).isEqualTo(3);
|
||||
TokenScopeStore store = new TokenScopeStore(t.config);
|
||||
assertThat(store.find(key.label()).getValueBoolean("readOnly")).isTrue();
|
||||
AuthentikAdmin.AdminResult rotated = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, key.label(), "replace"));
|
||||
assertThat(rotated.ok()).as(rotated.message()).isTrue();
|
||||
OneTimeKey next = rotated.takeSecret();
|
||||
assertThat(next.label()).isNotEqualTo(key.label());
|
||||
assertThat(store.find(key.label())).isNull();
|
||||
assertThat(store.find(next.label()).getValueString("tables")).isEqualTo("post");
|
||||
assertThat(store.find(next.label()).getValue("rotatedAt")).isNotNull();
|
||||
assertThat(t.authentik.tokenKeys).doesNotContainKey(key.label());
|
||||
assertThat(t.admin().execute(AdminRequest.of(AdminAction.REVOKE_TOKEN, next.label(), "done")).ok()).isTrue();
|
||||
assertThat(store.find(next.label())).isNull();
|
||||
assertNoKey(key, result);
|
||||
assertNoKey(next, rotated);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit PATCH fields preserve owner and intent for people and services.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void extendOwnAndServiceAndRotateService() throws QException
|
||||
{
|
||||
seed("personal", 3);
|
||||
AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "personal", "needed").with("expiresInDays", 60));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
JSONObject patch = t.authentik.writeBodies.get(0);
|
||||
assertThat(patch.getInt("user")).isEqualTo(3);
|
||||
assertThat(patch.getString("intent")).isEqualTo("app_password");
|
||||
assertThat(Instant.parse(patch.getString("expires"))).isAfter(Instant.now().plusSeconds(59 * 86400));
|
||||
t.loginAs("operator@example.test", IdentityPermission.values());
|
||||
seed("service", 6);
|
||||
assertThat(t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "needed").with("expiresInDays", 365)).ok()).isTrue();
|
||||
String old = t.authentik.tokenKeys.get("service");
|
||||
result = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace"));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
OneTimeKey key = result.takeSecret();
|
||||
assertThat(key.value()).hasSize(64).matches("[A-Za-z0-9_-]+").isNotEqualTo(old);
|
||||
assertThat(t.authentik.tokenKeys.get("service")).isEqualTo(key.value());
|
||||
assertNoKey(key, result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner/intent movement after either update fails safely and logs no key.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void raceGuardForPatchAndSetKey() throws QException
|
||||
{
|
||||
seed("personal", 3);
|
||||
t.authentik.moveTokenAfterUpdate = true;
|
||||
AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "personal", "needed").with("expiresInDays", 20));
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).isEqualTo(AuthentikTokens.TOKEN_MOVED);
|
||||
t.loginAs("operator@example.test", IdentityPermission.values());
|
||||
seed("service", 6);
|
||||
result = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace"));
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).isEqualTo(AuthentikTokens.TOKEN_MOVED);
|
||||
assertThat(logLines).anyMatch(line -> line.contains("Token moved"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Service creation sets a user lifetime cap and grants only requested groups.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void serviceAccountAndSubsetRule() throws QException
|
||||
{
|
||||
t.loginAs("operator@example.test", IdentityPermission.values());
|
||||
AdminRequest request = AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "automation").with("name", "new-service")
|
||||
.with("groups", "kof-website-publishers").with("expiresInDays", 365);
|
||||
AuthentikAdmin.AdminResult result = t.admin().execute(request);
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
Integer pk = t.admin().userPkByUsername("new-service");
|
||||
assertThat(t.authentik.groupNamesOf(pk)).containsExactly("kof-website-publishers");
|
||||
assertThat(t.authentik.users.get(pk).getJSONObject("attributes").getString("goauthentik.io/user/token-maximum-lifetime")).isEqualTo("days=365");
|
||||
assertThat(t.authentik.users.get(pk).getJSONObject("attributes").getBoolean("goauthentik.io/user/token-expires")).isTrue();
|
||||
OneTimeKey key = result.takeSecret();
|
||||
assertThat(key.label()).isEqualTo("service-account-new-service-password");
|
||||
assertNoKey(key, result);
|
||||
request.with("name", "refused-service").with("groups", "kof-website-analysts");
|
||||
refused(request, "do not hold");
|
||||
request.with("groups", "identity-admins");
|
||||
refused(request, "Protected account");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Administrators may revoke a non-protected human, but never issue for them.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ownerAndPermissionRules() throws QException
|
||||
{
|
||||
seed("someone", 7);
|
||||
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "someone", "reason"), "own tokens");
|
||||
refused(AdminRequest.of(AdminAction.EXTEND_TOKEN, "someone", "reason").with("expiresInDays", 1), "own tokens");
|
||||
refused(AdminRequest.of(AdminAction.REVOKE_TOKEN, "someone", "reason"), "own tokens");
|
||||
t.loginAs("sam@example.test", IdentityPermission.TOKENS_MANAGE);
|
||||
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "someone", "reason"), "another person's");
|
||||
refused(AdminRequest.of(AdminAction.EXTEND_TOKEN, "someone", "reason").with("expiresInDays", 1), "another person's");
|
||||
refused(new AdminRequest(AdminAction.CREATE_TOKEN, "7", create().params(), "reason"), "for you");
|
||||
assertThat(t.admin().execute(AdminRequest.of(AdminAction.REVOKE_TOKEN, "someone", "incident")).ok()).isTrue();
|
||||
refused(AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "reason"), "users.manage");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every protected class, including inherited admin membership, fails Review.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void protectedOwners() throws QException
|
||||
{
|
||||
t.loginAs("sam@example.test", IdentityPermission.values());
|
||||
for(int owner : List.of(1, 4, 5))
|
||||
{
|
||||
seed("protected-" + owner, owner);
|
||||
refused(AdminRequest.of(AdminAction.REVOKE_TOKEN, "protected-" + owner, "incident"), "Protected account");
|
||||
}
|
||||
t.authentik.users.get(3).put("is_superuser", true);
|
||||
refused(create(), "superuser");
|
||||
t.authentik.users.get(3).put("is_superuser", false);
|
||||
t.authentik.users.get(3).getJSONObject("attributes").put("qqq.io/protected", true);
|
||||
refused(create(), "qqq.io/protected");
|
||||
t.authentik.users.get(3).getJSONObject("attributes").remove("qqq.io/protected");
|
||||
t.config.withProtectedUsernames(List.of("sam"));
|
||||
refused(create(), "protectedUsernames");
|
||||
t.config.withProtectedUsernames(List.of()).withTokenProtectedGroups(List.of());
|
||||
t.authentik.group("child-admin", false, List.of(), List.of(), "identity-admins");
|
||||
t.authentik.groupByName("child-admin").getJSONArray("users").put(3);
|
||||
refused(create(), "identity-admins");
|
||||
t.authentik.groupByName("child-admin").put("users", new JSONArray());
|
||||
t.authentik.group("child-super", false, List.of(), List.of(), "authentik Admins");
|
||||
t.authentik.groupByName("child-super").getJSONArray("users").put(3);
|
||||
refused(create(), "superuser");
|
||||
t.authentik.groupByName("child-super").put("users", new JSONArray());
|
||||
t.config.withTokenProtectedGroups(List.of("token-users"));
|
||||
refused(create(), "token-users");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Expiry, scope opt-in, broker, delivery and feature guards all fail closed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void reviewRefusals() throws QException
|
||||
{
|
||||
AdminRequest missing = create();
|
||||
missing.params().remove("expiresInDays");
|
||||
refused(missing, "Expiry is required");
|
||||
refused(create().with("expiresInDays", 91), "90");
|
||||
refused(create().with("expiresInDays", 0), "1 and");
|
||||
refused(create().with("apps", "content"), "Scopes are not enabled");
|
||||
refused(create().with("description", ""), "description");
|
||||
t.config.withTokenBrokerClientSecret(null);
|
||||
refused(create(), "broker");
|
||||
t.config.withTokenBrokerClientSecret("test-secret").withSubjectTokenSource(session -> null);
|
||||
refused(create(), "signed-in Authentik session");
|
||||
t.config.withSubjectTokenSource(session -> "subject:3");
|
||||
TokenKeyDelivery.set(null);
|
||||
refused(create(), "upgrade QQQ");
|
||||
seed("own", 3);
|
||||
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "own", "replace"), "upgrade QQQ");
|
||||
t.loginAs("operator@example.test", IdentityPermission.values());
|
||||
seed("service", 6);
|
||||
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace"), "upgrade QQQ");
|
||||
refused(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "needed").with("expiresInDays", 366), "365");
|
||||
t.config.withTokensEnabled(false);
|
||||
refused(create(), "not enabled");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Service token approvals use the existing second-person queue and replay.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void twoPersonApproval() throws QException
|
||||
{
|
||||
t.config.withTokenTwoPersonApproval(true);
|
||||
t.loginAs("operator@example.test", IdentityPermission.values());
|
||||
seed("service", 6);
|
||||
for(AdminRequest request : List.of(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace"),
|
||||
AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "automation").with("name", "approved-service").with("expiresInDays", 100)))
|
||||
{
|
||||
assertThat(t.admin().plan(request).getState()).isEqualTo(AdminPlan.State.NEEDS_APPROVAL);
|
||||
assertThat(t.admin().execute(request.with("approved", true)).pending()).isTrue();
|
||||
}
|
||||
List<QRecord> approvals = new QueryAction().execute(new QueryInput().withTableName("authentik_approval_request")).getRecords();
|
||||
t.loginAs("second@example.test", IdentityPermission.values());
|
||||
for(QRecord approval : approvals)
|
||||
{
|
||||
AuthentikAdmin.AdminResult result = t.admin().approve(approval.getValue("id"));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
assertThat(result.takeSecret()).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Broker policy denial is mapped without echoing secrets or creating a token.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void brokerDenial() throws QException
|
||||
{
|
||||
t.authentik.deniedSubjects.add(3);
|
||||
AuthentikAdmin.AdminResult result = t.admin().execute(create());
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).isEqualTo("Authentik refused to act as you (invalid_grant)");
|
||||
assertThat(t.authentik.tokenKeys).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All key-producing actions go through real process state and response SPI.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void realProcessesNeverPersistKeysAndBulkRevoke() throws QException
|
||||
{
|
||||
t.loginAs("sam@example.test", IdentityPermission.AUDIT_READ);
|
||||
run("createToken", null, Map.of("description", "automation", "expiresInDays", 30, "reason", "test"));
|
||||
assertThat(delivered).hasSize(1);
|
||||
String id = delivered.get(0).label();
|
||||
run("rotateToken", List.of(id), Map.of("reason", "replace"));
|
||||
assertThat(delivered).hasSize(2);
|
||||
t.loginAs("operator@example.test", IdentityPermission.values());
|
||||
seed("service", 6);
|
||||
run("rotateToken", List.of("service"), Map.of("reason", "replace"));
|
||||
run("createServiceAccount", null, Map.of("name", "process-service", "expiresInDays", 365, "reason", "test"));
|
||||
assertThat(delivered).hasSize(4);
|
||||
for(OneTimeKey key : delivered)
|
||||
{
|
||||
assertNoKey(key, new AuthentikAdmin.AdminResult(true, false, false, "done", null));
|
||||
}
|
||||
seed("human-other", 7);
|
||||
run("revokeToken", List.of(delivered.get(1).label(), "human-other", "service"), Map.of("reason", "incident"));
|
||||
assertThat(t.authentik.tokenKeys).doesNotContainKeys(delivered.get(1).label(), "human-other", "service");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Loss of the response channel after Review revokes the new credential.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deliveryFailureRevokes() throws QException
|
||||
{
|
||||
t.loginAs("sam@example.test", IdentityPermission.AUDIT_READ);
|
||||
TokenKeyDelivery.set((output, key) ->
|
||||
{
|
||||
throw (new IllegalStateException("test channel failure"));
|
||||
});
|
||||
RunProcessOutput output = run("createToken", null, Map.of("description", "automation", "expiresInDays", 30, "reason", "test"));
|
||||
assertThat(JsonUtils.toJson(output.getValues())).contains("The token was revoked; nothing was kept.");
|
||||
assertThat(t.authentik.tokenKeys).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Drive details, Review and Result through QQQ, inspecting saved state.
|
||||
*******************************************************************************/
|
||||
private RunProcessOutput run(String action, List<String> identifiers, Map<String, Serializable> values) throws QException
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(t.config.applyPrefix(action));
|
||||
if(identifiers != null)
|
||||
{
|
||||
input.addValue("recordIds", String.join(",", identifiers));
|
||||
}
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
input.setProcessUUID(output.getProcessUUID());
|
||||
values.forEach(input::addValue);
|
||||
input.setStartAfterStep("details");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(output.getProcessState().getNextStepName()).contains("review");
|
||||
input.setStartAfterStep("review");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
String state = JsonUtils.toJson(RunProcessAction.getState(output.getProcessUUID()).orElseThrow().getValues());
|
||||
for(OneTimeKey key : delivered)
|
||||
{
|
||||
assertThat(state).doesNotContain(key.value());
|
||||
assertThat(JsonUtils.toJson(output.getValues())).doesNotContain(key.value());
|
||||
}
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Search every local table, events, diagnostics and safe request bodies.
|
||||
*******************************************************************************/
|
||||
private void assertNoKey(OneTimeKey key, AuthentikAdmin.AdminResult result) throws QException
|
||||
{
|
||||
assertThat(result.toString()).doesNotContain(key.value());
|
||||
assertThat(String.join("\n", logLines)).doesNotContain(key.value());
|
||||
assertThat(t.authentik.events.toString()).doesNotContain(key.value());
|
||||
for(QTableMetaData table : t.qInstance.getTables().values())
|
||||
{
|
||||
if(AdminTestSupport.LOCAL.equals(table.getBackendName()))
|
||||
{
|
||||
List<QRecord> records = new QueryAction().execute(new QueryInput().withTableName(table.getName())).getRecords();
|
||||
assertThat(JsonUtils.toJson(records)).as(table.getName()).doesNotContain(key.value());
|
||||
}
|
||||
}
|
||||
for(int i = 0; i < t.authentik.writes.size(); i++)
|
||||
{
|
||||
String path = t.authentik.writes.get(i);
|
||||
if(!path.endsWith("/set_key/") && !path.equals("POST /core/tokens/") && !path.equals("POST /core/users/service_account/"))
|
||||
{
|
||||
assertThat(t.authentik.writeBodies.get(i).toString()).doesNotContain(key.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Both independent read permissions pass core and qbit table guards.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tokenMetadataReadPermissions() throws QException
|
||||
{
|
||||
seed("readable", 6);
|
||||
for(IdentityPermission permission : List.of(IdentityPermission.AUDIT_READ, IdentityPermission.TOKENS_MANAGE))
|
||||
{
|
||||
t.loginAs("sam@example.test", permission);
|
||||
new com.kingsrook.qbits.authentik.security.TokenReadPermission().checkPermissionsThrowing(null, null);
|
||||
assertThat(new QueryAction().execute(new QueryInput().withTableName("authentik_token")).getRecords()).hasSize(1);
|
||||
}
|
||||
t.loginAs("sam@example.test");
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> new com.kingsrook.qbits.authentik.security.TokenReadPermission().checkPermissionsThrowing(null, null))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replanning closes a stale Review after a new protected-group membership.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rechecksProtectionAndNeedsNoServiceGroup() throws QException
|
||||
{
|
||||
AdminPlan plan = t.admin().plan(create());
|
||||
assertThat(plan.isRunnable()).isTrue();
|
||||
t.authentik.groupByName("identity-admins").getJSONArray("users").put(3);
|
||||
assertThat(t.admin().execute(plan, null).ok()).isFalse();
|
||||
assertThat(t.authentik.tokenKeys).isEmpty();
|
||||
t.loginAs("operator@example.test", IdentityPermission.values());
|
||||
AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "automation")
|
||||
.with("name", "missing-group").with("expiresInDays", 10));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
assertThat(result.message()).doesNotContain("is missing");
|
||||
assertThat(result.takeSecret().usageText()).contains("Expires:");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rotation refuses API/non-expiring tokens, and failed deletes retain the old.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rotationConstraintsAndPartialFailure() throws QException
|
||||
{
|
||||
JSONObject token = seed("old", 3);
|
||||
token.put("intent", "api");
|
||||
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "old", "replace"), "Only app-password");
|
||||
token.put("intent", "app_password").put("expiring", false);
|
||||
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "old", "replace"), "expiry");
|
||||
token.put("expiring", true);
|
||||
t.authentik.failTokenDelete = true;
|
||||
AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, "old", "replace"));
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).contains("revocation failed");
|
||||
assertThat(t.authentik.tokenKeys).containsKey("old");
|
||||
assertThat(result.takeSecret()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Feature defaults and metadata contain only non-secret token fields.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void featureDefaultsAndMetadata()
|
||||
{
|
||||
assertThat(t.config.hasFeature(AuthentikFeature.TOKENS)).isTrue();
|
||||
assertThat(t.config.withReadOnly(true).hasFeature(AuthentikFeature.TOKENS)).isFalse();
|
||||
t.config.withReadOnly(false).withTokensEnabled(false);
|
||||
assertThat(t.config.hasFeature(AuthentikFeature.TOKENS)).isFalse();
|
||||
t.config.withTokensEnabled(true).withFeatures(EnumSet.of(AuthentikFeature.READ));
|
||||
assertThat(t.config.hasFeature(AuthentikFeature.TOKENS)).isFalse();
|
||||
t.config.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS));
|
||||
assertThat(t.config.hasFeature(AuthentikFeature.TOKENS)).isTrue();
|
||||
assertThat(AuthentikTables.get(AuthentikTables.TOKEN).getField("key")).isNull();
|
||||
assertThat(t.qInstance.getTable("authentik_token").getFields()).containsKeys("ownerPk", "ownerType", "ownerName", "managed").doesNotContainKey("key");
|
||||
assertThat(t.qInstance.getTable("authentik_token_scope").getFields()).doesNotContainKey("key");
|
||||
assertThat(t.config.toString()).doesNotContain("test-broker-secret");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikTokenReminderProcessProducer;
|
||||
import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Event aggregation, scheduling, delivery dedupe and standard admin widgets.
|
||||
*******************************************************************************/
|
||||
class TokenOperationsTest
|
||||
{
|
||||
private AdminTestSupport t;
|
||||
private AuthentikActionUtil util;
|
||||
private final List<AuthentikTokenAlerts.Notice> notices = new ArrayList<>();
|
||||
private final Instant now = Instant.now();
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture delivery without a dependency on Alerts or a real mail service.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
t = AdminTestSupport.create();
|
||||
util = AuthentikActionUtil.forBackend(t.config.getApiBackendName());
|
||||
util.getRuntime().withTokenAlerts(notice -> { notices.add(notice); return (true); });
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed an expiring token for the chosen owner.
|
||||
*******************************************************************************/
|
||||
private JSONObject token(String id, int owner, int days)
|
||||
{
|
||||
return (t.authentik.seedToken(id, owner, "app_password", now.plus(days, ChronoUnit.DAYS).toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A login event using the pinned context.method_args.identifier contract.
|
||||
*******************************************************************************/
|
||||
private JSONObject event(String id, Instant at)
|
||||
{
|
||||
return (new JSONObject().put("action", "login").put("created", at.toString())
|
||||
.put("context", new JSONObject().put("method", "token").put("method_args", new JSONObject().put("identifier", id))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create non-secret bookkeeping with a known age for widget assertions.
|
||||
*******************************************************************************/
|
||||
private void created(String id, int days) throws QException
|
||||
{
|
||||
TokenScopeStore store = new TokenScopeStore(t.config);
|
||||
store.create(id, t.authentik.users.get(3), AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test"), "sam@example.test");
|
||||
QRecord row = store.find(id).withValue("createdAt", now.minus(days, ChronoUnit.DAYS));
|
||||
new UpdateAction().execute(new UpdateInput().withTableName("authentik_token_scope").withRecords(List.of(row)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Page, stop at 90 days, exclude non-token events, and reuse the 5-minute map.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void eventPagingWindowAndCache() throws QException
|
||||
{
|
||||
for(int i = 0; i < 101; i++)
|
||||
{
|
||||
t.authentik.loginEvents.add(event("token-" + i, now.minus(i, ChronoUnit.HOURS)));
|
||||
}
|
||||
t.authentik.loginEvents.add(event("token-0", now.minus(20, ChronoUnit.DAYS)));
|
||||
t.authentik.loginEvents.add(event("password", now).put("context", new JSONObject().put("method", "password")));
|
||||
t.authentik.loginEvents.add(event("wrong-action", now).put("action", "logout"));
|
||||
t.authentik.loginEvents.add(event("old", now.minus(91, ChronoUnit.DAYS)));
|
||||
Map<String, Instant> used = AuthentikTokenActivity.lastUsed(util);
|
||||
assertThat(used).hasSize(101).containsEntry("token-0", now).doesNotContainKeys("password", "wrong-action", "old");
|
||||
long requests = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count();
|
||||
assertThat(requests).isEqualTo(2);
|
||||
t.authentik.loginEvents.add(event("later", now));
|
||||
assertThat(AuthentikTokenActivity.lastUsed(util)).isEqualTo(used);
|
||||
assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count()).isEqualTo(requests);
|
||||
assertThat(util.getRuntime().getTokenActivityCache().getTimeout()).isEqualTo(java.time.Duration.ofMinutes(5));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** API activity is explicitly not tracked, and local creation/scope is shown.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void computedFields() throws QException
|
||||
{
|
||||
token("personal", 3, 5);
|
||||
created("personal", 31);
|
||||
t.authentik.loginEvents.add(event("personal", now));
|
||||
QRecord record = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), util.getApiJson("/core/tokens/personal/"), true);
|
||||
assertThat(record.getValueInstant("lastUsed")).isEqualTo(now);
|
||||
assertThat(record.getValueInstant("createdAt")).isEqualTo(now.minus(31, ChronoUnit.DAYS));
|
||||
assertThat(record.getValueString("scopeSummary")).isEqualTo("Owner permissions");
|
||||
JSONObject api = token("api", 3, 5).put("intent", "api");
|
||||
QRecord untracked = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), api, true);
|
||||
assertThat(untracked.getValue("lastUsed")).isNull();
|
||||
assertThat(untracked.getValueString("lastUsedNote")).isEqualTo("not tracked");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each window sends once to the owner's email; a minimal external row does
|
||||
** not invent restrictions or a creation date. Extending resets the windows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void remindersDedupeAcrossBothWindows() throws QException
|
||||
{
|
||||
token("external", 3, 5);
|
||||
token("tomorrow", 3, 1);
|
||||
token("api", 3, 1).put("intent", "api");
|
||||
token("expired", 3, -1);
|
||||
token("later", 3, 20);
|
||||
token("no-email", 6, 2);
|
||||
AuthentikTokenReminders.run(util, now);
|
||||
AuthentikTokenReminders.run(util, now);
|
||||
assertThat(notices).hasSize(2).allMatch(n -> "sam@example.test".equals(n.recipient()));
|
||||
assertThat(notices.getFirst().message()).contains("Your access token Test access (external)", "Preferences > Access tokens.");
|
||||
TokenScopeStore store = new TokenScopeStore(t.config);
|
||||
QRecord row = store.find("external");
|
||||
assertThat(row.getValue("createdAt")).isNull();
|
||||
assertThat(row.getValue("readOnly")).isNull();
|
||||
assertThat(row.getValue("lastReminder7d")).isNotNull();
|
||||
assertThat(row.getValue("lastReminder1d")).isNull();
|
||||
AuthentikTokenReminders.run(util, now.plus(4, ChronoUnit.DAYS));
|
||||
assertThat(notices).hasSize(3);
|
||||
assertThat(store.find("external").getValue("lastReminder1d")).isNotNull();
|
||||
store.resetReminders("external");
|
||||
assertThat(store.find("external").getValue("lastReminder7d")).isNull();
|
||||
AuthentikTokenScopes.apply(QContext.getQSession(), new JSONObject().put("qqq_token", "external"));
|
||||
assertThat(QContext.getQSession().getPermissions()).isNotEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delivery absence leaves dedupe unset so installing Alerts permits retry.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableDeliveryAndUnauthorizedRun() throws QException
|
||||
{
|
||||
token("external", 3, 5);
|
||||
util.getRuntime().withTokenAlerts(new AuthentikTokenAlerts()::send);
|
||||
AuthentikTokenReminders.run(util, now);
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// RC.6 core has the notification SPI: the reminder goes out through //
|
||||
// QAlerts.notify and is recorded for dedupe //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
assertThat(new TokenScopeStore(t.config).find("external")).isNotNull();
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// core QAlerts exists from RC.5: guard incidents always have a path //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
assertThat(new AuthentikTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null, "safe"))).isTrue();
|
||||
t.loginAs("sam@example.test");
|
||||
assertThatThrownBy(() -> AuthentikTokenReminders.run(util, now)).hasMessageContaining("tokens.manage");
|
||||
assertThatThrownBy(() -> AuthentikTokenWidget.data(util, now)).hasMessageContaining("tokens.manage");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The hidden daily process runs with core's system scheduler session.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scheduledProcessAndBulkRevokeMetadata() throws QException
|
||||
{
|
||||
t.qInstance.addScheduler(new SimpleSchedulerMetaData().withName("test"));
|
||||
AuthentikTokenReminderProcessProducer producer = new AuthentikTokenReminderProcessProducer();
|
||||
producer.setQBitConfig(t.config);
|
||||
var process = producer.produce(t.qInstance);
|
||||
assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(86400);
|
||||
assertThat(process.getSchedule().getSchedulerName()).isEqualTo("test");
|
||||
assertThat(process.getIsHidden()).isTrue();
|
||||
token("external", 3, 5);
|
||||
QContext.init(t.qInstance, new QSystemUserSession());
|
||||
new RunProcessAction().execute(new RunProcessInput().withProcessName("authentikTokenReminders"));
|
||||
assertThat(notices).hasSize(1);
|
||||
var revoke = t.qInstance.getProcess("authentikRevokeToken");
|
||||
assertThat(revoke.getTableName()).isEqualTo("authentik_token");
|
||||
assertThat(revoke.getMinInputRecords()).isEqualTo(1);
|
||||
assertThat(revoke.getMaxInputRecords()).isEqualTo(AuthentikAdmin.MAX_BULK);
|
||||
assertThat(Boolean.TRUE.equals(revoke.getIsHidden())).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Widget counts, known/unknown ages, links, deactivated owners and inactivity.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void adminWidgetUsesStandardTableAndSafeFilters() throws QException
|
||||
{
|
||||
token("new", 3, 5); created("new", 10);
|
||||
token("middle", 3, 5); created("middle", 45);
|
||||
token("unused", 3, 5); created("unused", 100);
|
||||
token("ancient", 3, 5); created("ancient", 400);
|
||||
token("unknown", 7, 5);
|
||||
token("api", 3, 5).put("intent", "api"); created("api", 100);
|
||||
t.authentik.loginEvents.add(event("middle", now));
|
||||
TableData data = AuthentikTokenWidget.data(util, now);
|
||||
assertThat(data.getRows()).contains(Map.of("category", "Age: unknown", "count", 1, "tokens", data.getRows().stream()
|
||||
.filter(row -> "Age: unknown".equals(row.get("category"))).findFirst().orElseThrow().get("tokens")));
|
||||
assertThat(data.getRows()).filteredOn(row -> "Unused for 60 days".equals(row.get("category"))).hasSize(2);
|
||||
assertThat(data.getRows()).filteredOn(row -> "Owner deactivated".equals(row.get("category"))).hasSize(1);
|
||||
assertThat(data.getRows().toString()).contains("?filter=", "identifier", "username").doesNotContain("lastUsed%22");
|
||||
QWidgetMetaData widget = (QWidgetMetaData) t.qInstance.getWidget("authentikAccessTokens");
|
||||
assertThat(widget.getType()).isEqualTo(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType());
|
||||
assertThat(new AuthentikTokenWidget().render(new RenderWidgetInput().withWidgetMetaData(widget))).isNotNull();
|
||||
t.config.withTokenUnusedDays(120);
|
||||
assertThat(AuthentikTokenWidget.data(util, now).getRows()).noneMatch(row -> "Unused for 120 days".equals(row.get("category")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A post-write owner move is a high-severity incident without a token key.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void protectedOwnerRaceRaisesAlert() throws QException
|
||||
{
|
||||
t.authentik.users.get(6).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365");
|
||||
token("service", 6, 5);
|
||||
t.authentik.moveTokenAfterUpdate = true;
|
||||
var result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "test").with("expiresInDays", 20));
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(notices).hasSize(1);
|
||||
assertThat(notices.getFirst().key()).isEqualTo("authentik_token_guard_bypass");
|
||||
assertThat(notices.getFirst().severity()).isEqualTo("high");
|
||||
assertThat(notices.toString()).doesNotContain(t.authentik.tokenKeys.get("service"));
|
||||
}
|
||||
/*******************************************************************************
|
||||
** External extension opens a new reminder window without rewriting scope.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void externalExtensionStartsNewReminderWindow() throws QException
|
||||
{
|
||||
JSONObject token = token("external", 3, 5);
|
||||
AuthentikTokenReminders.run(util, now);
|
||||
token.put("expires", now.plus(20, ChronoUnit.DAYS).toString());
|
||||
util.getRuntime().invalidateCache();
|
||||
AuthentikTokenReminders.run(util, now.plus(15, ChronoUnit.DAYS));
|
||||
assertThat(notices).hasSize(2);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
|
||||
import com.kingsrook.qbits.authentik.policy.AuthentikPolicyClaimMapper;
|
||||
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.Mode;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scope enforcement intersects existing permissions without Docker or OAuth.
|
||||
*******************************************************************************/
|
||||
class TokenScopesTest
|
||||
{
|
||||
private AdminTestSupport t;
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance includes a table whose permission base differs from its name.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
t = AdminTestSupport.create();
|
||||
t.config.withTokenScopesEnabled(true).withTokenBrokerClientId("broker").withTokenBrokerClientSecret("secret")
|
||||
.withSubjectTokenSource(session -> "subject:3");
|
||||
TokenKeyDelivery.set((output, key) -> {});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reset the response-only test adapter.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
TokenKeyDelivery.set(null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed a credential and optional local metadata.
|
||||
*******************************************************************************/
|
||||
private void seed(String id, AdminRequest scope) throws QException
|
||||
{
|
||||
t.authentik.seedToken(id, 3, "app_password", Instant.now().plusSeconds(86400).toString());
|
||||
if(scope != null)
|
||||
{
|
||||
new TokenScopeStore(t.config).create(id, t.authentik.users.get(3), scope, "sam@example.test");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No claim means no scope lookup and no changes to the permission set.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void absentAndExternalClaims() throws QException
|
||||
{
|
||||
QSession session = QContext.getQSession().withPermissions("post.read", "post.edit");
|
||||
Set<String> before = Set.copyOf(session.getPermissions());
|
||||
AuthentikTokenScopes.apply(session, new JSONObject());
|
||||
assertThat(session.getPermissions()).isEqualTo(before);
|
||||
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, null)).isEmpty();
|
||||
seed("external", null);
|
||||
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, new JSONObject().put("qqq_token", "external")).orElseThrow().mode()).isEqualTo(Mode.UNSCOPED);
|
||||
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "external"));
|
||||
assertThat(session.getPermissions()).isEqualTo(before);
|
||||
assertThat(session.getValue("qqq.credentialScope")).isEqualTo("external");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read-only strips writes and management hasAccess permissions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readOnlyAndDeletedToken() throws QException
|
||||
{
|
||||
seed("limited", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("readOnly", true));
|
||||
QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "post.read", "post.edit", "authentik.tokens.manage.hasAccess");
|
||||
JSONObject claims = new JSONObject().put("qqq_token", "limited");
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("cms.publish.hasAccess", "post.read");
|
||||
t.authentik.tokens.get("sam").clear();
|
||||
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, claims).orElseThrow().mode()).isEqualTo(Mode.DENY_ALL);
|
||||
assertThat(new TokenScopeStore(t.config).find("limited")).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A scope cannot grant missing read permissions, even with a valid table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableNamesResolvePermissionAliasesAndNeverWiden() throws QException
|
||||
{
|
||||
seed("table", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("tables", "post"));
|
||||
QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "kof22.analytics.hasAccess");
|
||||
JSONObject claims = new JSONObject().put("qqq_token", "table");
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).containsExactly("cms.publish.hasAccess");
|
||||
session.setPermissions(Set.of("kof22.analytics.hasAccess"));
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An app includes its table actions, reports and widgets, not other apps.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appChildrenAndExplicitTableUnion() throws QException
|
||||
{
|
||||
var rules = new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
QProcessMetaData process = new QProcessMetaData().withName("publish").withTableName("post").withPermissionRules(rules);
|
||||
QReportMetaData report = new QReportMetaData().withName("report").withPermissionRules(rules);
|
||||
QWidgetMetaData widget = new QWidgetMetaData().withName("widget").withType("TABLE").withPermissionRules(rules);
|
||||
t.qInstance.addProcess(process);
|
||||
t.qInstance.addReport(report);
|
||||
t.qInstance.addWidget(widget);
|
||||
t.qInstance.addApp(new QAppMetaData().withName("content").withPermissionRules(rules).withChild(t.qInstance.getTable("post"))
|
||||
.withChild(report).withWidgets(List.of("widget")));
|
||||
seed("app", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("apps", "content").with("tables", "analytics"));
|
||||
QSession session = t.loginAs("sam@example.test").withPermissions("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess",
|
||||
"widget.hasAccess", "kof22.analytics.hasAccess", "unrelated.read");
|
||||
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "app"));
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess",
|
||||
"widget.hasAccess", "kof22.analytics.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured claim is enforced even if offering new scopes is disabled.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configuredClaimCacheAndMalformedClaim() throws QException
|
||||
{
|
||||
t.config.withTokenScopesEnabled(false).withTokenScopeClaim("credential");
|
||||
seed("external", null);
|
||||
QSession session = t.loginAs("sam@example.test").withPermissions("post.read");
|
||||
JSONObject claims = new JSONObject().put("credential", "external");
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
t.authentik.tokens.get("sam").clear();
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).containsExactly("post.read");
|
||||
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
session.withPermission("post.read");
|
||||
AuthentikTokenScopes.apply(session, new JSONObject().put("credential", new JSONObject()));
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both claims customizers narrow after computing their permission unions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mappersCannotAddPermissionsAfterDenyAll()
|
||||
{
|
||||
JSONObject claims = new JSONObject().put("qqq_token", "deleted");
|
||||
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
|
||||
new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
session.withPermission("post.read");
|
||||
new AuthentikGroupPermissionMapper().withGroupPermissions("test", List.of("post.edit"))
|
||||
.customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims.put("groups", List.of("test"))));
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review validates current access and displays the scope; execution repeats.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void reviewRejectsUnknownOrUnheldAndExecutionRechecks() throws QException
|
||||
{
|
||||
t.loginAs("sam@example.test");
|
||||
AdminRequest request = AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("description", "CI").with("expiresInDays", 30).with("tables", "missing");
|
||||
assertThat(t.admin().plan(request).getMessage()).contains("Unknown");
|
||||
request.with("tables", "post");
|
||||
assertThat(t.admin().plan(request).getMessage()).contains("read access");
|
||||
QContext.getQSession().withPermission("cms.publish.hasAccess");
|
||||
assertThat(t.admin().plan(request).describe()).contains("tables: post");
|
||||
QContext.getQSession().setPermissions(Set.of());
|
||||
assertThat(t.admin().execute(request).ok()).isFalse();
|
||||
assertThat(t.changes()).isEmpty();
|
||||
}
|
||||
/*******************************************************************************
|
||||
** A long general response cache cannot extend the scope revocation window.
|
||||
** Lookup outages deny, rather than falling back to unrestricted access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void verificationCacheIsIndependentAndApiFailureDenies() throws QException
|
||||
{
|
||||
t.config.withListCacheTtlSeconds(3600);
|
||||
seed("cached", null);
|
||||
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
|
||||
JSONObject claims = new JSONObject().put("qqq_token", "cached");
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).containsExactly("post.read");
|
||||
t.authentik.tokens.get("sam").clear();
|
||||
AuthentikRuntime.forBackend(t.config.getApiBackendName()).getTokenScopeCache().clear();
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
session.withPermission("post.read");
|
||||
com.kingsrook.qbits.authentik.client.FixtureActionUtil.handler = request ->
|
||||
com.kingsrook.qbits.authentik.client.FixtureActionUtil.response(403, "{}");
|
||||
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "unavailable"));
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The ID token may supply policy context while the access token binds scope.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void idTokenContextCannotHideAccessTokenScope()
|
||||
{
|
||||
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
|
||||
new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of(
|
||||
"idToken", new JSONObject().put("email", "sam@example.test"),
|
||||
"jwtPayloadJsonObject", new JSONObject().put("qqq_token", "deleted")));
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -61,7 +61,7 @@ class AuthentikAppTest
|
||||
assertThat(app.getChildren()).extracting(c -> c.getName())
|
||||
.containsExactly("authentik_user", "authentik_invitation", "authentik_group", "authentik_application", "authentik_approval_request", "authentik_admin_action");
|
||||
assertThat(app.getSections()).extracting(s -> s.getLabel()).containsExactly("People", "Access", "Activity");
|
||||
assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikDriftReport");
|
||||
assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikAccessTokens", "authentikDriftReport");
|
||||
|
||||
for(String subTable : List.of("authentik_group_member", "authentik_session", "authentik_token", "authentik_event", "authentik_policy_binding", "authentik_role", "authentik_blueprint"))
|
||||
{
|
||||
@@ -169,14 +169,14 @@ class AuthentikAppTest
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(p).contains("util.write(") || Files.readString(p).contains(".write(auth"));
|
||||
return (Files.readString(p).contains("util.write(") || Files.readString(p).contains(".write(auth") || Files.readString(p).contains("util.writeAs("));
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}).toList();
|
||||
assertThat(callers).extracting(p -> p.getFileName().toString()).containsExactly("AuthentikAdmin.java");
|
||||
assertThat(callers).extracting(p -> p.getFileName().toString()).containsExactlyInAnyOrder("AuthentikAdmin.java", "AuthentikTokens.java");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,4 +67,18 @@ class AuthentikRedactorTest
|
||||
assertThat(AuthentikRedactor.isSensitivePath(null)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Broker and one-time credential fields are masked even in unrelated text.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void allTokenFieldsAreMasked()
|
||||
{
|
||||
for(String name : java.util.List.of("key", "token", "access_token", "subject_token", "client_secret"))
|
||||
{
|
||||
String json = new org.json.JSONObject().put(name, "credential-to-hide").toString();
|
||||
assertThat(AuthentikRedactor.redactText("prefix " + json + " suffix", null)).doesNotContain("credential-to-hide").contains("******");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact OAuth2 session-table contract from the locally pinned core sources.
|
||||
*******************************************************************************/
|
||||
class OAuth2SubjectTokenSourceTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A UUID identifies exactly one row, and hidden accessToken is readable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void currentSessionOnly() throws QException
|
||||
{
|
||||
AdminTestSupport t = AdminTestSupport.create();
|
||||
t.qInstance.addTable(new QTableMetaData().withName("oauth_sessions").withBackendName(AdminTestSupport.LOCAL).withPrimaryKeyField("id")
|
||||
.withUniqueKey(new com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey("uuid"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("uuid", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("accessToken", QFieldType.STRING).withIsHidden(true)));
|
||||
OAuth2AuthenticationMetaData oauth = new OAuth2AuthenticationMetaData().withUserSessionTableName("oauth_sessions");
|
||||
oauth.setName("oauth");
|
||||
QContext.getQSession().setUuid("current-session");
|
||||
new InsertAction().execute(new InsertInput().withTableName("oauth_sessions").withRecords(List.of(
|
||||
new QRecord().withValue("uuid", "current-session").withValue("accessToken", "subject:3"),
|
||||
new QRecord().withValue("uuid", "other-session").withValue("accessToken", "subject:4"))));
|
||||
t.qInstance.withInstanceDefaultAuthentication(oauth);
|
||||
OAuth2SubjectTokenSource source = new OAuth2SubjectTokenSource();
|
||||
assertThat(source.subjectToken(QContext.getQSession())).isEqualTo("subject:3");
|
||||
QContext.getQSession().setUuid("unknown");
|
||||
assertThatThrownBy(() -> source.subjectToken(QContext.getQSession())).hasMessage(OAuth2SubjectTokenSource.UNAVAILABLE);
|
||||
assertThatThrownBy(() -> source.subjectToken(null)).hasMessage(OAuth2SubjectTokenSource.UNAVAILABLE);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Static/mock sessions are never exchanged as the qbit service account.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableSessionRefused() throws QException
|
||||
{
|
||||
AdminTestSupport.create();
|
||||
assertThatThrownBy(() -> new OAuth2SubjectTokenSource().subjectToken(QContext.getQSession()))
|
||||
.hasMessage(OAuth2SubjectTokenSource.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.authentik.contract;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.json.JSONObject;
|
||||
@@ -56,8 +57,10 @@ public final class AuthentikTestStack
|
||||
public static final String BOOTSTRAP_BLUEPRINT = "kof22-identity-bootstrap";
|
||||
public static final String APP_SLUG = "kof22-website-prod";
|
||||
|
||||
public static final String TOKEN_BROKER_CLIENT_SECRET = "contract-test-token-broker-secret";
|
||||
public static final String TOKEN_BLUEPRINT = "kof22-qqq-tokens";
|
||||
private static final String SECRET_KEY = "contract-test-secret-key-0123456789abcdefghijklmnop";
|
||||
private static final String SA_TOKEN_ID = "qqq-contract-test";
|
||||
public static final String SA_TOKEN_ID = "qqq-contract-test";
|
||||
|
||||
private static AuthentikTestStack instance;
|
||||
|
||||
@@ -126,6 +129,7 @@ public final class AuthentikTestStack
|
||||
env.put("AUTHENTIK_DISABLE_UPDATE_CHECK", "true");
|
||||
env.put("AUTHENTIK_DISABLE_STARTUP_ANALYTICS", "true");
|
||||
env.put("KOF22_OAUTH2_CLIENT_SECRET", "contract-test-oauth2-client-secret");
|
||||
env.put("KOF22_TOKEN_BROKER_CLIENT_SECRET", TOKEN_BROKER_CLIENT_SECRET);
|
||||
|
||||
server = authentik(authentikImage, env, "server")
|
||||
.withNetworkAliases("authentik")
|
||||
@@ -148,6 +152,7 @@ public final class AuthentikTestStack
|
||||
.withEnv(env)
|
||||
.withCommand(command)
|
||||
.withCopyFileToContainer(MountableFile.forClasspathResource("blueprints/" + INFRA_BLUEPRINT + ".yaml"), "/blueprints/custom/" + INFRA_BLUEPRINT + ".yaml")
|
||||
.withCopyFileToContainer(MountableFile.forClasspathResource("blueprints/" + TOKEN_BLUEPRINT + ".yaml"), "/blueprints/custom/" + TOKEN_BLUEPRINT + ".yaml")
|
||||
.withCopyFileToContainer(MountableFile.forClasspathResource("blueprints/" + BOOTSTRAP_BLUEPRINT + ".yaml"), "/blueprints/custom/" + BOOTSTRAP_BLUEPRINT + ".yaml"));
|
||||
}
|
||||
|
||||
@@ -164,9 +169,26 @@ public final class AuthentikTestStack
|
||||
server.start();
|
||||
|
||||
TestApi admin = admin();
|
||||
waitFor(Duration.ofMinutes(8), "both kof22 blueprints applied", () ->
|
||||
isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT)
|
||||
&& admin.first("/core/users/?username=" + SERVICE_ACCOUNT) != null);
|
||||
try
|
||||
{
|
||||
waitFor(Duration.ofMinutes(8), "all three kof22 blueprints applied", () ->
|
||||
isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT) && isSuccessful(admin, TOKEN_BLUEPRINT)
|
||||
&& admin.first("/core/users/?username=" + SERVICE_ACCOUNT) != null);
|
||||
}
|
||||
catch(IllegalStateException e)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////
|
||||
// say which blueprint failed and why (the worker logs it) //
|
||||
////////////////////////////////////////////////////////////////
|
||||
for(String name : List.of(INFRA_BLUEPRINT, BOOTSTRAP_BLUEPRINT, TOKEN_BLUEPRINT))
|
||||
{
|
||||
JSONObject instance = blueprint(name);
|
||||
System.err.println("blueprint " + name + ": " + (instance == null ? "missing" : instance.optString("status")));
|
||||
}
|
||||
worker.getLogs().lines().filter(line -> line.contains("blueprint") && (line.contains("error") || line.contains("warning") || line.contains("fail")))
|
||||
.limit(60).forEach(line -> System.err.println("worker: " + line));
|
||||
throw (e);
|
||||
}
|
||||
|
||||
int serviceAccountPk = admin.userPk(SERVICE_ACCOUNT);
|
||||
TestApi.Response created = admin.post("/core/tokens/", new JSONObject().put("identifier", SA_TOKEN_ID).put("user", serviceAccountPk).put("intent", "api").put("expiring", false));
|
||||
@@ -284,6 +306,61 @@ public final class AuthentikTestStack
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Grant only permissions absent from the production fixture. Callers remove
|
||||
** exactly the returned list, preserving shared baseline role permissions.
|
||||
*******************************************************************************/
|
||||
public java.util.List<String> grantTemporaryPermissions(java.util.List<String> requested)
|
||||
{
|
||||
java.util.Set<String> baseline = new java.util.HashSet<>();
|
||||
try(java.io.InputStream input = AuthentikTestStack.class.getResourceAsStream("/blueprints/" + BOOTSTRAP_BLUEPRINT + ".yaml"))
|
||||
{
|
||||
String yaml = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("(?m)^ - (authentik_[a-z_]+\\.[a-z_]+)\\s*$").matcher(yaml);
|
||||
while(matcher.find())
|
||||
{
|
||||
baseline.add(matcher.group(1));
|
||||
}
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Cannot read baseline role permissions", e));
|
||||
}
|
||||
java.util.List<String> additional = requested.stream().filter(permission -> !baseline.contains(permission)).toList();
|
||||
if(!additional.isEmpty())
|
||||
{
|
||||
String role = admin().first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
|
||||
TestApi.Response response = admin().post("/rbac/permissions/assigned_by_roles/" + role + "/assign/",
|
||||
new JSONObject().put("permissions", new org.json.JSONArray(additional)));
|
||||
if(response.status() != 200)
|
||||
{
|
||||
throw (new IllegalStateException("Temporary permission grant failed: HTTP " + response.status()));
|
||||
}
|
||||
}
|
||||
return (additional);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove only the temporary grants, including on assertion failure.
|
||||
*******************************************************************************/
|
||||
public void removeTemporaryPermissions(java.util.List<String> additional)
|
||||
{
|
||||
if(!additional.isEmpty())
|
||||
{
|
||||
String role = admin().first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
|
||||
TestApi.Response response = admin().patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/",
|
||||
new JSONObject().put("permissions", new org.json.JSONArray(additional)));
|
||||
if(response.status() != 204)
|
||||
{
|
||||
throw (new IllegalStateException("Temporary permission cleanup failed: HTTP " + response.status()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The blueprint instance with this name.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -146,15 +146,19 @@ class BootstrapBlueprintIT
|
||||
JSONObject stage = admin.first("/stages/email/?name=kof22-recovery-email");
|
||||
assertThat(sa.get("/stages/email/" + stage.getString("pk") + "/").status()).isEqualTo(200);
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// and nothing beyond the phase 0/1 read set //
|
||||
///////////////////////////////////////////////////
|
||||
JSONArray tokens = sa.getOk("/core/tokens/?page_size=100").getJSONArray("results");
|
||||
for(int i = 0; i < tokens.length(); i++)
|
||||
// The production v1 role reads all token metadata but never other keys.
|
||||
assertThat(sa.get("/core/tokens/?page_size=1").status()).isEqualTo(200);
|
||||
assertThat(sa.get("/core/tokens/" + AuthentikTestStack.SA_TOKEN_ID + "/view_key/").status()).isEqualTo(403);
|
||||
TestApi.Response group = sa.post("/core/groups/", new JSONObject().put("name", "bootstrap-write-probe"));
|
||||
assertThat(group.status()).isEqualTo(201);
|
||||
try
|
||||
{
|
||||
assertThat(tokens.getJSONObject(i).getJSONObject("user_obj").getString("username")).as("only its own tokens are visible").isEqualTo(SERVICE_ACCOUNT);
|
||||
assertThat(sa.delete("/core/groups/" + group.json().getString("pk") + "/").status()).isEqualTo(403);
|
||||
}
|
||||
finally
|
||||
{
|
||||
assertThat(admin.delete("/core/groups/" + group.json().getString("pk") + "/").status()).isEqualTo(204);
|
||||
}
|
||||
assertThat(sa.post("/core/groups/", new JSONObject().put("name", "should-not-exist")).status()).isEqualTo(403);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ class QbitReadContractIT
|
||||
assertThat(badDate.status()).isEqualTo(400);
|
||||
FixtureRecorder.record("error-400-bad-filter", badDate.body());
|
||||
|
||||
TestApi.Response forbidden = sa.post("/core/groups/", new JSONObject().put("name", "x"));
|
||||
TestApi.Response forbidden = sa.get("/core/tokens/" + AuthentikTestStack.SA_TOKEN_ID + "/view_key/");
|
||||
assertThat(forbidden.status()).isEqualTo(403);
|
||||
FixtureRecorder.record("error-403", forbidden.body());
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class QbitWriteContractIT
|
||||
|
||||
private static AuthentikTestStack stack;
|
||||
private static TestApi admin;
|
||||
private static String roleUuid;
|
||||
private static List<String> temporaryPermissions = List.of();
|
||||
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
@@ -109,8 +109,7 @@ class QbitWriteContractIT
|
||||
{
|
||||
stack = AuthentikTestStack.get();
|
||||
admin = stack.admin();
|
||||
roleUuid = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
|
||||
admin.post("/rbac/permissions/assigned_by_roles/" + roleUuid + "/assign/", new JSONObject().put("permissions", new JSONArray(ROLE_PERMISSIONS)));
|
||||
temporaryPermissions = stack.grantTemporaryPermissions(ROLE_PERMISSIONS);
|
||||
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "wr-admins").put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.*"))).put("qqq.io/app-access", new JSONArray(List.of(AuthentikTestStack.APP_SLUG)))
|
||||
@@ -139,9 +138,9 @@ class QbitWriteContractIT
|
||||
@AfterAll
|
||||
static void afterAll()
|
||||
{
|
||||
if(admin != null && roleUuid != null)
|
||||
if(stack != null)
|
||||
{
|
||||
admin.patch("/rbac/permissions/assigned_by_roles/" + roleUuid + "/unassign/", new JSONObject().put("permissions", new JSONArray(ROLE_PERMISSIONS)));
|
||||
stack.removeTemporaryPermissions(temporaryPermissions);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ class SuperuserFloorIT
|
||||
private static AuthentikTestStack stack;
|
||||
private static TestApi admin;
|
||||
private static TestApi sa;
|
||||
private static String roleUuid;
|
||||
private static List<String> temporaryPermissions = List.of();
|
||||
private static int probeUser;
|
||||
private static String superuserGroup;
|
||||
private static String childOfSuperuser;
|
||||
@@ -77,8 +77,7 @@ class SuperuserFloorIT
|
||||
admin = stack.admin();
|
||||
sa = stack.serviceAccount();
|
||||
|
||||
roleUuid = admin.first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
|
||||
admin.post("/rbac/permissions/assigned_by_roles/" + roleUuid + "/assign/", new JSONObject().put("permissions", new JSONArray(PHASE_2_3_PERMISSIONS)));
|
||||
temporaryPermissions = stack.grantTemporaryPermissions(PHASE_2_3_PERMISSIONS);
|
||||
|
||||
admin.post("/core/users/", new JSONObject().put("username", "floor.probe").put("name", "Floor Probe").put("type", "internal").put("path", "users"));
|
||||
probeUser = admin.userPk("floor.probe");
|
||||
@@ -97,9 +96,9 @@ class SuperuserFloorIT
|
||||
@AfterAll
|
||||
static void afterAll()
|
||||
{
|
||||
if(admin != null && roleUuid != null)
|
||||
if(stack != null)
|
||||
{
|
||||
admin.patch("/rbac/permissions/assigned_by_roles/" + roleUuid + "/unassign/", new JSONObject().put("permissions", new JSONArray(PHASE_2_3_PERMISSIONS)));
|
||||
stack.removeTemporaryPermissions(temporaryPermissions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +178,12 @@ class SuperuserFloorIT
|
||||
{
|
||||
assertThat(sa.post("/core/users/" + probeUser + "/impersonate/", new JSONObject().put("reason", "contract test")).status()).isEqualTo(403);
|
||||
assertThat(sa.delete("/core/users/" + probeUser + "/").status()).isEqualTo(403);
|
||||
assertThat(sa.post("/core/users/" + probeUser + "/recovery/", new JSONObject()).status()).isEqualTo(403);
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// with the production write role (reset_user_password, DESIGN.md 5.1) //
|
||||
// the permission check passes and only the request is invalid (400): //
|
||||
// the qbit never calls /recovery/ (5.5); it is never a 2xx link here //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
assertThat(sa.post("/core/users/" + probeUser + "/recovery/", new JSONObject()).status()).isIn(400, 403);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitProducer;
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminAction;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminRequest;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
|
||||
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestReporter;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DESIGN 13.11 against the pinned server and production token blueprint.
|
||||
** Each test owns its users, groups and tokens; credentials stay in memory.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(45)
|
||||
class TokenContractIT
|
||||
{
|
||||
private static final String LIFETIME = "goauthentik.io/user/token-maximum-lifetime";
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Duration.ofSeconds(10)).build();
|
||||
private static AuthentikTestStack stack;
|
||||
private static TestApi admin;
|
||||
private final List<Integer> users = new ArrayList<>();
|
||||
private final List<String> groups = new ArrayList<>();
|
||||
private final List<String> tokens = new ArrayList<>();
|
||||
private final List<OneTimeKey> delivered = new ArrayList<>();
|
||||
private final List<String> eventIds = new ArrayList<>();
|
||||
private String appGroup;
|
||||
private AuthentikQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All three blueprints must be successful before any probe.
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void start()
|
||||
{
|
||||
stack = AuthentikTestStack.get();
|
||||
admin = stack.admin();
|
||||
assertThat(stack.blueprint(AuthentikTestStack.TOKEN_BLUEPRINT).getString("status")).isEqualTo("successful");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Independent app-access membership for every test.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup()
|
||||
{
|
||||
JSONObject created = expect(admin.post("/core/groups/", new JSONObject().put("name", unique("access")).put("attributes",
|
||||
new JSONObject().put("qqq.io/app-access", new JSONArray().put(AuthentikTestStack.APP_SLUG)))), 201);
|
||||
appGroup = created.getString("pk");
|
||||
groups.add(appGroup);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cascading user deletion also removes managed roles and credentials.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
TokenKeyDelivery.set(null);
|
||||
QContext.clear();
|
||||
for(String identifier : tokens)
|
||||
{
|
||||
assertThat(admin.delete(tokenPath(identifier)).status()).isIn(204, 404);
|
||||
}
|
||||
for(Integer pk : users)
|
||||
{
|
||||
assertThat(admin.delete("/core/users/" + pk + "/").status()).isIn(204, 404);
|
||||
}
|
||||
for(String pk : groups)
|
||||
{
|
||||
assertThat(admin.delete("/core/groups/" + pk + "/").status()).isIn(204, 404);
|
||||
}
|
||||
delivered.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit other-owner input does not override caller-owned creation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void nonSuperuserCreateBelongsToCaller()
|
||||
{
|
||||
int other = user("other");
|
||||
String identifier = unique("caller-owned");
|
||||
tokens.add(identifier);
|
||||
JSONObject created = expect(stack.serviceAccount().post("/core/tokens/", tokenBody(identifier, other, 1)), 201);
|
||||
assertOwner(created, admin.userPk(AuthentikTestStack.SERVICE_ACCOUNT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PATCH defaults move intent and owner; explicit fields retain both.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void patchDefaultsAreDangerous()
|
||||
{
|
||||
int owner = user("patch");
|
||||
String unsafe = appPassword(owner, 1);
|
||||
JSONObject changed = expect(stack.serviceAccount().patch(tokenPath(unsafe), new JSONObject().put("expires", expires(30))), 200);
|
||||
assertThat(changed.getString("intent")).isEqualTo("api");
|
||||
assertThat(changed.getInt("user")).isEqualTo(admin.userPk(AuthentikTestStack.SERVICE_ACCOUNT));
|
||||
String safe = appPassword(owner, 1);
|
||||
assertOwner(expect(stack.serviceAccount().patch(tokenPath(safe), new JSONObject()
|
||||
.put("intent", "app_password").put("user", owner).put("expires", expires(30))), 200), owner);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove the brand cap temporarily, restoring it even on assertion failure.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void expiryCapUsesBrandThenGroupsThenUser()
|
||||
{
|
||||
int owner = user("cap");
|
||||
TestApi caller = apiAs(owner);
|
||||
JSONObject brand = admin.first("/core/brands/?default=true");
|
||||
String path = "/core/brands/" + brand.getString("brand_uuid") + "/";
|
||||
JSONObject original = brand.getJSONObject("attributes");
|
||||
try
|
||||
{
|
||||
JSONObject without = new JSONObject(original.toString());
|
||||
without.remove(LIFETIME);
|
||||
expect(admin.patch(path, new JSONObject().put("attributes", without)), 200);
|
||||
refusedLifetime(caller, owner, 30);
|
||||
expect(admin.patch(path, new JSONObject().put("attributes", new JSONObject(original.toString()).put(LIFETIME, "days=90"))), 200);
|
||||
String identifier = unique("brand-cap");
|
||||
tokens.add(identifier);
|
||||
assertOwner(expect(caller.post("/core/tokens/", tokenBody(identifier, owner, 30)), 201), owner);
|
||||
refusedLifetime(caller, owner, 120);
|
||||
expect(admin.patch("/core/groups/" + appGroup + "/", new JSONObject().put("attributes", new JSONObject()
|
||||
.put("qqq.io/app-access", new JSONArray().put(AuthentikTestStack.APP_SLUG)).put(LIFETIME, "days=10"))), 200);
|
||||
refusedLifetime(caller, owner, 30);
|
||||
attributes(owner, new JSONObject().put(LIFETIME, "days=365"));
|
||||
String overridden = unique("user-cap");
|
||||
tokens.add(overridden);
|
||||
assertOwner(expect(caller.post("/core/tokens/", tokenBody(overridden, owner, 200)), 201), owner);
|
||||
}
|
||||
finally
|
||||
{
|
||||
expect(admin.patch(path, new JSONObject().put("attributes", original)), 200);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Broker bearers create, read keys, extend and delete only their own tokens.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void brokerSupportsPersonalLifecycleAndIsolatesOwners() throws Exception
|
||||
{
|
||||
int owner = user("broker");
|
||||
TestApi caller = exchanged(owner);
|
||||
TestApi other = exchanged(user("isolated"));
|
||||
String identifier = unique("broker-created");
|
||||
tokens.add(identifier);
|
||||
assertOwner(expect(caller.post("/core/tokens/", tokenBody(identifier, owner, 30)), 201), owner);
|
||||
String key = expect(caller.get(tokenPath(identifier) + "view_key/"), 200).getString("key");
|
||||
assertThat(key.isBlank()).isFalse();
|
||||
assertThat(other.get(tokenPath(identifier)).status()).isIn(403, 404);
|
||||
assertThat(other.get(tokenPath(identifier) + "view_key/").status()).isIn(403, 404);
|
||||
assertThat(other.delete(tokenPath(identifier)).status()).isIn(403, 404);
|
||||
assertOwner(expect(caller.patch(tokenPath(identifier), new JSONObject().put("intent", "app_password")
|
||||
.put("user", owner).put("expires", expires(40))), 200), owner);
|
||||
expect(caller.delete(tokenPath(identifier)), 204);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each privileged class is independently denied by the broker policy.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void brokerDeniesProtectedOwners(TestReporter reporter) throws Exception
|
||||
{
|
||||
for(String protection : List.of("identity-admins", "authentik Admins", "attribute"))
|
||||
{
|
||||
int owner = user("denied");
|
||||
if("attribute".equals(protection))
|
||||
{
|
||||
attributes(owner, new JSONObject().put("qqq.io/protected", true));
|
||||
}
|
||||
else
|
||||
{
|
||||
expect(admin.post("/core/groups/" + admin.groupPk(protection) + "/add_user/", new JSONObject().put("pk", owner)), 204);
|
||||
}
|
||||
TestApi.Response response = exchange(subject(owner));
|
||||
assertThat(response.status()).as("broker denial for %s: %s", protection, safeError(response)).isNotEqualTo(200);
|
||||
assertThat(response.json().optString("error")).isNotBlank();
|
||||
reporter.publishEntry(protection, "HTTP " + response.status() + ": " + safeError(response));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** service_account needs add_user AND add_token (2026.8.3), and set_key needs
|
||||
** add_token (POST object permission) as well as set_token_key: the role
|
||||
** holds all three (DESIGN.md 13.9), and add_token cannot mint for others.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void serviceAccountCreateSetKeyAndExtend() throws Exception
|
||||
{
|
||||
String name = unique("service");
|
||||
JSONObject request = new JSONObject().put("name", name).put("create_group", false).put("expiring", true).put("expires", expires(300));
|
||||
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", request), 200);
|
||||
int owner = created.getInt("user_pk");
|
||||
users.add(owner);
|
||||
String identifier = "service-account-" + name + "-password";
|
||||
tokens.add(identifier);
|
||||
assertThat(created.getString("token").isBlank()).isFalse();
|
||||
JSONObject before = admin.getOk(tokenPath(identifier));
|
||||
assertOwner(before, owner);
|
||||
assertThat(Instant.parse(before.getString("expires"))).isAfter(Instant.now().plus(299, ChronoUnit.DAYS));
|
||||
attributes(owner, new JSONObject().put(LIFETIME, "days=365"));
|
||||
expect(admin.post("/core/groups/" + appGroup + "/add_user/", new JSONObject().put("pk", owner)), 204);
|
||||
String key = UUID.randomUUID().toString() + UUID.randomUUID();
|
||||
expect(stack.serviceAccount().post(tokenPath(identifier) + "set_key/", new JSONObject().put("key", key)), 204);
|
||||
expect(credentials(name, key, "openid profile email"), 200);
|
||||
assertThat(admin.getOk(tokenPath(identifier) + "view_key/").getString("key").equals(key)).isTrue();
|
||||
assertOwner(expect(stack.serviceAccount().patch(tokenPath(identifier), new JSONObject().put("intent", "app_password")
|
||||
.put("user", owner).put("expires", expires(200))), 200), owner);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Profile controls the signed token binding and groups on the access JWT.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void clientCredentialsClaimsRequireProfile() throws Exception
|
||||
{
|
||||
int owner = user("claims");
|
||||
String identifier = appPassword(owner, 1);
|
||||
String key = admin.getOk(tokenPath(identifier) + "view_key/").getString("key");
|
||||
String username = admin.getOk("/core/users/" + owner + "/").getString("username");
|
||||
JSONObject full = claims(expect(credentials(username, key, "openid profile email"), 200).getString("access_token"));
|
||||
assertThat(full.getString("qqq_token")).isEqualTo(identifier);
|
||||
assertThat(full.has("groups")).isTrue();
|
||||
JSONObject limited = claims(expect(credentials(username, key, "openid email"), 200).getString("access_token"));
|
||||
assertThat(limited.has("qqq_token")).isFalse();
|
||||
assertThat(limited.has("groups")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Local notifications identify the actual protected-token update event.
|
||||
** Observe the negative case for a full worker-processing interval as well.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void detectiveRuleNotifiesOnlyForProtectedOwner() throws Exception
|
||||
{
|
||||
int protectedOwner = user("alarm");
|
||||
expect(admin.post("/core/groups/" + admin.groupPk("identity-admins") + "/add_user/", new JSONObject().put("pk", protectedOwner)), 204);
|
||||
String protectedToken = appPassword(protectedOwner, 1);
|
||||
JSONObject created = expect(admin.post("/core/users/service_account/", new JSONObject().put("name", unique("ordinary"))
|
||||
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
|
||||
users.add(created.getInt("user_pk"));
|
||||
String ordinaryToken = "service-account-" + created.getString("username") + "-password";
|
||||
tokens.add(ordinaryToken);
|
||||
String ordinaryEvent = setKeyEvent(ordinaryToken);
|
||||
String protectedEvent = setKeyEvent(protectedToken);
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "protected-token local notification", () ->
|
||||
notifications(protectedOwner, protectedEvent).length() > 0);
|
||||
Instant deadline = Instant.now().plusSeconds(60);
|
||||
do
|
||||
{
|
||||
assertThat(notifications(protectedOwner, ordinaryEvent).length()).isZero();
|
||||
Thread.sleep(2_000);
|
||||
}
|
||||
while(Instant.now().isBefore(deadline));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner decision A (2026-10-14): the role holds add_token because 2026.8.3
|
||||
** needs it for service_account and for set_key (POST object permission).
|
||||
** Pin both: 403 without add_token, 200/204 with it (restored in finally).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void serviceAccountActionsNeedAddToken() throws Exception
|
||||
{
|
||||
JSONObject seed = expect(admin.post("/core/users/service_account/", new JSONObject().put("name", unique("needs-add"))
|
||||
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
|
||||
users.add(seed.getInt("user_pk"));
|
||||
String seedToken = "service-account-" + seed.getString("username") + "-password";
|
||||
tokens.add(seedToken);
|
||||
String role = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
|
||||
JSONObject permissions = new JSONObject().put("permissions", new org.json.JSONArray().put("authentik_core.add_token"));
|
||||
String name = unique("no-add-token");
|
||||
assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", permissions).status()).isEqualTo(204);
|
||||
try
|
||||
{
|
||||
expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name)
|
||||
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 403);
|
||||
assertThat(admin.first("/core/users/?username=" + name)).isNull();
|
||||
expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 403);
|
||||
}
|
||||
finally
|
||||
{
|
||||
assertThat(admin.post("/rbac/permissions/assigned_by_roles/" + role + "/assign/", permissions).status()).isEqualTo(200);
|
||||
}
|
||||
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name)
|
||||
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
|
||||
users.add(created.getInt("user_pk"));
|
||||
tokens.add("service-account-" + name + "-password");
|
||||
expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 204);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alarm also notifies identity-admins whenever the qbit's account
|
||||
** creates a service account (owner decision 2026-10-14).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void detectiveRuleNotifiesOnServiceAccountCreation() throws Exception
|
||||
{
|
||||
int member = user("alarm-sa");
|
||||
expect(admin.post("/core/groups/" + admin.groupPk("identity-admins") + "/add_user/", new JSONObject().put("pk", member)), 204);
|
||||
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", unique("alarmed"))
|
||||
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
|
||||
int servicePk = created.getInt("user_pk");
|
||||
users.add(servicePk);
|
||||
tokens.add("service-account-" + created.getString("username") + "-password");
|
||||
String[] eventPk = { null };
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(30), "service-account model_created event", () ->
|
||||
{
|
||||
JSONArray events = admin.getOk("/events/events/?action=model_created&ordering=-created&page_size=50&username="
|
||||
+ TestApi.encode(AuthentikTestStack.SERVICE_ACCOUNT)).getJSONArray("results");
|
||||
for(int i = 0; i < events.length(); i++)
|
||||
{
|
||||
JSONObject model = events.getJSONObject(i).getJSONObject("context").optJSONObject("model");
|
||||
if(model != null && "user".equals(model.optString("model_name")) && String.valueOf(servicePk).equals(model.optString("pk")))
|
||||
{
|
||||
eventPk[0] = events.getJSONObject(i).getString("pk");
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
});
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "service-account creation notification", () ->
|
||||
notifications(member, eventPk[0]).length() > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real qbit calls, local audit/scope persistence and the response-only SPI.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void qbitLifecycleNeverPersistsOneTimeKeys() throws Exception
|
||||
{
|
||||
int owner = user("qbit");
|
||||
String username = admin.getOk("/core/users/" + owner + "/").getString("username");
|
||||
configureQbit(owner, subject(owner));
|
||||
OneTimeKey first = issued(run(AdminRequest.of(AdminAction.CREATE_TOKEN, "", "contract personal create")
|
||||
.with("description", "Contract access").with("expiresInDays", 30)));
|
||||
expect(credentials(username, first.value(), "openid profile email"), 200);
|
||||
run(AdminRequest.of(AdminAction.EXTEND_TOKEN, first.label(), "contract extend").with("expiresInDays", 40));
|
||||
assertOwner(admin.getOk(tokenPath(first.label())), owner);
|
||||
OneTimeKey rotated = issued(run(AdminRequest.of(AdminAction.ROTATE_TOKEN, first.label(), "contract rotate")));
|
||||
expect(credentials(username, rotated.value(), "openid profile email"), 200);
|
||||
assertThat(admin.get(tokenPath(first.label())).status()).isEqualTo(404);
|
||||
run(AdminRequest.of(AdminAction.REVOKE_TOKEN, rotated.label(), "contract revoke"));
|
||||
assertThat(admin.get(tokenPath(rotated.label())).status()).isEqualTo(404);
|
||||
String serviceName = unique("qbit-service");
|
||||
try
|
||||
{
|
||||
AdminRequest createService = AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "contract service create")
|
||||
.with("name", serviceName).with("groups", admin.getOk("/core/groups/" + appGroup + "/").getString("name")).with("expiresInDays", 300);
|
||||
OneTimeKey service = issued(run(createService));
|
||||
int servicePk = admin.userPk(serviceName);
|
||||
JSONObject attributes = admin.getOk("/core/users/" + servicePk + "/").getJSONObject("attributes");
|
||||
assertThat(attributes.getString(LIFETIME)).isEqualTo("days=365");
|
||||
assertThat(attributes.getBoolean("goauthentik.io/user/token-expires")).isTrue();
|
||||
expect(credentials(serviceName, service.value(), "openid profile email"), 200);
|
||||
OneTimeKey next = issued(run(AdminRequest.of(AdminAction.ROTATE_TOKEN, service.label(), "contract service rotate")));
|
||||
expect(credentials(serviceName, next.value(), "openid profile email"), 200);
|
||||
assertThat(credentials(serviceName, service.value(), "openid profile email").status()).isNotEqualTo(200);
|
||||
assertOwner(admin.getOk(tokenPath(next.label())), servicePk);
|
||||
run(AdminRequest.of(AdminAction.EXTEND_TOKEN, next.label(), "contract service extend").with("expiresInDays", 200));
|
||||
}
|
||||
finally
|
||||
{
|
||||
JSONObject service = admin.first("/core/users/?username=" + TestApi.encode(serviceName));
|
||||
if(service != null)
|
||||
{
|
||||
users.add(service.getInt("pk"));
|
||||
}
|
||||
}
|
||||
for(OneTimeKey key : delivered)
|
||||
{
|
||||
for(String table : List.of("authentik_admin_action", "authentik_token_scope", "authentik_approval_request"))
|
||||
{
|
||||
String rows = com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(new QueryAction().execute(new QueryInput().withTableName(table)).getRecords());
|
||||
assertThat(rows.contains(key.value())).as("No secret in %s", table).isFalse();
|
||||
}
|
||||
for(String eventId : eventIds)
|
||||
{
|
||||
assertThat(admin.getOk("/events/events/" + eventId + "/").toString().contains(key.value()))
|
||||
.as("No secret in qbit custom event").isFalse();
|
||||
}
|
||||
}
|
||||
assertThat(delivered).hasSize(4);
|
||||
assertThat(eventIds).hasSize(7);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A real runtime using the qbit bearer and a normal user's signed subject JWT.
|
||||
*******************************************************************************/
|
||||
private void configureQbit(int owner, String subject) throws Exception
|
||||
{
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin")
|
||||
.withListCacheTtlSeconds(0).withGroupCacheTtlSeconds(0).withBulkPacingMillis(0).withLocalBackendName("memory")
|
||||
.withRecoveryEmailStage("kof22-recovery-email")
|
||||
.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.TOKENS))
|
||||
.withTokenBrokerClientId("qqq-kof22-token-broker").withTokenBrokerClientSecret(AuthentikTestStack.TOKEN_BROKER_CLIENT_SECRET)
|
||||
.withTokenEndpointUrl(stack.baseUrl() + "/application/o/token/").withSubjectTokenSource(session -> subject)
|
||||
.withAppClientId(AuthentikTestStack.APP_SLUG).withAuthentikPublicUrl(stack.baseUrl()).withAppPublicUrl("https://app.example.test");
|
||||
QInstance instance = new QInstance();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
new AuthentikQBitProducer().withQBitConfig(config).produce(instance);
|
||||
Set<String> permissions = new HashSet<>();
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
permissions.add(permission.getSessionPermission());
|
||||
}
|
||||
QSession session = new QSession();
|
||||
session.setUser(new QUser().withIdReference(admin.getOk("/core/users/" + owner + "/").getString("email")));
|
||||
session.setPermissions(permissions);
|
||||
QContext.init(instance, session);
|
||||
TokenKeyDelivery.set((output, key) -> delivered.add(key));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve custom event identifiers without retaining any response secret.
|
||||
*******************************************************************************/
|
||||
private AuthentikAdmin.AdminResult run(AdminRequest request) throws Exception
|
||||
{
|
||||
AuthentikAdmin.AdminResult result = new AuthentikAdmin(AuthentikActionUtil.forBackend(config.getApiBackendName())).execute(request);
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
assertThat(result.eventPk()).isNotBlank();
|
||||
eventIds.add(result.eventPk());
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the one-time adapter and ensure a second take is empty.
|
||||
*******************************************************************************/
|
||||
private OneTimeKey issued(AuthentikAdmin.AdminResult result)
|
||||
{
|
||||
OneTimeKey key = result.takeSecret();
|
||||
assertThat(key).isNotNull();
|
||||
assertThat(result.takeSecret()).isNull();
|
||||
assertThat(TokenKeyDelivery.tryDeliver(new RunBackendStepOutput(), key)).isTrue();
|
||||
tokens.add(key.label());
|
||||
result.delivered();
|
||||
return (key);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** New ordinary user with independently owned membership and cleanup.
|
||||
*******************************************************************************/
|
||||
private int user(String prefix)
|
||||
{
|
||||
String name = unique(prefix);
|
||||
JSONObject created = expect(admin.post("/core/users/", new JSONObject().put("username", name).put("name", name)
|
||||
.put("email", name + "@example.test").put("type", "internal").put("path", "users")), 201);
|
||||
int pk = created.getInt("pk");
|
||||
users.add(pk);
|
||||
expect(admin.post("/core/groups/" + appGroup + "/add_user/", new JSONObject().put("pk", pk)), 204);
|
||||
return (pk);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Merge test attributes, preserving service-account flags.
|
||||
*******************************************************************************/
|
||||
private void attributes(int owner, JSONObject extra)
|
||||
{
|
||||
String path = "/core/users/" + owner + "/";
|
||||
JSONObject attributes = admin.getOk(path).getJSONObject("attributes");
|
||||
extra.keySet().forEach(key -> attributes.put(key, extra.get(key)));
|
||||
expect(admin.patch(path, new JSONObject().put("attributes", attributes)), 200);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bootstrap setup can create a credential for another user.
|
||||
*******************************************************************************/
|
||||
private String appPassword(int owner, int days)
|
||||
{
|
||||
String identifier = unique("password");
|
||||
tokens.add(identifier);
|
||||
assertOwner(expect(admin.post("/core/tokens/", tokenBody(identifier, owner, days)), 201), owner);
|
||||
return (identifier);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** API bearer for cap probes, independent of the broker under test.
|
||||
*******************************************************************************/
|
||||
private TestApi apiAs(int owner)
|
||||
{
|
||||
String identifier = unique("api");
|
||||
tokens.add(identifier);
|
||||
expect(admin.post("/core/tokens/", new JSONObject().put("identifier", identifier).put("user", owner).put("intent", "api")), 201);
|
||||
return (new TestApi(stack.apiRoot(), admin.getOk(tokenPath(identifier) + "view_key/").getString("key")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mint an app JWT using the explicitly enabled client-credentials grant.
|
||||
*******************************************************************************/
|
||||
private String subject(int owner) throws Exception
|
||||
{
|
||||
String identifier = appPassword(owner, 1);
|
||||
String key = admin.getOk(tokenPath(identifier) + "view_key/").getString("key");
|
||||
return (expect(credentials(admin.getOk("/core/users/" + owner + "/").getString("username"), key, "openid profile email"), 200).getString("access_token"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exchange through the production broker, without substituting admin access.
|
||||
*******************************************************************************/
|
||||
private TestApi exchanged(int owner) throws Exception
|
||||
{
|
||||
JSONObject response = expect(exchange(subject(owner)), 200);
|
||||
assertThat(response.getInt("expires_in")).isBetween(1, 120);
|
||||
return (new TestApi(stack.apiRoot(), response.getString("access_token")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The primary provider consumes an app password to authenticate its owner.
|
||||
*******************************************************************************/
|
||||
private TestApi.Response credentials(String username, String key, String scope) throws Exception
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// no client_secret: exactly what a PAT holder sends (the generated //
|
||||
// usage text). 2026.8.3 authenticates client_credentials by the //
|
||||
// username and app password even for a confidential client //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
return (form(Map.of("grant_type", "client_credentials", "client_id", AuthentikTestStack.APP_SLUG,
|
||||
"username", username, "password", key, "scope", scope)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RFC 8693 request used by both allowed and denied subject cases.
|
||||
*******************************************************************************/
|
||||
private TestApi.Response exchange(String subject) throws Exception
|
||||
{
|
||||
return (form(Map.of("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange", "client_id", "qqq-kof22-token-broker",
|
||||
"client_secret", AuthentikTestStack.TOKEN_BROKER_CLIENT_SECRET, "subject_token", subject,
|
||||
"subject_token_type", "urn:ietf:params:oauth:token-type:jwt", "scope", "goauthentik.io/api")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HTTP/1.1 avoids Authentik's h2c POST trap. No form in error messages.
|
||||
*******************************************************************************/
|
||||
private TestApi.Response form(Map<String, String> fields) throws Exception
|
||||
{
|
||||
String body = fields.entrySet().stream().map(entry -> TestApi.encode(entry.getKey()) + "=" + TestApi.encode(entry.getValue())).collect(Collectors.joining("&"));
|
||||
HttpResponse<String> response = HTTP.send(HttpRequest.newBuilder(URI.create(stack.baseUrl() + "/application/o/token/"))
|
||||
.timeout(Duration.ofSeconds(30)).header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
return (new TestApi.Response(response.statusCode(), response.body()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pin refusal instead of accepting a silently shortened lifetime.
|
||||
*******************************************************************************/
|
||||
private void refusedLifetime(TestApi caller, int owner, int days)
|
||||
{
|
||||
String identifier = unique("too-long");
|
||||
tokens.add(identifier);
|
||||
JSONObject response = expect(caller.post("/core/tokens/", tokenBody(identifier, owner, days)), 400);
|
||||
assertThat(response.getJSONArray("expires").toString()).contains("exceeds maximum lifetime");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Match the update by immutable token PK and the service-account actor.
|
||||
*******************************************************************************/
|
||||
private String setKeyEvent(String identifier)
|
||||
{
|
||||
String pk = admin.getOk(tokenPath(identifier)).getString("pk");
|
||||
expect(stack.serviceAccount().post(tokenPath(identifier) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 204);
|
||||
String[] found = { null };
|
||||
try
|
||||
{
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(30), "token model_updated event", () ->
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// set_key logs model_to_dict(token): identifier, intent, user (key //
|
||||
// masked), not the usual (app, model_name, pk) model reference //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
JSONArray events = admin.getOk("/events/events/?action=model_updated&ordering=-created&page_size=50"
|
||||
+ "&username=" + TestApi.encode(AuthentikTestStack.SERVICE_ACCOUNT)).getJSONArray("results");
|
||||
for(int i = 0; i < events.length(); i++)
|
||||
{
|
||||
JSONObject event = events.getJSONObject(i);
|
||||
JSONObject model = event.getJSONObject("context").optJSONObject("model");
|
||||
if(model != null && (pk.replace("-", "").equals(model.optString("pk").replace("-", "")) || identifier.equals(model.optString("identifier")))
|
||||
&& AuthentikTestStack.SERVICE_ACCOUNT.equals(event.getJSONObject("user").optString("username")))
|
||||
{
|
||||
found[0] = event.getString("pk");
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
});
|
||||
}
|
||||
catch(IllegalStateException e)
|
||||
{
|
||||
JSONArray recent = admin.getOk("/events/events/?ordering=-created&page_size=15").getJSONArray("results");
|
||||
for(int i = 0; i < recent.length(); i++)
|
||||
{
|
||||
JSONObject event = recent.getJSONObject(i);
|
||||
System.err.println("recent event " + event.optString("action") + " user=" + event.optJSONObject("user") + " ctx=" + event.optJSONObject("context"));
|
||||
}
|
||||
throw (e);
|
||||
}
|
||||
return (found[0]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exclude unrelated notifications with destination and exact event filters.
|
||||
*******************************************************************************/
|
||||
private JSONArray notifications(int user, String event)
|
||||
{
|
||||
return (admin.getOk("/events/notifications/?user=" + user + "&event=" + event).getJSONArray("results"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Status diagnostics include redacted errors, never success bodies.
|
||||
*******************************************************************************/
|
||||
private static JSONObject expect(TestApi.Response response, int status)
|
||||
{
|
||||
assertThat(response.status()).as("Expected HTTP %s; %s", status, safeError(response)).isEqualTo(status);
|
||||
return (response.json());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unexpected success must not leak a key through assertion diagnostics.
|
||||
*******************************************************************************/
|
||||
private static String safeError(TestApi.Response response)
|
||||
{
|
||||
if(response.status() < 400)
|
||||
{
|
||||
return ("HTTP " + response.status() + " (body withheld)");
|
||||
}
|
||||
return (com.kingsrook.qbits.authentik.client.AuthentikRedactor.redactText(response.body(), null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata assertions contain no credentials.
|
||||
*******************************************************************************/
|
||||
private static void assertOwner(JSONObject token, int owner)
|
||||
{
|
||||
assertThat(token.getInt("user")).isEqualTo(owner);
|
||||
assertThat(token.getString("intent")).isEqualTo("app_password");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supply every field whose omission triggers dangerous defaults.
|
||||
*******************************************************************************/
|
||||
private static JSONObject tokenBody(String identifier, int owner, int days)
|
||||
{
|
||||
return (new JSONObject().put("identifier", identifier).put("user", owner).put("intent", "app_password")
|
||||
.put("description", "Contract test").put("expiring", true).put("expires", expires(days)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decode the server-issued payload without printing the JWT.
|
||||
*******************************************************************************/
|
||||
private static JSONObject claims(String jwt)
|
||||
{
|
||||
return (new JSONObject(new String(Base64.getUrlDecoder().decode(jwt.split("\\.")[1]), StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identifiers compatible with token URLs and Django slugify.
|
||||
*******************************************************************************/
|
||||
private static String unique(String prefix)
|
||||
{
|
||||
return ("tc-" + prefix + "-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Relative expiry comfortably away from the cap boundary.
|
||||
*******************************************************************************/
|
||||
private static String expires(int days)
|
||||
{
|
||||
return (Instant.now().plus(days, ChronoUnit.DAYS).toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All identifiers here are internally generated safe URL components.
|
||||
*******************************************************************************/
|
||||
private static String tokenPath(String identifier)
|
||||
{
|
||||
return ("/core/tokens/" + identifier + "/");
|
||||
}
|
||||
}
|
||||
+176
@@ -17,11 +17,13 @@
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureRouter;
|
||||
@@ -35,6 +37,9 @@ import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
@@ -130,6 +135,177 @@ class AuthentikGroupPermissionMapperTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct API groups grant their own and inherited permissions to both
|
||||
** people and service accounts, preserving the host's session permissions.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { "internal", "service_account" })
|
||||
void automatedSessionsResolveGroups(String type)
|
||||
{
|
||||
JSONObject user = user(99, " Person@Example.Test ").put("type", type)
|
||||
.put("groups_obj", new JSONArray().put(new JSONObject().put("name", "child")));
|
||||
usersWithEmail(user, user(100, "other@example.test"));
|
||||
FixtureActionUtil.groupGraphBody = new JSONObject()
|
||||
.put("pagination", new JSONObject().put("count", 2).put("current", 1).put("total_pages", 1))
|
||||
.put("results", new JSONArray()
|
||||
.put(new JSONObject().put("pk", "parent-pk").put("name", "parent").put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.users.read"))))
|
||||
.put(new JSONObject().put("pk", "child-pk").put("name", "child").put("parents", new JSONArray().put("parent-pk")).put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.groups.manage"))))).toString();
|
||||
config.withStaticGroupPermissions(Map.of("child", List.of("config.fallback")));
|
||||
QSession session = new QSession();
|
||||
session.setPermissions(Set.of("already.there"));
|
||||
new AuthentikGroupPermissionMapper().withGroupPermissions("child", List.of("mapper.fallback"))
|
||||
.customizeAutomatedSessionForUser(qInstance, session, " person@example.test ");
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("already.there", "config.fallback", "mapper.fallback",
|
||||
IdentityPermission.USERS_READ.getSessionPermission(), IdentityPermission.GROUPS_MANAGE.getSessionPermission());
|
||||
assertThat(FixtureActionUtil.REQUESTS).anySatisfy(request -> assertThat(request.getURI().getRawQuery())
|
||||
.contains("email=person%40example.test", "include_groups=true", "include_roles=false"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unknown, blank, ambiguous and inactive identities never acquire groups,
|
||||
** host permissions or bootstrap permissions from an email alone.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = { "unknown", "duplicate", "inactive", "missing-active", "bounded" })
|
||||
void automatedSessionsRejectUnprovenUsers(String scenario)
|
||||
{
|
||||
JSONObject candidate = user(42, "owner@example.test").put("groups_obj",
|
||||
new JSONArray().put(new JSONObject().put("name", "phase1-child-of-identity-admins")));
|
||||
if("duplicate".equals(scenario))
|
||||
{
|
||||
usersWithEmail(candidate, user(99, " OWNER@example.test ").put("is_active", false));
|
||||
}
|
||||
else if("inactive".equals(scenario))
|
||||
{
|
||||
usersWithEmail(candidate.put("is_active", false));
|
||||
}
|
||||
else if("missing-active".equals(scenario))
|
||||
{
|
||||
candidate.remove("is_active");
|
||||
usersWithEmail(candidate);
|
||||
}
|
||||
else if("bounded".equals(scenario))
|
||||
{
|
||||
JSONObject[] candidates = new JSONObject[10];
|
||||
java.util.Arrays.fill(candidates, user(99, "other@example.test"));
|
||||
candidates[0] = candidate;
|
||||
usersWithEmail(candidates);
|
||||
}
|
||||
else
|
||||
{
|
||||
usersWithEmail();
|
||||
}
|
||||
config.withStaticGroupPermissions(Map.of("phase1-child-of-identity-admins", List.of("config.fallback")))
|
||||
.withHostPermissionSource((email, groups) -> Set.of("host.permission"));
|
||||
QSession session = new QSession();
|
||||
session.setPermissions(Set.of("already.there"));
|
||||
new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("mapper.fallback"))
|
||||
.customizeAutomatedSessionForUser(qInstance, session, scenario == null || scenario.isEmpty() ? scenario : "owner@example.test");
|
||||
assertThat(session.getPermissions()).containsExactly("already.there");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed API lookups and absent runtimes keep only existing permissions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void automatedSessionLookupFailure()
|
||||
{
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(403, "{\"detail\":\"Forbidden\"}");
|
||||
QSession session = new QSession();
|
||||
session.setPermissions(Set.of("already.there"));
|
||||
new AuthentikGroupPermissionMapper().customizeAutomatedSessionForUser(qInstance, session, "owner@example.test");
|
||||
assertThat(session.getPermissions()).containsExactly("already.there");
|
||||
AuthentikGroupPermissionMapper noRuntime = new AuthentikGroupPermissionMapper()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Deliberately missing backend.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getApiBackendName()
|
||||
{
|
||||
return ("missingAutomatedBackend");
|
||||
}
|
||||
};
|
||||
noRuntime.customizeAutomatedSessionForUser(qInstance, session, "owner@example.test");
|
||||
assertThat(session.getPermissions()).containsExactly("already.there");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A readable user's direct group names retain static fallbacks when the
|
||||
** group graph fails; group UUIDs can also be resolved when objects are absent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void automatedGroupFallbacks()
|
||||
{
|
||||
JSONObject child = new JSONObject(FixtureActionUtil.groupGraphBody).getJSONArray("results").toList().stream()
|
||||
.map(value -> new JSONObject((Map<?, ?>) value)).filter(group -> "phase1-child-of-identity-admins".equals(group.optString("name")))
|
||||
.findFirst().orElseThrow();
|
||||
usersWithEmail(user(99, "person@example.test").put("groups", new JSONArray().put(child.getString("pk"))));
|
||||
QSession session = new QSession();
|
||||
new AuthentikGroupPermissionMapper().customizeAutomatedSessionForUser(qInstance, session, "person@example.test");
|
||||
assertThat(session.getPermissions()).contains(IdentityPermission.USERS_READ.getSessionPermission());
|
||||
|
||||
usersWithEmail(user(99, "person@example.test").put("groups_obj", new JSONArray().put(child)));
|
||||
FixtureActionUtil.groupGraphBody = "not json";
|
||||
config.withStaticGroupPermissions(Map.of(child.getString("name"), List.of("config.fallback")));
|
||||
session = new QSession();
|
||||
new AuthentikGroupPermissionMapper().withGroupPermissions(child.getString("name"), List.of("mapper.fallback"))
|
||||
.customizeAutomatedSessionForUser(qInstance, session, "person@example.test");
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("config.fallback", "mapper.fallback");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cut-over floor is identical to sign-in and bootstrap recognizes both
|
||||
** API immutable ids without inventing an email_verified claim.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { "42", "uuid-42", "not-this-user" })
|
||||
void automatedCutoverFloor(String bootstrapSubject)
|
||||
{
|
||||
config.withBootstrapOperatorSubjects(bootstrapSubject)
|
||||
.withStaticGroupPermissions(Map.of("phase1-child-of-identity-admins", List.of("config.fallback")))
|
||||
.withHostPermissionSource((email, groups) ->
|
||||
{
|
||||
assertThat(email).isEqualTo("owner@example.test");
|
||||
assertThat(groups).containsExactly("phase1-child-of-identity-admins");
|
||||
return (Set.of("host.permission"));
|
||||
});
|
||||
AuthentikRuntime.forBackend(config.getApiBackendName()).markMapperInvoked();
|
||||
AuthentikRuntime.forBackend(config.getApiBackendName()).setMapperState(
|
||||
new AccessComparison.State(AccessComparison.Status.BLOCKED, "Test guard", Instant.now()));
|
||||
usersWithEmail(user(42, "owner@example.test").put("groups_obj",
|
||||
new JSONArray().put(new JSONObject().put("name", "phase1-child-of-identity-admins"))));
|
||||
QSession session = new QSession();
|
||||
new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("mapper.fallback"))
|
||||
.customizeAutomatedSessionForUser(qInstance, session, "owner@example.test");
|
||||
if("not-this-user".equals(bootstrapSubject))
|
||||
{
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("config.fallback", "mapper.fallback", "host.permission");
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(session.getPermissions()).contains("config.fallback", "mapper.fallback", "host.permission");
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
assertThat(session.getPermissions()).contains(permission.getSessionPermission());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bootstrap operators are keyed on immutable ids, never on an email: a
|
||||
** spoofed email (duplicate, or a different user) is denied, an unverified
|
||||
|
||||
+32
-39
@@ -26,26 +26,25 @@ import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table features use generic permission suffixes from core's existing catalog
|
||||
** API. No test or production code depends on the unreleased metadata hook.
|
||||
** Table features are discovered through core's registered metadata and catalog.
|
||||
*******************************************************************************/
|
||||
class PolicyFeaturePermissionsTest
|
||||
{
|
||||
@@ -61,31 +60,27 @@ class PolicyFeaturePermissionsTest
|
||||
QInstance instance = PolicyCatalogTest.instance();
|
||||
instance.addTable(new QTableMetaData().withName("person_records").withLabel("People").withBackendName("memory")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withPermissionBaseName("person")));
|
||||
instance.addTable(new QTableMetaData().withName("person_extra").withLabel("Extra People").withBackendName("memory")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.ofOrWithNew(instance);
|
||||
features.withPermission(feature("comment", "Comment", "person_records", "legacy"))
|
||||
.withPermission(feature("analyze", "Old label", "person_records"))
|
||||
.withPermission(feature("analyze", "Analyze", "person_records", "gate", "open_table"))
|
||||
.withPermission(feature("tag", "Tag", "shared_one", "shared_two"))
|
||||
.withPermission(feature("share", "Share", "person_extra"))
|
||||
.withPermission(feature("", "Empty", "person_records"));
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shape returned by PermissionsHelper when core registers a feature.
|
||||
** Register a feature only for the named tables; core supplies permission names.
|
||||
*******************************************************************************/
|
||||
private static AvailablePermission feature(String name, String label)
|
||||
private static TableFeaturePermission feature(String suffix, String label, String... tables)
|
||||
{
|
||||
return (new AvailablePermission().withName(name).withObjectName("People").withObjectType("Table").withPermissionType(label));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliberately unsorted; duplicates and standard suffixes are not features.
|
||||
*******************************************************************************/
|
||||
private static List<AvailablePermission> available()
|
||||
{
|
||||
return (List.of(feature("person.comment", "Comment"), feature("person.analyze", "Analyze"), feature("person.analyze", "Analyze"),
|
||||
feature("person.read", "Read"), feature("person.insert", "Insert"), feature("person.edit", "Edit"), feature("person.delete", "Delete"),
|
||||
feature("person.hasAccess", "Access"), feature("person_extra.share", "Share"), feature("person.", "Empty"),
|
||||
feature("gate.analyze", "Analyze"), feature("legacy.comment", "Comment"), feature("legacy.write", "Write"),
|
||||
feature("shared_base.tag", "Tag"), feature("open_table.analyze", "Analyze")));
|
||||
return (new TableFeaturePermission().withSuffix(suffix).withLabel(label)
|
||||
.withAppliesTo((instance, table) -> List.of(tables).contains(table.getName())));
|
||||
}
|
||||
|
||||
|
||||
@@ -106,11 +101,13 @@ class PolicyFeaturePermissionsTest
|
||||
@Test
|
||||
void catalogListsFeaturesSeparately()
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
QInstance instance = instance();
|
||||
assertThat(PermissionsHelper.getAllAvailablePermissions(instance)).filteredOn(permission -> permission.getName().equals("shared_base.tag")).hasSize(2);
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance);
|
||||
assertThat(catalog.get("person").actions()).containsExactly(PolicyAction.READ, PolicyAction.INSERT, PolicyAction.EDIT, PolicyAction.DELETE);
|
||||
assertThat(catalog.get("person").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"), new PolicyCatalog.Feature("comment", "Comment"));
|
||||
assertThat(catalog.get("gate").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"));
|
||||
assertThat(catalog.get("legacy").features()).containsExactly(new PolicyCatalog.Feature("comment", "Comment"), new PolicyCatalog.Feature("write", "Write"));
|
||||
assertThat(catalog.get("legacy").features()).containsExactly(new PolicyCatalog.Feature("comment", "Comment"), new PolicyCatalog.Feature("write", "WRITE"));
|
||||
assertThat(catalog.get("shared_base").features()).containsExactly(new PolicyCatalog.Feature("tag", "Tag"));
|
||||
assertThat(catalog.get("open_table")).isNull();
|
||||
assertThat(catalog.permissionNames()).contains("person.analyze", "person.comment", "gate.analyze", "legacy.comment", "legacy.write", "shared_base.tag")
|
||||
@@ -136,7 +133,7 @@ class PolicyFeaturePermissionsTest
|
||||
{
|
||||
QInstance instance = instance();
|
||||
instance.getTable("person_records").withoutCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
PolicyCatalog.Resource resource = PolicyCatalog.of(instance, available()).get("person");
|
||||
PolicyCatalog.Resource resource = PolicyCatalog.of(instance).get("person");
|
||||
assertThat(resource.actions()).isEmpty();
|
||||
assertThat(resource.features()).hasSize(2);
|
||||
}
|
||||
@@ -149,7 +146,7 @@ class PolicyFeaturePermissionsTest
|
||||
@Test
|
||||
void parserValidatesFeaturesPerResource() throws Exception
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance());
|
||||
assertThat(PolicyParser.parseStrict(document(List.of("read", "analyze"), "person"), FORMAT, catalog).permissions())
|
||||
.containsExactly("person.analyze", "person.read");
|
||||
assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "gate"), FORMAT, catalog)).containsExactly("gate.analyze", "person.analyze");
|
||||
@@ -171,7 +168,7 @@ class PolicyFeaturePermissionsTest
|
||||
@Test
|
||||
void wildcardDoesNotGrantFeatures()
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance());
|
||||
assertThat(PolicyParser.parse(document(List.of("*"), "person"), FORMAT, catalog))
|
||||
.containsExactlyInAnyOrder("person.hasAccess", "person.read", "person.insert", "person.edit", "person.delete");
|
||||
assertThat(PolicyParser.parse(document(List.of("*", "analyze"), "person"), FORMAT, catalog)).hasSize(6).contains("person.analyze").doesNotContain("person.comment");
|
||||
@@ -185,7 +182,7 @@ class PolicyFeaturePermissionsTest
|
||||
@Test
|
||||
void generatorRoundTripsFeatures() throws Exception
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance());
|
||||
List<String> permissions = List.of("person.comment", "person.read", "person.analyze", "gate.analyze");
|
||||
JSONObject generated = PolicyGenerator.generate(permissions, FORMAT, catalog);
|
||||
String compact = PolicyGenerator.toCompact(generated);
|
||||
@@ -206,22 +203,18 @@ class PolicyFeaturePermissionsTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The real mapper discovers simulated core entries and adds their exact names.
|
||||
** The real mapper discovers registered core features and adds their exact names.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void claimMapperUsesCoreCatalog()
|
||||
{
|
||||
QInstance instance = instance();
|
||||
try(MockedStatic<PermissionsHelper> helper = Mockito.mockStatic(PermissionsHelper.class, Mockito.CALLS_REAL_METHODS))
|
||||
{
|
||||
helper.when(() -> PermissionsHelper.getAllAvailablePermissions(instance)).thenReturn(available());
|
||||
QSession session = new QSession();
|
||||
session.setPermissions(Set.of("existing.read"));
|
||||
JSONObject claims = new JSONObject().put(FORMAT.claim(), document(List.of("analyze"), "person"));
|
||||
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.read", "person.analyze");
|
||||
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance))).containsExactly("person.analyze");
|
||||
}
|
||||
QSession session = new QSession();
|
||||
session.setPermissions(Set.of("existing.read"));
|
||||
JSONObject claims = new JSONObject().put(FORMAT.claim(), document(List.of("analyze"), "person"));
|
||||
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.read", "person.analyze");
|
||||
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance))).containsExactly("person.analyze");
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +225,7 @@ class PolicyFeaturePermissionsTest
|
||||
@Test
|
||||
void comparisonIncludesInheritedFeatures() throws Exception
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance());
|
||||
JSONObject parent = new JSONObject().put("pk", "parent").put("name", "Analysts")
|
||||
.put("attributes", new JSONObject().put(FORMAT.attribute(), document(List.of("analyze"), "person")));
|
||||
JSONObject child = new JSONObject().put("pk", "child").put("name", "Staff").put("parents", new JSONArray(List.of("parent")))
|
||||
|
||||
@@ -131,7 +131,14 @@ class AuthentikProcessesTest
|
||||
|
||||
for(QProcessMetaData process : t.qInstance.getProcesses().values())
|
||||
{
|
||||
assertThat(process.getTableName()).as(process.getName()).isNotBlank();
|
||||
if(!process.getName().equals("authentikTokenReminders"))
|
||||
{
|
||||
assertThat(process.getTableName()).as(process.getName()).isNotBlank();
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(process.getIsHidden()).isTrue();
|
||||
}
|
||||
assertThat(t.qInstance.getApp("authentikAdmin").getChildren()).extracting(c -> c.getName()).doesNotContain(process.getName());
|
||||
}
|
||||
assertThat(t.qInstance.getProcess("authentikCreateUser").getMaxInputRecords()).isEqualTo(0);
|
||||
|
||||
@@ -41,6 +41,53 @@ entries:
|
||||
- authentik_events.add_event
|
||||
- authentik_blueprints.view_blueprintinstance
|
||||
|
||||
# v1 write permissions (qbit-authentik DESIGN.md 5.1): never enable_group_superuser, view_token_key, impersonate, delete_user
|
||||
- authentik_core.add_user
|
||||
- authentik_core.change_user
|
||||
- authentik_core.reset_user_password
|
||||
- authentik_core.add_group
|
||||
- authentik_core.change_group
|
||||
- authentik_core.add_user_to_group
|
||||
- authentik_core.remove_user_from_group
|
||||
- authentik_core.view_token
|
||||
- authentik_core.delete_token
|
||||
# QQQ access tokens (qbit-authentik DESIGN.md 13, owner decisions
|
||||
# 2026-10-13). Guardrails, because token permissions are model-wide:
|
||||
# - view_token_key is NOT granted. add_token IS granted, and is safe: for a
|
||||
# non-superuser, POST /core/tokens/ always creates the token for the
|
||||
# CALLER (TokenViewSet.perform_create), so it can never mint a token for
|
||||
# someone else. Authentik needs it for POST /core/users/service_account/
|
||||
# (add_user + add_token) and for POST .../set_key/ (POST object
|
||||
# permission = add_token, plus set_token_key). Verified on 2026.8.3.
|
||||
# People's tokens are created AS THAT PERSON through the
|
||||
# qqq-kof22-token-broker exchange (kof22-qqq-tokens.yaml), whose deny
|
||||
# policy refuses superusers, authentik Admins, identity-admins,
|
||||
# qqq.io/protected and this service account.
|
||||
# - change_token extends expiry; every PATCH sends intent, user and
|
||||
# expires (Authentik's serializer defaults would otherwise turn an app
|
||||
# password into an API token or move it to this account), and the qbit
|
||||
# re-reads the token afterwards.
|
||||
# - set_token_key rotates SERVICE-ACCOUNT app passwords only; the qbit
|
||||
# refuses protected owners before the call, and the
|
||||
# qqq-kof22-token-alarm rule (kof22-qqq-tokens.yaml) notifies
|
||||
# identity-admins if this account ever touches a protected user's token.
|
||||
- authentik_core.add_token
|
||||
- authentik_core.change_token
|
||||
- authentik_core.set_token_key
|
||||
- authentik_core.delete_authenticatedsession
|
||||
- authentik_stages_authenticator_totp.view_totpdevice
|
||||
- authentik_stages_authenticator_totp.delete_totpdevice
|
||||
- authentik_stages_authenticator_static.view_staticdevice
|
||||
- authentik_stages_authenticator_static.delete_staticdevice
|
||||
- authentik_stages_authenticator_webauthn.view_webauthndevice
|
||||
- authentik_stages_authenticator_webauthn.delete_webauthndevice
|
||||
- authentik_stages_authenticator_email.view_emaildevice
|
||||
- authentik_stages_authenticator_email.delete_emaildevice
|
||||
- authentik_stages_authenticator_sms.view_smsdevice
|
||||
- authentik_stages_authenticator_sms.delete_smsdevice
|
||||
- authentik_stages_authenticator_duo.view_duodevice
|
||||
- authentik_stages_authenticator_duo.delete_duodevice
|
||||
|
||||
# The qbit service account. Its API token is minted once by hand (never here).
|
||||
- model: authentik_core.user
|
||||
state: created
|
||||
|
||||
@@ -13,6 +13,29 @@ metadata:
|
||||
labels:
|
||||
blueprints.goauthentik.io/instantiate: "true"
|
||||
entries:
|
||||
- model: authentik_providers_oauth2.scopemapping
|
||||
id: qqq-kof22-token-claim
|
||||
state: present
|
||||
identifiers:
|
||||
name: "QQQ - access token id"
|
||||
attrs:
|
||||
name: "QQQ - access token id"
|
||||
scope_name: profile
|
||||
description: "Identifier of the QQQ access token a client_credentials JWT was minted from"
|
||||
expression: |
|
||||
from authentik.core.models import Token
|
||||
try:
|
||||
http = http_request
|
||||
except NameError:
|
||||
http = None
|
||||
if http is None or http.POST.get("grant_type", "") != "client_credentials":
|
||||
return {}
|
||||
password = http.POST.get("password", "")
|
||||
if not password:
|
||||
return {}
|
||||
token = Token.objects.filter(key=password, intent="app_password", user=request.user).first()
|
||||
return {"qqq_token": token.identifier} if token else {}
|
||||
|
||||
- model: authentik_flows.flow
|
||||
id: kof22-recovery-flow
|
||||
state: present
|
||||
@@ -101,6 +124,14 @@ entries:
|
||||
client_type: confidential
|
||||
client_id: kof22-website-prod
|
||||
client_secret: !Env KOF22_OAUTH2_CLIENT_SECRET
|
||||
# Blueprint-created providers start with no grants on 2026.8.
|
||||
grant_types: [authorization_code, refresh_token, client_credentials]
|
||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, "authentik Self-signed Certificate"]]
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
|
||||
- !KeyOf qqq-kof22-token-claim
|
||||
redirect_uris:
|
||||
- matching_mode: strict
|
||||
url: "https://admin.kof22.test/token"
|
||||
@@ -370,3 +401,13 @@ entries:
|
||||
target: !KeyOf qqq-kof22-admin-rule
|
||||
policy: !KeyOf qqq-sync-user-write
|
||||
order: 10
|
||||
|
||||
# Preserve the built-in test brand and its login flow. The stack resolves the
|
||||
# default brand for localhost, so set the same production lifetime attribute.
|
||||
- model: authentik_brands.brand
|
||||
state: present
|
||||
identifiers:
|
||||
default: true
|
||||
attrs:
|
||||
attributes:
|
||||
goauthentik.io/user/token-maximum-lifetime: days=90
|
||||
@@ -0,0 +1,203 @@
|
||||
# Verbatim copy of cluster-gitops
|
||||
# tenants/kof22/website-authentik/kof22-qqq-tokens.yaml
|
||||
# Source: KofTwentyTwo/cluster-gitops PR #103.
|
||||
# QQQ access tokens (qbit-authentik DESIGN.md 13, owner decisions 2026-10-13).
|
||||
# Infrastructure only, `present`: the token broker the admin backend uses to
|
||||
# create a person's token AS THAT PERSON (RFC 8693 token exchange), the deny
|
||||
# policy for privileged accounts, and the detective alarm. A separate file so
|
||||
# a problem here (for example a missing broker secret) fails only this
|
||||
# blueprint, never sign-in.
|
||||
version: 1
|
||||
metadata:
|
||||
name: kof22-qqq-tokens
|
||||
labels:
|
||||
blueprints.goauthentik.io/instantiate: "true"
|
||||
entries:
|
||||
- model: authentik_blueprints.metaapplyblueprint
|
||||
attrs:
|
||||
identifiers:
|
||||
name: kof22-identity-bootstrap
|
||||
required: true
|
||||
|
||||
# Guardrail (a) inside Authentik: no broker token (so no self-service token
|
||||
# create, rotate or extend) for superusers (including inherited), members of
|
||||
# authentik Admins or identity-admins, anyone marked qqq.io/protected, or the
|
||||
# qbit's own service account.
|
||||
- model: authentik_policies_expression.expressionpolicy
|
||||
id: qqq-token-broker-deny
|
||||
state: present
|
||||
identifiers:
|
||||
name: qqq-kof22-token-broker-deny-protected
|
||||
attrs:
|
||||
name: qqq-kof22-token-broker-deny-protected
|
||||
expression: |
|
||||
user = request.user
|
||||
if user is None or not user.is_authenticated:
|
||||
return False
|
||||
if user.username == "qqq-kof22-admin-idadmin":
|
||||
return False
|
||||
if (user.attributes or {}).get("qqq.io/protected") is True:
|
||||
return False
|
||||
for group in user.all_groups():
|
||||
if group.is_superuser or group.name in ("authentik Admins", "identity-admins"):
|
||||
return False
|
||||
return True
|
||||
|
||||
# The broker: a confidential client the backend alone holds. It accepts only
|
||||
# the token-exchange grant, for subject tokens signed by the kof22 admin
|
||||
# provider, and issues 2-minute tokens with the authentik API scope.
|
||||
- model: authentik_providers_oauth2.oauth2provider
|
||||
id: qqq-token-broker-provider
|
||||
state: present
|
||||
identifiers:
|
||||
name: qqq-kof22-token-broker
|
||||
attrs:
|
||||
name: qqq-kof22-token-broker
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, kof22-authorization]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, kof22-invalidation-prod]]
|
||||
client_type: confidential
|
||||
client_id: qqq-kof22-token-broker
|
||||
client_secret: !Env KOF22_TOKEN_BROKER_CLIENT_SECRET
|
||||
grant_types:
|
||||
- urn:ietf:params:oauth:grant-type:token-exchange
|
||||
# No browser flow: the broker only answers token exchange.
|
||||
redirect_uris: []
|
||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, "authentik Self-signed Certificate"]]
|
||||
access_code_validity: minutes=1
|
||||
access_token_validity: minutes=2
|
||||
refresh_token_validity: minutes=2
|
||||
sub_mode: user_email
|
||||
issuer_mode: per_provider
|
||||
jwt_federation_providers:
|
||||
- !Find [authentik_providers_oauth2.oauth2provider, [name, kof22-website-prod]]
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-authentik_api]]
|
||||
|
||||
- model: authentik_core.application
|
||||
id: qqq-token-broker-application
|
||||
state: present
|
||||
identifiers:
|
||||
slug: qqq-kof22-token-broker
|
||||
attrs:
|
||||
name: "QQQ kof22 token broker"
|
||||
slug: qqq-kof22-token-broker
|
||||
provider: !KeyOf qqq-token-broker-provider
|
||||
policy_engine_mode: all
|
||||
meta_hide: true
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
state: present
|
||||
identifiers:
|
||||
target: !KeyOf qqq-token-broker-application
|
||||
policy: !KeyOf qqq-token-broker-deny
|
||||
attrs:
|
||||
target: !KeyOf qqq-token-broker-application
|
||||
policy: !KeyOf qqq-token-broker-deny
|
||||
order: 0
|
||||
enabled: true
|
||||
negate: false
|
||||
timeout: 30
|
||||
failure_result: false
|
||||
|
||||
# Detective control, guardrail (e): the qbit's service account must never
|
||||
# create, change, read or re-key a token whose owner is protected (a
|
||||
# superuser, a member of authentik Admins or identity-admins, or
|
||||
# qqq.io/protected). If it ever happens, guard (a) was bypassed:
|
||||
# identity-admins get an Authentik notification and an email.
|
||||
# It also notifies whenever that account creates a service account (each
|
||||
# comes with an app password; owner decision 2026-10-14), so in-app
|
||||
# service-account creation is always visible to identity-admins.
|
||||
- model: authentik_policies_expression.expressionpolicy
|
||||
id: qqq-token-alarm-policy
|
||||
state: present
|
||||
identifiers:
|
||||
name: qqq-kof22-token-alarm
|
||||
attrs:
|
||||
name: qqq-kof22-token-alarm
|
||||
expression: |
|
||||
from authentik.core.models import Token, User
|
||||
event = request.context.get("event")
|
||||
if event is None:
|
||||
return False
|
||||
if event.action not in ("model_created", "model_updated", "model_deleted", "secret_view", "secret_rotate"):
|
||||
return False
|
||||
if (event.user or {}).get("username") != "qqq-kof22-admin-idadmin":
|
||||
return False
|
||||
model = event.context.get("model") or event.context.get("secret") or {}
|
||||
# Also notify (owner decision 2026-10-14) whenever the qbit's account
|
||||
# creates a service account: each one comes with an app password.
|
||||
if event.action == "model_created" and model.get("app") == "authentik_core" and model.get("model_name") == "user":
|
||||
created = User.objects.filter(pk=model.get("pk")).first()
|
||||
return created is not None and created.type == "service_account"
|
||||
# Two shapes: the usual model reference (app, model_name, pk), and set_key's
|
||||
# model_to_dict(token) (token_uuid, identifier, intent, user; key masked).
|
||||
is_reference = model.get("app") == "authentik_core" and model.get("model_name") == "token"
|
||||
is_dict = "identifier" in model and "intent" in model
|
||||
if not is_reference and not is_dict:
|
||||
return False
|
||||
owner = None
|
||||
token = None
|
||||
if is_reference:
|
||||
token = Token.objects.including_expired().filter(pk=model.get("pk")).first()
|
||||
elif model.get("identifier"):
|
||||
token = Token.objects.including_expired().filter(identifier=model.get("identifier")).first()
|
||||
if token is not None:
|
||||
owner = token.user
|
||||
elif model.get("user"):
|
||||
owner = User.objects.filter(pk=model.get("user")).first()
|
||||
if owner is None:
|
||||
return False
|
||||
if (owner.attributes or {}).get("qqq.io/protected") is True:
|
||||
return True
|
||||
for group in owner.all_groups():
|
||||
if group.is_superuser or group.name in ("authentik Admins", "identity-admins"):
|
||||
return True
|
||||
return False
|
||||
|
||||
- model: authentik_events.notificationtransport
|
||||
id: qqq-token-alarm-local
|
||||
state: present
|
||||
identifiers:
|
||||
name: qqq-kof22-token-alarm-local
|
||||
attrs:
|
||||
name: qqq-kof22-token-alarm-local
|
||||
mode: local
|
||||
send_once: false
|
||||
|
||||
- model: authentik_events.notificationtransport
|
||||
id: qqq-token-alarm-email
|
||||
state: present
|
||||
identifiers:
|
||||
name: qqq-kof22-token-alarm-email
|
||||
attrs:
|
||||
name: qqq-kof22-token-alarm-email
|
||||
mode: email
|
||||
send_once: false
|
||||
|
||||
- model: authentik_events.notificationrule
|
||||
id: qqq-token-alarm-rule
|
||||
state: present
|
||||
identifiers:
|
||||
name: qqq-kof22-token-alarm
|
||||
attrs:
|
||||
name: qqq-kof22-token-alarm
|
||||
severity: alert
|
||||
destination_group: !Find [authentik_core.group, [name, identity-admins]]
|
||||
destination_event_user: false
|
||||
transports:
|
||||
- !KeyOf qqq-token-alarm-local
|
||||
- !KeyOf qqq-token-alarm-email
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
state: present
|
||||
identifiers:
|
||||
target: !KeyOf qqq-token-alarm-rule
|
||||
policy: !KeyOf qqq-token-alarm-policy
|
||||
attrs:
|
||||
target: !KeyOf qqq-token-alarm-rule
|
||||
policy: !KeyOf qqq-token-alarm-policy
|
||||
order: 0
|
||||
enabled: true
|
||||
negate: false
|
||||
timeout: 30
|
||||
failure_result: false
|
||||
Reference in new issue
Block a user