diff --git a/AGENTS.md b/AGENTS.md index e3cab22..03f1077 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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,14 +30,18 @@ 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 `.hasAccess`). +- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `.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) @@ -46,7 +51,9 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f169b..a9c9c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group. +- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events. + +### Added (access tokens, DESIGN.md section 13) +- Default-on `TOKENS` with personal create/rotate/revoke/extend, service-account creation and rotation, protected-owner guardrails and optional two-person approval. +- Action-scoped OAuth2 token exchange, response-only one-time key delivery (fail closed on older QQQ), generated usage instructions and non-secret token scope records. +- Token-manager read access, expanded token metadata, secret redaction and in-memory process/persistence tests. + ### Changed (core baseline, #20 / F19) - Build against the released QQQ BOM `4.1.0-RC.5` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. No Java API compatibility fixes were needed. - Core actions now enforce permissions for non-HTTP callers. Authentik-specific guards remain for direct util calls and domain rules. See [UPGRADE.md](UPGRADE.md) for host impact and the remaining test stand-in. diff --git a/CLAUDE.md b/CLAUDE.md index b9b8242..03f1077 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `.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. @@ -49,9 +51,9 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post - 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 diff --git a/README.md b/README.md index a104a3d..eba2c4c 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -71,9 +72,9 @@ The editor discovers features and labels through `PermissionsHelper.getAllAvaila ### 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 @@ -84,7 +85,7 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo | Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. | | QQQ version | QQQ 4.1.0-RC.5 (released BOM, no core SNAPSHOT). Java 21. | | Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. | -| Tables | Create `authentik_admin_action` and `authentik_approval_request` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. | +| Tables | Create `authentik_admin_action`, `authentik_approval_request` and `authentik_token_scope` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. | | Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. | | Required config | `baseUrl`, `apiToken`, `appKey`. For writes: `features`, `localBackendName`, `recoveryEmailStage` (users and credentials). Optional: `invitationFlow`, `twoPersonApproval` (default on), `userScopePathStartsWith`, `protectedUsernames`, `applicationsInScope`, `linkPrefix` (default `/app`), `throttleRequestsPerSecond` (default 10), `bulkPacingMillis`. | | Secrets / env | `apiToken` as a `${env.X}` reference; `bootstrapOperatorSubjects` (the lockout escape hatch) defaults to `${env.AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS}`: immutable ids only (the Authentik user pk or uuid, or the token `sub`), never emails, because users can change their own email. Empty it once real group grants exist. Keep secrets in the host's secret store. | @@ -106,3 +107,9 @@ Contract tests start Authentik (server and worker), Postgres and Mailpit, and ta ## License Apache 2.0 + +Access-token metadata is readable with `authentik.audit.read` or `authentik.tokens.manage`. Personal actions have no additional process permission; ownership is checked live. Managing service-account tokens needs `authentik.tokens.manage`, and creating a service account also needs `authentik.users.manage`. Configure `tokenBrokerClientId`, `tokenBrokerClientSecret` (environment references supported), `appClientId`, `appPublicUrl` and `authentikPublicUrl` for exchange and usage instructions. Scope fields remain unavailable until `tokenScopesEnabled=true`. + +Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added. + +Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`). diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1c937d2..e790fb6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -72,7 +72,7 @@ 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--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--token-broker` provider (with a deny policy for protected users). Service accounts use `POST /core/users/service_account/` (create), `set_key` (rotate) and `PATCH` (extend). The qbit role gains `change_token`, `set_token_key` and `add_token`, keeps `delete_token` and `add_user`, and **never** holds `view_token_key`. `add_token` was found necessary by the contract tests (`service_account` needs `add_user` + `add_token`; `set_key` needs the POST object permission `add_token` + `set_token_key`) and is safe because a non-superuser's create is always the caller's (§13.9). Limitation: one app password per service account. | ### 0.2 Phase 0 findings (2026-10-11, verified on Authentik 2026.8.3) @@ -624,7 +624,7 @@ The role `qqq--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. @@ -1319,6 +1319,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"; @@ -1438,9 +1440,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); @@ -1463,6 +1467,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 | diff --git a/docs/schema.sql b/docs/schema.sql index cac7979..87206d8 100644 --- a/docs/schema.sql +++ b/docs/schema.sql @@ -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) +); diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikFeature.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikFeature.java index c154f24..a27f5c1 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikFeature.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikFeature.java @@ -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, diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java index cc7baa4..c482fa1 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java @@ -46,15 +46,31 @@ public class AuthentikQBitConfig implements QBitConfig { /** Features implemented in this release. Others are refused by validate(). */ public static final Set 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 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 tokenProtectedGroups = new ArrayList<>(List.of("authentik Admins", "identity-admins")); + private Boolean tokenTwoPersonApproval = false; + private Boolean tokenScopesEnabled = false; + private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}"; + private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}"; + private String tokenEndpointUrl = null; + private String appPublicUrl = null; + private String authentikPublicUrl = null; + private String appClientId = null; + private Integer tokenUnusedDays = 60; + private String serviceAccountTokenLifetime = "days=365"; + private com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource subjectTokenSource = new com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource(); + private String baseUrl; private String apiToken; private String appKey; @@ -106,6 +122,19 @@ public class AuthentikQBitConfig implements QBitConfig @Override public void validate(QInstance qInstance, List errors) { + if(tokenMaxDaysUser == null || tokenMaxDaysUser < 1) + { + errors.add("tokenMaxDaysUser must be at least 1"); + } + if(tokenMaxDaysServiceAccount == null || tokenMaxDaysServiceAccount < 1) + { + errors.add("tokenMaxDaysServiceAccount must be at least 1"); + } + if(tokenUnusedDays == null || tokenUnusedDays < 1) + { + errors.add("tokenUnusedDays must be at least 1"); + } + if(!StringUtils.hasContent(baseUrl)) { errors.add("baseUrl is required (the Authentik host root, e.g. https://auth.example.com)"); @@ -422,6 +451,11 @@ public class AuthentikQBitConfig implements QBitConfig *******************************************************************************/ public boolean hasFeature(AuthentikFeature feature) { + if(feature == AuthentikFeature.TOKENS) + { + return (!Boolean.TRUE.equals(readOnly) && Boolean.TRUE.equals(getTokensEnabled()) && features != null + && features.stream().anyMatch(WRITE_FEATURES::contains)); + } return (features != null && features.contains(feature)); } @@ -454,6 +488,19 @@ public class AuthentikQBitConfig implements QBitConfig public String toString() { return ("AuthentikQBitConfig{baseUrl=" + baseUrl + ", appKey=" + appKey + ", tableNamePrefix=" + tableNamePrefix + + ", tokensEnabled=" + tokensEnabled + + ", tokenMaxDaysUser=" + tokenMaxDaysUser + + ", tokenMaxDaysServiceAccount=" + tokenMaxDaysServiceAccount + + ", tokenProtectedGroups=" + tokenProtectedGroups + + ", tokenTwoPersonApproval=" + tokenTwoPersonApproval + + ", tokenScopesEnabled=" + tokenScopesEnabled + + ", tokenBrokerClientId=" + tokenBrokerClientId + + ", tokenEndpointUrl=" + tokenEndpointUrl + + ", appPublicUrl=" + appPublicUrl + + ", authentikPublicUrl=" + authentikPublicUrl + + ", appClientId=" + appClientId + + ", tokenUnusedDays=" + tokenUnusedDays + + ", serviceAccountTokenLifetime=" + serviceAccountTokenLifetime + ", features=" + features + ", readOnly=" + readOnly + ", apiToken=" + (StringUtils.hasContent(apiToken) ? "******" : "(unset)") + "}"); } @@ -1356,4 +1403,472 @@ 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 getTokenProtectedGroups() + { + return (tokenProtectedGroups); + } + + + + /******************************************************************************* + ** Setter for tokenProtectedGroups. + *******************************************************************************/ + public void setTokenProtectedGroups(List tokenProtectedGroups) + { + this.tokenProtectedGroups = tokenProtectedGroups; + } + + + + /******************************************************************************* + ** Fluent setter for tokenProtectedGroups. + *******************************************************************************/ + public AuthentikQBitConfig withTokenProtectedGroups(List 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); + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AdminAction.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AdminAction.java index 0ecc399..0b01c0f 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AdminAction.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AdminAction.java @@ -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), diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java index 0ad1afc..b965620 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java @@ -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())) { diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java index 2fb0b79..6f010f7 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java @@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; *******************************************************************************/ public final class AuthentikLocalTables { + public static final String TOKEN_SCOPE = "token_scope"; public static final String ADMIN_ACTION = "admin_action"; public static final String APPROVAL_REQUEST = "approval_request"; public static final String POLICY = "policy"; @@ -269,4 +270,34 @@ public final class AuthentikLocalTables return (new QFieldMetaData(name, type).withIsEditable(false)); } + + + /******************************************************************************* + ** Non-secret scope metadata only (DESIGN section 13.5). + *******************************************************************************/ + public static QTableMetaData tokenScope(AuthentikQBitConfig config) + { + QTableMetaData table = base(config, TOKEN_SCOPE, "Token Scopes", "key", IdentityPermission.TOKENS_MANAGE) + .withPermissionRules(com.kingsrook.qbits.authentik.security.TokenReadPermission.rules()) + .withRecordLabelFormatAndFields("%s", "tokenIdentifier") + .withUniqueKey(new UniqueKey("tokenIdentifier")) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD)) + .withField(ro("tokenIdentifier", QFieldType.STRING).withMaxLength(255)) + .withField(ro("ownerUsername", QFieldType.STRING).withMaxLength(255)) + .withField(ro("ownerPk", QFieldType.INTEGER)) + .withField(ro("readOnly", QFieldType.BOOLEAN)) + .withField(ro("createdBy", QFieldType.STRING).withMaxLength(255)); + table.getFields().remove("createDate"); + table.getFields().remove("modifyDate"); + for(String name : List.of("apps", "tables", "processes", "reports", "widgets", "operationIds")) + { + table.withField(ro(name, QFieldType.TEXT)); + } + for(String name : List.of("createdAt", "rotatedAt", "lastReminder7d", "lastReminder1d")) + { + table.withField(ro(name, QFieldType.DATE_TIME)); + } + return (table); + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokens.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokens.java new file mode 100644 index 0000000..13d3e26 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokens.java @@ -0,0 +1,659 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.admin; + + +import java.security.SecureRandom; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker; +import com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource; +import com.kingsrook.qbits.authentik.permissions.GroupGraph; +import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery; +import com.kingsrook.qbits.authentik.security.AuthentikGuard; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qbits.authentik.security.WriteAuthorization; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** Access-token actions (DESIGN section 13). All target checks run at Review and + ** again at execution; credentials exist only in the action and one-time result. + *******************************************************************************/ +public class AuthentikTokens +{ + public static final String DELIVERY_UNAVAILABLE = "This QQQ version cannot show a key once; upgrade QQQ (4.1.0-RC.7+)"; + public static final String TOKEN_MOVED = "Token moved: owner or intent changed after the update; investigate in Authentik."; + private static final QLogger LOG = QLogger.getLogger(AuthentikTokens.class); + private static final SecureRandom RANDOM = new SecureRandom(); + private final AuthentikAdmin admin; + private final AuthentikActionUtil util; + private final AuthentikQBitConfig config; + private final TokenScopeStore scopes; + + /******************************************************************************* + ** Delegate with the existing admin's live operator and subset checks. + *******************************************************************************/ + public AuthentikTokens(AuthentikAdmin admin) + { + this.admin = admin; + util = admin.getUtil(); + config = util.getRuntime().getConfig(); + scopes = new TokenScopeStore(config); + } + + + + /******************************************************************************* + ** Review all constraints, without exchanging or creating a credential. + *******************************************************************************/ + public AdminPlan plan(AdminPlan plan) throws QException + { + AdminRequest request = plan.getRequest(); + AdminAction action = request.action(); + util.getRuntime().invalidateCache(); + try + { + if(action == AdminAction.CREATE_SERVICE_ACCOUNT) + { + if(!request.str("name").matches("[A-Za-z0-9][A-Za-z0-9._-]{0,99}")) + { + return (plan.refuse("A service-account name (letters, numbers, dot, dash or underscore) is required.")); + } + if(admin.userPkByUsername(request.str("name")) != null) + { + return (plan.refuse("That username already exists.")); + } + JSONObject candidate = new JSONObject().put("username", request.str("name")).put("type", "service_account"); + checkProtected(candidate); + expiry(request, true); + if(!TokenKeyDelivery.get().available()) + { + return (plan.refuse(DELIVERY_UNAVAILABLE)); + } + for(GroupGraph.Node group : serviceGroups(request)) + { + String problem = admin.checkGroupGrantable(group); + if(problem != null) + { + return (plan.refuse(problem)); + } + checkProtectedGroup(group); + } + plan.withTargetLabel(request.str("name")); + return (approval(plan)); + } + + JSONObject token = action == AdminAction.CREATE_TOKEN ? null : token(request.targetKey()); + Integer operatorPk = admin.operator().pk(); + int ownerPk = token == null ? (operatorPk == null ? -1 : operatorPk) : ownerPk(token); + if(action == AdminAction.CREATE_TOKEN && (!request.targetKey().isBlank() && !request.targetKey().equals(String.valueOf(operatorPk)))) + { + return (plan.refuse("Create Token only creates a token for you.")); + } + if(ownerPk < 0) + { + return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE)); + } + JSONObject owner = owner(ownerPk); + boolean own = Objects.equals(operatorPk, ownerPk); + boolean service = "service_account".equals(owner.optString("type")); + boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE); + if(service && action == AdminAction.CREATE_TOKEN) + { + return (plan.refuse("Service accounts have one app password. Use Rotate Token or Create Service Account.")); + } + if(!own && !manager || service && !manager) + { + return (plan.refuse("You may only manage your own tokens; service accounts need authentik.tokens.manage.")); + } + if(!own && !service && action != AdminAction.REVOKE_TOKEN) + { + return (plan.refuse("You cannot create, rotate or extend another person's token.")); + } + checkProtected(owner); + plan.withTargetLabel(token == null ? owner.getString("username") : request.targetKey()); + if(action != AdminAction.REVOKE_TOKEN && token != null && !"app_password".equals(token.optString("intent"))) + { + return (plan.refuse("Only app-password tokens can be rotated or extended here. API tokens can be revoked.")); + } + if(action == AdminAction.CREATE_TOKEN || action == AdminAction.EXTEND_TOKEN) + { + expiry(request, service); + } + if(action == AdminAction.ROTATE_TOKEN) + { + checkExpiry(token, service); + } + if(action == AdminAction.CREATE_TOKEN) + { + if(request.str("description").isBlank()) + { + return (plan.refuse("A description is required.")); + } + if(!Boolean.TRUE.equals(config.getTokenScopesEnabled()) && TokenScopeStore.SCOPE_FIELDS.stream().anyMatch(request.params()::containsKey)) + { + return (plan.refuse("Scopes are not enabled on this app yet.")); + } + } + if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN) + { + if(!TokenKeyDelivery.get().available()) + { + return (plan.refuse(DELIVERY_UNAVAILABLE)); + } + } + if(own && !service) + { + if(blank(config.getTokenBrokerClientId()) || blank(config.getTokenBrokerClientSecret())) + { + return (plan.refuse("Self-service tokens need a configured Authentik token broker.")); + } + if(config.getSubjectTokenSource() == null || blank(config.getSubjectTokenSource().subjectToken(QContext.getQSession()))) + { + return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE)); + } + } + return (service && action == AdminAction.ROTATE_TOKEN ? approval(plan) : plan); + } + catch(QUserFacingException e) + { + return (plan.refuse(e.getMessage())); + } + } + + + + /******************************************************************************* + ** Optional approval for new service accounts and service-account rotation. + *******************************************************************************/ + private AdminPlan approval(AdminPlan plan) + { + if(Boolean.TRUE.equals(config.getTokenTwoPersonApproval()) && !plan.getRequest().bool("approved")) + { + plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("Service-account tokens require a second person's approval."); + } + return (plan); + } + + + + /******************************************************************************* + ** Resolve requested groups with the same subset rules used for membership. + *******************************************************************************/ + private List serviceGroups(AdminRequest request) throws QException + { + Set names = new LinkedHashSet<>(AuthentikSafety.parseList(request.str("groups"))); + GroupGraph graph = util.groupGraph(); + List 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 protectedNames = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER); + protectedNames.addAll(List.of("authentik Admins", "identity-admins")); + if(config.getTokenProtectedGroups() != null) + { + protectedNames.addAll(config.getTokenProtectedGroups()); + } + for(GroupGraph.Node ancestor : util.groupGraph().selfAndAncestors(group.pk())) + { + if(ancestor.ownSuperuser() || protectedNames.contains(ancestor.name())) + { + throw (protectedAccount("member of " + ancestor.name())); + } + } + } + + + + /******************************************************************************* + ** Reuse account protection, adding inherited protected-group membership. + *******************************************************************************/ + private void checkProtected(JSONObject owner) throws QException + { + AuthentikGuard.ProtectionResult protection = util.getRuntime().getGuard().protection(owner); + if(protection.isProtected()) + { + throw (protectedAccount(protection.reason())); + } + JSONArray groups = owner.optJSONArray("groups_obj"); + for(int i = 0; groups != null && i < groups.length(); i++) + { + GroupGraph.Node node = util.groupGraph().byPk(groups.getJSONObject(i).getString("pk")); + if(node == null) + { + throw (protectedAccount("group protection could not be verified")); + } + checkProtectedGroup(node); + } + } + + + + /******************************************************************************* + ** Consistent protected-owner refusal, including service-account set_key. + *******************************************************************************/ + private static QUserFacingException protectedAccount(String reason) + { + return (new QUserFacingException("Protected account (" + reason + "): tokens for it are managed in the Authentik admin UI.")); + } + + + + /******************************************************************************* + ** Fetch token metadata without retaining a bearer or key. + *******************************************************************************/ + private JSONObject token(String identifier) throws QException + { + util.getRuntime().invalidateCache(); + JSONObject token = util.getApiJsonOrNull(path(identifier)); + if(token == null) + { + throw (new QUserFacingException("That token no longer exists.")); + } + return (token); + } + + + + /******************************************************************************* + ** Token owner pk from the pinned schema. + *******************************************************************************/ + private static int ownerPk(JSONObject token) + { + return (token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk")); + } + + + + /******************************************************************************* + ** Fetch live owner facts, refusing missing owners. + *******************************************************************************/ + private JSONObject owner(int pk) throws QException + { + util.getRuntime().invalidateCache(); + JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/"); + if(owner == null) + { + throw (new QUserFacingException("The token's owner no longer exists.")); + } + return (owner); + } + + + + /******************************************************************************* + ** Validated token URL component. + *******************************************************************************/ + private static String path(String identifier) throws QException + { + return ("/core/tokens/" + AuthentikAdmin.safeKey(identifier) + "/"); + } + + + + /******************************************************************************* + ** Whether a resolved config string is absent. + *******************************************************************************/ + private static boolean blank(String value) + { + return (value == null || value.isBlank()); + } + + + + /******************************************************************************* + ** Execute only after auditing has begun; return the key outside audit data. + *******************************************************************************/ + public AuthentikAdmin.AdminResult perform(AdminPlan plan, WriteAuthorization auth) throws QException + { + AdminRequest request = plan.getRequest(); + if((request.action() == AdminAction.CREATE_TOKEN || request.action() == AdminAction.ROTATE_TOKEN || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT) + && !TokenKeyDelivery.get().available()) + { + throw (new QUserFacingException(DELIVERY_UNAVAILABLE)); + } + if(request.action() == AdminAction.CREATE_SERVICE_ACCOUNT) + { + return (createServiceAccount(plan, auth)); + } + JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey()); + JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before)); + checkProtected(owner); + boolean service = "service_account".equals(owner.optString("type")); + boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk")); + boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE); + if((!own || service) && !manager || !own && !service && request.action() != AdminAction.REVOKE_TOKEN) + { + throw (new QUserFacingException("Token ownership changed; review again.")); + } + String bearer = own && !service ? new AuthentikTokenBroker().exchange(util, auth) : config.getResolvedApiToken(); + switch(request.action()) + { + case CREATE_TOKEN: + return (createPersonal(request, owner, expiry(request, false), null, bearer, auth)); + case REVOKE_TOKEN: + util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null); + scopes.delete(request.targetKey()); + return (success("Token revoked.")); + case EXTEND_TOKEN: + util.writeAs(bearer, auth, "PATCH", path(request.targetKey()), new JSONObject().put("intent", before.getString("intent")) + .put("user", owner.getInt("pk")).put("expires", expiry(request, service).toString()).put("expiring", true)); + raceGuard(before, token(request.targetKey())); + return (success("Token expiry updated.")); + case ROTATE_TOKEN: + if(!service) + { + return (createPersonal(request, owner, checkExpiry(before, false), before, bearer, auth)); + } + String scope = scopeSummary(request.targetKey()); + Instant expires = checkExpiry(before, true); + byte[] random = new byte[48]; + RANDOM.nextBytes(random); + String key = Base64.getUrlEncoder().withoutPadding().encodeToString(random); + util.writeAs(bearer, auth, "POST", path(request.targetKey()) + "set_key/", new JSONObject().put("key", key)); + JSONObject after = token(request.targetKey()); + raceGuard(before, after); + return (success("Service-account token rotated. The old key no longer works.") + .withSecret(oneTime(request.targetKey(), key, owner, expires, scope), () -> + { + throw (new QUserFacingException("The old key no longer works and the new key is lost. Rotate it in the Authentik admin UI.")); + })); + default: + throw (new QException("Unsupported token action")); + } + } + + + + /******************************************************************************* + ** Guard owner and intent after every PATCH or set_key, with a safe warning. + *******************************************************************************/ + private static void raceGuard(JSONObject before, JSONObject after) throws QUserFacingException + { + if(ownerPk(before) != ownerPk(after) || !before.getString("intent").equals(after.getString("intent"))) + { + LOG.warn(TOKEN_MOVED); + throw (new QUserFacingException(TOKEN_MOVED)); + } + } + + + + /******************************************************************************* + ** Create as the operator, fetch their key, and optionally replace the old id. + ** On partial failure discard the new credential; the old one stays usable. + *******************************************************************************/ + private AuthentikAdmin.AdminResult createPersonal(AdminRequest request, JSONObject owner, Instant expires, JSONObject old, + String bearer, WriteAuthorization auth) throws QException + { + String identifier = identifier(owner.getString("username")); + String description = old == null ? request.str("description") : old.optString("description"); + util.writeAs(bearer, auth, "POST", "/core/tokens/", new JSONObject().put("identifier", identifier).put("intent", "app_password") + .put("description", description).put("expiring", true).put("expires", expires.toString())); + try + { + JSONObject created = util.getAs(bearer, auth, path(identifier)); + if(ownerPk(created) != owner.getInt("pk") || !"app_password".equals(created.optString("intent"))) + { + throw (new QUserFacingException(TOKEN_MOVED)); + } + String key = util.getAs(bearer, auth, path(identifier) + "view_key/").getString("key"); + Instant actualExpiry = checkExpiry(created, false); + if(old == null) + { + scopes.create(identifier, owner, request, admin.operator().email()); + } + else + { + QRecord previous = scopes.find(request.targetKey()); + if(previous == null) + { + scopes.create(identifier, owner, AdminRequest.of(request.action(), identifier, request.reason()), admin.operator().email()); + } + else + { + scopes.move(request.targetKey(), identifier); + } + } + OneTimeKey secret = oneTime(identifier, key, owner, actualExpiry, scopeSummary(identifier)); + if(old != null) + { + try + { + util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null); + } + catch(QException e) + { + scopes.move(identifier, request.targetKey()); + throw (new QUserFacingException("Rotation failed: the old token could not be deleted and remains listed.")); + } + } + return (success(old == null ? "Token created. The key is shown once." : "Token rotated. The old token was deleted.") + .withSecret(secret, () -> revokeIssued(identifier, bearer, auth))); + } + catch(Exception e) + { + revokeIssued(identifier, bearer, auth); + if(e instanceof QUserFacingException facing) + { + throw (facing); + } + throw (new QUserFacingException("The new token could not be completed and was revoked.")); + } + } + + + + /******************************************************************************* + ** Create the single service-account app password and grant checked groups. + *******************************************************************************/ + private AuthentikAdmin.AdminResult createServiceAccount(AdminPlan plan, WriteAuthorization auth) throws QException + { + AdminRequest request = plan.getRequest(); + String identifier = "service-account-" + request.str("name") + "-password"; + JSONObject created = util.write(auth, "POST", "/core/users/service_account/", new JSONObject().put("name", request.str("name")) + .put("create_group", false).put("expiring", true).put("expires", expiry(request, true).toString())); + try + { + Integer pk = created.has("user_pk") ? created.getInt("user_pk") : admin.userPkByUsername(created.optString("username", request.str("name"))); + if(pk == null) + { + throw (new QException("The service account could not be found.")); + } + JSONObject current = owner(pk); + JSONObject attributes = new JSONObject(current.optJSONObject("attributes") == null ? "{}" : current.getJSONObject("attributes").toString()); + attributes.put("goauthentik.io/user/token-maximum-lifetime", config.getServiceAccountTokenLifetime()); + util.write(auth, "PATCH", "/core/users/" + pk + "/", new JSONObject().put("attributes", attributes)); + for(GroupGraph.Node group : serviceGroups(request)) + { + checkProtectedGroup(group); + String problem = admin.checkGroupGrantable(group); + if(problem != null) + { + throw (new QUserFacingException(problem)); + } + util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk)); + } + JSONObject owner = owner(pk); + checkProtected(owner); + JSONObject after = token(identifier); + scopes.create(identifier, owner, request, admin.operator().email()); + return (success("Service account created. " + String.join(" ", plan.getNotes())) + .withSecret(oneTime(identifier, created.getString("token"), owner, checkExpiry(after, true), "Owner permissions"), + () -> revokeIssued(identifier, config.getResolvedApiToken(), auth))); + } + catch(Exception e) + { + revokeIssued(identifier, config.getResolvedApiToken(), auth); + throw (new QUserFacingException("The service account was created, but setup failed. Its token was revoked; finish setup in Authentik.")); + } + } + + + + /******************************************************************************* + ** Emergency revocation for a newly created token whose key cannot be shown. + *******************************************************************************/ + private void revokeIssued(String identifier, String bearer, WriteAuthorization auth) throws QException + { + try + { + util.writeAs(bearer, auth, "DELETE", path(identifier), null); + scopes.delete(identifier); + } + catch(QException e) + { + throw (new QUserFacingException("The new key could not be delivered and token revocation failed. Revoke it in the Authentik admin UI.")); + } + } + + + + /******************************************************************************* + ** Scope summary contains metadata only. + *******************************************************************************/ + private String scopeSummary(String identifier) throws QException + { + QRecord row = scopes.find(identifier); + List 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/OneTimeKey.java b/src/main/java/com/kingsrook/qbits/authentik/admin/OneTimeKey.java new file mode 100644 index 0000000..eaa763a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/OneTimeKey.java @@ -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]}" ); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/TokenScopeStore.java b/src/main/java/com/kingsrook/qbits/authentik/admin/TokenScopeStore.java new file mode 100644 index 0000000..6b877d8 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/TokenScopeStore.java @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.admin; + + +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONObject; + + +/******************************************************************************* + ** Non-secret bookkeeping. No method accepts a key or a bearer. + *******************************************************************************/ +public class TokenScopeStore +{ + public static final List SCOPE_FIELDS = List.of("readOnly", "apps", "tables", "processes", "reports", "widgets", "operationIds"); + private final AuthentikQBitConfig config; + + /******************************************************************************* + ** Bind to one qbit's local backend. + *******************************************************************************/ + public TokenScopeStore(AuthentikQBitConfig config) + { + this.config = config; + } + + + + /******************************************************************************* + ** Local table name. + *******************************************************************************/ + private String table() + { + return (config.applyTablePrefix(AuthentikLocalTables.TOKEN_SCOPE)); + } + + + + /******************************************************************************* + ** Fetch the unique scope row. + *******************************************************************************/ + public QRecord find(String identifier) throws QException + { + List 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 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 rows) throws QException + { + if(rows.isEmpty() || !rows.get(0).getErrors().isEmpty()) + { + throw (new QException("Token scope metadata could not be saved.")); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/TokenUsageText.java b/src/main/java/com/kingsrook/qbits/authentik/admin/TokenUsageText.java new file mode 100644 index 0000000..c8b0630 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/TokenUsageText.java @@ -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("'", "'\"'\"'") + "'"); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java index c23b193..7b8d133 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java @@ -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. @@ -1232,13 +1302,22 @@ public class AuthentikActionUtil extends BaseAPIActionUtil ** error mapping, logging. Returns 2xx and 404 responses. *******************************************************************************/ public QHttpResponse execute(HttpRequestBase request) throws QException + { + return (execute(request, token(), false)); + } + + + + /******************************************************************************* + ** Shared request loop; exchange responses and all token traffic stay secret. + *******************************************************************************/ + private QHttpResponse execute(HttpRequestBase request, String token, boolean exchange) throws QException { String path = pathOf(request.getURI().toString()); int attempts = 0; int backoffMs = INITIAL_BACKOFF_MS; - String token = token(); - if(!StringUtils.hasContent(token)) + if(!exchange && !StringUtils.hasContent(token)) { throw (new QException("The Authentik API token is not configured (apiToken resolved to an empty value).")); } @@ -1247,11 +1326,14 @@ public class AuthentikActionUtil extends BaseAPIActionUtil { attempts++; runtime().getThrottle().acquire(); - request.setHeader("Authorization", "Bearer " + token); + if(!exchange) + { + request.setHeader("Authorization", "Bearer " + token); + } request.setHeader("Accept", "application/json"); if(request instanceof HttpEntityEnclosingRequest) { - request.setHeader("Content-Type", "application/json"); + request.setHeader("Content-Type", exchange ? "application/x-www-form-urlencoded" : "application/json"); } long start = System.currentTimeMillis(); @@ -1262,7 +1344,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil } catch(Exception e) { - String message = AuthentikRedactor.redactText(e.getMessage(), token); + String message = exchange || AuthentikRedactor.isSensitivePath(path) ? "Sensitive request failed" : AuthentikRedactor.redactText(e.getMessage(), token); LOG.warn("Authentik API call failed", logPair("method", request.getMethod()), logPair("path", path), logPair("error", message)); if(attempts < MAX_RETRIES && "GET".equals(request.getMethod())) { @@ -1275,7 +1357,10 @@ public class AuthentikActionUtil extends BaseAPIActionUtil int status = response.getStatusCode() == null ? 0 : response.getStatusCode(); LOG.info("Authentik API call", logPair("method", request.getMethod()), logPair("path", path), logPair("status", status), logPair("millis", System.currentTimeMillis() - start)); - logOutboundCall(request, path, response, token); + if(!exchange) + { + logOutboundCall(request, path, response, token); + } checkVersionOnce(status); if(status == 429 && attempts <= MAX_RETRIES) @@ -1295,6 +1380,23 @@ public class AuthentikActionUtil extends BaseAPIActionUtil { return (response); } + if(exchange) + { + String error = "exchange_failed"; + try + { + String code = new JSONObject(response.getContent()).optString("error"); + if(List.of("invalid_grant", "access_denied", "invalid_client", "invalid_request", "unsupported_grant_type", "invalid_scope").contains(code)) + { + error = code; + } + } + catch(JSONException ignored) + { + // Never echo an untrusted broker error body. + } + throw (new QUserFacingException("Authentik refused to act as you (" + error + ")")); + } throw (mapError(request, path, status, response.getContent(), token)); } } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java index 4962297..80d5553 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java @@ -58,6 +58,12 @@ public final class AuthentikApiSurface { List 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 // diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikRedactor.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikRedactor.java index 5a61a5d..acbaa34 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikRedactor.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikRedactor.java @@ -37,10 +37,10 @@ public final class AuthentikRedactor /** Path fragments whose request and response bodies are never logged. */ private static final List 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._~+/=-]+"); diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikSubjectTokenSource.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikSubjectTokenSource.java new file mode 100644 index 0000000..37b3b73 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikSubjectTokenSource.java @@ -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; +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTableSpec.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTableSpec.java index 8454c6f..49c3140 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTableSpec.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTableSpec.java @@ -41,6 +41,7 @@ public class AuthentikTableSpec private final Map fields = new LinkedHashMap<>(); private final List defaultListParams = new ArrayList<>(); private final List defaultGetParams = new ArrayList<>(); + private IdentityPermission alternativeReadPermission; private IdentityPermission readPermission = IdentityPermission.USERS_READ; private String defaultOrdering; private List 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()); + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java index ae3dde4..d6514e6 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java @@ -400,12 +400,13 @@ public final class AuthentikTables /******************************************************************************* ** /core/tokens/ (identifier). Metadata only: there is NO key field, and the - ** service account is never given token-key permissions. Needs audit.read. + ** service account never reads token keys. Auditors and token managers may read. *******************************************************************************/ static AuthentikTableSpec tokens() { AuthentikTableSpec spec = new AuthentikTableSpec(TOKEN, "Tokens", "key", "/core/tokens/", "identifier") .withReadPermission(IdentityPermission.AUDIT_READ) + .withAlternativeReadPermission(IdentityPermission.TOKENS_MANAGE) .withDefaultOrdering("identifier") .withRecordLabelFields("identifier"); spec.withField(new AuthentikFieldSpec("identifier", "Identifier", QFieldType.STRING, string("identifier")).withSearchable().withOrdering("identifier")); @@ -414,6 +415,10 @@ public final class AuthentikTables spec.withField(new AuthentikFieldSpec("description", "Description", QFieldType.STRING, string("description"))); spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires"))); spec.withField(new AuthentikFieldSpec("expiring", "Expiring", QFieldType.BOOLEAN, bool("expiring")).withFilter(QCriteriaOperator.EQUALS, "expiring")); + spec.withField(new AuthentikFieldSpec("ownerPk", "Owner Id", QFieldType.INTEGER, intAt("user_obj", "pk"))); + spec.withField(new AuthentikFieldSpec("ownerType", "Owner Type", QFieldType.STRING, at("user_obj", "type"))); + spec.withField(new AuthentikFieldSpec("ownerName", "Owner", QFieldType.STRING, at("user_obj", "name"))); + spec.withField(new AuthentikFieldSpec("managed", "Managed", QFieldType.STRING, string("managed"))); return (spec); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java new file mode 100644 index 0000000..e4d4925 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java @@ -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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/OAuth2SubjectTokenSource.java b/src/main/java/com/kingsrook/qbits/authentik/client/OAuth2SubjectTokenSource.java new file mode 100644 index 0000000..c995b49 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/client/OAuth2SubjectTokenSource.java @@ -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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AbstractAuthentikTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AbstractAuthentikTableProducer.java index 89d638a..f8eeffe 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AbstractAuthentikTableProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AbstractAuthentikTableProducer.java @@ -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); diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenScopeTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenScopeTableProducer.java new file mode 100644 index 0000000..c6ee656 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenScopeTableProducer.java @@ -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 +{ + + /******************************************************************************* + ** 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); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java index b7a6c0c..24f7073 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java +++ b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java @@ -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 createTokenFields = new ArrayList<>(List.of( + new QFieldMetaData("description", QFieldType.STRING).withIsRequired(true), + new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(30))); + if(Boolean.TRUE.equals(config.getTokenScopesEnabled())) + { + createTokenFields.add(new QFieldMetaData("readOnly", QFieldType.BOOLEAN)); + createTokenFields.add(new QFieldMetaData("apps", QFieldType.TEXT)); + createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT)); + } + createTokenFields.add(reason); + defs.add(new Def("createToken", "Create Token", AdminAction.CREATE_TOKEN, Kind.CREATE, AuthentikTables.TOKEN, "key", false, + createTokenFields, "Creates a token for you. The key is shown once, on the next screen.")); + defs.add(new Def("rotateToken", "Rotate Token", AdminAction.ROTATE_TOKEN, Kind.TOKENS, AuthentikTables.TOKEN, "key", false, List.of(reason), null)); + defs.add(new Def("revokeToken", "Revoke Token", AdminAction.REVOKE_TOKEN, Kind.TOKENS, AuthentikTables.TOKEN, "key_off", false, List.of(reason), null)); + defs.add(new Def("extendToken", "Extend Expiry", AdminAction.EXTEND_TOKEN, Kind.TOKENS, AuthentikTables.TOKEN, "event", false, List.of( + new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true), reason), null)); + defs.add(new Def("createServiceAccount", "Create Service Account", AdminAction.CREATE_SERVICE_ACCOUNT, Kind.CREATE, users, "person_add", false, List.of( + new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true), + new QFieldMetaData("groups", QFieldType.TEXT).withLabel("Groups (names, one per line)"), + new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(365), reason), + "Creates a service account with one app password, shown once.")); + defs.add(new Def("createUser", "Create User", AdminAction.CREATE_USER, Kind.CREATE, users, "person_add", false, List.of( new QFieldMetaData("username", QFieldType.STRING).withLabel("Username").withIsRequired(true), new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name").withIsRequired(true), @@ -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). *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/processes/TokenKeyDelivery.java b/src/main/java/com/kingsrook/qbits/authentik/processes/TokenKeyDelivery.java new file mode 100644 index 0000000..0e0d470 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/processes/TokenKeyDelivery.java @@ -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")); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java index bea8334..f12f3e1 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java @@ -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())); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java b/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java index 218a00d..13dd836 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java @@ -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"); diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/TokenReadPermission.java b/src/main/java/com/kingsrook/qbits/authentik/security/TokenReadPermission.java new file mode 100644 index 0000000..aaa1e77 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/security/TokenReadPermission.java @@ -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.")); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java index 272fb85..dd49b48 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java @@ -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(); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java b/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java index 09381a1..44b733e 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java @@ -44,6 +44,7 @@ import org.json.JSONObject; *******************************************************************************/ public class FakeAuthentik { + public final JSONObject brandAttributes = new JSONObject(); public final Map users = new LinkedHashMap<>(); public final Map groups = new LinkedHashMap<>(); public final Map> tokens = new LinkedHashMap<>(); @@ -54,6 +55,12 @@ public class FakeAuthentik public final List events = new ArrayList<>(); public final List writeBodies = new ArrayList<>(); + public final Map tokenKeys = new LinkedHashMap<>(); + public final java.util.Set deniedSubjects = new java.util.HashSet<>(); + public final List tokenCallers = new ArrayList<>(); + public final List 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> query = parseQuery(uri.getRawQuery()); + if(path.equals("/application/o/token/")) + { + try + { + Map> 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 rs = new ArrayList<>(tokens.getOrDefault(first(query, "user__username"), List.of())); + List 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,11 @@ public class FakeAuthentik JSONObject invitation = invitations.get(path.split("/")[4]); return (invitation == null ? FixtureActionUtil.response(404, "{}") : ok(200, invitation)); } - if(path.equals("/events/events/") || path.equals("/managed/blueprints/") || path.equals("/core/applications/")) + if(path.equals("/events/events/")) + { + return (page(loginEvents)); + } + if(path.equals("/managed/blueprints/") || path.equals("/core/applications/")) { return (page(new ArrayList<>())); } @@ -489,6 +543,152 @@ public class FakeAuthentik + /******************************************************************************* + ** Seed an app password or API token with an independent fake key. + *******************************************************************************/ + public JSONObject seedToken(String identifier, int owner, String intent, String expires) + { + JSONObject token = new JSONObject().put("identifier", identifier).put("user", owner).put("intent", intent) + .put("description", "Test access").put("expiring", true).put("expires", expires).put("managed", JSONObject.NULL); + tokens.computeIfAbsent(users.get(owner).getString("username"), k -> new ArrayList<>()).add(token); + tokenKeys.put(identifier, UUID.randomUUID().toString() + UUID.randomUUID()); + return (token); + } + + + + /******************************************************************************* + ** Metadata never contains a key, including list and detail responses. + *******************************************************************************/ + private JSONObject renderToken(JSONObject token) + { + JSONObject result = new JSONObject(token.toString()); + if(token.has("user") && users.containsKey(token.getInt("user"))) + { + result.put("user_obj", render(users.get(token.getInt("user")))); + } + return (result); + } + + + + /******************************************************************************* + ** Authentik 2026.8.3 caller ownership, PATCH defaults, caps and key access. + *******************************************************************************/ + private QHttpResponse tokenRequest(String method, String path, JSONObject body, int caller) + { + tokenCallers.add(caller); + if(path.equals("/core/tokens/") && method.equals("POST")) + { + int owner = render(users.get(caller)).optBoolean("is_superuser") ? body.optInt("user", caller) : caller; + JSONObject token = seedToken(body.getString("identifier"), owner, body.optString("intent", "api"), body.optString("expires", java.time.Instant.now().plusSeconds(1800).toString())); + token.put("description", body.optString("description")); + if(!capExpiry(token)) + { + tokens.values().forEach(list -> list.remove(token)); + tokenKeys.remove(token.getString("identifier")); + return (ok(400, new JSONObject().put("expires", "Token expires exceeds maximum lifetime"))); + } + return (ok(201, renderToken(token))); + } + String identifier = path.split("/")[3]; + JSONObject token = tokens.values().stream().flatMap(List::stream).filter(t -> identifier.equals(t.optString("identifier"))).findFirst().orElse(null); + if(token == null) + { + return (ok(404, new JSONObject())); + } + if(path.endsWith("/view_key/")) + { + return (token.optInt("user") == caller ? ok(200, new JSONObject().put("key", tokenKeys.get(identifier))) : ok(403, new JSONObject())); + } + if(method.equals("GET")) + { + return (ok(200, renderToken(token))); + } + if(caller != 5 && token.optInt("user") != caller) + { + return (ok(403, new JSONObject())); + } + if(method.equals("DELETE")) + { + if(failTokenDelete) + { + return (ok(400, new JSONObject().put("detail", "delete refused"))); + } + tokens.values().forEach(list -> list.removeIf(t -> identifier.equals(t.optString("identifier")))); + tokenKeys.remove(identifier); + return (ok(204, null)); + } + if(path.endsWith("/set_key/")) + { + tokenKeys.put(identifier, body.getString("key")); + } + else if(method.equals("PATCH")) + { + // validate_user runs for explicitly supplied user; setdefault is later. + if(body.has("user") && body.getInt("user") != token.getInt("user")) + { + return (ok(400, new JSONObject().put("user", "User cannot be changed"))); + } + JSONObject original = new JSONObject(token.toString()); + token.put("intent", body.optString("intent", "api")).put("user", body.optInt("user", caller)); + if(body.has("expires")) + { + token.put("expires", body.getString("expires")); + } + token.put("expiring", body.optBoolean("expiring", token.optBoolean("expiring"))); + if(!capExpiry(token)) + { + original.keySet().forEach(key -> token.put(key, original.get(key))); + return (ok(400, new JSONObject().put("expires", "Token expires exceeds maximum lifetime"))); + } + } + if(moveTokenAfterUpdate) + { + token.put("user", 4).put("intent", "api"); + } + return (ok(200, renderToken(token))); + } + + + + /******************************************************************************* + ** Brand attributes, then group attributes, then user attributes set the cap. + *******************************************************************************/ + private boolean capExpiry(JSONObject token) + { + java.time.Instant now = java.time.Instant.now(); + long seconds = 1800; + if("app_password".equals(token.optString("intent"))) + { + JSONObject attributes = new JSONObject(brandAttributes.toString()); + com.kingsrook.qbits.authentik.permissions.GroupGraph graph = com.kingsrook.qbits.authentik.permissions.GroupGraph.fromGroups(groups.values()); + graph.groupsOfUser(token.getInt("user")).stream().flatMap(pk -> graph.selfAndAncestors(pk).stream()) + .distinct().sorted(java.util.Comparator.comparing(com.kingsrook.qbits.authentik.permissions.GroupGraph.Node::name)).forEach(group -> + { + JSONObject values = groups.get(group.pk()).getJSONObject("attributes"); + values.keySet().forEach(key -> attributes.put(key, values.get(key))); + }); + JSONObject values = users.get(token.getInt("user")).getJSONObject("attributes"); + values.keySet().forEach(key -> attributes.put(key, values.get(key))); + String lifetime = attributes.optString("goauthentik.io/user/token-maximum-lifetime"); + if(lifetime.matches("days=[0-9]+")) + { + seconds = Long.parseLong(lifetime.substring(5)) * 86400; + } + } + java.time.Instant cap = now.plusSeconds(seconds); + java.time.Instant requested = java.time.Instant.parse(token.getString("expires")); + if("api".equals(token.optString("intent"))) + { + token.put("expires", cap.toString()); + return (true); + } + return (!requested.isAfter(cap)); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentikTokenTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentikTokenTest.java new file mode 100644 index 0000000..17b8957 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentikTokenTest.java @@ -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)); + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenManagementTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenManagementTest.java new file mode 100644 index 0000000..5d37410 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenManagementTest.java @@ -0,0 +1,532 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.admin; + +import java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.client.AuthentikTables; +import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** Token actions against the in-memory Authentik 2026.8.3 model. No Docker. + *******************************************************************************/ +class TokenManagementTest +{ + private AdminTestSupport t; + private final List delivered = new ArrayList<>(); + private final List logLines = new ArrayList<>(); + private AbstractAppender appender; + + /******************************************************************************* + ** A non-protected person, broker, lifetime groups and capturing response SPI. + *******************************************************************************/ + @BeforeEach + void setUp() throws QException + { + t = AdminTestSupport.create(); + t.config.withTokenBrokerClientId("test-broker").withTokenBrokerClientSecret("test-broker-secret") + .withSubjectTokenSource(session -> "subject:3").withAppClientId("test-app").withAppPublicUrl("https://app.example.test") + .withAuthentikPublicUrl("https://auth.example.test"); + t.authentik.group("token-users", false, List.of(), List.of()); + t.authentik.groupByName("token-users").getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=90"); + t.authentik.groupByName("token-users").getJSONArray("users").put(3); + t.authentik.users.get(6).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365"); + t.loginAs("sam@example.test"); + TokenKeyDelivery.set((output, key) -> delivered.add(key)); + appender = new AbstractAppender("token-test-capture", null, null, false, Property.EMPTY_ARRAY) + { + /******************************************************************************* + ** Capture formatted log messages without writing them elsewhere. + *******************************************************************************/ + @Override + public void append(LogEvent event) + { + logLines.add(event.getMessage().getFormattedMessage()); + } + }; + appender.start(); + ((Logger) LogManager.getRootLogger()).addAppender(appender); + } + + /******************************************************************************* + ** Do not leak a test adapter into other tests. + *******************************************************************************/ + @AfterEach + void tearDown() + { + TokenKeyDelivery.set(null); + ((Logger) LogManager.getRootLogger()).removeAppender(appender); + appender.stop(); + } + + /******************************************************************************* + ** A valid personal create request. + *******************************************************************************/ + private AdminRequest create() + { + return (AdminRequest.of(AdminAction.CREATE_TOKEN, "", "automation").with("description", "CI access").with("expiresInDays", 30)); + } + + /******************************************************************************* + ** A seeded expiring token. + *******************************************************************************/ + private JSONObject seed(String id, int owner) + { + return (t.authentik.seedToken(id, owner, "app_password", Instant.now().plusSeconds(86400).toString())); + } + + /******************************************************************************* + ** Assert a Review refusal before Authentik mutates anything. + *******************************************************************************/ + private void refused(AdminRequest request, String message) throws QException + { + int before = t.changes().size(); + AdminPlan plan = t.admin().plan(request); + assertThat(plan.getState()).isEqualTo(AdminPlan.State.REFUSED); + assertThat(plan.getMessage()).containsIgnoringCase(message); + assertThat(t.changes()).hasSize(before); + } + + /******************************************************************************* + ** Personal create belongs to the exchanged caller and carries a one-time key. + *******************************************************************************/ + @Test + void createOwnAndRotateScopeThenRevoke() throws QException + { + t.config.withTokenScopesEnabled(true); + AuthentikAdmin.AdminResult result = t.admin().execute(create().with("readOnly", true).with("tables", "post")); + assertThat(result.ok()).as(result.message()).isTrue(); + OneTimeKey key = result.takeSecret(); + assertThat(result.takeSecret()).isNull(); + assertThat(key.label()).matches("qqq-pat-sam-[0-9]{8}-[a-z0-9]{6}"); + assertThat(key.usageText()).contains("https://auth.example.test/application/o/token/", "username=sam", "test-app", "https://app.example.test/qqq/v1/metaData"); + assertThat(key.masked()).endsWith(key.value().substring(key.value().length() - 4)); + assertThat(key.toString()).doesNotContain(key.value()); + assertThat(t.authentik.tokenCallers).containsOnly(3); + assertThat(t.authentik.tokens.get("sam").get(0).getInt("user")).isEqualTo(3); + TokenScopeStore store = new TokenScopeStore(t.config); + assertThat(store.find(key.label()).getValueBoolean("readOnly")).isTrue(); + AuthentikAdmin.AdminResult rotated = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, key.label(), "replace")); + assertThat(rotated.ok()).as(rotated.message()).isTrue(); + OneTimeKey next = rotated.takeSecret(); + assertThat(next.label()).isNotEqualTo(key.label()); + assertThat(store.find(key.label())).isNull(); + assertThat(store.find(next.label()).getValueString("tables")).isEqualTo("post"); + assertThat(store.find(next.label()).getValue("rotatedAt")).isNotNull(); + assertThat(t.authentik.tokenKeys).doesNotContainKey(key.label()); + assertThat(t.admin().execute(AdminRequest.of(AdminAction.REVOKE_TOKEN, next.label(), "done")).ok()).isTrue(); + assertThat(store.find(next.label())).isNull(); + assertNoKey(key, result); + assertNoKey(next, rotated); + } + + /******************************************************************************* + ** Explicit PATCH fields preserve owner and intent for people and services. + *******************************************************************************/ + @Test + void extendOwnAndServiceAndRotateService() throws QException + { + seed("personal", 3); + AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "personal", "needed").with("expiresInDays", 60)); + assertThat(result.ok()).as(result.message()).isTrue(); + JSONObject patch = t.authentik.writeBodies.get(0); + assertThat(patch.getInt("user")).isEqualTo(3); + assertThat(patch.getString("intent")).isEqualTo("app_password"); + assertThat(Instant.parse(patch.getString("expires"))).isAfter(Instant.now().plusSeconds(59 * 86400)); + t.loginAs("operator@example.test", IdentityPermission.values()); + seed("service", 6); + assertThat(t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "needed").with("expiresInDays", 365)).ok()).isTrue(); + String old = t.authentik.tokenKeys.get("service"); + result = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace")); + assertThat(result.ok()).as(result.message()).isTrue(); + OneTimeKey key = result.takeSecret(); + assertThat(key.value()).hasSize(64).matches("[A-Za-z0-9_-]+").isNotEqualTo(old); + assertThat(t.authentik.tokenKeys.get("service")).isEqualTo(key.value()); + assertNoKey(key, result); + } + + /******************************************************************************* + ** Owner/intent movement after either update fails safely and logs no key. + *******************************************************************************/ + @Test + void raceGuardForPatchAndSetKey() throws QException + { + seed("personal", 3); + t.authentik.moveTokenAfterUpdate = true; + AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "personal", "needed").with("expiresInDays", 20)); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).isEqualTo(AuthentikTokens.TOKEN_MOVED); + t.loginAs("operator@example.test", IdentityPermission.values()); + seed("service", 6); + result = t.admin().execute(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace")); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).isEqualTo(AuthentikTokens.TOKEN_MOVED); + assertThat(logLines).anyMatch(line -> line.contains("Token moved")); + } + + /******************************************************************************* + ** Service creation sets a user lifetime cap and grants only requested groups. + *******************************************************************************/ + @Test + void serviceAccountAndSubsetRule() throws QException + { + t.loginAs("operator@example.test", IdentityPermission.values()); + AdminRequest request = AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "automation").with("name", "new-service") + .with("groups", "kof-website-publishers").with("expiresInDays", 365); + AuthentikAdmin.AdminResult result = t.admin().execute(request); + assertThat(result.ok()).as(result.message()).isTrue(); + Integer pk = t.admin().userPkByUsername("new-service"); + assertThat(t.authentik.groupNamesOf(pk)).containsExactly("kof-website-publishers"); + assertThat(t.authentik.users.get(pk).getJSONObject("attributes").getString("goauthentik.io/user/token-maximum-lifetime")).isEqualTo("days=365"); + assertThat(t.authentik.users.get(pk).getJSONObject("attributes").getBoolean("goauthentik.io/user/token-expires")).isTrue(); + OneTimeKey key = result.takeSecret(); + assertThat(key.label()).isEqualTo("service-account-new-service-password"); + assertNoKey(key, result); + request.with("name", "refused-service").with("groups", "kof-website-analysts"); + refused(request, "do not hold"); + request.with("groups", "identity-admins"); + refused(request, "Protected account"); + } + + /******************************************************************************* + ** Administrators may revoke a non-protected human, but never issue for them. + *******************************************************************************/ + @Test + void ownerAndPermissionRules() throws QException + { + seed("someone", 7); + refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "someone", "reason"), "own tokens"); + refused(AdminRequest.of(AdminAction.EXTEND_TOKEN, "someone", "reason").with("expiresInDays", 1), "own tokens"); + refused(AdminRequest.of(AdminAction.REVOKE_TOKEN, "someone", "reason"), "own tokens"); + t.loginAs("sam@example.test", IdentityPermission.TOKENS_MANAGE); + refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "someone", "reason"), "another person's"); + refused(AdminRequest.of(AdminAction.EXTEND_TOKEN, "someone", "reason").with("expiresInDays", 1), "another person's"); + refused(new AdminRequest(AdminAction.CREATE_TOKEN, "7", create().params(), "reason"), "for you"); + assertThat(t.admin().execute(AdminRequest.of(AdminAction.REVOKE_TOKEN, "someone", "incident")).ok()).isTrue(); + refused(AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "reason"), "users.manage"); + } + + /******************************************************************************* + ** Every protected class, including inherited admin membership, fails Review. + *******************************************************************************/ + @Test + void protectedOwners() throws QException + { + t.loginAs("sam@example.test", IdentityPermission.values()); + for(int owner : List.of(1, 4, 5)) + { + seed("protected-" + owner, owner); + refused(AdminRequest.of(AdminAction.REVOKE_TOKEN, "protected-" + owner, "incident"), "Protected account"); + } + t.authentik.users.get(3).put("is_superuser", true); + refused(create(), "superuser"); + t.authentik.users.get(3).put("is_superuser", false); + t.authentik.users.get(3).getJSONObject("attributes").put("qqq.io/protected", true); + refused(create(), "qqq.io/protected"); + t.authentik.users.get(3).getJSONObject("attributes").remove("qqq.io/protected"); + t.config.withProtectedUsernames(List.of("sam")); + refused(create(), "protectedUsernames"); + t.config.withProtectedUsernames(List.of()).withTokenProtectedGroups(List.of()); + t.authentik.group("child-admin", false, List.of(), List.of(), "identity-admins"); + t.authentik.groupByName("child-admin").getJSONArray("users").put(3); + refused(create(), "identity-admins"); + t.authentik.groupByName("child-admin").put("users", new JSONArray()); + t.authentik.group("child-super", false, List.of(), List.of(), "authentik Admins"); + t.authentik.groupByName("child-super").getJSONArray("users").put(3); + refused(create(), "superuser"); + t.authentik.groupByName("child-super").put("users", new JSONArray()); + t.config.withTokenProtectedGroups(List.of("token-users")); + refused(create(), "token-users"); + } + + /******************************************************************************* + ** Expiry, scope opt-in, broker, delivery and feature guards all fail closed. + *******************************************************************************/ + @Test + void reviewRefusals() throws QException + { + AdminRequest missing = create(); + missing.params().remove("expiresInDays"); + refused(missing, "Expiry is required"); + refused(create().with("expiresInDays", 91), "90"); + refused(create().with("expiresInDays", 0), "1 and"); + refused(create().with("apps", "content"), "Scopes are not enabled"); + refused(create().with("description", ""), "description"); + t.config.withTokenBrokerClientSecret(null); + refused(create(), "broker"); + t.config.withTokenBrokerClientSecret("test-secret").withSubjectTokenSource(session -> null); + refused(create(), "signed-in Authentik session"); + t.config.withSubjectTokenSource(session -> "subject:3"); + TokenKeyDelivery.set(null); + refused(create(), "upgrade QQQ"); + seed("own", 3); + refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "own", "replace"), "upgrade QQQ"); + t.loginAs("operator@example.test", IdentityPermission.values()); + seed("service", 6); + refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace"), "upgrade QQQ"); + refused(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "needed").with("expiresInDays", 366), "365"); + t.config.withTokensEnabled(false); + refused(create(), "not enabled"); + } + + /******************************************************************************* + ** Service token approvals use the existing second-person queue and replay. + *******************************************************************************/ + @Test + void twoPersonApproval() throws QException + { + t.config.withTokenTwoPersonApproval(true); + t.loginAs("operator@example.test", IdentityPermission.values()); + seed("service", 6); + for(AdminRequest request : List.of(AdminRequest.of(AdminAction.ROTATE_TOKEN, "service", "replace"), + AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "automation").with("name", "approved-service").with("expiresInDays", 100))) + { + assertThat(t.admin().plan(request).getState()).isEqualTo(AdminPlan.State.NEEDS_APPROVAL); + assertThat(t.admin().execute(request.with("approved", true)).pending()).isTrue(); + } + List 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 identifiers, Map 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 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"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java b/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java index 3c366bd..441f6f4 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java @@ -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"); } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikRedactorTest.java b/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikRedactorTest.java index a21ca57..a8c5f35 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikRedactorTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikRedactorTest.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("******"); + } + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/client/OAuth2SubjectTokenSourceTest.java b/src/test/java/com/kingsrook/qbits/authentik/client/OAuth2SubjectTokenSourceTest.java new file mode 100644 index 0000000..eed014c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/client/OAuth2SubjectTokenSourceTest.java @@ -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); + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java b/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java index 42274f9..7d54de4 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java @@ -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 grantTemporaryPermissions(java.util.List requested) + { + java.util.Set 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 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 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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/BootstrapBlueprintIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/BootstrapBlueprintIT.java index 79afe92..09cc09b 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/BootstrapBlueprintIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/BootstrapBlueprintIT.java @@ -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); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java index b2ae375..8591048 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java @@ -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()); diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java index 1eaf861..e0006a2 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java @@ -92,7 +92,7 @@ class QbitWriteContractIT private static AuthentikTestStack stack; private static TestApi admin; - private static String roleUuid; + private static List 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); } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/SuperuserFloorIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/SuperuserFloorIT.java index 7a8e3a3..d313039 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/SuperuserFloorIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/SuperuserFloorIT.java @@ -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 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); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java new file mode 100644 index 0000000..4f11e05 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java @@ -0,0 +1,731 @@ +/* + * Copyright 2026 Kingsrook, LLC + * SPDX-License-Identifier: Apache-2.0 + */ +package com.kingsrook.qbits.authentik.contract; + + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Base64; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.AuthentikQBitProducer; +import com.kingsrook.qbits.authentik.RequiresDockerCondition; +import com.kingsrook.qbits.authentik.admin.AdminAction; +import com.kingsrook.qbits.authentik.admin.AdminRequest; +import com.kingsrook.qbits.authentik.admin.AuthentikAdmin; +import com.kingsrook.qbits.authentik.admin.OneTimeKey; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestReporter; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** DESIGN 13.11 against the pinned server and production token blueprint. + ** Each test owns its users, groups and tokens; credentials stay in memory. + *******************************************************************************/ +@ExtendWith(RequiresDockerCondition.class) +@Order(45) +class TokenContractIT +{ + private static final String LIFETIME = "goauthentik.io/user/token-maximum-lifetime"; + private static final HttpClient HTTP = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(10)).build(); + private static AuthentikTestStack stack; + private static TestApi admin; + private final List users = new ArrayList<>(); + private final List groups = new ArrayList<>(); + private final List tokens = new ArrayList<>(); + private final List delivered = new ArrayList<>(); + private final List eventIds = new ArrayList<>(); + private String appGroup; + private AuthentikQBitConfig config; + + + + /******************************************************************************* + ** All three blueprints must be successful before any probe. + *******************************************************************************/ + @BeforeAll + static void start() + { + stack = AuthentikTestStack.get(); + admin = stack.admin(); + assertThat(stack.blueprint(AuthentikTestStack.TOKEN_BLUEPRINT).getString("status")).isEqualTo("successful"); + } + + + + /******************************************************************************* + ** Independent app-access membership for every test. + *******************************************************************************/ + @BeforeEach + void setup() + { + JSONObject created = expect(admin.post("/core/groups/", new JSONObject().put("name", unique("access")).put("attributes", + new JSONObject().put("qqq.io/app-access", new JSONArray().put(AuthentikTestStack.APP_SLUG)))), 201); + appGroup = created.getString("pk"); + groups.add(appGroup); + } + + + + /******************************************************************************* + ** Cascading user deletion also removes managed roles and credentials. + *******************************************************************************/ + @AfterEach + void cleanup() + { + TokenKeyDelivery.set(null); + QContext.clear(); + for(String identifier : tokens) + { + assertThat(admin.delete(tokenPath(identifier)).status()).isIn(204, 404); + } + for(Integer pk : users) + { + assertThat(admin.delete("/core/users/" + pk + "/").status()).isIn(204, 404); + } + for(String pk : groups) + { + assertThat(admin.delete("/core/groups/" + pk + "/").status()).isIn(204, 404); + } + delivered.clear(); + } + + + + /******************************************************************************* + ** Explicit other-owner input does not override caller-owned creation. + *******************************************************************************/ + @Test + void nonSuperuserCreateBelongsToCaller() + { + int other = user("other"); + String identifier = unique("caller-owned"); + tokens.add(identifier); + JSONObject created = expect(stack.serviceAccount().post("/core/tokens/", tokenBody(identifier, other, 1)), 201); + assertOwner(created, admin.userPk(AuthentikTestStack.SERVICE_ACCOUNT)); + } + + + + /******************************************************************************* + ** PATCH defaults move intent and owner; explicit fields retain both. + *******************************************************************************/ + @Test + void patchDefaultsAreDangerous() + { + int owner = user("patch"); + String unsafe = appPassword(owner, 1); + JSONObject changed = expect(stack.serviceAccount().patch(tokenPath(unsafe), new JSONObject().put("expires", expires(30))), 200); + assertThat(changed.getString("intent")).isEqualTo("api"); + assertThat(changed.getInt("user")).isEqualTo(admin.userPk(AuthentikTestStack.SERVICE_ACCOUNT)); + String safe = appPassword(owner, 1); + assertOwner(expect(stack.serviceAccount().patch(tokenPath(safe), new JSONObject() + .put("intent", "app_password").put("user", owner).put("expires", expires(30))), 200), owner); + } + + + + /******************************************************************************* + ** Remove the brand cap temporarily, restoring it even on assertion failure. + *******************************************************************************/ + @Test + void expiryCapUsesBrandThenGroupsThenUser() + { + int owner = user("cap"); + TestApi caller = apiAs(owner); + JSONObject brand = admin.first("/core/brands/?default=true"); + String path = "/core/brands/" + brand.getString("brand_uuid") + "/"; + JSONObject original = brand.getJSONObject("attributes"); + try + { + JSONObject without = new JSONObject(original.toString()); + without.remove(LIFETIME); + expect(admin.patch(path, new JSONObject().put("attributes", without)), 200); + refusedLifetime(caller, owner, 30); + expect(admin.patch(path, new JSONObject().put("attributes", new JSONObject(original.toString()).put(LIFETIME, "days=90"))), 200); + String identifier = unique("brand-cap"); + tokens.add(identifier); + assertOwner(expect(caller.post("/core/tokens/", tokenBody(identifier, owner, 30)), 201), owner); + refusedLifetime(caller, owner, 120); + expect(admin.patch("/core/groups/" + appGroup + "/", new JSONObject().put("attributes", new JSONObject() + .put("qqq.io/app-access", new JSONArray().put(AuthentikTestStack.APP_SLUG)).put(LIFETIME, "days=10"))), 200); + refusedLifetime(caller, owner, 30); + attributes(owner, new JSONObject().put(LIFETIME, "days=365")); + String overridden = unique("user-cap"); + tokens.add(overridden); + assertOwner(expect(caller.post("/core/tokens/", tokenBody(overridden, owner, 200)), 201), owner); + } + finally + { + expect(admin.patch(path, new JSONObject().put("attributes", original)), 200); + } + } + + + + /******************************************************************************* + ** Broker bearers create, read keys, extend and delete only their own tokens. + *******************************************************************************/ + @Test + void brokerSupportsPersonalLifecycleAndIsolatesOwners() throws Exception + { + int owner = user("broker"); + TestApi caller = exchanged(owner); + TestApi other = exchanged(user("isolated")); + String identifier = unique("broker-created"); + tokens.add(identifier); + assertOwner(expect(caller.post("/core/tokens/", tokenBody(identifier, owner, 30)), 201), owner); + String key = expect(caller.get(tokenPath(identifier) + "view_key/"), 200).getString("key"); + assertThat(key.isBlank()).isFalse(); + assertThat(other.get(tokenPath(identifier)).status()).isIn(403, 404); + assertThat(other.get(tokenPath(identifier) + "view_key/").status()).isIn(403, 404); + assertThat(other.delete(tokenPath(identifier)).status()).isIn(403, 404); + assertOwner(expect(caller.patch(tokenPath(identifier), new JSONObject().put("intent", "app_password") + .put("user", owner).put("expires", expires(40))), 200), owner); + expect(caller.delete(tokenPath(identifier)), 204); + } + + + + /******************************************************************************* + ** Each privileged class is independently denied by the broker policy. + *******************************************************************************/ + @Test + void brokerDeniesProtectedOwners(TestReporter reporter) throws Exception + { + for(String protection : List.of("identity-admins", "authentik Admins", "attribute")) + { + int owner = user("denied"); + if("attribute".equals(protection)) + { + attributes(owner, new JSONObject().put("qqq.io/protected", true)); + } + else + { + expect(admin.post("/core/groups/" + admin.groupPk(protection) + "/add_user/", new JSONObject().put("pk", owner)), 204); + } + TestApi.Response response = exchange(subject(owner)); + assertThat(response.status()).as("broker denial for %s: %s", protection, safeError(response)).isNotEqualTo(200); + assertThat(response.json().optString("error")).isNotBlank(); + reporter.publishEntry(protection, "HTTP " + response.status() + ": " + safeError(response)); + } + } + + + + /******************************************************************************* + ** service_account needs add_user AND add_token (2026.8.3), and set_key needs + ** add_token (POST object permission) as well as set_token_key: the role + ** holds all three (DESIGN.md 13.9), and add_token cannot mint for others. + *******************************************************************************/ + @Test + void serviceAccountCreateSetKeyAndExtend() throws Exception + { + String name = unique("service"); + JSONObject request = new JSONObject().put("name", name).put("create_group", false).put("expiring", true).put("expires", expires(300)); + JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", request), 200); + int owner = created.getInt("user_pk"); + users.add(owner); + String identifier = "service-account-" + name + "-password"; + tokens.add(identifier); + assertThat(created.getString("token").isBlank()).isFalse(); + JSONObject before = admin.getOk(tokenPath(identifier)); + assertOwner(before, owner); + assertThat(Instant.parse(before.getString("expires"))).isAfter(Instant.now().plus(299, ChronoUnit.DAYS)); + attributes(owner, new JSONObject().put(LIFETIME, "days=365")); + expect(admin.post("/core/groups/" + appGroup + "/add_user/", new JSONObject().put("pk", owner)), 204); + String key = UUID.randomUUID().toString() + UUID.randomUUID(); + expect(stack.serviceAccount().post(tokenPath(identifier) + "set_key/", new JSONObject().put("key", key)), 204); + expect(credentials(name, key, "openid profile email"), 200); + assertThat(admin.getOk(tokenPath(identifier) + "view_key/").getString("key").equals(key)).isTrue(); + assertOwner(expect(stack.serviceAccount().patch(tokenPath(identifier), new JSONObject().put("intent", "app_password") + .put("user", owner).put("expires", expires(200))), 200), owner); + } + + + + /******************************************************************************* + ** Profile controls the signed token binding and groups on the access JWT. + *******************************************************************************/ + @Test + void clientCredentialsClaimsRequireProfile() throws Exception + { + int owner = user("claims"); + String identifier = appPassword(owner, 1); + String key = admin.getOk(tokenPath(identifier) + "view_key/").getString("key"); + String username = admin.getOk("/core/users/" + owner + "/").getString("username"); + JSONObject full = claims(expect(credentials(username, key, "openid profile email"), 200).getString("access_token")); + assertThat(full.getString("qqq_token")).isEqualTo(identifier); + assertThat(full.has("groups")).isTrue(); + JSONObject limited = claims(expect(credentials(username, key, "openid email"), 200).getString("access_token")); + assertThat(limited.has("qqq_token")).isFalse(); + assertThat(limited.has("groups")).isFalse(); + } + + + + /******************************************************************************* + ** Local notifications identify the actual protected-token update event. + ** Observe the negative case for a full worker-processing interval as well. + *******************************************************************************/ + @Test + void detectiveRuleNotifiesOnlyForProtectedOwner() throws Exception + { + int protectedOwner = user("alarm"); + expect(admin.post("/core/groups/" + admin.groupPk("identity-admins") + "/add_user/", new JSONObject().put("pk", protectedOwner)), 204); + String protectedToken = appPassword(protectedOwner, 1); + JSONObject created = expect(admin.post("/core/users/service_account/", new JSONObject().put("name", unique("ordinary")) + .put("create_group", false).put("expiring", true).put("expires", expires(30))), 200); + users.add(created.getInt("user_pk")); + String ordinaryToken = "service-account-" + created.getString("username") + "-password"; + tokens.add(ordinaryToken); + String ordinaryEvent = setKeyEvent(ordinaryToken); + String protectedEvent = setKeyEvent(protectedToken); + AuthentikTestStack.waitFor(Duration.ofSeconds(60), "protected-token local notification", () -> + notifications(protectedOwner, protectedEvent).length() > 0); + Instant deadline = Instant.now().plusSeconds(60); + do + { + assertThat(notifications(protectedOwner, ordinaryEvent).length()).isZero(); + Thread.sleep(2_000); + } + while(Instant.now().isBefore(deadline)); + } + + + + /******************************************************************************* + ** Real qbit calls, local audit/scope persistence and the response-only SPI. + *******************************************************************************/ + @Test + void qbitLifecycleNeverPersistsOneTimeKeys() throws Exception + { + int owner = user("qbit"); + String username = admin.getOk("/core/users/" + owner + "/").getString("username"); + configureQbit(owner, subject(owner)); + OneTimeKey first = issued(run(AdminRequest.of(AdminAction.CREATE_TOKEN, "", "contract personal create") + .with("description", "Contract access").with("expiresInDays", 30))); + expect(credentials(username, first.value(), "openid profile email"), 200); + run(AdminRequest.of(AdminAction.EXTEND_TOKEN, first.label(), "contract extend").with("expiresInDays", 40)); + assertOwner(admin.getOk(tokenPath(first.label())), owner); + OneTimeKey rotated = issued(run(AdminRequest.of(AdminAction.ROTATE_TOKEN, first.label(), "contract rotate"))); + expect(credentials(username, rotated.value(), "openid profile email"), 200); + assertThat(admin.get(tokenPath(first.label())).status()).isEqualTo(404); + run(AdminRequest.of(AdminAction.REVOKE_TOKEN, rotated.label(), "contract revoke")); + assertThat(admin.get(tokenPath(rotated.label())).status()).isEqualTo(404); + String serviceName = unique("qbit-service"); + try + { + AdminRequest createService = AdminRequest.of(AdminAction.CREATE_SERVICE_ACCOUNT, "", "contract service create") + .with("name", serviceName).with("groups", admin.getOk("/core/groups/" + appGroup + "/").getString("name")).with("expiresInDays", 300); + OneTimeKey service = issued(run(createService)); + int servicePk = admin.userPk(serviceName); + JSONObject attributes = admin.getOk("/core/users/" + servicePk + "/").getJSONObject("attributes"); + assertThat(attributes.getString(LIFETIME)).isEqualTo("days=365"); + assertThat(attributes.getBoolean("goauthentik.io/user/token-expires")).isTrue(); + expect(credentials(serviceName, service.value(), "openid profile email"), 200); + OneTimeKey next = issued(run(AdminRequest.of(AdminAction.ROTATE_TOKEN, service.label(), "contract service rotate"))); + expect(credentials(serviceName, next.value(), "openid profile email"), 200); + assertThat(credentials(serviceName, service.value(), "openid profile email").status()).isNotEqualTo(200); + assertOwner(admin.getOk(tokenPath(next.label())), servicePk); + run(AdminRequest.of(AdminAction.EXTEND_TOKEN, next.label(), "contract service extend").with("expiresInDays", 200)); + } + finally + { + JSONObject service = admin.first("/core/users/?username=" + TestApi.encode(serviceName)); + if(service != null) + { + users.add(service.getInt("pk")); + } + } + for(OneTimeKey key : delivered) + { + for(String table : List.of("authentik_admin_action", "authentik_token_scope", "authentik_approval_request")) + { + String rows = com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(new QueryAction().execute(new QueryInput().withTableName(table)).getRecords()); + assertThat(rows.contains(key.value())).as("No secret in %s", table).isFalse(); + } + for(String eventId : eventIds) + { + assertThat(admin.getOk("/events/events/" + eventId + "/").toString().contains(key.value())) + .as("No secret in qbit custom event").isFalse(); + } + } + assertThat(delivered).hasSize(4); + assertThat(eventIds).hasSize(7); + } + + + + /******************************************************************************* + ** A real runtime using the qbit bearer and a normal user's signed subject JWT. + *******************************************************************************/ + private void configureQbit(int owner, String subject) throws Exception + { + MemoryRecordStore.getInstance().reset(); + config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin") + .withListCacheTtlSeconds(0).withGroupCacheTtlSeconds(0).withBulkPacingMillis(0).withLocalBackendName("memory") + .withRecoveryEmailStage("kof22-recovery-email") + .withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.TOKENS)) + .withTokenBrokerClientId("qqq-kof22-token-broker").withTokenBrokerClientSecret(AuthentikTestStack.TOKEN_BROKER_CLIENT_SECRET) + .withTokenEndpointUrl(stack.baseUrl() + "/application/o/token/").withSubjectTokenSource(session -> subject) + .withAppClientId(AuthentikTestStack.APP_SLUG).withAuthentikPublicUrl(stack.baseUrl()).withAppPublicUrl("https://app.example.test"); + QInstance instance = new QInstance(); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + new AuthentikQBitProducer().withQBitConfig(config).produce(instance); + Set permissions = new HashSet<>(); + for(IdentityPermission permission : IdentityPermission.values()) + { + permissions.add(permission.getSessionPermission()); + } + QSession session = new QSession(); + session.setUser(new QUser().withIdReference(admin.getOk("/core/users/" + owner + "/").getString("email"))); + session.setPermissions(permissions); + QContext.init(instance, session); + TokenKeyDelivery.set((output, key) -> delivered.add(key)); + } + + + + /******************************************************************************* + ** Preserve custom event identifiers without retaining any response secret. + *******************************************************************************/ + private AuthentikAdmin.AdminResult run(AdminRequest request) throws Exception + { + AuthentikAdmin.AdminResult result = new AuthentikAdmin(AuthentikActionUtil.forBackend(config.getApiBackendName())).execute(request); + assertThat(result.ok()).as(result.message()).isTrue(); + assertThat(result.eventPk()).isNotBlank(); + eventIds.add(result.eventPk()); + return (result); + } + + + + /******************************************************************************* + ** Exercise the one-time adapter and ensure a second take is empty. + *******************************************************************************/ + private OneTimeKey issued(AuthentikAdmin.AdminResult result) + { + OneTimeKey key = result.takeSecret(); + assertThat(key).isNotNull(); + assertThat(result.takeSecret()).isNull(); + assertThat(TokenKeyDelivery.tryDeliver(new RunBackendStepOutput(), key)).isTrue(); + tokens.add(key.label()); + result.delivered(); + return (key); + } + + + + /******************************************************************************* + ** New ordinary user with independently owned membership and cleanup. + *******************************************************************************/ + private int user(String prefix) + { + String name = unique(prefix); + JSONObject created = expect(admin.post("/core/users/", new JSONObject().put("username", name).put("name", name) + .put("email", name + "@example.test").put("type", "internal").put("path", "users")), 201); + int pk = created.getInt("pk"); + users.add(pk); + expect(admin.post("/core/groups/" + appGroup + "/add_user/", new JSONObject().put("pk", pk)), 204); + return (pk); + } + + + + /******************************************************************************* + ** Merge test attributes, preserving service-account flags. + *******************************************************************************/ + private void attributes(int owner, JSONObject extra) + { + String path = "/core/users/" + owner + "/"; + JSONObject attributes = admin.getOk(path).getJSONObject("attributes"); + extra.keySet().forEach(key -> attributes.put(key, extra.get(key))); + expect(admin.patch(path, new JSONObject().put("attributes", attributes)), 200); + } + + + + /******************************************************************************* + ** Bootstrap setup can create a credential for another user. + *******************************************************************************/ + private String appPassword(int owner, int days) + { + String identifier = unique("password"); + tokens.add(identifier); + assertOwner(expect(admin.post("/core/tokens/", tokenBody(identifier, owner, days)), 201), owner); + return (identifier); + } + + + + /******************************************************************************* + ** API bearer for cap probes, independent of the broker under test. + *******************************************************************************/ + private TestApi apiAs(int owner) + { + String identifier = unique("api"); + tokens.add(identifier); + expect(admin.post("/core/tokens/", new JSONObject().put("identifier", identifier).put("user", owner).put("intent", "api")), 201); + return (new TestApi(stack.apiRoot(), admin.getOk(tokenPath(identifier) + "view_key/").getString("key"))); + } + + + + /******************************************************************************* + ** Mint an app JWT using the explicitly enabled client-credentials grant. + *******************************************************************************/ + private String subject(int owner) throws Exception + { + String identifier = appPassword(owner, 1); + String key = admin.getOk(tokenPath(identifier) + "view_key/").getString("key"); + return (expect(credentials(admin.getOk("/core/users/" + owner + "/").getString("username"), key, "openid profile email"), 200).getString("access_token")); + } + + + + /******************************************************************************* + ** Exchange through the production broker, without substituting admin access. + *******************************************************************************/ + private TestApi exchanged(int owner) throws Exception + { + JSONObject response = expect(exchange(subject(owner)), 200); + assertThat(response.getInt("expires_in")).isBetween(1, 120); + return (new TestApi(stack.apiRoot(), response.getString("access_token"))); + } + + + + /******************************************************************************* + ** The primary provider consumes an app password to authenticate its owner. + *******************************************************************************/ + private TestApi.Response credentials(String username, String key, String scope) throws Exception + { + return (form(Map.of("grant_type", "client_credentials", "client_id", AuthentikTestStack.APP_SLUG, + "client_secret", "contract-test-oauth2-client-secret", "username", username, "password", key, "scope", scope))); + } + + + + /******************************************************************************* + ** RFC 8693 request used by both allowed and denied subject cases. + *******************************************************************************/ + private TestApi.Response exchange(String subject) throws Exception + { + return (form(Map.of("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange", "client_id", "qqq-kof22-token-broker", + "client_secret", AuthentikTestStack.TOKEN_BROKER_CLIENT_SECRET, "subject_token", subject, + "subject_token_type", "urn:ietf:params:oauth:token-type:jwt", "scope", "goauthentik.io/api"))); + } + + + + /******************************************************************************* + ** HTTP/1.1 avoids Authentik's h2c POST trap. No form in error messages. + *******************************************************************************/ + private TestApi.Response form(Map fields) throws Exception + { + String body = fields.entrySet().stream().map(entry -> TestApi.encode(entry.getKey()) + "=" + TestApi.encode(entry.getValue())).collect(Collectors.joining("&")); + HttpResponse 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 + "/"); + } +} diff --git a/src/test/resources/blueprints/kof22-identity-bootstrap.yaml b/src/test/resources/blueprints/kof22-identity-bootstrap.yaml index 3c8b225..bfddbd1 100644 --- a/src/test/resources/blueprints/kof22-identity-bootstrap.yaml +++ b/src/test/resources/blueprints/kof22-identity-bootstrap.yaml @@ -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 diff --git a/src/test/resources/blueprints/kof22-oauth2-apps.yaml b/src/test/resources/blueprints/kof22-oauth2-apps.yaml index 9464a3b..859d2a9 100644 --- a/src/test/resources/blueprints/kof22-oauth2-apps.yaml +++ b/src/test/resources/blueprints/kof22-oauth2-apps.yaml @@ -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 diff --git a/src/test/resources/blueprints/kof22-qqq-tokens.yaml b/src/test/resources/blueprints/kof22-qqq-tokens.yaml new file mode 100644 index 0000000..eaf3cc5 --- /dev/null +++ b/src/test/resources/blueprints/kof22-qqq-tokens.yaml @@ -0,0 +1,195 @@ +# Verbatim copy of cluster-gitops +# tenants/kof22/website-authentik/kof22-qqq-tokens.yaml +# Source: KofTwentyTwo/cluster-gitops PR #103. +# QQQ access tokens (qbit-authentik DESIGN.md 13, owner decisions 2026-10-13). +# Infrastructure only, `present`: the token broker the admin backend uses to +# create a person's token AS THAT PERSON (RFC 8693 token exchange), the deny +# policy for privileged accounts, and the detective alarm. A separate file so +# a problem here (for example a missing broker secret) fails only this +# blueprint, never sign-in. +version: 1 +metadata: + name: kof22-qqq-tokens + labels: + blueprints.goauthentik.io/instantiate: "true" +entries: + - model: authentik_blueprints.metaapplyblueprint + attrs: + identifiers: + name: kof22-identity-bootstrap + required: true + + # Guardrail (a) inside Authentik: no broker token (so no self-service token + # create, rotate or extend) for superusers (including inherited), members of + # authentik Admins or identity-admins, anyone marked qqq.io/protected, or the + # qbit's own service account. + - model: authentik_policies_expression.expressionpolicy + id: qqq-token-broker-deny + state: present + identifiers: + name: qqq-kof22-token-broker-deny-protected + attrs: + name: qqq-kof22-token-broker-deny-protected + expression: | + user = request.user + if user is None or not user.is_authenticated: + return False + if user.username == "qqq-kof22-admin-idadmin": + return False + if (user.attributes or {}).get("qqq.io/protected") is True: + return False + for group in user.all_groups(): + if group.is_superuser or group.name in ("authentik Admins", "identity-admins"): + return False + return True + + # The broker: a confidential client the backend alone holds. It accepts only + # the token-exchange grant, for subject tokens signed by the kof22 admin + # provider, and issues 2-minute tokens with the authentik API scope. + - model: authentik_providers_oauth2.oauth2provider + id: qqq-token-broker-provider + state: present + identifiers: + name: qqq-kof22-token-broker + attrs: + name: qqq-kof22-token-broker + authorization_flow: !Find [authentik_flows.flow, [slug, kof22-authorization]] + invalidation_flow: !Find [authentik_flows.flow, [slug, kof22-invalidation-prod]] + client_type: confidential + client_id: qqq-kof22-token-broker + client_secret: !Env KOF22_TOKEN_BROKER_CLIENT_SECRET + grant_types: + - urn:ietf:params:oauth:grant-type:token-exchange + # No browser flow: the broker only answers token exchange. + redirect_uris: [] + signing_key: !Find [authentik_crypto.certificatekeypair, [name, "authentik Self-signed Certificate"]] + access_code_validity: minutes=1 + access_token_validity: minutes=2 + refresh_token_validity: minutes=2 + sub_mode: user_email + issuer_mode: per_provider + jwt_federation_providers: + - !Find [authentik_providers_oauth2.oauth2provider, [name, kof22-website-prod]] + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-authentik_api]] + + - model: authentik_core.application + id: qqq-token-broker-application + state: present + identifiers: + slug: qqq-kof22-token-broker + attrs: + name: "QQQ kof22 token broker" + slug: qqq-kof22-token-broker + provider: !KeyOf qqq-token-broker-provider + policy_engine_mode: all + meta_hide: true + + - model: authentik_policies.policybinding + state: present + identifiers: + target: !KeyOf qqq-token-broker-application + policy: !KeyOf qqq-token-broker-deny + attrs: + target: !KeyOf qqq-token-broker-application + policy: !KeyOf qqq-token-broker-deny + order: 0 + enabled: true + negate: false + timeout: 30 + failure_result: false + + # Detective control, guardrail (e): the qbit's service account must never + # create, change, read or re-key a token whose owner is protected (a + # superuser, a member of authentik Admins or identity-admins, or + # qqq.io/protected). If it ever happens, guard (a) was bypassed: + # identity-admins get an Authentik notification and an email. + - model: authentik_policies_expression.expressionpolicy + id: qqq-token-alarm-policy + state: present + identifiers: + name: qqq-kof22-token-alarm + attrs: + name: qqq-kof22-token-alarm + expression: | + from authentik.core.models import Token, User + event = request.context.get("event") + if event is None: + return False + if event.action not in ("model_created", "model_updated", "model_deleted", "secret_view", "secret_rotate"): + return False + if (event.user or {}).get("username") != "qqq-kof22-admin-idadmin": + return False + model = event.context.get("model") or event.context.get("secret") or {} + # Two shapes: the usual model reference (app, model_name, pk), and set_key's + # model_to_dict(token) (token_uuid, identifier, intent, user; key masked). + is_reference = model.get("app") == "authentik_core" and model.get("model_name") == "token" + is_dict = "identifier" in model and "intent" in model + if not is_reference and not is_dict: + return False + owner = None + token = None + if is_reference: + token = Token.objects.including_expired().filter(pk=model.get("pk")).first() + elif model.get("identifier"): + token = Token.objects.including_expired().filter(identifier=model.get("identifier")).first() + if token is not None: + owner = token.user + elif model.get("user"): + owner = User.objects.filter(pk=model.get("user")).first() + if owner is None: + return False + if (owner.attributes or {}).get("qqq.io/protected") is True: + return True + for group in owner.all_groups(): + if group.is_superuser or group.name in ("authentik Admins", "identity-admins"): + return True + return False + + - model: authentik_events.notificationtransport + id: qqq-token-alarm-local + state: present + identifiers: + name: qqq-kof22-token-alarm-local + attrs: + name: qqq-kof22-token-alarm-local + mode: local + send_once: false + + - model: authentik_events.notificationtransport + id: qqq-token-alarm-email + state: present + identifiers: + name: qqq-kof22-token-alarm-email + attrs: + name: qqq-kof22-token-alarm-email + mode: email + send_once: false + + - model: authentik_events.notificationrule + id: qqq-token-alarm-rule + state: present + identifiers: + name: qqq-kof22-token-alarm + attrs: + name: qqq-kof22-token-alarm + severity: alert + destination_group: !Find [authentik_core.group, [name, identity-admins]] + destination_event_user: false + transports: + - !KeyOf qqq-token-alarm-local + - !KeyOf qqq-token-alarm-email + + - model: authentik_policies.policybinding + state: present + identifiers: + target: !KeyOf qqq-token-alarm-rule + policy: !KeyOf qqq-token-alarm-policy + attrs: + target: !KeyOf qqq-token-alarm-rule + policy: !KeyOf qqq-token-alarm-policy + order: 0 + enabled: true + negate: false + timeout: 30 + failure_result: false