diff --git a/CHANGELOG.md b/CHANGELOG.md index 987fdd3..7e45979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +### Added (v1: full user lifecycle and group/permission management, phases 2-3) +- **Writes through the Authentik API**, behind the features `USERS`, `GROUPS`, `MEMBERSHIP`, `CREDENTIALS` and `SESSIONS` (read-only stays the default; `readOnly` is a hard kill switch). Every change goes through `AuthentikAdmin`: it plans against live Authentik data (the guardrails of DESIGN.md 5.4), audits first, calls Authentik, posts a `custom_qqq_admin_action` event naming the operator, and finishes the audit row. `AuthentikActionUtil.write` needs a `WriteAuthorization`, which only `AuthentikGuard.authorize` can mint. +- **Actions** (table and record actions with Review and Result steps; none in the nav): Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject. +- **Safety**: the subset (no escalation) rule evaluated from the operator's live groups; protected accounts and superuser groups; no self-edits; last-administrator protection; reasons; typed confirmations (and a typed count above 25 targets); a 100-target cap; stale checks by fingerprint (groups have no `last_updated`); attribute edits merge only the `qqq.io/*` keys into the current attributes; two-person approval for granting `authentik.*`. +- **Local tables** `authentik_admin_action` (append-only audit) and `authentik_approval_request` (DDL in `docs/schema.sql`), on the new `localBackendName`. +- **Read tables** `authentik_application`, `authentik_policy_binding`, `authentik_blueprint`, `authentik_role`, `authentik_token` (metadata only, never a key) and `authentik_invitation`. Sub-tables appear as child lists on their parents; the nav lists only the main tables. +- **Home**: the `authentikHome` widget (needs attention, people, groups and permissions, recent changes, connection). +- Config: `localBackendName` (replaces `mirrorBackendName`), `twoPersonApproval`, `schedulerName`, `linkPrefix`. +- Tests: an in-memory Authentik for the rules, process tests through `RunProcessAction`, and write contract tests on Authentik 2026.8.3 (`QbitWriteContractIT`, including the recovery email arriving in Mailpit). + +### Security +- `bootstrapOperators` (emails) is replaced by `bootstrapOperatorSubjects` (env `AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS`): users can change their own email in Authentik, so an email must never confer identity permissions. A bootstrap operator is now matched on the token `sub` (email-looking subs ignored) or, with a verified email, on the single active Authentik user with that email whose pk or uuid is configured. Spoofed, duplicate or unverified emails are denied; entries containing `@` fail validation. The admin service uses the same rule. + +### Fixed +- Count reported no distinct count, so a process Review showed no total; group lists now include member pks (member counts were 0). +- Spec: multi-value filters are repeated plain names (`type=a&type=b`), never `type[]=a` (which Authentik silently ignores); stale role-state, `check_access`, re-parenting-risk, private-repo and backend-name wording corrected (spec revision 4). + +### Not in this release +- The Authentik webhook, the local mirror tables and reconcile, the access-matrix widget, snapshot export, RBAC role management (phase 4) and the Alerts producer integration. `MIRROR`, `WEBHOOK`, `ACCESS_MATRIX`, `SNAPSHOT_EXPORT` and `RBAC` are refused by config validation. + + ### Changed - **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md). - **Table names are lowercase snake_case with the `authentik_` prefix** (owner naming rule, 2026-10-12): `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event` (were `authentikUser`, ...). New `AuthentikQBitConfig.applyTablePrefix` and `toSnakeCase`; apps, widgets, the backend and processes keep camelCase names (`authentikAdmin`, `authentikApi`). The old names are kept as `@Deprecated` `AuthentikTables.LEGACY_*_TABLE_NAME` constants. See [UPGRADE.md](UPGRADE.md). diff --git a/CLAUDE.md b/CLAUDE.md index e5809d6..1f08c74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code when working with code in this reposi ## 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 @@ -29,14 +29,17 @@ 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. 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). - `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`). +- `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,6 +49,8 @@ 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. +- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage. - The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions. ## Code Style diff --git a/README.md b/README.md index 08e34a1..fe31379 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,73 @@ # QBit Authentik -A QQQ QBit for managing [Authentik](https://goauthentik.io) identities (users, groups, memberships, and the QQQ permissions a group grants) from a QQQ app. **Authentik stays the source of truth.** The qbit reads Authentik live and will write through the Authentik API. Changes made in the Authentik admin UI are equally valid. +A QQQ QBit to manage [Authentik](https://goauthentik.io) identities from a QQQ app: **the full user lifecycle, and groups with the permissions they grant**. Authentik stays the source of truth. The qbit reads Authentik live and writes through the Authentik API, never its database. Changes made in the Authentik admin UI are equally valid, and the qbit never overwrites them blindly. -Design: [`docs/DESIGN.md`](docs/DESIGN.md) (copy of the approved spec, QRun-IO/qqq#1065). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Status and linked PRs: [`docs/STATUS.md`](docs/STATUS.md). +Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065). Status: [`docs/STATUS.md`](docs/STATUS.md). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Table DDL: [`docs/schema.sql`](docs/schema.sql). -## Status +## What you get -`0.1.0-SNAPSHOT`, the **read-only** release line (phases 0 and 1 of DESIGN.md section 11): - -| Available | Not yet (refused by config validation) | +| | | |---|---| -| `READ`: Authentik tables over the API | `MEMBERSHIP`, `GROUPS`, `USERS`, `CREDENTIALS`, `SESSIONS` writes (phases 2-3) | -| `MIRROR` (phase 1) | `RBAC` (phase 4), `WEBHOOK`, `SNAPSHOT_EXPORT` | +| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. No hard delete. | +| **Groups and permissions** | Create and edit groups (parents), add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). | +| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. | +| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. | +| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, connection), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. | -Every write is refused by `AuthentikGuard` in this release. +Everything is a standard QQQ table, action and widget, so it works in any QQQ UI and over the QQQ API. ## Usage ```java AuthentikQBitConfig config = new AuthentikQBitConfig() - .withBaseUrl("https://auth.example.com") // host root; /api/v3 is appended - .withApiToken("${env.AUTHENTIK_API_TOKEN}") // service-account token, resolved by the qbit - .withAppKey("my-app-admin") // service account: qqq-my-app-admin-idadmin - .withApplicationsInScope(List.of("my-app-prod")); + .withBaseUrl("https://auth.example.com") // host root; /api/v3 is appended + .withApiToken("${env.AUTHENTIK_API_TOKEN}") // service-account token, resolved by the qbit + .withAppKey("my-app-admin") // service account: qqq-my-app-admin-idadmin + .withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS)) + .withLocalBackendName("rdbms") // audit trail and approval requests (see docs/schema.sql) + .withRecoveryEmailStage("my-recovery-email") // Authentik email stage (name or uuid) + .withInvitationFlow("my-enrollment"); // optional: enables invitations new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance); ``` -This produces: -- an API backend `authentikApi` -- read-only tables `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session` and `authentik_event` -- the Identity Drift Report widget -- an app `authentikAdmin`, labelled "Identity", that hosts can nest, for example under Admin +Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill switch that refuses every write whatever the features say. -Access is controlled by QQQ permissions: +### What it produces -| Permission (held as `.hasAccess`) | Grants | +| | | |---|---| -| `authentik.users.read` | the Identity app and its tables | -| `authentik.audit.read` | sensitive fields and tables | +| API backend | `authentikApi` | +| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_application`, `authentik_approval_request`, `authentik_admin_action` | +| Child lists (on their parents, not in the nav) | `authentik_group_member`, `authentik_session`, `authentik_token` (metadata only, never a key), `authentik_event`, `authentik_policy_binding`, `authentik_role`, `authentik_blueprint` | +| App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report | +| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject | -The full `authentik.*` set is in DESIGN.md section 5.2. +### Permissions + +QQQ permissions, held as `.hasAccess`, granted through the group attribute `qqq.io/permissions` and read by `AuthentikGroupPermissionMapper`: + +| Permission | Grants | +|---|---| +| `authentik.users.read` | the Identity app, home, users, groups, applications, bindings, roles, blueprints | +| `authentik.users.manage` | create, invite, edit, deactivate, reactivate users; cancel invitations | +| `authentik.groups.manage` | create and edit groups | +| `authentik.memberships.manage` | add and remove members (subset rule) | +| `authentik.permissions.manage` | edit a group's permissions and app access (subset rule) | +| `authentik.credentials.reset` | password reset email, reset MFA, revoke app passwords | +| `authentik.sessions.revoke` | revoke sessions | +| `authentik.audit.read` | sessions, tokens, events, the audit trail, sensitive fields | +| `authentik.approve` | approve or reject a second person's request | +| `authentik.admin` | the drift report | +| `authentik.roles.manage` | reserved (role management is phase 4) | + +`authentik.*` grants all of them. ### Authentik side -The qbit's API token belongs to a dedicated Authentik service account whose role holds only the read permissions it needs. The bootstrap blueprint that creates it lives in the host's GitOps repo. One host's version, which the contract tests exercise, is [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml); treat it as an example, not something to apply as is. +The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, token-key, impersonate or delete permissions. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example. + +Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. The contract tests grant exactly this list. ## Adopting in another app @@ -52,22 +75,21 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo | Item | What to do | |---|---| -| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release candidate or release is on Maven Central yet; CircleCI publishes snapshots from `develop`. Build from source or use a snapshot until `0.1.0` ships. | +| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. | | QQQ version | QQQ 4.1 (see `pom.xml` for the exact RC). Java 21. | -| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl` (`/api/v3` is appended). | -| Authentik setup | A dedicated service account (default username `qqq--idadmin`) whose role holds only the read permissions the QBit needs, created by a bootstrap blueprint in the host's GitOps repository. `src/test/resources/blueprints/kof22-identity-bootstrap.yaml` is a worked example from one host, not something to apply as is. | -| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. Produces the `authentikApi` API backend, read-only tables (`authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event`), the drift report widget, and the `authentikAdmin` ("Identity") app, which the host may nest. | -| Required config | `baseUrl`, `apiToken`, `appKey` (lower-case letters, digits, dashes; chooses the service-account name), `applicationsInScope` (Authentik application slugs). Optional: `userScopePathStartsWith`, `groupScopeNamePrefix`, `mirrorBackendName` (phase 1 `MIRROR`), `throttleRequestsPerSecond` (default 10). | -| Secrets / env | `apiToken` as a `${env.X}` reference (the example uses `AUTHENTIK_API_TOKEN`); `bootstrapOperators` defaults to `${env.AUTHENTIK_QBIT_BOOTSTRAP_OPERATORS}` and `webhookSecret` to `${env.AUTHENTIK_WEBHOOK_SECRET}`. Keep them in the host's secret store, never in config files. | -| Permissions | `authentik.users.read` (Identity app and tables), `authentik.audit.read` (sensitive fields and tables); the full `authentik.*` set is in DESIGN.md section 5.2. Granted through the Authentik group attribute `qqq.io/permissions`, read by `AuthentikGroupPermissionMapper`. | -| Storage | None for the read-only line, except the mirror tables when `MIRROR` is enabled on `mirrorBackendName`. | -| Known limitations | Read-only: every write is refused by `AuthentikGuard` until phases 2-3. `RBAC`, `WEBHOOK` and `SNAPSHOT_EXPORT` are refused by config validation. | -| Upgrading | First release line; nothing to migrate. | +| 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. | +| 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. | +| Optional host wiring | ESB: publish the local tables with `EsbTableMetaData.ofOrWithNew(table).withPublication(...)`. Quick Search: register `authentik_user` and `authentik_group` with the Quick Search qbit. Alerts: see Known limitations. | +| Known limitations | Not in this release: the Authentik webhook, the local mirror tables and reconcile, the access-matrix widget, snapshot export, RBAC role management (phase 4), Alerts producer integration. Invitations add people but not their groups (add them to groups after they sign up). Group and user lists are read live (cached 30 s). | +| Upgrading | See `UPGRADE.md`. | ## Build ```bash -./mvnw test # unit tests (recorded 2026.8.3 fixtures, no network) +./mvnw test # unit tests (recorded 2026.8.3 fixtures and an in-memory Authentik, no network) CI=true ./mvnw verify # + contract tests against Authentik 2026.8.3 in Docker (Testcontainers) ./mvnw verify -Dauthentik.test.image=ghcr.io/goauthentik/server:2026.10.0 # rehearse an upgrade ./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 79c9db8..5a8bef9 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ # Authentik qbit: Design Spec -Status: Approved, revision 3 (phase 0 findings applied) · 2026-10-11 · Author: James Maes (drafted with Claude) +Status: Approved, revision 4 (phase 0 findings applied; review fixes; owner decisions of 2026-10-12) · 2026-10-12 · Author: James Maes (drafted with Claude) Targets: QQQ 4.1.x, QQQ Next UI 1.0.x, Authentik 2026.8.x (researched against the live `auth.kof22.com` schema, `info.version: 2026.8.3`), new repo `QRun-IO/qbit-authentik` ## 0. Summary @@ -38,6 +38,8 @@ Seven design rules shape everything below: ### 0.1 Owner decisions (2026-10-11) +**Owner decisions (2026-10-12): all recommendations accepted.** Every open question in this spec (§11.2, and the options marked *recommended* anywhere) is decided as the spec recommends, and the design is approved to build in phase order; adjustments follow once it can be seen working. The earlier decisions are below. + | # | Decision | |---|---| | 1 | **Source of truth.** Authentik's database. Users, groups, memberships, roles and permissions can be edited from **both** QQQ and the Authentik admin UI. Blueprints cover infrastructure plus `state: created` bootstrap objects only, with no member lists. A nightly read-only export to Git is optional (for audit and DR, not authoritative). | @@ -138,7 +140,7 @@ These starting objects use `state: created`. Authentik's blueprint importer skip - later: Authentik RBAC roles - **See** who has access and why: - groups (hierarchy), effective QQQ permissions per user, roles - - application access (`check_access`) and policy bindings + - application access (computed by the qbit from `qqq.io/app-access` and the app's bindings, §0.2 iii) and policy bindings - sessions, MFA devices (metadata only), tokens (metadata only), invitations, events - **Work alongside the Authentik admin UI**, with no lost updates and no "wrong" place to make a change. - **Integrate with the host app.** @@ -170,7 +172,7 @@ These starting objects use `state: created`. Authentik's blueprint importer skip **A. "Add Sam as a publisher."** - James opens Users → *Create user* and enters name, email and group `kof-website-publishers`. -- The preview shows the effective QQQ permissions Sam will get (`cms.publish`, …) and the `check_access` result for `kof22-website-prod` ("will be able to sign in: yes"). +- The preview shows the effective QQQ permissions Sam will get (`cms.publish`, …) and the computed access result for `kof22-website-prod` ("will be able to sign in: yes", from `qqq.io/app-access` and the app's bindings; not Authentik's `check_access`, §0.2 iii). - James confirms. QQQ calls `POST /core/users/` and `add_user`, then `recovery_email`, so Sam sets his own password. - The local audit row and the Authentik event are written, and the webhook refreshes the mirror. @@ -202,10 +204,10 @@ Facts come from the live OpenAPI schema at `https://auth.kof22.com/api/v3/schema | Sessions | `GET /core/authenticated_sessions/` (filter `user__username`), `DELETE …/{uuid}/`, `DELETE …/bulk_delete/?user_pks=` | Fields: `last_ip`, `last_user_agent`, `last_used`, `expires`, `geo_ip`, `asn`. Deactivating a user deletes their sessions (2026.8). | | MFA devices | `GET /authenticators/admin/all/?user=`. Per type, CRUD on `/authenticators/admin/{totp,webauthn,static,duo,sms,email}/{id}/` | Device metadata only: name, type, confirmed, created, last_used. | | Tokens | `GET/POST /core/tokens/`, `GET/PUT/PATCH/DELETE /core/tokens/{identifier}/`, `GET …/view_key/`, `POST …/set_key/` | Intents: `api`, `app_password`, `recovery`, `verification`. Keyed by identifier. `view_token_key` is a separate permission, and the qbit must never hold it. **Any user may create tokens for itself and read their keys** without `add_token` (§0.2 v). | -| Groups | `GET/POST /core/groups/`, `GET/PUT/PATCH/DELETE /core/groups/{uuid}/`, `POST …/add_user/` and `…/remove_user/` (`{pk}`) | **`parents` is many-to-many since 2025.12.** Groups inherit roles **and superuser status** from every ancestor. Names are unique (2025.12). Filters: `members_by_username[]`, `include_users`, `include_children`, `include_parents`, `include_inherited_roles`, `is_superuser`, `attributes`. Custom permissions: `add_user_to_group`, `remove_user_from_group`, `enable_group_superuser`, `disable_group_superuser`. | +| Groups | `GET/POST /core/groups/`, `GET/PUT/PATCH/DELETE /core/groups/{uuid}/`, `POST …/add_user/` and `…/remove_user/` (`{pk}`) | **`parents` is many-to-many since 2025.12.** Groups inherit roles **and superuser status** from every ancestor. Names are unique (2025.12). Filters: `members_by_username` (repeat the plain name for several values), `include_users`, `include_children`, `include_parents`, `include_inherited_roles`, `is_superuser`, `attributes`. Custom permissions: `add_user_to_group`, `remove_user_from_group`, `enable_group_superuser`, `disable_group_superuser`. | | RBAC | `GET/POST /rbac/roles/`, `…/{uuid}/add_user/` and `…/remove_user/`, `GET /rbac/permissions/` (catalogue), `GET /rbac/permissions/assigned_by_roles/?model=&object_pk=`, `POST …/assigned_by_roles/{uuid}/assign/`, `PATCH …/unassign/`, `GET /rbac/permissions/roles/?uuid=`, `/rbac/initial_permissions/` | **RBAC has been fully role-based since 2025.12.** Roles are assigned to users directly or through groups; there are no direct user permissions. `assign` with `object_pk` grants an object permission; without it, a global one. Initial permissions automatically grant a role permissions on objects that its members create. | | Apps and policies | `GET /core/applications/{slug}/`, `GET /core/applications/{slug}/check_access/?for_user=`, `GET/POST /policies/bindings/` (target = `pbm_uuid`, plus exactly one of policy/group/user), `/policies/all/`, `POST /policies/all/{uuid}/test/` | **`for_user` is honoured only for superusers** (silently ignored otherwise), the policy test endpoint returns 500 for non-superusers, and the app list shows a non-superuser only its own apps (§0.2 iii). Bindings can expire (2026.8). | -| Events | `GET /events/events/` (filters `action`, `actions[]`, `username`, `client_ip`, `context_model_app`, `context_model_name`, `context_model_pk`, `context_authorized_app`), `POST /events/events/` (custom `custom_*` actions), `/actions/`, `/volume/`, `/top_per_user/` | Relevant actions: `login`, `login_failed`, `logout`, `password_set`, `model_created/updated/deleted`, `impersonation_*`, `secret_view`, `invitation_used`, `user_write`, `suspicious_request`, `custom_`. | +| Events | `GET /events/events/` (filters `action`, `actions` (repeated plain name), `username`, `client_ip`, `context_model_app`, `context_model_name`, `context_model_pk`, `context_authorized_app`), `POST /events/events/` (custom `custom_*` actions), `/actions/`, `/volume/`, `/top_per_user/` | Relevant actions: `login`, `login_failed`, `logout`, `password_set`, `model_created/updated/deleted`, `impersonation_*`, `secret_view`, `invitation_used`, `user_write`, `suspicious_request`, `custom_`. | | Notifications | `/events/transports/` (mode `webhook` with `webhook_mapping_body`/`_headers`), `/events/rules/` (needs `destination_group` or `destination_event_user`), `/policies/event_matcher/` (action, app, model, client_ip) | Delivery runs as Dramatiq tasks with 5 retries, at least once, with no idempotency key and no ordering guarantee. The default body has no event id or model, so a custom body mapping is required. | | Invitations | `GET/POST /stages/invitation/invitations/` (`fixed_data`, `expires`, `single_use`, `flow`), `POST …/{uuid}/send_email/` | Needs an enrollment flow with an invitation stage, plus working email. | | Blueprints | `GET /managed/blueprints/`, `POST …/{uuid}/apply/`, `GET …/available/` | Each instance has `status`, `last_applied`, `managed_models`. | @@ -299,7 +301,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which │ /qqq/v1/... (normal QQQ routes) /qqq/v1/authentik/* (widgets) ┌──────────────▼──────────────── qbit-authentik (Java) ────────────────────┐ │ AuthentikQBitProducer / AuthentikQBitConfig │ -│ ├─ API backend "authentik" ── AuthentikActionUtil (BaseAPIActionUtil) │ +│ ├─ API backend "authentikApi" ── AuthentikActionUtil (BaseAPIActionUtil) │ │ │ ├─ AuthentikGuard (QQQ permission, escalation, protection) │ │ │ ├─ FilterMapper (QQueryFilter → Authentik query params) │ │ │ └─ Throttle (token bucket, bulk pacing) │ @@ -314,7 +316,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which │ Bearer token of service account "qqq--idadmin" ┌──────────────▼──────────────── Authentik (source of truth) ──────────────┐ │ DB: users, groups, memberships, attributes, roles ◄── Authentik admin UI│ -│ role qqq--idadmin (bootstrap blueprint, state: created + perms) │ +│ role qqq--idadmin (bootstrap blueprint, present, perms only) │ │ notification rule + event_matcher policy + webhook transport → QQQ │ │ blueprints: flows/stages/providers/apps/policies/branding (present) │ └───────────────────────────────────────────────────────────────────────────┘ @@ -328,14 +330,14 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which |---|---|---| | `baseUrl` | required | Authentik host root, e.g. `https://auth.kof22.com`. `/api/v3` is appended. This is separate from the OAuth2 issuer URL, as MHP learned. | | `apiToken` | required | Usually `${env.AUTHENTIK_API_TOKEN}`. Interpolated by the qbit (because `APIBackendMetaData.enrich()` is disabled) and redacted in `toString()` | -| `tableNamePrefix` / namespace | `authentik` | Prefix for tables (joined with `_`, snake_case: `authentik_user`), processes and widgets (camelCase: `authentikAdmin`) | +| `tableNamePrefix` / namespace | `authentik` | Prefix for tables (joined with `_`: `authentik_user`), processes and widgets (camel-cased: `authentikResyncMirror`) | | `features` | `{READ}` | A subset of `READ`, `USERS` (create, invite, edit, deactivate), `GROUPS` (create, edit, permissions and app-access attributes), `MEMBERSHIP`, `CREDENTIALS` (recovery email, MFA reset, app-password revoke), `SESSIONS`, `RBAC` (phase 4), `ACCESS_MATRIX`, `MIRROR`, `WEBHOOK`, `SNAPSHOT_EXPORT`. A feature that is off produces no metadata. | | `readOnly` | `false` | Hard kill switch. Every write is refused. | | `appKey` | required | Identifies this QQQ app in attributes, audit and the app-access attribute, e.g. `kof22-admin` | | `userScope` | `internal` + `external` users | Optional `path_startswith` and/or `groups_by_name` filter, to partition a shared Authentik between apps. Out-of-scope users are invisible and cannot be written. | | `groupScope` | all non-superuser groups | Optional name prefix or attribute filter (`qqq.io/app: `) for multi-app Authentiks | | `protectedUsernames` | `[]` | Added to the built-in protected set: the service account, users with `qqq.io/protected: true`, and superusers (§5.4) | -| `bootstrapOperators` | `${env.AUTHENTIK_QBIT_BOOTSTRAP_OPERATORS}` (empty) | Emails that receive all `authentik.*` permissions even if the group attributes are wrong. A lockout escape hatch that logs a warning while in use; intended to be emptied after go-live. | +| `bootstrapOperatorSubjects` | `${env.AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS}` (empty) | **Immutable ids, never emails** (users can change their own email in Authentik, and emails are not unique). A person is a bootstrap operator when the token `sub` equals an entry (a `sub` that looks like an email is ignored; kof22's provider uses `sub_mode: user_email`), or when the token email is verified, exactly ONE active Authentik user has it, and that user's pk or uuid equals an entry. Find the value in Authentik admin: Directory, Users, the user's numeric id (pk) or uuid. Grants all `authentik.*` permissions even if the group attributes are wrong: a lockout escape hatch that logs a warning while in use. **Empty it once real group grants exist** (after `identity-admins` membership is verified). Entries containing `@` fail validation. | | `permissionCatalog` | derived | The QQQ permission names that can be granted through `qqq.io/permissions`, taken from `PermissionsHelper.getAllAvailablePermissions` plus the `authentik.*` names. Unknown names are rejected. | | `recoveryEmailStage` | required if `CREDENTIALS` or `USERS` | Email stage slug or uuid for `recovery_email`, e.g. `kof22-recovery-email` | | `invitationFlow` | required for invites | Enrollment flow slug, e.g. `kof22-enrollment` | @@ -343,7 +345,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which | `applicationsInScope` | all visible | Application slugs shown in the access matrix and the app-access editor | | `confirmations` | see §6.3 | Per-action confirmation level: `CONFIRM`, `TYPED`, `TWO_PERSON` | | `throttle` | 10 req/s, burst 20; bulk pacing 100 ms | Self-imposed; authenticated calls are not throttled server-side | -| `cacheTtl` | 30 s lists, 60 s group attributes, 5 min `check_access` | Memoization TTLs | +| `cacheTtl` | 30 s lists, 60 s group attributes, 5 min computed app access | Memoization TTLs | | `mirror` | backend name, 15-minute reconcile, 30-day inbox retention | Used if `MIRROR` is on | | `webhookSecret` | `${env.AUTHENTIK_WEBHOOK_SECRET}` | Shared secret in a header set by the transport's header mapping | | `esbDestinationName` | optional | If set, webhook events go through the ESB; otherwise they are processed inline | @@ -364,7 +366,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which | Concern | Lives in | Edited from | Blueprint state | |---|---|---|---| | Users, groups, memberships, group parents, group attributes (`qqq.io/permissions`, `qqq.io/app-access`), user attributes | **Authentik DB** | QQQ qbit **and** Authentik admin UI | none (data); bootstrap seeds only, as `created` | -| Authentik RBAC roles and their assignments | Authentik DB | Authentik UI (v1); QQQ (phase 4) | the qbit's own role: `created`, with `permissions:` | +| Authentik RBAC roles and their assignments | Authentik DB | Authentik UI (v1); QQQ (phase 4) | the qbit's own role: `present`, `permissions` only (§4.3) | | Flows, stages, prompts, email templates | Git | cluster-gitops PR | `present` | | OAuth2/other providers, applications, scope mappings | Git | cluster-gitops PR | `present` | | Policies and policy bindings (incl. the app sign-in policy) | Git | cluster-gitops PR | `present` | @@ -515,7 +517,7 @@ Notes (resolved in phase 0, §0.2): - **Global permissions** are the role's `attrs.permissions` (finding i). The role is `present` with nothing but `permissions`, so it is re-asserted additively on every apply and carries no users or groups. `roles:` on the `created` service account applies on first create only; the role's later permission changes still reach it. - **Ordering on a fresh install** (finding iv): the leading `metaapplyblueprint` entry applies `kof22-oauth2-apps` first, inside the bootstrap's transaction. Verified on an empty 2026.8.3. If the infra instance did not exist yet, only the bootstrap fails, and it retries on the next discovery. - **Object permission** on `kof22-recovery-email` (needed by `recovery_email`, phase 3) is applied through the bare `created` stage entry's `permissions:` on every run. -- **Seeding James's membership.** `groups:` on a `created` user only applies when the user is first created. James's account already exists, so his `identity-admins` membership is a one-time manual step in the Authentik UI (owner checklist item 10). `bootstrapOperators` covers the window before it's done. +- **Seeding James's membership.** `groups:` on a `created` user only applies when the user is first created. James's account already exists, so his `identity-admins` membership is a one-time manual step in the Authentik UI (owner checklist item 10). `bootstrapOperatorSubjects` covers the window before it's done. - **Break-glass password.** The break-glass admin has no usable password from the blueprint. Its password is set once by hand and stored in 1Password. Its recovery path is `ak create_recovery_key` on the worker pod. ### 4.4 Migration from today's blueprint @@ -682,7 +684,7 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace 1. It takes the user's group names from the `groups` claim. The current kof22 `profile` mapping emits **direct** groups only. 2. It resolves each group through `EffectivePermissionResolver`: group attributes plus all ancestors via `include_parents`, from the mirror or the API, cached 60 s. The result is the union of `qqq.io/permissions`, with `authentik.*` and other wildcard prefixes expanded against `permissionCatalog`. -3. It adds static fallbacks if configured, through `withGroupPermissions(...)` (useful in tests and dev), plus `bootstrapOperators`. +3. It adds static fallbacks if configured, through `withGroupPermissions(...)` (useful in tests and dev), plus `bootstrapOperatorSubjects`. 4. It sets QQQ session permissions, user, email and full name. **Freshness.** Permissions are computed at session creation. The QQQ session is re-validated on each access-token refresh (1 hour in kof22), and the mapper runs again then. A webhook on a group or membership change also marks affected sessions in `qqq_user_session` for re-evaluation on their next request (phase 2). Phase 0 checks this is possible through the OAuth2 module's session store. If it isn't, the staleness is up to 1 hour, which is documented. @@ -692,7 +694,9 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace --- ## 6. Data model -### 6.1 API-backed tables (backend `authentik`, `AuthentikActionUtil`) +**Naming (owner rule, 2026-10-12):** "table names should always be lowercase and use _, and qbits should always be prefixed with their name, ie, cdn_ etc". Every table, API-backed or local, is lowercase snake_case with the `authentik_` prefix (`authentik_user`, `authentik_admin_action`). Where QQQ separates the table name from the backend name, both are identical; possible-value sources use the table name. Permissions follow the same rule (owner, 2026-10-12): the former `identity.*` permissions are now `authentik.*` (`authentik.users.read`, `authentik.memberships.manage`, ...); the shorthand `users.read` etc. in the action tables means the `authentik.` name. Java classes stay CamelCase; field, process and widget names are unchanged. The one name that is not a table is the API backend, a connection rather than a table: it is `authentikApi` everywhere (diagram, README, code), camel-cased like the process and widget names. No version of qbit-authentik has been released yet, so this is a rename in the design and in the unreleased branch code (whose `applyPrefix` camel-joins `authentik` + name today), not a migration. + +### 6.1 API-backed tables (backend `authentikApi`, `AuthentikActionUtil`) | Table | Source | Key | Capabilities | Notes | |---|---|---|---|---| @@ -713,7 +717,7 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace **`FilterMapper`** maps QQQ criteria to Authentik query params: - `EQUALS` → `field=` -- `IN` → repeated `field[]=` where supported (`groups_by_name`, `type`, `actions`) +- `IN` → the plain name repeated once per value, e.g. `type=a&type=b` (`groups_by_name`, `type`, `actions`). **Never the bracketed `field[]=a` form: Authentik silently ignores it and returns unfiltered results (§0.2 vi).** - `GREATER_THAN` / `LESS_THAN` on date fields → `__gt` / `__lt` - `IS_BLANK` → `__isnull=true` - `CONTAINS` / `STARTS_WITH` on text fields → `search=` (or `path_startswith`) @@ -745,7 +749,7 @@ Unsupported criteria (OR across fields, `NOT_EQUALS`, nested filters) are **reje Each process: - is bound with `withTableName(...)` and placed in `QMenu` slots (`isHidden(true)` for navigation) -- uses `StreamedETLWithFrontendProcess` with steps: **input** (targets and values; **reason** is required for writes), **preview** (server-computed per-target before → after, effective-permission deltas, `check_access` deltas, guard results, and skipped targets with reasons), **confirm** (per `confirmations`), **execute** (audit row → stale check → API call → Authentik custom event → cache and mirror invalidation), and **result** (per-record outcome with links to audit rows) +- uses `StreamedETLWithFrontendProcess` with steps: **input** (targets and values; **reason** is required for writes), **preview** (server-computed per-target before → after, effective-permission deltas, computed-access deltas, guard results, and skipped targets with reasons), **confirm** (per `confirmations`), **execute** (audit row → stale check → API call → Authentik custom event → cache and mirror invalidation), and **result** (per-record outcome with links to audit rows) | Process | Table / slot | Records | Permission | Default confirmation | Authentik calls | |---|---|---|---|---|---| @@ -759,7 +763,7 @@ Each process: | Create group | `authentik_group` query action | 0 | `authentik.groups.manage` (+ `permissions.manage` if permissions given) | CONFIRM | `POST /core/groups/` (name, parents, attributes) | | Edit group | `authentik_group` view | 1 | `authentik.groups.manage` | CONFIRM | `PATCH` name, parents and non-reserved attributes (merge-patch) | | Edit permissions and app access | `authentik_group` view | 1 | `authentik.permissions.manage` | CONFIRM; **TWO_PERSON** when adding any `authentik.*` permission | `PATCH attributes` (`qqq.io/permissions`, `qqq.io/app-access`), re-read first | -| Check access / Effective permissions | `authentik_user` view | 1 | `authentik.users.read` | none | `check_access` per in-scope app; the resolver | +| Check access / Effective permissions | `authentik_user` view | 1 | `authentik.users.read` | none | computed access per in-scope app (§5.1, not `check_access`); the resolver | | Send password reset email | `authentik_user` | 1–25 | `authentik.credentials.reset` | CONFIRM | `recovery_email` | | Reset MFA | `authentik_user` | 1 | `authentik.credentials.reset` | TYPED | per-device `DELETE` | | Revoke app passwords | `authentik_user`, `authentik_token` | 1–25 | `authentik.credentials.reset` | TYPED | `DELETE /core/tokens/{identifier}/` | @@ -844,14 +848,14 @@ The qbit produces an app `authentikAdmin` (label **"Identity"**, icon `badge`). | Groups | `AUTHENTIK_GROUP_TREE` widget + generic list | Tree over `parents` (many-to-many; shared children marked). Badges: superuser (red, locked), has-permissions, app-access. Member counts. | | Group detail | generic view + child widgets | Members (Add/Remove); **Permissions** (own + inherited, with an editor that offers only permissions the operator holds); **App access**; parents and children; roles (read); other attributes (`authentik.audit.read`) | | Access matrix | `AUTHENTIK_ACCESS_MATRIX` widget | Users in scope × `applicationsInScope`, cells computed by the qbit (not `check_access`, §0.2 iii; cached 5 min, 25 rows per page) | -| Health | `AUTHENTIK_HEALTH` widget | API reachability and version, token expiry, blueprint instance status, webhook lag, last reconcile, possible blueprint reversions, `bootstrapOperators` in use | +| Health | `AUTHENTIK_HEALTH` widget | API reachability and version, token expiry, blueprint instance status, webhook lag, last reconcile, possible blueprint reversions, `bootstrapOperatorSubjects` in use | | Audit | generic query on `authentik_admin_action` + `authentik_event` | Filter by operator, target, action, status, and source (QQQ vs Authentik UI) | ### 8.3 Admin-home widgets (standard widget types, from the mirror) - Statistics: active users, inactive users, service accounts, users without MFA, failed logins in 24 h. - Table: recent auth events. -- Table: "attention" items: admins without MFA, tokens expiring within 14 days (including the qbit's own token), blueprint status not `successful`, `bootstrapOperators` still set. +- Table: "attention" items: admins without MFA, tokens expiring within 14 days (including the qbit's own token), blueprint status not `successful`, `bootstrapOperatorSubjects` still set. ### 8.4 Frontend delivery @@ -911,7 +915,7 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh |---|---| | A compromised QQQ backend escalates in Authentik | The service account can't create superusers, touch superuser groups, read or mint tokens, impersonate, delete users, or change flows and policies (§5.1) | | A QQQ operator escalates themselves or a friend | Subset rule, no self-edits, two-person approval for `authentik.*` grants, protected accounts | -| Lockout (last admin removed or deactivated) | Last-admin guard; break-glass superuser outside QQQ; `bootstrapOperators` | +| Lockout (last admin removed or deactivated) | Last-admin guard; break-glass superuser outside QQQ; `bootstrapOperatorSubjects` | | A blueprint re-apply reverts QQQ edits | No `present` identity entries, no member lists (CI lint); bootstrap as `created` (verified, §4.2); reversion detector | | Lost update between QQQ and the Authentik UI | Stale check at execute; attribute merge-patch after a fresh read | | Account takeover through a recovery link | Links are never shown; email only | @@ -958,7 +962,7 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a | **0. Foundations and migration** | See the task list below | Contract tests (b), (c), (d), (h) green; the kof22 migration done in staging; read-only token wired | 2 wk | | **1. Read-only + permissions model** | Tables (users, groups, members, roles, applications, bindings, blueprints; sensitive tables behind `audit.read`). `EffectivePermissionResolver` + `AuthentikGroupPermissionMapper` (kof22 cut over, sign-in policy fallback removed). Group tree, Effective permissions and Access tabs. Health widget. Mirror with scheduled reconcile. Admin-home widgets. | kof22 Web Users shows groups, last login and MFA; kof22 permissions come from `qqq.io/permissions`; guard matrix green for reads | 2.5 wk | | **2. Memberships and permission editing** | Add/remove (both sides, bulk), the subset rule, the last-admin guard, the group permissions and app-access editor with two-person approval for `authentik.*`, `authentik_admin_action` + Authentik custom events, webhook route + ESB trigger + inbox, session re-evaluation marking | Scenarios B and D end to end; safety matrix 100%; webhook latency < 10 s | 2.5 wk | -| **3. Full user lifecycle** | Create user, invite (+ enrollment flow in Git), edit, deactivate/reactivate, recovery email, revoke sessions, reset MFA, revoke app passwords, create and edit groups, cancel invitations; optional snapshot export | Scenarios A and C end to end; contract tests (a), (e), (f), (g) green | 3 wk | +| **3. Full user lifecycle** | Create user, invite (+ enrollment flow in Git), edit, deactivate/reactivate, recovery email, revoke sessions, reset MFA, revoke app passwords, create and edit groups, cancel invitations; optional snapshot export | Scenarios A and C end to end; contract tests (a), (e), (f) green; access computation pinned by `AccessCheckIT` and `SignInPolicyIT` (§10 g) | 3 wk | | **4. RBAC (post-v1)** | Authentik role assignment with two-person approval, access-matrix polish, MHP migration off `AuthentikAdminApiClient` | MHP running on the qbit | 3 wk | **v1 (phases 0–3) is about 10 weeks**, and phase 4 adds about 3 more. @@ -969,7 +973,7 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a | # | Task | Who | |---|---|---| -| 1 | Create repo `QRun-IO/qbit-authentik` from `qbit-template-extension`; copy this spec to `docs/DESIGN.md`; add `AGENTS.md`/`CLAUDE.md` | agent: **done** (repo private for now, see the owner checklist) | +| 1 | Create repo `QRun-IO/qbit-authentik` from `qbit-template-extension`; copy this spec to `docs/DESIGN.md`; add `AGENTS.md`/`CLAUDE.md` | agent: **done** (the repo is public) | | 2 | `AuthentikQBitConfig` + producer skeleton; `AuthentikActionUtil` (bearer auth, page/page_size paging, FilterMapper, throttle, redaction); pinned `schema.yml` + drift test | agent | | 3 | Testcontainers harness (Authentik 2026.8.3 + Postgres + Mailpit), fixture recorder; contract tests (b), (c), (d), (h) | agent | | 4 | Verify on the container: (i) how to grant *global* role permissions declaratively in 2026.8.3; (ii) whether a `parents` change under a superuser group is refused without `enable_group_superuser`; (iii) the permission `check_access` needs; (iv) the `metaapplyblueprint` ordering. Then fix §4.3 and §5.1. | agent: **done**, §0.2 | @@ -988,9 +992,9 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a | The service account holds global user/group change rights (decision 1), so its blast radius is wider than revision 1's | Hard Authentik floor (no superuser, tokens, impersonation or delete); `userScope`/`groupScope`; guard; audit on both sides; 90-day token | | Someone reintroduces a `present` identity entry, and members get wiped | CI lint; contract test (d); reversion detector | | A bootstrap entry error rolls back the infrastructure blueprint | Separate bootstrap file; `metaapplyblueprint` ordering; minimal attrs; health widget | -| A lockout through bad permission edits | Last-admin guard, break-glass, `bootstrapOperators`, two-person approval for `authentik.*` | +| A lockout through bad permission edits | Last-admin guard, break-glass, `bootstrapOperatorSubjects`, two-person approval for `authentik.*` | | Session permissions go stale after a demotion | Token-refresh re-evaluation, webhook marking, live checks in the guard | -| `parents` re-parenting under a superuser group isn't refused by Authentik | Guard refusal; phase 0 check (ii) | +| The service account can lock administrators out (remove members from `authentik Admins`, deactivate break-glass, strip `qqq.io/protected`, §0.2 ii-b). Re-parenting under a superuser group **is** refused by Authentik (§0.2 ii) | Guard refusal of every write that targets a protected account or a superuser group; drift report; recovery through cluster access | | Load on the shared Postgres | Throttle, caps, mirror | | Webhook loss | Inbox, 15-minute reconcile, nightly sweep | | QQQ core gaps (#1061, #1062) | Guard in the backend util, ArchUnit, field stripping | diff --git a/docs/PHASE0-OWNER-CHECKLIST.md b/docs/PHASE0-OWNER-CHECKLIST.md index fb3c206..e31fd15 100644 --- a/docs/PHASE0-OWNER-CHECKLIST.md +++ b/docs/PHASE0-OWNER-CHECKLIST.md @@ -5,13 +5,13 @@ These are the steps only James can take: items 6, 7, 8 and 10 of DESIGN.md secti Links: - cluster-gitops draft PR: KofTwentyTwo/cluster-gitops#72 (the identity bootstrap blueprint, the removal of the `present` entries, the attribute-driven sign-in policy, inert webhook plumbing, and the blueprint lint) - Website-Backend and cluster-gitops wiring draft PRs: see `docs/STATUS.md` -- Spec: QRun-IO/qqq#1065 (revision 3 records the phase 0 findings) +- Spec: QRun-IO/qqq#1065 (revision 4: phase 0 findings, review fixes, owner decisions of 2026-10-12) ## 0. Decisions surfaced by phase 0 (read first) | # | Decision | Recommendation | |---|---|---| -| D1 | **Repo visibility.** `QRun-IO/qbit-authentik` was created **private**, because creating a public repo was blocked for the agent. Other generic qbits (quick-search, sql-workbench) are public, and the spec is already public in qqq#1065. CI publishes `develop` snapshots and `release/*` RCs to Maven Central / Sonatype (with sources jars), which would make the code public anyway. | Make it public before the first publish: *Settings → General → Change visibility*. Then check that CircleCI follows the repo (it must, for PR CI). | +| D1 | **Repo visibility.** Resolved: `QRun-IO/qbit-authentik` is public. | Check that CircleCI follows the repo (it does for PR CI); nothing else to do. | | D2 | **Lockout risk.** Authentik stops the service account from *escalating*, but not from *locking out*. With the phase 2/3 permissions it can do all of the following (contract test `SuperuserFloorIT`): remove members from `authentik Admins`, deactivate `kof22-breakglass`, strip `qqq.io/protected`, and rename the superuser group. The qbit guard refuses all of these; Authentik does not. | Accept, with the mitigations in the spec (§5.1, revision 3). The real break-glass for a compromised QQQ is cluster access: `kubectl exec` into the Authentik worker and run `ak shell` to reactivate or re-add. Optionally, phase 2 grants `remove_user_from_group` per group (object permissions) instead of globally. | | D3 | **Self-minted tokens.** Any authenticated user, including the service account, can create API tokens **for itself** without `add_token`, and read their keys. A compromised QQQ could therefore keep a second token alive after you rotate the first. | Accept. The phase 1 drift report flags service-account tokens beyond the expected one. Check that list after every rotation (item 8). | | D4 | **Access matrix.** `check_access?for_user=` only works for superusers. For the service account it silently answers *for itself*. The policy-test endpoint returns HTTP 500 for non-superusers on 2026.8.3. | Accepted in the spec: the qbit computes app access itself from `qqq.io/app-access` and the app's bindings, and shows other expression policies as "not evaluated". | @@ -77,6 +77,7 @@ Do these in order, in one sitting. ```yaml qqq.io/app-access: ["kof22-website-prod"] qqq.io/permissions: + - authentik.users.read - esbView.hasAccess - esbOperate.hasAccess - esbDelete.hasAccess @@ -90,7 +91,7 @@ Do these in order, in one sitting. - quickSearchFailedEvent.write - dataHealth.hasAccess ``` - Keep the existing attributes of the group. Add only these two keys. + Keep the existing attributes of the group. Add only these two keys. `authentik.users.read` lets `kof22-admins` open the Identity app; the seed group `identity-admins` already carries `authentik.*` (all identity permissions) from the blueprint, so you get everything through it once you join it (step 1). 3. On group **`kof-website-publishers`** → *Attributes*: `qqq.io/app-access: ["kof22-website-prod"]`. This keeps the `kof-publisher-prod` service account's client-credentials tokens working once the name fallback is removed. 4. **`kof-website-drafters`**: leave it without `qqq.io/app-access` until you decide that drafters should sign in to the admin. Granting it later is that one attribute. 5. **Break-glass password**: @@ -101,3 +102,26 @@ Do these in order, in one sitting. ## After items 6-10 Tell the agent "phase 0 owner steps done". Next come item 11 (the qbit in read-only mode on staging, behind `AUTHENTIK_QBIT_ENABLED`), then the phase 1 mapper cut-over, then removal of the sign-in name fallback. + + +## Item 12: turn on the full lifecycle (v1) + +The qbit can now create and change users and groups, not just read. Do this only after items 6-11 are done and the read-only screens look right on staging. Nothing here is applied by an agent. + +1. **Add the write permissions to the service account's role.** In the identity bootstrap blueprint, append these to `qqq-kof22-admin-idadmin`'s `permissions` list (they are re-asserted additively on every apply; the PR is prepared as a separate commit on cluster-gitops#72): + ``` + 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 + authentik_core.delete_authenticatedsession + authentik_stages_authenticator_{totp,static,webauthn,email,sms,duo}.view_device + authentik_stages_authenticator_{totp,static,webauthn,email,sms,duo}.delete_device + ``` + Never add `enable_group_superuser`, `view_token_key`, `set_token_key`, `add_token`, `impersonate` or `delete_user`. Invitations are not used at kof22 yet (there is no enrollment flow), so the invitation permissions and `view_flow` are left out; add them with the enrollment flow. +2. **Check the brand's recovery flow.** The password reset email (also used by Create User) fails with "No recovery flow set." unless the brand that serves `auth.kof22.com` has a recovery flow. In Authentik admin, *System → Brands*, edit the brand and set *Recovery flow* to `kof22-recovery`. This is a one-time setting. +3. **Create the two local tables** (`authentik_admin_action`, `authentik_approval_request`) with the Liquibase change in the Website-Backend PR (see `docs/schema.sql` for the DDL it follows). +4. **Turn the features on** in the Website-Backend deployment (the draft PR defines the flags; the default stays read-only). Start with `AUTHENTIK_QBIT_WRITES=false`, check the Identity home, then switch writes on. +5. **Try it on a test user**: create one, add them to `kof-website-publishers`, deactivate them. Check that Authentik's *Events* shows `custom_qqq_admin_action` events naming you, and that *Identity → Admin Actions* shows the rows. +6. **Two-person approval.** Granting `authentik.*` to a group needs a second person with `authentik.approve`. While you are the only identity administrator, make those grants in the Authentik UI (the break-glass in the spec), or turn `twoPersonApproval` off until a second administrator exists. diff --git a/docs/STATUS.md b/docs/STATUS.md index bdaf80f..49889f5 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,41 +2,57 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated with each PR. +## v1 = phases 0-3 (the full user lifecycle and group/permission management) + +| Phase | State | +|---|---| +| 0. Foundations and migration | agent items done; **owner items open** (checklist items 6-8, 10, then 12) | +| 1. Read-only plus the permissions model | done, except the mirror tables, reconcile and the access-matrix widget (see "Not built yet") | +| 2. Memberships and permission editing | done, except the webhook route, ESB trigger, inbox and session re-evaluation marking | +| 3. Full user lifecycle | done, except the optional snapshot export | +| 4. RBAC role management | post-v1, not started | + +### Built in this line (0.1) + +| Area | What | +|---|---| +| People | Create, invite, edit, deactivate, reactivate, password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations | +| Groups | Create, edit (parents), add and remove members (bulk), edit permissions and app access | +| Safety | Subset rule on live groups, protected accounts and superuser groups, no self-edits, last-admin protection, reasons, typed confirmations, bulk caps, stale checks, two-person approval for `authentik.*` | +| Audit | `authentik_admin_action` (before the call), Authentik `custom_qqq_admin_action` events, approval requests | +| Screens | Home dashboard, main tables in the nav, child lists on parents, every action as a table or record action | +| Tests | Unit tests on an in-memory Authentik (rules, processes, app, home), contract tests on Authentik 2026.8.3 | + +### Not built yet (and why) + +| Item | Note | +|---|---| +| Authentik webhook, ESB trigger, inbox, session re-evaluation marking | Needs the notification transport in cluster-gitops (inert today); reads are live (30 s cache) so screens are correct without it | +| Mirror tables and reconcile | For Web Users joins and widget counts; the home reads live | +| Access matrix widget, group tree widget | Needs Next UI custom widgets (separate frontend PRs) | +| Snapshot export | Optional in the spec | +| RBAC role management | Phase 4 | +| Alerts producer integration | Optional until the Alerts qbit SPI (QRun-IO/qqq#1090) lands | +| Quick Search for users and groups | They are API-backed (read-through), so they are searched with the table's own search box, which Authentik evaluates live | +| Invitations add groups | Groups for invited people are added after they sign up (needs the webhook) | + ## Phase 0: foundations (agent items done; owner items open) | # | Item | State | Where | |---|---|---|---| -| 1 | Repo, DESIGN.md, CI, README, CLAUDE.md | done | this repo (`develop`); private for now, see owner checklist D1 | +| 1 | Repo, DESIGN.md, CI, README, CLAUDE.md | done | this repo (`develop`); public | | 2 | Config, producer, `AuthentikActionUtil`, pinned schema + drift test | done | phase 0 PR | -| 3 | Testcontainers harness, fixture recorder, contract tests (b), (c), (d), (h) | done | phase 0 PR, `src/test/java/.../contract` | -| 4 | Open points (i)-(iv) resolved | done | DESIGN.md section 0.2; QRun-IO/qqq#1065 revision 3 | +| 3 | Testcontainers harness, fixture recorder, contract tests (b), (c), (d), (h) | done | `src/test/java/.../contract` | +| 4 | Open points (i)-(iv) resolved | done | DESIGN.md section 0.2 | | 5 | cluster-gitops PR (bootstrap, removals, sign-in policy, webhook, lint) | draft | KofTwentyTwo/cluster-gitops#72 | | 6 | Staging rehearsal | **owner** | `docs/PHASE0-OWNER-CHECKLIST.md`, `docs/cluster-gitops-phase0/rehearse-phase0.sh` | | 7 | Production apply | **owner** | checklist | | 8 | Service-account token, Infisical, webhook secret | **owner** | checklist | -| 9 | Website-Backend wiring (ExternalSecret + env, qbit behind a disabled flag) | draft | KofTwentyTwo/cluster-gitops#73, Website-Backend draft PR | +| 9 | Website-Backend wiring (ExternalSecret + env, qbit behind a disabled flag) | draft | KofTwentyTwo/cluster-gitops#73, Website-Backend#94 | | 10 | Seed attributes, break-glass password | **owner** | checklist | | 11 | Read-only on staging | after 6-10 | flip `AUTHENTIK_QBIT_ENABLED` | +| 12 | Turn on the full lifecycle (role permissions, brand recovery flow, tables, flags) | after 11 | checklist item 12 | -## Phase 1: read-only MVP +## Contract and unit tests (Authentik 2026.8.3) -| Item | State | -|---|---| -| Users, groups, memberships, sessions, events tables | done (phase 1 PR) | -| Effective permissions (resolver), inherited superuser | done | -| Identity drift report (widget) | done | -| Identity app under Admin, after Web Users (kof22) | Website-Backend draft PR #94 | -| `AuthentikGroupPermissionMapper` (sign-in permissions from groups) | done in the qbit; the kof22 cut-over (replacing `AuthentikAuthenticationCustomizer`) waits for owner item 10 | -| Code review (general + silent-failure) | done; all high and most medium findings fixed, with regression tests | -| Mirror tables, group tree, access-matrix widgets | next PRs | - -## Contract test results (Authentik 2026.8.3) - -`CI=true ./mvnw verify`: - -| Phase | Unit tests | Contract tests | -|---|---|---| -| 0 | 48 | 27 | -| 1 | 68 | 31 | - -All green, JaCoCo gate met. CircleCI is not enabled for this repo yet (owner checklist D1). +`CI=true ./mvnw verify`. The numbers are filled in at the end of each PR; see the PR description. diff --git a/docs/schema.sql b/docs/schema.sql new file mode 100644 index 0000000..1b1a374 --- /dev/null +++ b/docs/schema.sql @@ -0,0 +1,51 @@ +-- The qbit's own tables (DESIGN.md section 6.2) for the host's local RDBMS backend. +-- Column names are the snake_case of the QQQ field names. PostgreSQL syntax; adapt the +-- types for another database. The tables are written only by the qbit (the generic +-- insert, update and delete capabilities are off); authentik_admin_action is append-only +-- in practice (rows move from RUNNING to a final status, and are never deleted). +-- Retention: the audit trail is kept 2 years (DESIGN.md section 9.4). + +CREATE TABLE authentik_admin_action +( + id SERIAL PRIMARY KEY, + create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + correlation_id VARCHAR(64), + action_name VARCHAR(60) NOT NULL, + operator VARCHAR(255), + reason TEXT, + target_type VARCHAR(30), + target_key VARCHAR(100), + target_label VARCHAR(255), + before_text TEXT, + after_text TEXT, + status VARCHAR(30) NOT NULL, + error_message TEXT, + authentik_event_pk VARCHAR(64), + approver VARCHAR(255) +); + +CREATE INDEX idx_authentik_admin_action_create_date ON authentik_admin_action (create_date); +CREATE INDEX idx_authentik_admin_action_target ON authentik_admin_action (target_type, target_key); +CREATE INDEX idx_authentik_admin_action_operator ON authentik_admin_action (operator); + +CREATE TABLE authentik_approval_request +( + id SERIAL PRIMARY KEY, + create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + action_name VARCHAR(60) NOT NULL, + target_key VARCHAR(100), + target_label VARCHAR(255), + summary TEXT, + reason TEXT, + payload_json TEXT NOT NULL, + requester VARCHAR(255) NOT NULL, + approver VARCHAR(255), + status VARCHAR(30) NOT NULL, + expires_date TIMESTAMP WITH TIME ZONE, + decided_date TIMESTAMP WITH TIME ZONE, + error_message TEXT +); + +CREATE INDEX idx_authentik_approval_request_status ON authentik_approval_request (status); diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java index 5d5ce3b..3e6a32b 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java @@ -44,7 +44,12 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils; 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.MIRROR); + public static final Set AVAILABLE_FEATURES = EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, + AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS); + + /** 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); 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}"); @@ -60,9 +65,12 @@ public class AuthentikQBitConfig implements QBitConfig private List userScopeGroupNames = new ArrayList<>(); private String groupScopeNamePrefix; private List protectedUsernames = new ArrayList<>(); - private String bootstrapOperators = "${env.AUTHENTIK_QBIT_BOOTSTRAP_OPERATORS}"; + private String bootstrapOperatorSubjects = "${env.AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS}"; private List applicationsInScope = new ArrayList<>(); - private String mirrorBackendName; + private String localBackendName; + private Boolean twoPersonApproval = true; + private String schedulerName; + private String linkPrefix = "/app"; private String recoveryEmailStage; private String invitationFlow; private String newUserPath = "users"; @@ -147,15 +155,16 @@ public class AuthentikQBitConfig implements QBitConfig } } - if(requested.contains(AuthentikFeature.MIRROR)) + boolean writes = requested.stream().anyMatch(WRITE_FEATURES::contains); + if(writes || StringUtils.hasContent(localBackendName)) { - if(!StringUtils.hasContent(mirrorBackendName)) + if(!StringUtils.hasContent(localBackendName)) { - errors.add("feature MIRROR needs mirrorBackendName (an RDBMS backend of the instance)"); + errors.add("features that change Authentik data need localBackendName (an RDBMS backend of the instance, for the audit trail)"); } - else if(qInstance != null && qInstance.getBackends() != null && qInstance.getBackend(mirrorBackendName) == null) + else if(qInstance != null && qInstance.getBackends() != null && qInstance.getBackend(localBackendName) == null) { - errors.add("mirrorBackendName: backend not found: " + mirrorBackendName); + errors.add("localBackendName: backend not found: " + localBackendName); } } @@ -169,6 +178,15 @@ public class AuthentikQBitConfig implements QBitConfig errors.add("feature WEBHOOK needs webhookSecret"); } + for(String subject : getResolvedBootstrapSubjects()) + { + if(subject.contains("@")) + { + errors.add("bootstrapOperatorSubjects must be immutable ids (the token sub, or the Authentik user pk or uuid), never emails: users can change their own email"); + break; + } + } + for(Integer value : new Integer[] { throttleRequestsPerSecond, throttleBurst, listCacheTtlSeconds, groupCacheTtlSeconds, connectTimeoutMillis, socketTimeoutMillis }) { if(value == null || value < 0) @@ -347,20 +365,22 @@ public class AuthentikQBitConfig implements QBitConfig /******************************************************************************* - ** Bootstrap operator emails, resolved and split on commas, lower-cased. + ** Bootstrap operator subjects, resolved and split on commas. Each is an + ** IMMUTABLE identifier (the token's sub, or the Authentik user pk or uuid); + ** never an email, because users can change their own email. *******************************************************************************/ @JsonIgnore - public Set getResolvedBootstrapOperators() + public Set getResolvedBootstrapSubjects() { Set rs = new LinkedHashSet<>(); - String resolved = resolve(bootstrapOperators); + String resolved = resolve(bootstrapOperatorSubjects); if(StringUtils.hasContent(resolved)) { for(String part : resolved.split(",")) { if(StringUtils.hasContent(part)) { - rs.add(part.trim().toLowerCase()); + rs.add(part.trim()); } } } @@ -704,21 +724,21 @@ public class AuthentikQBitConfig implements QBitConfig /******************************************************************************* - ** Getter for bootstrapOperators (raw value) + ** Getter for bootstrapOperatorSubjects (raw value) *******************************************************************************/ - public String getBootstrapOperators() + public String getBootstrapOperatorSubjects() { - return (this.bootstrapOperators); + return (this.bootstrapOperatorSubjects); } /******************************************************************************* - ** Fluent setter for bootstrapOperators (comma-separated emails or ${env.X}) + ** Fluent setter for bootstrapOperatorSubjects (comma-separated emails or ${env.X}) *******************************************************************************/ - public AuthentikQBitConfig withBootstrapOperators(String bootstrapOperators) + public AuthentikQBitConfig withBootstrapOperatorSubjects(String bootstrapOperatorSubjects) { - this.bootstrapOperators = bootstrapOperators; + this.bootstrapOperatorSubjects = bootstrapOperatorSubjects; return (this); } @@ -748,21 +768,87 @@ public class AuthentikQBitConfig implements QBitConfig /******************************************************************************* - ** Getter for mirrorBackendName + ** Getter for localBackendName: the RDBMS backend holding the qbit's own + ** tables (admin actions, approval requests). *******************************************************************************/ - public String getMirrorBackendName() + public String getLocalBackendName() { - return (this.mirrorBackendName); + return (this.localBackendName); } /******************************************************************************* - ** Fluent setter for mirrorBackendName + ** Fluent setter for localBackendName *******************************************************************************/ - public AuthentikQBitConfig withMirrorBackendName(String mirrorBackendName) + public AuthentikQBitConfig withLocalBackendName(String localBackendName) { - this.mirrorBackendName = mirrorBackendName; + this.localBackendName = localBackendName; + return (this); + } + + + + /******************************************************************************* + ** Getter for twoPersonApproval: whether adding authentik.* permissions to a + ** group needs a second person (authentik.approve) to approve it. + *******************************************************************************/ + public Boolean getTwoPersonApproval() + { + return (this.twoPersonApproval); + } + + + + /******************************************************************************* + ** Fluent setter for twoPersonApproval + *******************************************************************************/ + public AuthentikQBitConfig withTwoPersonApproval(Boolean twoPersonApproval) + { + this.twoPersonApproval = twoPersonApproval; + return (this); + } + + + + /******************************************************************************* + ** Getter for linkPrefix: the path prefix of table and record links the home + ** page renders (the Next UI serves tables at /app/{table}). + *******************************************************************************/ + public String getLinkPrefix() + { + return (this.linkPrefix); + } + + + + /******************************************************************************* + ** Fluent setter for linkPrefix + *******************************************************************************/ + public AuthentikQBitConfig withLinkPrefix(String linkPrefix) + { + this.linkPrefix = linkPrefix; + return (this); + } + + + + /******************************************************************************* + ** Getter for schedulerName (optional; runs the reconcile/attention checks) + *******************************************************************************/ + public String getSchedulerName() + { + return (this.schedulerName); + } + + + + /******************************************************************************* + ** Fluent setter for schedulerName + *******************************************************************************/ + public AuthentikQBitConfig withSchedulerName(String schedulerName) + { + this.schedulerName = schedulerName; return (this); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java index ee46b7e..841755d 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java @@ -21,11 +21,17 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.Properties; +import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables; +import com.kingsrook.qbits.authentik.app.AuthentikRelations; +import com.kingsrook.qbits.authentik.client.AuthentikTables; import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer; +import com.kingsrook.qbits.authentik.processes.AuthentikProcesses; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -178,6 +184,13 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer notes = new ArrayList<>(); + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public AdminPlan(AdminRequest request) + { + this.request = request; + this.targetType = request.action().getTargetType(); + } + + + + /******************************************************************************* + ** Refuse, with the reason shown to the operator. + *******************************************************************************/ + public AdminPlan refuse(String reason) + { + this.state = State.REFUSED; + this.message = reason; + return (this); + } + + + + /******************************************************************************* + ** Mark as already done. + *******************************************************************************/ + public AdminPlan noChange(String why) + { + this.state = State.NO_CHANGE; + this.message = why; + return (this); + } + + + + /******************************************************************************* + ** Whether the plan will make a change when executed. + *******************************************************************************/ + public boolean isRunnable() + { + return (state == State.READY); + } + + + + /******************************************************************************* + ** Getter for request + *******************************************************************************/ + public AdminRequest getRequest() + { + return (request); + } + + + + /******************************************************************************* + ** Getter for state + *******************************************************************************/ + public State getState() + { + return (state); + } + + + + /******************************************************************************* + ** Setter for state + *******************************************************************************/ + public AdminPlan withState(State state) + { + this.state = state; + return (this); + } + + + + /******************************************************************************* + ** Getter for message + *******************************************************************************/ + public String getMessage() + { + return (message); + } + + + + /******************************************************************************* + ** Fluent setter for message + *******************************************************************************/ + public AdminPlan withMessage(String message) + { + this.message = message; + return (this); + } + + + + /******************************************************************************* + ** Getter for targetType + *******************************************************************************/ + public String getTargetType() + { + return (targetType); + } + + + + /******************************************************************************* + ** Getter for targetLabel + *******************************************************************************/ + public String getTargetLabel() + { + return (targetLabel); + } + + + + /******************************************************************************* + ** Fluent setter for targetLabel + *******************************************************************************/ + public AdminPlan withTargetLabel(String targetLabel) + { + this.targetLabel = targetLabel; + return (this); + } + + + + /******************************************************************************* + ** Getter for before + *******************************************************************************/ + public String getBefore() + { + return (before); + } + + + + /******************************************************************************* + ** Getter for after + *******************************************************************************/ + public String getAfter() + { + return (after); + } + + + + /******************************************************************************* + ** Set the before and after text. + *******************************************************************************/ + public AdminPlan withChange(String before, String after) + { + this.before = before; + this.after = after; + return (this); + } + + + + /******************************************************************************* + ** Notes for the operator (warnings and consequences). + *******************************************************************************/ + public List getNotes() + { + return (notes); + } + + + + /******************************************************************************* + ** A one-line description for review lines and audit. + *******************************************************************************/ + public String describe() + { + return (request.action().getLabel() + ": " + (targetLabel == null ? request.targetKey() : targetLabel) + + (before != null || after != null ? " (" + (before == null ? "" : before) + " -> " + (after == null ? "" : after) + ")" : "")); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AdminRequest.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AdminRequest.java new file mode 100644 index 0000000..e4e2f6f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AdminRequest.java @@ -0,0 +1,116 @@ +/* + * 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.util.LinkedHashMap; +import java.util.Map; +import org.json.JSONObject; + + +/******************************************************************************* + ** One requested change: the action, its target key (a user pk, a group uuid, + ** "groupPk:userPk" for memberships, an invitation uuid, or blank when + ** creating), its parameters, and the operator's reason. Serializable to JSON + ** so a two-person request can be stored and applied later. + ** + ** @param action what to do + ** @param targetKey the target (see above) + ** @param params action parameters (strings and booleans) + ** @param reason why (required for every write) + *******************************************************************************/ +public record AdminRequest(AdminAction action, String targetKey, Map params, String reason) +{ + + /******************************************************************************* + ** A request with no parameters. + *******************************************************************************/ + public static AdminRequest of(AdminAction action, String targetKey, String reason) + { + return (new AdminRequest(action, targetKey, new LinkedHashMap<>(), reason)); + } + + + + /******************************************************************************* + ** Fluent parameter setter (returns this request). + *******************************************************************************/ + public AdminRequest with(String name, Serializable value) + { + params.put(name, value); + return (this); + } + + + + /******************************************************************************* + ** A parameter as trimmed text, or "" when absent. + *******************************************************************************/ + public String str(String name) + { + Serializable value = params.get(name); + return (value == null ? "" : String.valueOf(value).trim()); + } + + + + /******************************************************************************* + ** A parameter as a boolean (true for true or "true"). + *******************************************************************************/ + public boolean bool(String name) + { + Serializable value = params.get(name); + return (value instanceof Boolean b ? b : value != null && "true".equalsIgnoreCase(String.valueOf(value).trim())); + } + + + + /******************************************************************************* + ** To JSON (for a stored approval request). + *******************************************************************************/ + public String toJson() + { + return (new JSONObject() + .put("action", action.name()) + .put("targetKey", targetKey) + .put("reason", reason) + .put("params", new JSONObject(params)).toString()); + } + + + + /******************************************************************************* + ** From JSON written by toJson. + *******************************************************************************/ + public static AdminRequest fromJson(String json) + { + JSONObject object = new JSONObject(json); + Map params = new LinkedHashMap<>(); + JSONObject stored = object.optJSONObject("params"); + if(stored != null) + { + for(String key : stored.keySet()) + { + Object value = stored.get(key); + params.put(key, value instanceof Boolean || value instanceof Number ? (Serializable) value : String.valueOf(value)); + } + } + return (new AdminRequest(AdminAction.valueOf(object.getString("action")), object.optString("targetKey", ""), params, object.optString("reason", ""))); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java new file mode 100644 index 0000000..2567a9e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java @@ -0,0 +1,1856 @@ +/* + * 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.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.regex.Pattern; +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.AuthentikRuntime; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.client.AuthentikJson; +import com.kingsrook.qbits.authentik.client.AuthentikRedactor; +import com.kingsrook.qbits.authentik.client.AuthentikTables; +import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver; +import com.kingsrook.qbits.authentik.permissions.GroupGraph; +import com.kingsrook.qbits.authentik.security.AuthentikGuard; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qbits.authentik.security.WriteAuthorization; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import org.json.JSONArray; +import org.json.JSONObject; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Plans and performs every change the qbit makes to Authentik (DESIGN.md + ** sections 5 and 6.3). + ** + ** plan() applies the guardrails of section 5.4 against LIVE Authentik data and + ** the operator's LIVE effective permissions: feature and permission, protected + ** accounts and groups, no self-edits, the subset (no escalation) rule, + ** last-admin protection, stale checks, and two-person approval. execute() + ** plans again (so the rules are checked at preview and again at execute), + ** writes the audit row BEFORE calling Authentik, sends the change, posts a + ** custom event naming the human operator, and finishes the audit row. QQQ + ** never sets, shows or sends a credential: new users and resets go through + ** Authentik's own emails. + *******************************************************************************/ +public class AuthentikAdmin +{ + private static final QLogger LOG = QLogger.getLogger(AuthentikAdmin.class); + + public static final int MAX_BULK = 100; + public static final int TYPED_ABOVE = 25; + public static final int APPROVAL_HOURS = 24; + + private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"); + private static final Pattern USERNAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._@+-]{0,149}"); + private static final Pattern EMAIL = Pattern.compile("[^@\\s]+@[^@\\s]+\\.[^@\\s]+"); + + private final AuthentikActionUtil util; + private final AuthentikRuntime runtime; + private final AuthentikQBitConfig config; + private final AuthentikGuard guard; + private final AdminActionAuditor auditor; + + private Operator operator; + private GroupGraph graph; + private GroupGraph membersGraph; + private List userFacts; + + + + /******************************************************************************* + ** The operator performing the request: who they are in Authentik and what + ** they may grant (live, from their current groups). + ** + ** @param email the QQQ user (an email in Authentik-signed-in apps) + ** @param pk the operator's Authentik user pk, or null when not found + ** @param permissions effective QQQ permissions (session form) + ** @param appAccess app keys the operator can sign in to + ** @param bootstrap listed in bootstrapOperatorSubjects + *******************************************************************************/ + public record Operator(String email, Integer pk, Set permissions, Set appAccess, boolean bootstrap) + { + } + + + + /******************************************************************************* + ** The result of executing one plan. + ** + ** @param ok whether the change was made (or queued) + ** @param pending true when queued for a second person + ** @param noChange true when the target was already in the wanted state + ** @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) + { + } + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public AuthentikAdmin(AuthentikActionUtil util) + { + this.util = util; + this.runtime = util.getRuntime(); + this.config = runtime.getConfig(); + this.guard = runtime.getGuard(); + this.auditor = new AdminActionAuditor(config); + } + + + + /******************************************************************************* + ** An admin for the current instance's qbit backend. + *******************************************************************************/ + public static AuthentikAdmin forBackend(String backendName) throws QException + { + return (new AuthentikAdmin(AuthentikActionUtil.forBackend(backendName))); + } + + + + /******************************************************************************* + ** Getter for util (readers that need raw Authentik reads). + *******************************************************************************/ + public AuthentikActionUtil getUtil() + { + return (util); + } + + + + /******************************************************************************* + ** The operator, resolved once per instance from live Authentik data. When + ** the operator is not an Authentik user (static or development sessions), + ** the session's own permissions stand in. bootstrapOperatorSubjects additionally + ** hold every authentik.* permission. + *******************************************************************************/ + public Operator operator() throws QException + { + if(operator != null) + { + return (operator); + } + + String email = guard.operatorEmail(); + boolean bootstrap = email != null && com.kingsrook.qbits.authentik.security.BootstrapOperators.matchesUniqueUser(config.getResolvedBootstrapSubjects(), util, email); + Integer pk = null; + Set permissions = new TreeSet<>(); + Set appAccess = new TreeSet<>(); + + JSONObject user = email == null ? null : findUserByEmail(email); + if(user != null) + { + pk = user.optInt("pk"); + List groupPks = new ArrayList<>(); + JSONArray groups = user.optJSONArray("groups_obj"); + for(int i = 0; groups != null && i < groups.length(); i++) + { + groupPks.add(groups.getJSONObject(i).optString("pk")); + } + EffectivePermissionResolver.Resolution resolution = resolver().resolveGroups(groupPks); + permissions.addAll(resolution.sessionPermissions()); + appAccess.addAll(resolution.appAccess()); + } + else if(QContext.getQSession() != null && QContext.getQSession().getPermissions() != null) + { + permissions.addAll(QContext.getQSession().getPermissions()); + } + + if(bootstrap) + { + for(IdentityPermission permission : IdentityPermission.values()) + { + permissions.add(permission.getSessionPermission()); + } + } + operator = new Operator(email, pk, permissions, appAccess, bootstrap); + return (operator); + } + + + + /******************************************************************************* + ** Plan a request: apply every rule against live data and say what would + ** happen. Never changes anything. + *******************************************************************************/ + public AdminPlan plan(AdminRequest request) throws QException + { + AdminPlan plan = new AdminPlan(request); + AdminAction action = request.action(); + + try + { + guard.authorize(action); + } + catch(QPermissionDeniedException e) + { + return (plan.refuse(e.getMessage())); + } + + if(!StringUtils.hasContent(request.reason())) + { + return (plan.refuse("A reason is required for every change.")); + } + + return switch(action) + { + case CREATE_USER -> planCreateUser(plan, request); + case INVITE_USER -> planInvite(plan, request); + case CANCEL_INVITATION -> planCancelInvitation(plan, request); + case EDIT_USER -> planEditUser(plan, request); + case DEACTIVATE_USER, REACTIVATE_USER -> planActive(plan, request); + case ADD_MEMBER, REMOVE_MEMBER -> planMembership(plan, request); + case CREATE_GROUP -> planCreateGroup(plan, request); + case EDIT_GROUP -> planEditGroup(plan, request); + case EDIT_PERMISSIONS -> planEditPermissions(plan, request); + case SEND_RECOVERY_EMAIL -> planRecovery(plan, request); + case RESET_MFA -> planResetMfa(plan, request); + case REVOKE_APP_PASSWORDS -> planAppPasswords(plan, request); + case REVOKE_SESSIONS -> planSessions(plan, request); + }; + } + + + + /******************************************************************************* + ** Plan again against live data, then perform. Refusals and failures are + ** audited; a change that needs a second person is queued instead. + *******************************************************************************/ + public AdminResult execute(AdminRequest request) throws QException + { + runtime.invalidateCache(); + graph = null; + membersGraph = null; + userFacts = null; + return (execute(plan(request), null)); + } + + + + /******************************************************************************* + ** Perform a plan. approver is the second person when this is the + ** application of an approved request, else null. + *******************************************************************************/ + public AdminResult execute(AdminPlan plan, String approver) throws QException + { + String correlationId = UUID.randomUUID().toString(); + String operatorName = guard.operatorEmail(); + AdminAction action = plan.getRequest().action(); + + switch(plan.getState()) + { + case REFUSED: + tryAudit(plan, correlationId, operatorName, AuthentikLocalTables.ActionStatus.REFUSED, approver); + return (new AdminResult(false, false, false, plan.getMessage(), null)); + case NO_CHANGE: + return (new AdminResult(true, false, true, plan.getMessage(), null)); + case NEEDS_APPROVAL: + return (queueApproval(plan, correlationId, operatorName)); + default: + break; + } + + WriteAuthorization authorization = guard.authorize(action); + Serializable auditId = auditor.begin(plan, correlationId, operatorName, AuthentikLocalTables.ActionStatus.RUNNING, approver); + + String message; + try + { + message = perform(plan, authorization, correlationId); + } + catch(Exception e) + { + String text = AuthentikRedactor.redactText(e.getMessage(), config.getResolvedApiToken()); + LOG.warn("Authentik change failed", logPair("action", action.name()), logPair("target", plan.getTargetLabel()), logPair("error", text)); + try + { + auditor.finish(auditId, AuthentikLocalTables.ActionStatus.FAILED, text, null, approver); + } + catch(Exception auditException) + { + LOG.warn("Could not mark the audit row failed", logPair("id", auditId)); + } + return (new AdminResult(false, false, false, text, null)); + } + + String eventPk = postEvent(authorization, plan, correlationId, operatorName, approver); + try + { + auditor.finish(auditId, AuthentikLocalTables.ActionStatus.SUCCEEDED, null, eventPk, approver); + } + catch(Exception e) + { + LOG.warn("Could not mark the audit row succeeded", logPair("id", auditId)); + } + return (new AdminResult(true, false, false, message, eventPk)); + } + + + + /******************************************************************************* + ** Store a change that needs a second person: an approval request plus a + ** PENDING_APPROVAL audit row. Nothing is sent to Authentik. + *******************************************************************************/ + private AdminResult queueApproval(AdminPlan plan, String correlationId, String operatorName) throws QException + { + auditor.begin(plan, correlationId, operatorName, AuthentikLocalTables.ActionStatus.PENDING_APPROVAL, null); + + QRecord record = new QRecord() + .withValue("actionName", plan.getRequest().action().name()) + .withValue("targetKey", plan.getRequest().targetKey()) + .withValue("targetLabel", plan.getTargetLabel()) + .withValue("summary", plan.describe()) + .withValue("reason", plan.getRequest().reason()) + .withValue("payloadJson", plan.getRequest().toJson()) + .withValue("requester", operatorName) + .withValue("status", AuthentikLocalTables.ApprovalStatus.PENDING.name()) + .withValue("expiresDate", Instant.now().plus(APPROVAL_HOURS, ChronoUnit.HOURS)) + .withValue("createDate", Instant.now()) + .withValue("modifyDate", Instant.now()); + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.APPROVAL_REQUEST)); + insertInput.setRecords(List.of(record)); + new InsertAction().execute(insertInput); + return (new AdminResult(true, true, false, "Waiting for approval by a second person (" + plan.getMessage() + ").", null)); + } + + + + /******************************************************************************* + ** Apply a stored approval request as the approver: a different person who + ** holds authentik.approve. The change is planned again against live data + ** under the approver's own permissions. + *******************************************************************************/ + public AdminResult approve(Serializable approvalId) throws QException + { + QRecord request = loadApproval(approvalId); + String approver = guard.operatorEmail(); + AdminResult refusal = checkApprover(request, approver); + if(refusal != null) + { + return (refusal); + } + + AdminRequest stored = AdminRequest.fromJson(request.getValueString("payloadJson")).with("approved", true); + runtime.invalidateCache(); + AdminPlan plan = plan(stored); + AdminResult result; + if(plan.getState() == AdminPlan.State.NEEDS_APPROVAL) + { + result = new AdminResult(false, false, false, "The change still needs approval after re-checking.", null); + } + else + { + result = execute(plan, approver); + } + decide(approvalId, result.ok() ? AuthentikLocalTables.ApprovalStatus.APPROVED : AuthentikLocalTables.ApprovalStatus.FAILED, approver, result.ok() ? null : result.message()); + return (result); + } + + + + /******************************************************************************* + ** Reject a stored approval request (a different person with authentik.approve). + *******************************************************************************/ + public AdminResult reject(Serializable approvalId) throws QException + { + QRecord request = loadApproval(approvalId); + String approver = guard.operatorEmail(); + AdminResult refusal = checkApprover(request, approver); + if(refusal != null) + { + return (refusal); + } + decide(approvalId, AuthentikLocalTables.ApprovalStatus.REJECTED, approver, null); + return (new AdminResult(true, false, false, "Rejected. Nothing was changed in Authentik.", null)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private QRecord loadApproval(Serializable id) throws QException + { + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.APPROVAL_REQUEST)); + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id))); + List records = new QueryAction().execute(queryInput).getRecords(); + if(records.isEmpty()) + { + throw (new QUserFacingException("That approval request no longer exists.")); + } + return (records.get(0)); + } + + + + /******************************************************************************* + ** Whether the current session may decide a request: not the requester, holds + ** authentik.approve, still pending and not expired. Returns the refusal, or + ** null when allowed. + *******************************************************************************/ + private AdminResult checkApprover(QRecord request, String approver) + { + if(!AuthentikLocalTables.ApprovalStatus.PENDING.name().equals(request.getValueString("status"))) + { + return (new AdminResult(false, false, false, "This request was already decided.", null)); + } + if(approver == null || !AuthentikGuard.has(QContext.getQSession(), IdentityPermission.APPROVE)) + { + return (new AdminResult(false, false, false, "You need the authentik.approve permission to decide requests.", null)); + } + if(approver.equalsIgnoreCase(request.getValueString("requester"))) + { + return (new AdminResult(false, false, false, "A second person must approve this: you cannot approve your own request.", null)); + } + Instant expires = request.getValueInstant("expiresDate"); + if(expires != null && expires.isBefore(Instant.now())) + { + try + { + decide(request.getValue("id"), AuthentikLocalTables.ApprovalStatus.EXPIRED, approver, "expired"); + } + catch(Exception e) + { + LOG.warn("Could not mark an approval request expired"); + } + return (new AdminResult(false, false, false, "This request expired (" + APPROVAL_HOURS + " hours). Ask for it again.", null)); + } + return (null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void decide(Serializable id, AuthentikLocalTables.ApprovalStatus status, String approver, String error) throws QException + { + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.APPROVAL_REQUEST)); + updateInput.setRecords(List.of(new QRecord().withValue("id", id).withValue("status", status.name()).withValue("approver", approver) + .withValue("decidedDate", Instant.now()).withValue("modifyDate", Instant.now()).withValue("errorMessage", error))); + new UpdateAction().execute(updateInput); + } + + + + /******************************************************************************* + ** Best-effort audit row for a refusal (the refusal itself is returned + ** regardless). + *******************************************************************************/ + private void tryAudit(AdminPlan plan, String correlationId, String operatorName, AuthentikLocalTables.ActionStatus status, String approver) + { + try + { + auditor.begin(plan, correlationId, operatorName, status, approver); + } + catch(Exception e) + { + LOG.warn("Could not audit a refused change", logPair("action", plan.getRequest().action().name())); + } + } + + + + /******************************************************************************* + ** Post the custom event to Authentik so its own event log names the human + ** operator (the service account is the actor of the model events). + ** Best effort: a failure is logged, never undoes the change. + *******************************************************************************/ + private String postEvent(WriteAuthorization authorization, AdminPlan plan, String correlationId, String operatorName, String approver) + { + JSONObject context = new JSONObject() + .put("correlationId", correlationId) + .put("operator", operatorName == null ? "" : operatorName) + .put("operatorEmail", operatorName == null ? "" : operatorName) + .put("reason", plan.getRequest().reason()) + .put("action", plan.getRequest().action().name()) + .put("target", plan.getTargetLabel() == null ? plan.getRequest().targetKey() : plan.getTargetLabel()) + .put("appKey", config.getAppKey()); + if(approver != null) + { + context.put("approver", approver); + } + + for(String action : List.of("custom_qqq_admin_action", "custom_")) + { + try + { + JSONObject response = util.write(authorization, "POST", "/events/events/", new JSONObject().put("action", action).put("app", "qqq").put("context", context)); + return (response.optString("pk", null)); + } + catch(Exception e) + { + LOG.info("Could not post the Authentik audit event", logPair("action", action), logPair("error", AuthentikRedactor.redactText(e.getMessage(), config.getResolvedApiToken()))); + } + } + return (null); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // planning + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Create a user. Groups are checked like memberships; no password is set. + *******************************************************************************/ + private AdminPlan planCreateUser(AdminPlan plan, AdminRequest request) throws QException + { + String username = request.str("username"); + String email = request.str("email"); + String name = request.str("name"); + + if(!USERNAME.matcher(username).matches()) + { + return (plan.refuse("Enter a username (letters, digits and . _ @ + -).")); + } + if(!EMAIL.matcher(email).matches()) + { + return (plan.refuse("Enter a valid email address: the person sets their own password from a link sent there.")); + } + if(!StringUtils.hasContent(name)) + { + return (plan.refuse("Enter the person's name.")); + } + if(findUserByUsername(username) != null) + { + return (plan.refuse("A user named " + username + " already exists.")); + } + if(findUserByEmail(email) != null) + { + return (plan.refuse("A user with the email " + email + " already exists.")); + } + + plan.withTargetLabel(username); + List groupNames = AuthentikSafety.parseList(request.str("groups")); + List labels = new ArrayList<>(); + for(String groupName : groupNames) + { + GroupGraph.Node node = graph().byName(groupName); + if(node == null) + { + return (plan.refuse("No group named " + groupName + ".")); + } + String problem = checkGroupGrantable(node); + if(problem != null) + { + return (plan.refuse(problem)); + } + labels.add(groupName); + } + + boolean recovery = !request.params().containsKey("sendRecovery") || request.bool("sendRecovery"); + if(recovery && !StringUtils.hasContent(config.getRecoveryEmailStage())) + { + return (plan.refuse("The password email is not configured (recoveryEmailStage).")); + } + plan.withChange("(new)", username + " <" + email + ">" + (labels.isEmpty() ? "" : ", in " + String.join(", ", labels))); + plan.getNotes().add(recovery ? email + " receives an email from Authentik to set their own password. QQQ never sees it." : "No password email is sent; use Send password reset email later."); + return (plan); + } + + + + /******************************************************************************* + ** Invite a user: an Authentik invitation (single use, 7 days) and its email. + *******************************************************************************/ + private AdminPlan planInvite(AdminPlan plan, AdminRequest request) throws QException + { + if(!StringUtils.hasContent(config.getInvitationFlow())) + { + return (plan.refuse("Invitations are not configured (invitationFlow).")); + } + String email = request.str("email"); + if(!EMAIL.matcher(email).matches()) + { + return (plan.refuse("Enter a valid email address.")); + } + if(findUserByEmail(email) != null) + { + return (plan.refuse("A user with the email " + email + " already exists.")); + } + plan.withTargetLabel(email).withChange(null, "invitation to " + email + " (single use, expires in 7 days)"); + plan.getNotes().add("Group memberships are added after the person has signed up (Add to group)."); + return (plan); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private AdminPlan planCancelInvitation(AdminPlan plan, AdminRequest request) throws QException + { + JSONObject invitation = util.getApiJsonOrNull("/stages/invitation/invitations/" + safeKey(request.targetKey()) + "/"); + if(invitation == null) + { + return (plan.noChange("The invitation no longer exists.")); + } + return (plan.withTargetLabel(invitation.optString("name", request.targetKey())).withChange("invitation open", "cancelled")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private AdminPlan planEditUser(AdminPlan plan, AdminRequest request) throws QException + { + JSONObject user = targetUser(plan, request.targetKey(), false); + if(user == null) + { + return (plan); + } + String name = request.params().containsKey("name") ? request.str("name") : user.optString("name"); + String email = request.params().containsKey("email") ? request.str("email") : user.optString("email"); + String path = request.params().containsKey("path") ? request.str("path") : user.optString("path"); + if(!StringUtils.hasContent(name)) + { + return (plan.refuse("The name cannot be empty.")); + } + if(StringUtils.hasContent(email) && !EMAIL.matcher(email).matches()) + { + return (plan.refuse("Enter a valid email address.")); + } + if(StringUtils.hasContent(email) && !email.equalsIgnoreCase(user.optString("email"))) + { + JSONObject other = findUserByEmail(email); + if(other != null && other.optInt("pk") != user.optInt("pk")) + { + return (plan.refuse("Another user already has the email " + email + ".")); + } + } + if(!StringUtils.hasContent(path)) + { + return (plan.refuse("The path cannot be empty.")); + } + String fingerprint = request.str("expectedFingerprint"); + if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.userFingerprint(user))) + { + return (plan.refuse("This user was changed (for example in the Authentik admin UI) since you opened it. Review it again.")); + } + + String before = user.optString("name") + " <" + user.optString("email") + ">, " + user.optString("path"); + String after = name + " <" + email + ">, " + path; + if(before.equals(after)) + { + return (plan.noChange("Nothing to change.")); + } + return (plan.withChange(before, after)); + } + + + + /******************************************************************************* + ** Deactivate or reactivate. + *******************************************************************************/ + private AdminPlan planActive(AdminPlan plan, AdminRequest request) throws QException + { + boolean deactivate = request.action() == AdminAction.DEACTIVATE_USER; + JSONObject user = targetUser(plan, request.targetKey(), true); + if(user == null) + { + return (plan); + } + boolean active = user.optBoolean("is_active", true); + if(deactivate && !active) + { + return (plan.noChange("Already inactive.")); + } + if(!deactivate && active) + { + return (plan.noChange("Already active.")); + } + + if(deactivate) + { + String typed = typedProblem(request, user.optString("username")); + if(typed != null) + { + return (plan.refuse(typed)); + } + Set before = adminsNow(); + if(before.contains(user.optInt("pk"))) + { + List changed = new ArrayList<>(); + for(AuthentikSafety.UserFacts facts : userFacts()) + { + changed.add(facts.pk() == user.optInt("pk") ? new AuthentikSafety.UserFacts(facts.pk(), false, facts.human(), facts.protectedAccount()) : facts); + } + if(AuthentikSafety.removesLastAdmin(before, AuthentikSafety.qualifyingAdmins(membersGraph(), new EffectivePermissionResolver(membersGraph(), runtime.getPermissionCatalog()), changed))) + { + return (plan.refuse("This is the last active person who can manage memberships and permissions. Add another administrator first.")); + } + } + plan.getNotes().add("Their sessions end now" + (request.bool("revokeAppPasswords") ? " and their app passwords are revoked." : ".")); + } + return (plan.withChange(active ? "active" : "inactive", deactivate ? "inactive" : "active")); + } + + + + /******************************************************************************* + ** Add or remove one membership; the key is "groupPk:userPk". + *******************************************************************************/ + private AdminPlan planMembership(AdminPlan plan, AdminRequest request) throws QException + { + boolean add = request.action() == AdminAction.ADD_MEMBER; + String[] parts = request.targetKey().split(":", 2); + if(parts.length != 2) + { + return (plan.refuse("Bad membership key.")); + } + JSONObject user = targetUser(plan, parts[1], false); + if(user == null) + { + return (plan); + } + GroupGraph.Node node = graph().byPk(parts[0]); + if(node == null) + { + return (plan.refuse("That group no longer exists.")); + } + plan.withTargetLabel(user.optString("username") + " / " + node.name()); + + boolean member = false; + JSONArray groups = user.optJSONArray("groups_obj"); + for(int i = 0; groups != null && i < groups.length(); i++) + { + if(parts[0].equals(groups.getJSONObject(i).optString("pk"))) + { + member = true; + } + } + + if(add) + { + if(member) + { + return (plan.noChange("Already a member.")); + } + String problem = checkGroupGrantable(node); + if(problem != null) + { + return (plan.refuse(problem)); + } + return (plan.withChange("not a member", "member of " + node.name())); + } + + if(!member) + { + return (plan.noChange("Not a member.")); + } + if(graph().isSuperuserInherited(node.pk())) + { + return (plan.refuse("Superuser groups cannot be managed here (use the Authentik admin UI).")); + } + EffectivePermissionResolver resolver = new EffectivePermissionResolver(membersGraph(), runtime.getPermissionCatalog()); + Set conferred = resolver.resolveGroups(List.of(node.pk())).sessionPermissions(); + if(conferred.contains(IdentityPermission.MEMBERSHIPS_MANAGE.getSessionPermission()) || conferred.contains(IdentityPermission.PERMISSIONS_MANAGE.getSessionPermission())) + { + Set before = adminsNow(); + GroupGraph.Node current = membersGraph().byPk(node.pk()); + Set after = AuthentikSafety.qualifyingAdmins(AuthentikSafety.withNodes(membersGraph(), AuthentikSafety.withoutMember(current, user.optInt("pk"))), + new EffectivePermissionResolver(AuthentikSafety.withNodes(membersGraph(), AuthentikSafety.withoutMember(current, user.optInt("pk"))), runtime.getPermissionCatalog()), userFacts()); + if(AuthentikSafety.removesLastAdmin(before, after)) + { + return (plan.refuse("This is the last person who can manage memberships and permissions. Add another administrator first.")); + } + } + return (plan.withChange("member of " + node.name(), "not a member")); + } + + + + /******************************************************************************* + ** Create a group: name, parents (names), permissions and app access. + *******************************************************************************/ + private AdminPlan planCreateGroup(AdminPlan plan, AdminRequest request) throws QException + { + String name = request.str("name"); + if(!StringUtils.hasContent(name) || name.length() > 200) + { + return (plan.refuse("Enter a group name.")); + } + plan.withTargetLabel(name); + if(graph().byName(name) != null) + { + return (plan.refuse("A group named " + name + " already exists.")); + } + + List parentPks = new ArrayList<>(); + for(String parentName : AuthentikSafety.parseList(request.str("parents"))) + { + GroupGraph.Node parent = graph().byName(parentName); + if(parent == null) + { + return (plan.refuse("No parent group named " + parentName + ".")); + } + String problem = checkParentAllowed(parent); + if(problem != null) + { + return (plan.refuse(problem)); + } + parentPks.add(parent.pk()); + } + + List permissions = AuthentikSafety.parseList(request.str("permissions")); + List appAccess = AuthentikSafety.parseList(request.str("appAccess")); + String problem = checkAttributeAdditions(plan, request, permissions, appAccess, List.of(), List.of()); + if(problem != null) + { + return (plan.refuse(problem)); + } + + plan.withChange(null, name + (parentPks.isEmpty() ? "" : ", under " + request.str("parents")) + (permissions.isEmpty() ? "" : ", permissions " + String.join(", ", permissions))); + return (plan); + } + + + + /******************************************************************************* + ** Edit a group's name and parents. + *******************************************************************************/ + private AdminPlan planEditGroup(AdminPlan plan, AdminRequest request) throws QException + { + JSONObject group = targetGroup(plan, request.targetKey()); + if(group == null) + { + return (plan); + } + GroupGraph.Node node = graph().byPk(request.targetKey()); + + String newName = request.str("name"); + if(!StringUtils.hasContent(newName)) + { + newName = group.optString("name"); + } + else if(!newName.equals(group.optString("name")) && graph().byName(newName) != null) + { + return (plan.refuse("A group named " + newName + " already exists.")); + } + + List currentParents = new ArrayList<>(node.parentPks()); + List newParents = currentParents; + String parentsText = request.str("parents"); + if(StringUtils.hasContent(parentsText)) + { + newParents = new ArrayList<>(); + if(!parentsText.equalsIgnoreCase("none")) + { + Set descendants = AuthentikSafety.descendantPks(graph(), node.pk()); + for(String parentName : AuthentikSafety.parseList(parentsText)) + { + GroupGraph.Node parent = graph().byName(parentName); + if(parent == null) + { + return (plan.refuse("No parent group named " + parentName + ".")); + } + if(parent.pk().equals(node.pk()) || descendants.contains(parent.pk())) + { + return (plan.refuse(parentName + " is below this group; that would make a loop.")); + } + if(!currentParents.contains(parent.pk())) + { + String problem = checkParentAllowed(parent); + if(problem != null) + { + return (plan.refuse(problem)); + } + } + newParents.add(parent.pk()); + } + } + } + + String fingerprint = request.str("expectedFingerprint"); + if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.groupFingerprint(group))) + { + return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again.")); + } + if(newName.equals(group.optString("name")) && new LinkedHashSet<>(newParents).equals(new LinkedHashSet<>(currentParents))) + { + return (plan.noChange("Nothing to change.")); + } + + if(!new LinkedHashSet<>(newParents).equals(new LinkedHashSet<>(currentParents))) + { + GroupGraph.Node current = membersGraph().byPk(node.pk()); + GroupGraph simulated = AuthentikSafety.withNodes(membersGraph(), AuthentikSafety.withParents(current, newParents)); + Set before = adminsNow(); + if(AuthentikSafety.removesLastAdmin(before, AuthentikSafety.qualifyingAdmins(simulated, new EffectivePermissionResolver(simulated, runtime.getPermissionCatalog()), userFacts()))) + { + return (plan.refuse("Changing the parents would leave nobody able to manage memberships and permissions.")); + } + } + + plan.getRequest().params().put("resolvedParentPks", String.join(",", newParents)); + return (plan.withChange(group.optString("name") + ", parents " + names(currentParents), newName + ", parents " + names(newParents))); + } + + + + /******************************************************************************* + ** Edit a group's qqq.io/permissions and qqq.io/app-access: the subset rule, + ** unknown-name rejection, stale check, last-admin protection and (for + ** authentik.*) two-person approval. + *******************************************************************************/ + private AdminPlan planEditPermissions(AdminPlan plan, AdminRequest request) throws QException + { + JSONObject group = targetGroup(plan, request.targetKey()); + if(group == null) + { + return (plan); + } + GroupGraph.Node node = graph().byPk(request.targetKey()); + + List newPermissions = AuthentikSafety.parseList(request.str("permissions")); + List newAppAccess = AuthentikSafety.parseList(request.str("appAccess")); + List oldPermissions = nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)); + List oldAppAccess = nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)); + + String fingerprint = request.str("expectedFingerprint"); + if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.groupFingerprint(group))) + { + return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again.")); + } + if(new LinkedHashSet<>(newPermissions).equals(new LinkedHashSet<>(oldPermissions)) && new LinkedHashSet<>(newAppAccess).equals(new LinkedHashSet<>(oldAppAccess))) + { + return (plan.noChange("Nothing to change.")); + } + + String problem = checkAttributeAdditions(plan, request, newPermissions, newAppAccess, oldPermissions, oldAppAccess); + if(problem != null) + { + return (plan.refuse(problem)); + } + + GroupGraph.Node current = membersGraph().byPk(node.pk()); + GroupGraph simulated = AuthentikSafety.withNodes(membersGraph(), AuthentikSafety.withPermissions(current, newPermissions, newAppAccess)); + Set before = adminsNow(); + if(AuthentikSafety.removesLastAdmin(before, AuthentikSafety.qualifyingAdmins(simulated, new EffectivePermissionResolver(simulated, runtime.getPermissionCatalog()), userFacts()))) + { + return (plan.refuse("Removing these permissions would leave nobody able to manage memberships and permissions.")); + } + + int members = current == null ? 0 : current.memberPks().size(); + plan.withChange("permissions: " + joinOrNone(oldPermissions) + "; app access: " + joinOrNone(oldAppAccess), + "permissions: " + joinOrNone(newPermissions) + "; app access: " + joinOrNone(newAppAccess)); + plan.getNotes().add(members + " member(s) get the change at their next session refresh."); + + if(config.getTwoPersonApproval() && !request.bool("approved") && addsIdentityPermission(newPermissions, oldPermissions)) + { + plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("granting authentik.* needs a second person"); + } + return (plan); + } + + + + /******************************************************************************* + ** Send the password-reset email (Authentik's own email; no link is shown). + *******************************************************************************/ + private AdminPlan planRecovery(AdminPlan plan, AdminRequest request) throws QException + { + JSONObject user = targetUser(plan, request.targetKey(), false); + if(user == null) + { + return (plan); + } + if(!user.optBoolean("is_active", true)) + { + return (plan.refuse("The user is inactive. Reactivate them first.")); + } + if(!StringUtils.hasContent(user.optString("email"))) + { + return (plan.refuse("The user has no email address.")); + } + if(!StringUtils.hasContent(config.getRecoveryEmailStage())) + { + return (plan.refuse("The password email is not configured (recoveryEmailStage).")); + } + return (plan.withChange(null, "password reset email to " + user.optString("email"))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private AdminPlan planResetMfa(AdminPlan plan, AdminRequest request) throws QException + { + JSONObject user = targetUser(plan, request.targetKey(), false); + if(user == null) + { + return (plan); + } + String typed = typedProblem(request, user.optString("username")); + if(typed != null) + { + return (plan.refuse(typed)); + } + int devices = mfaDevices(user.optInt("pk")).size(); + if(devices == 0) + { + return (plan.noChange("No MFA devices.")); + } + return (plan.withChange(devices + " MFA device(s)", "none (they set up MFA again)")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private AdminPlan planAppPasswords(AdminPlan plan, AdminRequest request) throws QException + { + JSONObject user = targetUser(plan, request.targetKey(), true); + if(user == null) + { + return (plan); + } + String typed = typedProblem(request, user.optString("username")); + if(typed != null) + { + return (plan.refuse(typed)); + } + int tokens = appPasswords(user.optString("username")).size(); + if(tokens == 0) + { + return (plan.noChange("No app passwords.")); + } + return (plan.withChange(tokens + " app password(s)", "none")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private AdminPlan planSessions(AdminPlan plan, AdminRequest request) throws QException + { + JSONObject user = targetUser(plan, request.targetKey(), false); + if(user == null) + { + return (plan); + } + int sessions = sessions(user.optString("username")).size(); + if(sessions == 0) + { + return (plan.noChange("No active sessions.")); + } + return (plan.withChange(sessions + " session(s)", "none (signed out)")); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // shared rule checks + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Load a target user and apply the user rules: it exists, is in scope, is + ** not protected, is not the operator, and (unless allowed) is not a service + ** account. Refuses the plan and returns null when a rule fails. + ** + ** @param serviceAccountsOk whether service accounts may be targeted + *******************************************************************************/ + private JSONObject targetUser(AdminPlan plan, String pk, boolean serviceAccountsOk) throws QException + { + JSONObject user = util.getObject(AuthentikTables.get(AuthentikTables.USER), pk); + if(user == null) + { + plan.refuse("That user no longer exists."); + return (null); + } + plan.withTargetLabel(user.optString("username")); + if(!guard.isUserInScope(user)) + { + plan.refuse("That user is outside the users this app manages."); + return (null); + } + AuthentikGuard.ProtectionResult protection = guard.protection(user); + if(protection.isProtected()) + { + plan.refuse("Protected account (" + protection.reason() + "). Use the Authentik admin UI."); + return (null); + } + Operator op = operator(); + if((op.pk() != null && op.pk() == user.optInt("pk")) || (op.email() != null && op.email().equalsIgnoreCase(user.optString("email")))) + { + plan.refuse("You cannot change your own account."); + return (null); + } + if("service_account".equals(user.optString("type")) && !serviceAccountsOk) + { + plan.refuse("Service accounts can only be deactivated or have their app passwords revoked."); + return (null); + } + return (user); + } + + + + /******************************************************************************* + ** Load a target group (with members) and refuse superuser groups and groups + ** that inherit superuser status. + *******************************************************************************/ + private JSONObject targetGroup(AdminPlan plan, String pk) throws QException + { + JSONObject group = util.getApiJsonOrNull("/core/groups/" + safeKey(pk) + "/?include_users=true&include_parents=true&include_children=true"); + if(group == null) + { + plan.refuse("That group no longer exists."); + return (null); + } + plan.withTargetLabel(group.optString("name")); + if(graph().byPk(pk) == null) + { + plan.refuse("That group is not in this app's scope."); + return (null); + } + if(graph().isSuperuserInherited(pk)) + { + plan.refuse("Superuser groups (and their descendants) cannot be managed here. Use the Authentik admin UI."); + return (null); + } + return (group); + } + + + + /******************************************************************************* + ** Whether the operator may put people in a group: it is not a superuser + ** 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 + { + if(graph().isSuperuserInherited(node.pk())) + { + return ("Superuser groups cannot be managed here (use the Authentik admin UI): " + node.name()); + } + EffectivePermissionResolver.Resolution conferred = resolver().resolveGroups(List.of(node.pk())); + Set missing = AuthentikSafety.missing(conferred.sessionPermissions(), operator().permissions()); + if(!missing.isEmpty()) + { + return ("You cannot add people to " + node.name() + " because it grants permissions you do not hold yourself: " + String.join(", ", missing)); + } + Set apps = AuthentikSafety.missing(conferred.appAccess(), operator().appAccess()); + if(!apps.isEmpty() && !operator().bootstrap()) + { + return ("You cannot add people to " + node.name() + " because it gives sign-in access to apps you cannot access yourself: " + String.join(", ", apps)); + } + return (null); + } + + + + /******************************************************************************* + ** Whether a group may become a parent: not a superuser group or descendant, + ** and its permissions are ones the operator holds. Returns the refusal, or + ** null. + *******************************************************************************/ + private String checkParentAllowed(GroupGraph.Node parent) throws QException + { + if(graph().isSuperuserInherited(parent.pk())) + { + return ("A group cannot be placed under a superuser group (or one of its descendants): " + parent.name()); + } + Set missing = AuthentikSafety.missing(resolver().resolveGroups(List.of(parent.pk())).sessionPermissions(), operator().permissions()); + if(!missing.isEmpty()) + { + return ("The parent group " + parent.name() + " grants permissions you do not hold yourself: " + String.join(", ", missing)); + } + return (null); + } + + + + /******************************************************************************* + ** Check new qqq.io/permissions and qqq.io/app-access values: every entry + ** must be a known permission (or authentik.*), and every ADDED entry must be + ** held by the operator (the subset rule); added app keys must be apps the + ** operator can access. Returns the refusal, or null. + *******************************************************************************/ + private String checkAttributeAdditions(AdminPlan plan, AdminRequest request, List permissions, List appAccess, + List oldPermissions, List oldAppAccess) throws QException + { + EffectivePermissionResolver resolver = resolver(); + Set needed = new TreeSet<>(); + for(String raw : permissions) + { + Set expanded = resolver.expand(raw); + if(expanded.isEmpty()) + { + if(!oldPermissions.contains(raw)) + { + return ("Unknown permission: " + raw + ". Pick a permission this app defines, or authentik.*."); + } + continue; + } + if(!oldPermissions.contains(raw)) + { + needed.addAll(expanded); + } + } + Set missing = AuthentikSafety.missing(needed, operator().permissions()); + if(!missing.isEmpty()) + { + return ("You cannot grant permissions you do not hold yourself: " + String.join(", ", missing)); + } + + for(String app : appAccess) + { + if(!oldAppAccess.contains(app) && !operator().appAccess().contains(app) && !operator().bootstrap()) + { + return ("You cannot give sign-in access to " + app + " because you cannot sign in to it yourself."); + } + } + return (null); + } + + + + /******************************************************************************* + ** Whether the new permission list adds an entry that expands to any + ** authentik.* permission. + *******************************************************************************/ + private boolean addsIdentityPermission(List newPermissions, List oldPermissions) throws QException + { + EffectivePermissionResolver resolver = resolver(); + for(String raw : newPermissions) + { + if(oldPermissions.contains(raw)) + { + continue; + } + for(String expanded : resolver.expand(raw)) + { + if(expanded.startsWith("authentik.")) + { + return (true); + } + } + } + return (false); + } + + + + /******************************************************************************* + ** The typed-confirmation rule: the operator types the target's name. + ** Returns the refusal, or null. + *******************************************************************************/ + private String typedProblem(AdminRequest request, String expected) + { + if(request.str("confirmText").equalsIgnoreCase(expected) || request.bool("approved")) + { + return (null); + } + return ("Type the username (" + expected + ") to confirm."); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // performing + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Send the change to Authentik. Returns the message for the Result step. + *******************************************************************************/ + private String perform(AdminPlan plan, WriteAuthorization auth, String correlationId) throws QException + { + AdminRequest request = plan.getRequest(); + switch(request.action()) + { + case CREATE_USER: + return (performCreateUser(plan, auth)); + case INVITE_USER: + { + JSONObject body = new JSONObject() + .put("name", "qqq-" + correlationId.substring(0, 8)) + .put("single_use", true) + .put("expires", Instant.now().plus(7, ChronoUnit.DAYS).toString()) + .put("fixed_data", new JSONObject().put("email", request.str("email"))); + if(StringUtils.hasContent(request.str("name"))) + { + body.getJSONObject("fixed_data").put("name", request.str("name")); + } + String flow = flowUuid(config.getInvitationFlow()); + if(flow != null) + { + body.put("flow", flow); + } + JSONObject invitation = util.write(auth, "POST", "/stages/invitation/invitations/", body); + util.write(auth, "POST", "/stages/invitation/invitations/" + invitation.optString("pk") + "/send_email/", + new JSONObject().put("email_addresses", new JSONArray().put(request.str("email")))); + return ("Invitation sent to " + request.str("email") + "."); + } + case CANCEL_INVITATION: + util.write(auth, "DELETE", "/stages/invitation/invitations/" + safeKey(request.targetKey()) + "/", null); + return ("Invitation cancelled."); + case EDIT_USER: + { + JSONObject body = new JSONObject(); + for(String key : List.of("name", "email", "path")) + { + if(request.params().containsKey(key)) + { + body.put(key, request.str(key)); + } + } + util.write(auth, "PATCH", "/core/users/" + safeKey(request.targetKey()) + "/", body); + return ("User updated."); + } + case DEACTIVATE_USER: + { + util.write(auth, "PATCH", "/core/users/" + safeKey(request.targetKey()) + "/", new JSONObject().put("is_active", false)); + String suffix = ""; + if(request.bool("revokeAppPasswords")) + { + int revoked = revokeAppPasswords(auth, plan.getTargetLabel()); + suffix = " " + revoked + " app password(s) revoked."; + } + return ("User deactivated; their sessions were ended." + suffix); + } + case REACTIVATE_USER: + util.write(auth, "PATCH", "/core/users/" + safeKey(request.targetKey()) + "/", new JSONObject().put("is_active", true)); + return ("User reactivated."); + case ADD_MEMBER: + { + String[] parts = request.targetKey().split(":", 2); + util.write(auth, "POST", "/core/groups/" + safeKey(parts[0]) + "/add_user/", new JSONObject().put("pk", Integer.parseInt(parts[1]))); + return ("Added to the group."); + } + case REMOVE_MEMBER: + { + String[] parts = request.targetKey().split(":", 2); + util.write(auth, "POST", "/core/groups/" + safeKey(parts[0]) + "/remove_user/", new JSONObject().put("pk", Integer.parseInt(parts[1]))); + return ("Removed from the group."); + } + case CREATE_GROUP: + { + JSONArray parents = new JSONArray(); + for(String parentName : AuthentikSafety.parseList(request.str("parents"))) + { + parents.put(graph().byName(parentName).pk()); + } + JSONObject attributes = new JSONObject(); + putList(attributes, AuthentikJson.ATTR_PERMISSIONS, AuthentikSafety.parseList(request.str("permissions"))); + putList(attributes, AuthentikJson.ATTR_APP_ACCESS, AuthentikSafety.parseList(request.str("appAccess"))); + util.write(auth, "POST", "/core/groups/", new JSONObject().put("name", request.str("name")).put("is_superuser", false).put("parents", parents).put("attributes", attributes)); + return ("Group created."); + } + case EDIT_GROUP: + { + JSONObject body = new JSONObject(); + if(StringUtils.hasContent(request.str("name"))) + { + body.put("name", request.str("name")); + } + if(request.params().containsKey("resolvedParentPks")) + { + JSONArray parents = new JSONArray(); + for(String pk : AuthentikSafety.parseList(request.str("resolvedParentPks"))) + { + parents.put(pk); + } + body.put("parents", parents); + } + util.write(auth, "PATCH", "/core/groups/" + safeKey(request.targetKey()) + "/", body); + return ("Group updated."); + } + case EDIT_PERMISSIONS: + { + /////////////////////////////////////////////////////////////////////////// + // read the group again right before writing, and merge only the two // + // qqq.io keys into its CURRENT attributes, so other attributes (and // + // edits made in the Authentik UI) survive // + /////////////////////////////////////////////////////////////////////////// + runtime.invalidateCache(); + JSONObject fresh = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false"); + if(fresh == null) + { + throw (new QUserFacingException("That group no longer exists.")); + } + JSONObject attributes = fresh.optJSONObject("attributes") == null ? new JSONObject() : new JSONObject(fresh.getJSONObject("attributes").toString()); + putList(attributes, AuthentikJson.ATTR_PERMISSIONS, AuthentikSafety.parseList(request.str("permissions"))); + putList(attributes, AuthentikJson.ATTR_APP_ACCESS, AuthentikSafety.parseList(request.str("appAccess"))); + util.write(auth, "PATCH", "/core/groups/" + safeKey(request.targetKey()) + "/", new JSONObject().put("attributes", attributes)); + return ("Permissions and app access updated."); + } + case SEND_RECOVERY_EMAIL: + sendRecovery(auth, request.targetKey()); + return ("Password reset email sent by Authentik."); + case RESET_MFA: + { + int removed = 0; + for(JSONObject device : mfaDevices(Integer.parseInt(request.targetKey()))) + { + String path = devicePath(device); + if(path != null) + { + util.write(auth, "DELETE", path, null); + removed++; + } + } + return (removed + " MFA device(s) removed; they set up MFA again at next sign-in."); + } + case REVOKE_APP_PASSWORDS: + return (revokeAppPasswords(auth, plan.getTargetLabel()) + " app password(s) revoked."); + case REVOKE_SESSIONS: + util.write(auth, "DELETE", "/core/authenticated_sessions/bulk_delete/?user_pks=" + safeKey(request.targetKey()), null); + return ("Sessions revoked."); + default: + throw (new QException("Unsupported action " + request.action())); + } + } + + + + /******************************************************************************* + ** Create the user (no password), add them to their groups, send the + ** password email. A failure after the user exists says so. + *******************************************************************************/ + private String performCreateUser(AdminPlan plan, WriteAuthorization auth) throws QException + { + AdminRequest request = plan.getRequest(); + JSONObject body = new JSONObject() + .put("username", request.str("username")) + .put("name", request.str("name")) + .put("email", request.str("email")) + .put("is_active", true) + .put("type", "internal") + .put("path", config.getNewUserPath()) + .put("attributes", new JSONObject().put("qqq.io/created-by", guard.operatorEmail() == null ? "" : guard.operatorEmail())); + JSONObject created = util.write(auth, "POST", "/core/users/", body); + int pk = created.optInt("pk", -1); + if(pk < 0) + { + throw (new QException("Authentik did not return the new user's id.")); + } + + try + { + for(String groupName : AuthentikSafety.parseList(request.str("groups"))) + { + util.write(auth, "POST", "/core/groups/" + safeKey(graph().byName(groupName).pk()) + "/add_user/", new JSONObject().put("pk", pk)); + } + if(!request.params().containsKey("sendRecovery") || request.bool("sendRecovery")) + { + sendRecovery(auth, String.valueOf(pk)); + } + } + catch(Exception e) + { + throw (new QException("The user " + request.str("username") + " was created, but a later step failed: " + e.getMessage() + + " Finish with Add to group and Send password reset email.", e)); + } + return ("User created. They set their own password from the email Authentik sent."); + } + + + + /******************************************************************************* + ** Authentik's recovery email (the user gets the link; QQQ never does). + *******************************************************************************/ + private void sendRecovery(WriteAuthorization auth, String userPk) throws QException + { + String stage = flowOrStageUuid("/stages/email/", config.getRecoveryEmailStage()); + if(stage == null) + { + throw (new QUserFacingException("The recovery email stage was not found in Authentik: " + config.getRecoveryEmailStage())); + } + util.write(auth, "POST", "/core/users/" + safeKey(userPk) + "/recovery_email/", new JSONObject().put("email_stage", stage).put("token_duration", "minutes=30")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int revokeAppPasswords(WriteAuthorization auth, String username) throws QException + { + int revoked = 0; + for(JSONObject token : appPasswords(username)) + { + util.write(auth, "DELETE", "/core/tokens/" + safeKey(token.optString("identifier")) + "/", null); + revoked++; + } + return (revoked); + } + + + + /******************************************************************************* + ** The user's app passwords only (intent app_password): never API tokens, + ** recovery or verification tokens. + *******************************************************************************/ + private List appPasswords(String username) throws QException + { + return (util.getAllPages("/core/tokens/?user__username=" + enc(username) + "&intent=app_password", 1000)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List sessions(String username) throws QException + { + return (util.getAllPages("/core/authenticated_sessions/?user__username=" + enc(username), 1000)); + } + + + + /******************************************************************************* + ** A user's MFA devices (metadata only). + *******************************************************************************/ + private List mfaDevices(int userPk) throws QException + { + JSONArray devices = util.getApiArray("/authenticators/admin/all/?user=" + userPk); + List rs = new ArrayList<>(); + if(devices != null) + { + for(int i = 0; i < devices.length(); i++) + { + rs.add(devices.getJSONObject(i)); + } + } + return (rs); + } + + + + /******************************************************************************* + ** The delete path of an MFA device from its type and pk. + *******************************************************************************/ + public static String devicePath(JSONObject device) + { + String type = (device.optString("type", "") + " " + device.optString("meta_model_name", "")).toLowerCase(Locale.ROOT); + String pk = device.has("pk") ? String.valueOf(device.get("pk")) : null; + if(pk == null) + { + return (null); + } + for(String kind : List.of("totp", "webauthn", "static", "email", "sms", "duo")) + { + if(type.contains(kind)) + { + return ("/authenticators/admin/" + kind + "/" + pk + "/"); + } + } + return (null); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // reads + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** The pk of the user with a username (within the qbit's user scope), or + ** null. + *******************************************************************************/ + public Integer userPkByUsername(String username) throws QException + { + JSONObject user = findUserByUsername(username); + return (user == null || !guard.isUserInScope(user) ? null : user.optInt("pk")); + } + + + + /******************************************************************************* + ** The group graph without members (cached by the util). + *******************************************************************************/ + private GroupGraph graph() throws QException + { + if(graph == null) + { + graph = util.groupGraph(); + } + return (graph); + } + + + + /******************************************************************************* + ** The group graph with members. + *******************************************************************************/ + private GroupGraph membersGraph() throws QException + { + if(membersGraph == null) + { + membersGraph = util.groupGraphWithMembers(); + } + return (membersGraph); + } + + + + /******************************************************************************* + ** A resolver over the plain graph. + *******************************************************************************/ + private EffectivePermissionResolver resolver() throws QException + { + return (new EffectivePermissionResolver(graph(), runtime.getPermissionCatalog())); + } + + + + /******************************************************************************* + ** Facts about every user, for last-admin protection. + *******************************************************************************/ + private List userFacts() throws QException + { + if(userFacts == null) + { + userFacts = new ArrayList<>(); + for(JSONObject user : util.allUsers()) + { + String type = user.optString("type"); + userFacts.add(new AuthentikSafety.UserFacts(user.optInt("pk"), user.optBoolean("is_active", true), + "internal".equals(type) || "external".equals(type), guard.protection(user).isProtected())); + } + } + return (userFacts); + } + + + + /******************************************************************************* + ** pks of the people who can administer memberships and permissions now. + *******************************************************************************/ + private Set adminsNow() throws QException + { + return (AuthentikSafety.qualifyingAdmins(membersGraph(), new EffectivePermissionResolver(membersGraph(), runtime.getPermissionCatalog()), userFacts())); + } + + + + /******************************************************************************* + ** A user by exact email (case-insensitive match on the result), or null. + *******************************************************************************/ + private JSONObject findUserByEmail(String email) throws QException + { + for(JSONObject user : util.getAllPages("/core/users/?email=" + enc(email) + "&include_groups=true&include_roles=false", 10)) + { + if(email.equalsIgnoreCase(user.optString("email"))) + { + return (user); + } + } + return (null); + } + + + + /******************************************************************************* + ** A user by exact username, or null. + *******************************************************************************/ + private JSONObject findUserByUsername(String username) throws QException + { + for(JSONObject user : util.getAllPages("/core/users/?username=" + enc(username) + "&include_groups=false&include_roles=false", 10)) + { + if(username.equalsIgnoreCase(user.optString("username"))) + { + return (user); + } + } + return (null); + } + + + + /******************************************************************************* + ** The uuid of a stage or flow given by uuid or by name/slug. + *******************************************************************************/ + private String flowOrStageUuid(String listPath, String nameOrUuid) throws QException + { + if(!StringUtils.hasContent(nameOrUuid)) + { + return (null); + } + if(UUID_PATTERN.matcher(nameOrUuid).matches()) + { + return (nameOrUuid); + } + for(JSONObject stage : util.getAllPages(listPath + "?name=" + enc(nameOrUuid), 10)) + { + if(nameOrUuid.equals(stage.optString("name"))) + { + return (stage.optString("pk")); + } + } + return (null); + } + + + + /******************************************************************************* + ** The uuid of the invitation flow (by uuid or slug). + *******************************************************************************/ + private String flowUuid(String slugOrUuid) throws QException + { + if(UUID_PATTERN.matcher(slugOrUuid).matches()) + { + return (slugOrUuid); + } + for(JSONObject flow : util.getAllPages("/flows/instances/?slug=" + enc(slugOrUuid), 10)) + { + if(slugOrUuid.equals(flow.optString("slug"))) + { + return (flow.optString("pk")); + } + } + return (null); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // small helpers + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Put a list attribute, or remove the key when empty. + *******************************************************************************/ + private static void putList(JSONObject attributes, String key, List values) + { + if(values.isEmpty()) + { + attributes.remove(key); + } + else + { + attributes.put(key, new JSONArray(values)); + } + } + + + + /******************************************************************************* + ** Group names for pks, for before/after text. + *******************************************************************************/ + private String names(List pks) throws QException + { + List names = new ArrayList<>(); + for(String pk : pks) + { + GroupGraph.Node node = graph().byPk(pk); + names.add(node == null ? pk : node.name()); + } + return (names.isEmpty() ? "none" : String.join(", ", names)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String joinOrNone(List values) + { + return (values.isEmpty() ? "none" : String.join(", ", values)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static List nonNull(List list) + { + return (list == null ? List.of() : list); + } + + + + /******************************************************************************* + ** A path segment that is a plain key (digits, letters, dashes, dots, colons). + *******************************************************************************/ + static String safeKey(String key) throws QUserFacingException + { + if(key == null || !key.matches("[A-Za-z0-9._-]{1,150}")) + { + throw (new QUserFacingException("Bad key: " + key)); + } + return (key); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String enc(String value) + { + return (URLEncoder.encode(value, StandardCharsets.UTF_8)); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java new file mode 100644 index 0000000..916e49a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java @@ -0,0 +1,244 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.admin; + + +import java.util.List; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +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.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** The qbit's own tables (DESIGN.md section 6.2), standard visible QQQ tables on + ** the host's local RDBMS backend, named authentik_* (lowercase snake_case). + ** Both are written only by the qbit: the generic insert, update and delete + ** capabilities are off for everyone, and the audit table is append-only. + ** + ** The host creates the SQL tables (see docs/schema.sql). + *******************************************************************************/ +public final class AuthentikLocalTables +{ + public static final String ADMIN_ACTION = "admin_action"; + public static final String APPROVAL_REQUEST = "approval_request"; + public static final String PVS_ACTION_STATUS = "authentik_action_status"; + public static final String PVS_APPROVAL_STATUS = "authentik_approval_status"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AuthentikLocalTables() + { + } + + + + /******************************************************************************* + ** Status of an audited action. + *******************************************************************************/ + public enum ActionStatus implements PossibleValueEnum + { + PENDING_APPROVAL("Waiting for approval"), + RUNNING("Running"), + SUCCEEDED("Succeeded"), + FAILED("Failed"), + REFUSED("Refused"); + + private final String label; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + ActionStatus(String label) + { + this.label = label; + } + + + + /******************************************************************************* + ** Getter for possibleValueId. + *******************************************************************************/ + @Override + public String getPossibleValueId() + { + return (name()); + } + + + + /******************************************************************************* + ** Getter for possibleValueLabel. + *******************************************************************************/ + @Override + public String getPossibleValueLabel() + { + return (label); + } + } + + + + /******************************************************************************* + ** Status of a two-person approval request. + *******************************************************************************/ + public enum ApprovalStatus implements PossibleValueEnum + { + PENDING("Waiting for approval"), + APPROVED("Approved and applied"), + REJECTED("Rejected"), + EXPIRED("Expired"), + FAILED("Approved but failed"); + + private final String label; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + ApprovalStatus(String label) + { + this.label = label; + } + + + + /******************************************************************************* + ** Getter for possibleValueId. + *******************************************************************************/ + @Override + public String getPossibleValueId() + { + return (name()); + } + + + + /******************************************************************************* + ** Getter for possibleValueLabel. + *******************************************************************************/ + @Override + public String getPossibleValueLabel() + { + return (label); + } + } + + + + /******************************************************************************* + ** The possible-value sources the local tables use. + *******************************************************************************/ + public static List possibleValueSources() + { + return (List.of( + QPossibleValueSource.newForEnum(PVS_ACTION_STATUS, ActionStatus.values()), + QPossibleValueSource.newForEnum(PVS_APPROVAL_STATUS, ApprovalStatus.values()))); + } + + + + /******************************************************************************* + ** authentik_admin_action: append-only audit and outbox of every write the + ** qbit attempts. The row is inserted BEFORE the API call. + *******************************************************************************/ + public static QTableMetaData adminAction(AuthentikQBitConfig config) + { + return (base(config, ADMIN_ACTION, "Admin Actions", "history", IdentityPermission.AUDIT_READ) + .withRecordLabelFormatAndFields("%s %s", "actionName", "targetLabel") + .withField(ro("correlationId", QFieldType.STRING).withMaxLength(64)) + .withField(ro("actionName", QFieldType.STRING).withMaxLength(60)) + .withField(ro("operator", QFieldType.STRING).withMaxLength(255)) + .withField(ro("reason", QFieldType.TEXT)) + .withField(ro("targetType", QFieldType.STRING).withMaxLength(30)) + .withField(ro("targetKey", QFieldType.STRING).withMaxLength(100)) + .withField(ro("targetLabel", QFieldType.STRING).withMaxLength(255)) + .withField(ro("beforeText", QFieldType.TEXT)) + .withField(ro("afterText", QFieldType.TEXT)) + .withField(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_ACTION_STATUS)) + .withField(ro("errorMessage", QFieldType.TEXT)) + .withField(ro("authentikEventPk", QFieldType.STRING).withMaxLength(64)) + .withField(ro("approver", QFieldType.STRING).withMaxLength(255))); + } + + + + /******************************************************************************* + ** authentik_approval_request: two-person requests (a second person with + ** authentik.approve applies or rejects the stored change). + *******************************************************************************/ + public static QTableMetaData approvalRequest(AuthentikQBitConfig config) + { + return (base(config, APPROVAL_REQUEST, "Approval Requests", "how_to_reg", IdentityPermission.APPROVE) + .withRecordLabelFormatAndFields("%s %s", "actionName", "targetLabel") + .withField(ro("actionName", QFieldType.STRING).withMaxLength(60)) + .withField(ro("targetKey", QFieldType.STRING).withMaxLength(100)) + .withField(ro("targetLabel", QFieldType.STRING).withMaxLength(255)) + .withField(ro("summary", QFieldType.TEXT)) + .withField(ro("reason", QFieldType.TEXT)) + .withField(ro("payloadJson", QFieldType.TEXT)) + .withField(ro("requester", QFieldType.STRING).withMaxLength(255)) + .withField(ro("approver", QFieldType.STRING).withMaxLength(255)) + .withField(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_APPROVAL_STATUS)) + .withField(ro("expiresDate", QFieldType.DATE_TIME)) + .withField(ro("decidedDate", QFieldType.DATE_TIME)) + .withField(ro("errorMessage", QFieldType.TEXT))); + } + + + + /******************************************************************************* + ** Common shape: id, create and modify dates, no generic writes. + *******************************************************************************/ + private static QTableMetaData base(AuthentikQBitConfig config, String name, String label, String icon, IdentityPermission permission) + { + return (new QTableMetaData() + .withName(config.applyTablePrefix(name)) + .withLabel(label) + .withIcon(new QIcon(icon)) + .withBackendName(config.getLocalBackendName()) + .withPrimaryKeyField("id") + .withPermissionRules(permission.rules()) + .withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_EXPORT) + .withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false)) + .withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false))); + } + + + + /******************************************************************************* + ** A read-only field. + *******************************************************************************/ + private static QFieldMetaData ro(String name, QFieldType type) + { + return (new QFieldMetaData(name, type).withIsEditable(false)); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java new file mode 100644 index 0000000..a7ca66c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java @@ -0,0 +1,279 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import com.kingsrook.qbits.authentik.client.AuthentikJson; +import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver; +import com.kingsrook.qbits.authentik.permissions.GroupGraph; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** The pure rules of DESIGN.md section 5.4, separated from I/O so they can be + ** tested exhaustively: the subset (no escalation) rule, last-admin + ** protection, descendant checks and group fingerprints. + *******************************************************************************/ +public final class AuthentikSafety +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AuthentikSafety() + { + } + + + + /******************************************************************************* + ** What a user is, for last-admin protection. + ** + ** @param pk Authentik user pk + ** @param active whether the account is active + ** @param human internal or external (not a service account) + ** @param protectedAccount break-glass, superuser, service account + *******************************************************************************/ + public record UserFacts(int pk, boolean active, boolean human, boolean protectedAccount) + { + } + + + + /******************************************************************************* + ** The permissions in needed that held does not contain (the subset rule: + ** an operator may only grant what they hold). + *******************************************************************************/ + public static Set missing(Collection needed, Collection held) + { + Set rs = new TreeSet<>(needed); + rs.removeAll(held); + return (rs); + } + + + + /******************************************************************************* + ** pks of the users who qualify as identity administrators in a graph: active, + ** human, not protected, and holding both authentik.memberships.manage and + ** authentik.permissions.manage through their groups (inherited included). + *******************************************************************************/ + public static Set qualifyingAdmins(GroupGraph graph, EffectivePermissionResolver resolver, Collection users) + { + Set rs = new LinkedHashSet<>(); + for(UserFacts user : users) + { + if(!user.active() || !user.human() || user.protectedAccount()) + { + continue; + } + EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(graph.groupsOfUser(user.pk())); + if(EffectivePermissionResolver.holdsAll(resolution, List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE))) + { + rs.add(user.pk()); + } + } + return (rs); + } + + + + /******************************************************************************* + ** Whether a change leaves the qbit without an administrator that it had + ** before: some admin existed, and none does after. A state that already had + ** none (for example a bootstrap window) is not made worse, so it never + ** blocks. + *******************************************************************************/ + public static boolean removesLastAdmin(Set before, Set after) + { + return (!before.isEmpty() && after.isEmpty()); + } + + + + /******************************************************************************* + ** A copy of a graph with some nodes replaced (matched by pk), for + ** simulating a change. + *******************************************************************************/ + public static GroupGraph withNodes(GroupGraph graph, GroupGraph.Node... replacements) + { + GroupGraph copy = new GroupGraph(); + graph.nodes().forEach(copy::add); + for(GroupGraph.Node node : replacements) + { + copy.add(node); + } + return (copy); + } + + + + /******************************************************************************* + ** A node without a member. + *******************************************************************************/ + public static GroupGraph.Node withoutMember(GroupGraph.Node node, int userPk) + { + List members = new ArrayList<>(node.memberPks()); + members.remove(Integer.valueOf(userPk)); + return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed())); + } + + + + /******************************************************************************* + ** A node with other permissions and app access. + *******************************************************************************/ + public static GroupGraph.Node withPermissions(GroupGraph.Node node, List permissions, List appAccess) + { + return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed())); + } + + + + /******************************************************************************* + ** A node with other parents. + *******************************************************************************/ + public static GroupGraph.Node withParents(GroupGraph.Node node, List parentPks) + { + return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed())); + } + + + + /******************************************************************************* + ** Every group below a group (children, their children, ...), cycle-safe. + *******************************************************************************/ + public static Set descendantPks(GroupGraph graph, String pk) + { + Set rs = new HashSet<>(); + for(GroupGraph.Node node : graph.nodes()) + { + if(!node.pk().equals(pk) && graph.ancestorPks(node.pk()).contains(pk)) + { + rs.add(node.pk()); + } + } + return (rs); + } + + + + /******************************************************************************* + ** Split free text (new lines or commas) into distinct trimmed entries. + *******************************************************************************/ + public static List parseList(String text) + { + List rs = new ArrayList<>(); + if(text == null) + { + return (rs); + } + for(String part : text.split("[\\r\\n,;]+")) + { + String trimmed = part.trim(); + if(!trimmed.isEmpty() && !rs.contains(trimmed)) + { + rs.add(trimmed); + } + } + return (rs); + } + + + + /******************************************************************************* + ** A short fingerprint of a group's editable state: name, parents, + ** qqq.io/* attributes and member count. Authentik groups have no + ** last_updated (DESIGN.md section 0.2 vi), so this is how a change made in + ** the Authentik UI after a preview is noticed. + *******************************************************************************/ + public static String groupFingerprint(JSONObject group) + { + JSONObject attributes = group.optJSONObject("attributes"); + Map qqq = new java.util.TreeMap<>(); + if(attributes != null) + { + for(String key : attributes.keySet()) + { + if(key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX)) + { + qqq.put(key, String.valueOf(attributes.get(key))); + } + } + } + + List parents = new ArrayList<>(); + JSONArray array = group.optJSONArray("parents"); + for(int i = 0; array != null && i < array.length(); i++) + { + Object value = array.get(i); + parents.add(value instanceof JSONObject object ? object.optString("pk") : String.valueOf(value)); + } + java.util.Collections.sort(parents); + + int members = group.optJSONArray("users") == null ? -1 : group.getJSONArray("users").length(); + return (sha(group.optString("name") + "|" + parents + "|" + qqq + "|" + members)); + } + + + + /******************************************************************************* + ** A short fingerprint of a user's editable state (name, email, path, type, + ** active, last_updated). + *******************************************************************************/ + public static String userFingerprint(JSONObject user) + { + return (sha(user.optString("username") + "|" + user.optString("name") + "|" + user.optString("email") + "|" + user.optString("path") + "|" + + user.optString("type") + "|" + user.optBoolean("is_active", true) + "|" + user.optString("last_updated"))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String sha(String text) + { + try + { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for(int i = 0; i < 8; i++) + { + hex.append(String.format("%02x", digest[i])); + } + return (hex.toString()); + } + catch(NoSuchAlgorithmException e) + { + throw (new IllegalStateException(e)); + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeData.java b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeData.java new file mode 100644 index 0000000..2ac8fdb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeData.java @@ -0,0 +1,540 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.app; + + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.client.AuthentikJson; +import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport; +import com.kingsrook.qbits.authentik.drift.DriftFinding; +import com.kingsrook.qbits.authentik.permissions.GroupGraph; +import com.kingsrook.qbits.authentik.security.AuthentikGuard; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONObject; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Everything the Identity home shows (DESIGN.md section 8.3), read live from + ** Authentik and the qbit's own tables. Each section is loaded on its own, so a + ** failure shows as "unavailable" for that card instead of hiding the page. + ** Sections that expose sensitive data need authentik.audit.read. + *******************************************************************************/ +public class AuthentikHomeData +{ + private static final QLogger LOG = QLogger.getLogger(AuthentikHomeData.class); + + public static final int TOKEN_WARNING_DAYS = 14; + + private boolean peopleAvailable; + private int activePeople; + private int inactivePeople; + private int serviceAccounts; + private int neverSignedIn; + private int protectedAccounts; + + private boolean groupsAvailable; + private int groups; + private int groupsWithPermissions; + private int groupsWithAppAccess; + private int superuserGroups; + private int emptyGroups; + + private boolean approvalsAvailable; + private int pendingApprovals; + + private boolean driftAvailable; + private int driftErrors; + private int driftWarnings; + + private boolean mayAudit; + private boolean actionsAvailable; + private List recentActions = new ArrayList<>(); + private List attention = new ArrayList<>(); + + private AuthentikQBitConfig config; + private String version; + + + + /******************************************************************************* + ** Load everything for the current session. + *******************************************************************************/ + public static AuthentikHomeData load(AuthentikQBitConfig config) throws Exception + { + AuthentikHomeData data = new AuthentikHomeData(); + data.config = config; + data.mayAudit = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.AUDIT_READ); + AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName()); + + data.loadPeople(util); + data.loadGroups(util); + data.loadApprovals(); + data.loadActions(); + data.loadAttention(util); + data.version = util.fetchServerVersion().orElse(null); + return (data); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void loadPeople(AuthentikActionUtil util) + { + try + { + for(JSONObject user : util.allUsers()) + { + String type = user.optString("type"); + if("service_account".equals(type)) + { + serviceAccounts++; + } + else if(user.optBoolean("is_active", true)) + { + activePeople++; + } + else + { + inactivePeople++; + } + if(user.isNull("last_login") && !"service_account".equals(type) && user.optBoolean("is_active", true)) + { + neverSignedIn++; + } + if(util.getRuntime().getGuard().protection(user).isProtected()) + { + protectedAccounts++; + } + } + peopleAvailable = true; + } + catch(Exception e) + { + LOG.info("Identity home: people unavailable", logPair("error", e.getClass().getSimpleName())); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void loadGroups(AuthentikActionUtil util) + { + try + { + GroupGraph graph = util.groupGraphWithMembers(); + for(GroupGraph.Node node : graph.nodes()) + { + groups++; + if(!node.permissions().isEmpty()) + { + groupsWithPermissions++; + } + if(!node.appAccess().isEmpty()) + { + groupsWithAppAccess++; + } + if(graph.isSuperuserInherited(node.pk())) + { + superuserGroups++; + } + if(node.memberPks().isEmpty()) + { + emptyGroups++; + } + } + groupsAvailable = true; + } + catch(Exception e) + { + LOG.info("Identity home: groups unavailable", logPair("error", e.getClass().getSimpleName())); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void loadApprovals() + { + if(!hasLocal() || !config.getTwoPersonApproval() || !config.hasFeature(AuthentikFeature.GROUPS)) + { + return; + } + try + { + CountInput countInput = new CountInput(); + countInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.APPROVAL_REQUEST)); + countInput.setFilter(new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, AuthentikLocalTables.ApprovalStatus.PENDING.name()))); + pendingApprovals = new CountAction().execute(countInput).getCount(); + approvalsAvailable = true; + } + catch(Exception e) + { + LOG.info("Identity home: approvals unavailable", logPair("error", e.getClass().getSimpleName())); + } + } + + + + /******************************************************************************* + ** The latest admin actions (audit.read only). + *******************************************************************************/ + private void loadActions() + { + if(!hasLocal() || !mayAudit) + { + return; + } + try + { + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION)); + queryInput.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false)).withLimit(8)); + recentActions = new QueryAction().execute(queryInput).getRecords(); + actionsAvailable = true; + } + catch(Exception e) + { + LOG.info("Identity home: admin actions unavailable", logPair("error", e.getClass().getSimpleName())); + } + } + + + + /******************************************************************************* + ** Things that need a person: drift findings, blueprint state, tokens about + ** to expire, bootstrap operators still set. + *******************************************************************************/ + private void loadAttention(AuthentikActionUtil util) + { + if(!config.getResolvedBootstrapSubjects().isEmpty()) + { + attention.add("bootstrapOperatorSubjects is still set: remove it once an identity administrator is set up in Authentik."); + } + if(Boolean.TRUE.equals(config.getReadOnly())) + { + attention.add("The Identity screens are in read-only mode: no change can be made from here."); + } + if(AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN)) + { + try + { + for(DriftFinding finding : new AuthentikDriftReport(util, config).run()) + { + if(finding.severity() == DriftFinding.Severity.ERROR) + { + driftErrors++; + } + else if(finding.severity() == DriftFinding.Severity.WARNING) + { + driftWarnings++; + } + } + driftAvailable = true; + } + catch(Exception e) + { + LOG.info("Identity home: drift report unavailable", logPair("error", e.getClass().getSimpleName())); + } + } + if(mayAudit) + { + try + { + Instant soon = Instant.now().plus(TOKEN_WARNING_DAYS, ChronoUnit.DAYS); + for(JSONObject token : util.getAllPages("/core/tokens/?user__username=" + java.net.URLEncoder.encode(config.getEffectiveServiceAccountUsername(), java.nio.charset.StandardCharsets.UTF_8), 50)) + { + Instant expires = AuthentikJson.parseInstant(token.optString("expires", null)); + if(token.optBoolean("expiring", true) && expires != null && expires.isBefore(soon)) + { + attention.add("The qbit's API token (" + token.optString("identifier") + ") expires on " + + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(java.time.ZoneOffset.UTC).format(expires) + ": rotate it (owner checklist)."); + } + } + } + catch(Exception e) + { + LOG.debug("Identity home: token expiry not checked", e); + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private boolean hasLocal() + { + return (config.getLocalBackendName() != null && QContext.getQInstance().getTable(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION)) != null); + } + + + + /******************************************************************************* + ** Getter for config + *******************************************************************************/ + public AuthentikQBitConfig getConfig() + { + return (config); + } + + + + /******************************************************************************* + ** Getter for version + *******************************************************************************/ + public String getVersion() + { + return (version); + } + + + + /******************************************************************************* + ** Getter for peopleAvailable + *******************************************************************************/ + public boolean isPeopleAvailable() + { + return (peopleAvailable); + } + + + + /******************************************************************************* + ** Getter for activePeople + *******************************************************************************/ + public int getActivePeople() + { + return (activePeople); + } + + + + /******************************************************************************* + ** Getter for inactivePeople + *******************************************************************************/ + public int getInactivePeople() + { + return (inactivePeople); + } + + + + /******************************************************************************* + ** Getter for serviceAccounts + *******************************************************************************/ + public int getServiceAccounts() + { + return (serviceAccounts); + } + + + + /******************************************************************************* + ** Getter for neverSignedIn + *******************************************************************************/ + public int getNeverSignedIn() + { + return (neverSignedIn); + } + + + + /******************************************************************************* + ** Getter for protectedAccounts + *******************************************************************************/ + public int getProtectedAccounts() + { + return (protectedAccounts); + } + + + + /******************************************************************************* + ** Getter for groupsAvailable + *******************************************************************************/ + public boolean isGroupsAvailable() + { + return (groupsAvailable); + } + + + + /******************************************************************************* + ** Getter for groups + *******************************************************************************/ + public int getGroups() + { + return (groups); + } + + + + /******************************************************************************* + ** Getter for groupsWithPermissions + *******************************************************************************/ + public int getGroupsWithPermissions() + { + return (groupsWithPermissions); + } + + + + /******************************************************************************* + ** Getter for groupsWithAppAccess + *******************************************************************************/ + public int getGroupsWithAppAccess() + { + return (groupsWithAppAccess); + } + + + + /******************************************************************************* + ** Getter for superuserGroups + *******************************************************************************/ + public int getSuperuserGroups() + { + return (superuserGroups); + } + + + + /******************************************************************************* + ** Getter for emptyGroups + *******************************************************************************/ + public int getEmptyGroups() + { + return (emptyGroups); + } + + + + /******************************************************************************* + ** Getter for approvalsAvailable + *******************************************************************************/ + public boolean isApprovalsAvailable() + { + return (approvalsAvailable); + } + + + + /******************************************************************************* + ** Getter for pendingApprovals + *******************************************************************************/ + public int getPendingApprovals() + { + return (pendingApprovals); + } + + + + /******************************************************************************* + ** Getter for driftAvailable + *******************************************************************************/ + public boolean isDriftAvailable() + { + return (driftAvailable); + } + + + + /******************************************************************************* + ** Getter for driftErrors + *******************************************************************************/ + public int getDriftErrors() + { + return (driftErrors); + } + + + + /******************************************************************************* + ** Getter for driftWarnings + *******************************************************************************/ + public int getDriftWarnings() + { + return (driftWarnings); + } + + + + /******************************************************************************* + ** Getter for mayAudit + *******************************************************************************/ + public boolean isMayAudit() + { + return (mayAudit); + } + + + + /******************************************************************************* + ** Getter for actionsAvailable + *******************************************************************************/ + public boolean isActionsAvailable() + { + return (actionsAvailable); + } + + + + /******************************************************************************* + ** Getter for recentActions + *******************************************************************************/ + public List getRecentActions() + { + return (recentActions); + } + + + + /******************************************************************************* + ** Getter for attention + *******************************************************************************/ + public List getAttention() + { + return (attention); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java new file mode 100644 index 0000000..0d38e4e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java @@ -0,0 +1,295 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.app; + + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables; +import com.kingsrook.qbits.authentik.client.AuthentikTables; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** Renders the Identity home as one responsive HTML block (no tables, so it + ** stays readable at every width): cards stack to one column on narrow + ** screens, long values wrap, every card has an honest empty or unavailable + ** state, and it follows the app's light and dark theme (inherited colors). + *******************************************************************************/ +public final class AuthentikHomeView +{ + private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC); + + private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr));gap:1rem;"; + private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;"; + private static final String LIST = "display:grid;grid-template-columns:minmax(8rem,auto) 1fr;gap:.25rem .75rem;margin:0 0 .75rem;font-size:.9rem;"; + private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;font-size:.9rem;"; + private static final String MUTED = "opacity:.75;"; + private static final String WARN = "font-weight:600;"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AuthentikHomeView() + { + } + + + + /******************************************************************************* + ** The whole home. + *******************************************************************************/ + public static String render(AuthentikHomeData data) + { + AuthentikQBitConfig config = data.getConfig(); + StringBuilder html = new StringBuilder("
"); + attention(html, data, config); + people(html, data, config); + groups(html, data, config); + activity(html, data, config); + connection(html, data); + return (html.append("
").toString()); + } + + + + /******************************************************************************* + ** Needs attention: the first thing a person should see. + *******************************************************************************/ + private static void attention(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config) + { + open(html, "Needs attention"); + boolean any = false; + html.append("
    "); + if(data.isApprovalsAvailable() && data.getPendingApprovals() > 0) + { + any = true; + html.append("
  • ").append(data.getPendingApprovals()).append(" change(s) waiting for a second person to approve. ") + .append(link(config, AuthentikLocalTables.APPROVAL_REQUEST, "Open approval requests")).append("
  • "); + } + if(data.isDriftAvailable() && data.getDriftErrors() + data.getDriftWarnings() > 0) + { + any = true; + html.append("
  • ").append(data.getDriftErrors()).append(" drift error(s) and ").append(data.getDriftWarnings()).append(" warning(s) in the identity drift report below.
  • "); + } + for(String line : data.getAttention()) + { + any = true; + html.append("
  • ").append(esc(line)).append("
  • "); + } + if(data.isPeopleAvailable() && data.getNeverSignedIn() > 0) + { + any = true; + html.append("
  • ").append(data.getNeverSignedIn()).append(" active person(s) have never signed in.
  • "); + } + html.append("
"); + if(!any) + { + empty(html, "Nothing needs attention."); + } + close(html); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void people(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config) + { + open(html, "People"); + if(!data.isPeopleAvailable()) + { + empty(html, "People are unavailable right now (Authentik could not be reached)."); + } + else + { + html.append("
"); + row(html, "Active", String.valueOf(data.getActivePeople())); + row(html, "Inactive", String.valueOf(data.getInactivePeople())); + row(html, "Service accounts", String.valueOf(data.getServiceAccounts())); + row(html, "Protected", data.getProtectedAccounts() + " (cannot be changed from here)"); + html.append("
"); + } + html.append(link(config, AuthentikTables.USER, "Users")); + close(html); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void groups(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config) + { + open(html, "Groups and permissions"); + if(!data.isGroupsAvailable()) + { + empty(html, "Groups are unavailable right now (Authentik could not be reached)."); + } + else + { + html.append("
"); + row(html, "Groups", String.valueOf(data.getGroups())); + row(html, "Grant QQQ permissions", String.valueOf(data.getGroupsWithPermissions())); + row(html, "Grant app sign-in", String.valueOf(data.getGroupsWithAppAccess())); + row(html, "Superuser (locked)", String.valueOf(data.getSuperuserGroups())); + row(html, "Empty", String.valueOf(data.getEmptyGroups())); + html.append("
"); + } + html.append(link(config, AuthentikTables.GROUP, "Groups")); + close(html); + } + + + + /******************************************************************************* + ** Recent changes made from here (audit.read only). + *******************************************************************************/ + private static void activity(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config) + { + open(html, "Recent changes"); + if(!data.isMayAudit()) + { + empty(html, "The change history needs the authentik.audit.read permission."); + } + else if(!data.isActionsAvailable()) + { + empty(html, "The change history is unavailable right now."); + } + else if(data.getRecentActions().isEmpty()) + { + empty(html, "No changes have been made from here yet."); + } + else + { + html.append("
    "); + for(QRecord row : data.getRecentActions()) + { + Instant created = ValueUtils.getValueAsInstant(row.getValue("createDate")); + html.append("
  • ") + .append(esc(row.getValueString("actionName"))).append(" ").append(esc(row.getValueString("targetLabel"))).append(", ") + .append(esc(row.getValueString("status"))).append(created == null ? "" : ", " + WHEN.format(created)).append("
  • "); + } + html.append("
"); + } + if(data.isMayAudit()) + { + html.append(link(config, AuthentikLocalTables.ADMIN_ACTION, "All changes")); + } + close(html); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void connection(StringBuilder html, AuthentikHomeData data) + { + open(html, "Connection"); + html.append("
"); + row(html, "Authentik", data.getConfig().getBaseUrl()); + row(html, "Version", data.getVersion() == null ? "Not reachable" : data.getVersion()); + row(html, "Service account", data.getConfig().getEffectiveServiceAccountUsername()); + row(html, "Changes", Boolean.TRUE.equals(data.getConfig().getReadOnly()) ? "Off (read-only)" : "On, every change is audited here and in Authentik"); + html.append("
"); + close(html); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String prefix(AuthentikQBitConfig config) + { + return (config.getLinkPrefix() == null ? "" : config.getLinkPrefix()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String link(AuthentikQBitConfig config, String table, String text) + { + String name = AuthentikLocalTables.APPROVAL_REQUEST.equals(table) || AuthentikLocalTables.ADMIN_ACTION.equals(table) + ? config.applyTablePrefix(table) : config.applyTablePrefix(table); + return ("" + esc(text) + ""); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void open(StringBuilder html, String title) + { + html.append("

").append(esc(title)).append("

"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void close(StringBuilder html) + { + html.append("
"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void empty(StringBuilder html, String text) + { + html.append("

").append(esc(text)).append("

"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void row(StringBuilder html, String label, String value) + { + html.append("
").append(esc(label)).append("
").append(esc(value)).append("
"); + } + + + + /******************************************************************************* + ** HTML-escape. + *******************************************************************************/ + static String esc(String text) + { + if(text == null) + { + return (""); + } + return (text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeWidget.java b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeWidget.java new file mode 100644 index 0000000..73ce0ee --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeWidget.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.app; + + +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.AuthentikRuntime; +import com.kingsrook.qbits.authentik.metadata.AuthentikHomeWidgetProducer; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; + + +/******************************************************************************* + ** Holder of the Identity home widget renderer. + *******************************************************************************/ +public final class AuthentikHomeWidget +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AuthentikHomeWidget() + { + } + + + + /******************************************************************************* + ** Renders the home. QQQ checks the widget's permission rules (authentik.users.read); + ** each card checks the finer permissions it needs itself. + *******************************************************************************/ + public static class Renderer extends AbstractWidgetRenderer + { + /******************************************************************************* + ** Render. + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(AuthentikHomeWidgetProducer.BACKEND_NAME_KEY)); + AuthentikQBitConfig config = AuthentikRuntime.forBackend(backendName).getConfig(); + try + { + return (new RenderWidgetOutput(new RawHTML(null, AuthentikHomeView.render(AuthentikHomeData.load(config))))); + } + catch(QException e) + { + throw (e); + } + catch(Exception e) + { + throw (new QException("The Identity home could not be built: " + e.getMessage(), e)); + } + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java new file mode 100644 index 0000000..68ac4c5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java @@ -0,0 +1,140 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.app; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.client.AuthentikTables; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; + + +/******************************************************************************* + ** Sub-tables appear on their parents' record screens as child lists, not in + ** the nav (owner standard): a user shows its memberships, sessions, tokens and + ** events; a group shows its members; an application shows its policy bindings; + ** an approval request needs none. The joins are metadata only (the lists + ** query the child table filtered by the parent's key), so they work on the + ** API backend. + *******************************************************************************/ +public final class AuthentikRelations +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AuthentikRelations() + { + } + + + + /******************************************************************************* + ** Add the joins, child-list widgets and record sections for the tables the + ** config produced. Safe to call once per instance. + *******************************************************************************/ + public static void apply(QInstance qInstance, AuthentikQBitConfig config) + { + String user = config.applyTablePrefix(AuthentikTables.USER); + String group = config.applyTablePrefix(AuthentikTables.GROUP); + String membership = config.applyTablePrefix(AuthentikTables.GROUP_MEMBER); + String session = config.applyTablePrefix(AuthentikTables.SESSION); + String token = config.applyTablePrefix(AuthentikTables.TOKEN); + String event = config.applyTablePrefix(AuthentikTables.EVENT); + String application = config.applyTablePrefix(AuthentikTables.APPLICATION); + String binding = config.applyTablePrefix(AuthentikTables.POLICY_BINDING); + + List userSections = new ArrayList<>(); + userSections.add(new QFieldSection("identity", "Identity", new QIcon("badge"), Tier.T1, + List.of("username", "name", "email", "isActive", "type", "isProtected", "isSuperuser", "path", "lastLogin", "dateJoined", "lastUpdated", "pk"))); + userSections.add(new QFieldSection("access", "Access", new QIcon("admin_panel_settings"), Tier.T2, + List.of("groupNames", "roleNames", "effectivePermissions"))); + relate(qInstance, userSections, "memberships", "Memberships", "group_add", user, membership, "pk", "userPk"); + relate(qInstance, userSections, "sessions", "Sessions", "devices", user, session, "username", "username"); + relate(qInstance, userSections, "tokens", "Tokens and app passwords", "key", user, token, "username", "username"); + relate(qInstance, userSections, "events", "History", "history", user, event, "username", "username"); + userSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("qqqAttributes", "passwordChangeDate", "attributes"))); + applySections(qInstance, user, userSections); + + List groupSections = new ArrayList<>(); + groupSections.add(new QFieldSection("details", "Details", new QIcon("groups"), Tier.T1, + List.of("name", "isSuperuser", "isSuperuserInherited", "parentNames", "childNames", "memberCount", "roleNames", "pk"))); + groupSections.add(new QFieldSection("permissions", "Permissions and app access", new QIcon("admin_panel_settings"), Tier.T2, + List.of("permissions", "effectivePermissions", "appAccess"))); + relate(qInstance, groupSections, "members", "Members", "group", group, membership, "pk", "groupPk"); + groupSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("attributes"))); + applySections(qInstance, group, groupSections); + + List appSections = new ArrayList<>(); + appSections.add(new QFieldSection("details", "Details", new QIcon("apps"), Tier.T1, + List.of("name", "slug", "group", "providerName", "policyEngineMode", "launchUrl", "publisher", "hiddenFromLibrary"))); + relate(qInstance, appSections, "bindings", "Who can open it (policy bindings)", "rule", application, binding, "bindingTarget", "target"); + applySections(qInstance, application, appSections); + } + + + + /******************************************************************************* + ** One join plus its child-list widget and the section that shows it. + *******************************************************************************/ + private static void relate(QInstance qInstance, List sections, String key, String label, String icon, + String parentTable, String childTable, String parentField, String childField) + { + if(qInstance.getTable(parentTable) == null || qInstance.getTable(childTable) == null) + { + return; + } + + String joinName = parentTable + "_" + key; + QJoinMetaData join = new QJoinMetaData() + .withName(joinName) + .withLeftTable(parentTable) + .withRightTable(childTable) + .withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn(parentField, childField)); + qInstance.addJoin(join); + + QWidgetMetaData widget = ChildRecordListRenderer.widgetMetaDataBuilder(join).withLabel(label).withMaxRows(20).getWidgetMetaData(); + qInstance.addWidget(widget); + sections.add(new QFieldSection(key, new QIcon(icon), Tier.T2).withLabel(label).withWidgetName(widget.getName())); + } + + + + /******************************************************************************* + ** Replace a table's sections (the enricher would otherwise skip the field + ** sections once a widget section exists). + *******************************************************************************/ + private static void applySections(QInstance qInstance, String tableName, List sections) + { + QTableMetaData table = qInstance.getTable(tableName); + if(table != null) + { + table.setSections(sections); + } + } + +} 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 8d1dfe0..be4a32b 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java @@ -30,6 +30,7 @@ import com.kingsrook.qbits.authentik.AuthentikRuntime; import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver; import com.kingsrook.qbits.authentik.permissions.GroupGraph; import com.kingsrook.qbits.authentik.security.AuthentikGuard; +import com.kingsrook.qbits.authentik.security.WriteAuthorization; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.context.QContext; @@ -62,9 +63,14 @@ import org.apache.http.Header; import org.apache.http.HttpEntityEnclosingRequest; import org.apache.http.NameValuePair; import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpDelete; 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.client.methods.HttpRequestBase; import org.apache.http.client.utils.URIBuilder; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.client.config.RequestConfig; @@ -168,6 +174,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil if(spec.isVirtual()) { output.setCount(memberships(spec, countInput.getFilter()).size()); + output.setDistinctCount(output.getCount()); return (output); } @@ -175,11 +182,13 @@ public class AuthentikActionUtil extends BaseAPIActionUtil if(mapped.isKeyLookup()) { output.setCount(fetchByKeys(table, spec, mapped.keyLookup(), null).size()); + output.setDistinctCount(output.getCount()); return (output); } JSONObject page = getJson(listUrl(spec, mapped, 1, 1)); output.setCount(pagination(page).optInt("count", 0)); + output.setDistinctCount(output.getCount()); return (output); } @@ -357,6 +366,61 @@ public class AuthentikActionUtil extends BaseAPIActionUtil + /******************************************************************************* + ** Send a request that changes Authentik data. Needs the guard's + ** authorization; every successful write drops the response and group caches. + ** The path is relative to /api/v3. Returns the response body as JSON (an + ** empty object for 204 / empty bodies); a missing target is an error. + ** + ** @param method POST, PATCH or DELETE + ** @param body request body (null for none) + *******************************************************************************/ + public JSONObject write(WriteAuthorization authorization, String method, String pathAndQuery, JSONObject body) throws QException + { + if(authorization == null) + { + throw (new QPermissionDeniedException("Authentik data can only be changed through the guard.")); + } + if(pathAndQuery == null || pathAndQuery.contains("..") || !pathAndQuery.startsWith("/")) + { + throw (new QException("Bad Authentik path")); + } + + String url = runtime().getConfig().getApiRoot() + pathAndQuery; + HttpRequestBase request = switch(method) + { + case "POST" -> new HttpPost(url); + case "PATCH" -> new HttpPatch(url); + case "DELETE" -> new HttpDelete(url); + default -> throw (new QException("Unsupported write method " + method)); + }; + if(body != null && request instanceof HttpEntityEnclosingRequest entityRequest) + { + entityRequest.setEntity(new StringEntity(body.toString(), ContentType.APPLICATION_JSON)); + } + + try + { + QHttpResponse response = execute(request); + 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).")); + } + String content = response.getContent(); + return (content == null || content.isBlank() ? new JSONObject() : new JSONObject(content)); + } + catch(JSONException e) + { + return (new JSONObject()); + } + finally + { + runtime().invalidateCache(); + } + } + + + /******************************************************************************* ** Fetch a single Authentik object by key (null when it doesn't exist). ** No permission check: callers check first. @@ -397,6 +461,26 @@ public class AuthentikActionUtil extends BaseAPIActionUtil + /******************************************************************************* + ** GET an API path whose response is a bare JSON array (not paged), such as + ** the MFA device list. + *******************************************************************************/ + public JSONArray getApiArray(String pathAndQuery) throws QException + { + String url = runtime().getConfig().getApiRoot() + pathAndQuery; + String body = getBody(url, false); + try + { + return (new JSONArray(body)); + } + catch(JSONException e) + { + throw (new QException("Authentik returned an unexpected response for " + pathOf(url))); + } + } + + + /******************************************************************************* ** Fetch every page of a list endpoint (relative path with query string, ** without page parameters). Bounded by maxObjects. @@ -599,6 +683,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil try { QRecord record = new QRecord(); + record.setTableName(tableNameOf(spec)); for(AuthentikFieldSpec field : spec.getFields()) { if(field.isSensitive() && !maySeeSensitive) @@ -690,6 +775,86 @@ public class AuthentikActionUtil extends BaseAPIActionUtil + /******************************************************************************* + ** The produced (prefixed) table name for a spec: records must carry it (the + ** Next UI rejects a record response without a tableName). + *******************************************************************************/ + private String tableNameOf(AuthentikTableSpec spec) + { + return (runtime().getConfig().applyTablePrefix(spec.getName())); + } + + + + /******************************************************************************* + ** The runtime of the backend this util serves (config, guard, caches). + *******************************************************************************/ + public AuthentikRuntime getRuntime() + { + return (runtime()); + } + + + + /******************************************************************************* + ** The group graph WITH member pks (one sweep; cached like the plain graph + ** under a separate key). Used by safety checks that need to know who is in + ** which group (last-admin protection). + *******************************************************************************/ + public GroupGraph groupGraphWithMembers() throws QException + { + AuthentikRuntime runtime = runtime(); + boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0; + if(cache) + { + Optional cached = runtime.getGroupGraphCache().getMemoizedResult("members").map(m -> m.getResult()); + if(cached.isPresent()) + { + return (cached.get()); + } + } + List groups = getAllPages("/core/groups/?include_users=true&include_parents=false&include_children=false&ordering=name", MAX_SWEEP_OBJECTS); + GroupGraph graph = GroupGraph.fromGroups(groups); + if(cache) + { + runtime.getGroupGraphCache().storeResult("members", graph); + } + return (graph); + } + + + + /******************************************************************************* + ** Every user the qbit may see (default types, user scope applied), without + ** group objects. Bounded sweep. + *******************************************************************************/ + public List allUsers() throws QException + { + try + { + URIBuilder builder = new URIBuilder("/core/users/"); + boolean groupScope = runtime().getConfig().getUserScopeGroupNames() != null && !runtime().getConfig().getUserScopeGroupNames().isEmpty(); + builder.addParameter("include_groups", String.valueOf(groupScope)); + builder.addParameter("include_roles", "false"); + addUserScope(builder, List.of()); + List rs = new ArrayList<>(); + for(JSONObject user : getAllPages(builder.build().toString(), MAX_SWEEP_OBJECTS)) + { + if(runtime().getGuard().isUserInScope(user)) + { + rs.add(user); + } + } + return (rs); + } + catch(java.net.URISyntaxException e) + { + throw (new QException("Bad Authentik URL", e)); + } + } + + + /******************************************************************************* ** A resolver over the current group graph and the instance's permissions. *******************************************************************************/ @@ -1276,7 +1441,11 @@ public class AuthentikActionUtil extends BaseAPIActionUtil } String requestBody = null; - if(request instanceof HttpEntityEnclosingRequest entityRequest && entityRequest.getEntity() != null) + if("POST".equals(request.getMethod()) && path.endsWith("/core/users/")) + { + path = path + " [body omitted: user create]"; + } + else if(request instanceof HttpEntityEnclosingRequest entityRequest && entityRequest.getEntity() != null) { requestBody = EntityUtils.toString(entityRequest.getEntity(), StandardCharsets.UTF_8); } 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 b2dac72..ae3dde4 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java @@ -49,6 +49,12 @@ public final class AuthentikTables public static final String GROUP_MEMBER = "groupMember"; public static final String SESSION = "session"; public static final String EVENT = "event"; + public static final String APPLICATION = "application"; + public static final String POLICY_BINDING = "policyBinding"; + public static final String BLUEPRINT = "blueprint"; + public static final String ROLE = "role"; + public static final String TOKEN = "token"; + public static final String INVITATION = "invitation"; /******************************************************************************* ** Table names produced before the owner naming rule (2026-10-12), with the @@ -78,6 +84,12 @@ public final class AuthentikTables register(groupMembers()); register(sessions()); register(events()); + register(applications()); + register(policyBindings()); + register(blueprints()); + register(roles()); + register(tokens()); + register(invitations()); } @@ -181,10 +193,10 @@ public final class AuthentikTables static AuthentikTableSpec groups() { AuthentikTableSpec spec = new AuthentikTableSpec(GROUP, "Groups", "groups", "/core/groups/", "pk") - .withDefaultListParam("include_users", "false") + .withDefaultListParam("include_users", "true") .withDefaultListParam("include_parents", "true") .withDefaultListParam("include_children", "true") - .withDefaultGetParam("include_users", "false") + .withDefaultGetParam("include_users", "true") .withDefaultGetParam("include_parents", "true") .withDefaultGetParam("include_children", "true") .withDefaultOrdering("name") @@ -303,4 +315,127 @@ public final class AuthentikTables return (spec); } + /******************************************************************************* + ** /core/applications/ (slug). Read only. A non-superuser caller (the service + ** account) sees only applications it can itself access, so the qbit never + ** relies on this list for access decisions (DESIGN.md 0.2 iii). + *******************************************************************************/ + static AuthentikTableSpec applications() + { + AuthentikTableSpec spec = new AuthentikTableSpec(APPLICATION, "Applications", "apps", "/core/applications/", "slug") + .withDefaultOrdering("name") + .withRecordLabelFields("name"); + spec.withField(new AuthentikFieldSpec("slug", "Slug", QFieldType.STRING, string("slug")).withFilter(QCriteriaOperator.EQUALS, "slug")); + spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name")); + spec.withField(new AuthentikFieldSpec("group", "Group", QFieldType.STRING, string("group")).withFilter(QCriteriaOperator.EQUALS, "group")); + spec.withField(new AuthentikFieldSpec("providerName", "Provider", QFieldType.STRING, at("provider_obj", "name"))); + spec.withField(new AuthentikFieldSpec("policyEngineMode", "Policy Engine Mode", QFieldType.STRING, string("policy_engine_mode"))); + spec.withField(new AuthentikFieldSpec("launchUrl", "Launch URL", QFieldType.STRING, string("launch_url"))); + spec.withField(new AuthentikFieldSpec("publisher", "Publisher", QFieldType.STRING, string("meta_publisher")).withFilter(QCriteriaOperator.EQUALS, "meta_publisher")); + spec.withField(new AuthentikFieldSpec("hiddenFromLibrary", "Hidden", QFieldType.BOOLEAN, bool("meta_hide"))); + spec.withField(new AuthentikFieldSpec("bindingTarget", "Binding Target", QFieldType.STRING, string("pbm_uuid")).withHidden()); + return (spec); + } + + + + /******************************************************************************* + ** /policies/bindings/ (uuid): who or what an application's access policy + ** binds. Read only. + *******************************************************************************/ + static AuthentikTableSpec policyBindings() + { + AuthentikTableSpec spec = new AuthentikTableSpec(POLICY_BINDING, "Policy Bindings", "rule", "/policies/bindings/", "pk") + .withDefaultOrdering("order") + .withRecordLabelFields("pk"); + spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk"))); + spec.withField(new AuthentikFieldSpec("target", "Target Id", QFieldType.STRING, string("target")).withFilter(QCriteriaOperator.EQUALS, "target").withHidden()); + spec.withField(new AuthentikFieldSpec("policyName", "Policy", QFieldType.STRING, at("policy_obj", "name"))); + spec.withField(new AuthentikFieldSpec("groupName", "Group", QFieldType.STRING, at("group_obj", "name"))); + spec.withField(new AuthentikFieldSpec("username", "User", QFieldType.STRING, at("user_obj", "username"))); + spec.withField(new AuthentikFieldSpec("negate", "Negate", QFieldType.BOOLEAN, bool("negate"))); + spec.withField(new AuthentikFieldSpec("enabled", "Enabled", QFieldType.BOOLEAN, bool("enabled")).withFilter(QCriteriaOperator.EQUALS, "enabled")); + spec.withField(new AuthentikFieldSpec("order", "Order", QFieldType.INTEGER, integer("order")).withFilter(QCriteriaOperator.EQUALS, "order").withOrdering("order")); + spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires"))); + return (spec); + } + + + + /******************************************************************************* + ** /managed/blueprints/ (uuid). Read only; the drift report uses it too. + *******************************************************************************/ + static AuthentikTableSpec blueprints() + { + AuthentikTableSpec spec = new AuthentikTableSpec(BLUEPRINT, "Blueprints", "description", "/managed/blueprints/", "pk") + .withDefaultOrdering("name") + .withRecordLabelFields("name"); + spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk"))); + spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name")); + spec.withField(new AuthentikFieldSpec("path", "Path", QFieldType.STRING, string("path")).withFilter(QCriteriaOperator.EQUALS, "path")); + spec.withField(new AuthentikFieldSpec("status", "Status", QFieldType.STRING, string("status"))); + spec.withField(new AuthentikFieldSpec("enabled", "Enabled", QFieldType.BOOLEAN, bool("enabled"))); + spec.withField(new AuthentikFieldSpec("lastApplied", "Last Applied", QFieldType.DATE_TIME, instant("last_applied"))); + return (spec); + } + + + + /******************************************************************************* + ** /rbac/roles/ (uuid). Read only in v1 (role management is phase 4); + ** system-managed roles are hidden. + *******************************************************************************/ + static AuthentikTableSpec roles() + { + AuthentikTableSpec spec = new AuthentikTableSpec(ROLE, "Roles", "admin_panel_settings", "/rbac/roles/", "pk") + .withDefaultListParam("managed__isnull", "true") + .withDefaultOrdering("name") + .withRecordLabelFields("name"); + spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk"))); + spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name")); + return (spec); + } + + + + /******************************************************************************* + ** /core/tokens/ (identifier). Metadata only: there is NO key field, and the + ** service account is never given token-key permissions. Needs audit.read. + *******************************************************************************/ + static AuthentikTableSpec tokens() + { + AuthentikTableSpec spec = new AuthentikTableSpec(TOKEN, "Tokens", "key", "/core/tokens/", "identifier") + .withReadPermission(IdentityPermission.AUDIT_READ) + .withDefaultOrdering("identifier") + .withRecordLabelFields("identifier"); + spec.withField(new AuthentikFieldSpec("identifier", "Identifier", QFieldType.STRING, string("identifier")).withSearchable().withOrdering("identifier")); + spec.withField(new AuthentikFieldSpec("intent", "Intent", QFieldType.STRING, string("intent")).withFilter(QCriteriaOperator.EQUALS, "intent")); + spec.withField(new AuthentikFieldSpec("username", "User", QFieldType.STRING, at("user_obj", "username")).withFilter(QCriteriaOperator.EQUALS, "user__username")); + 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")); + return (spec); + } + + + + /******************************************************************************* + ** /stages/invitation/invitations/ (uuid). Created by the Invite User action; + ** cancelled by Cancel Invitation. + *******************************************************************************/ + static AuthentikTableSpec invitations() + { + AuthentikTableSpec spec = new AuthentikTableSpec(INVITATION, "Invitations", "mail", "/stages/invitation/invitations/", "pk") + .withDefaultOrdering("name") + .withRecordLabelFields("name"); + spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk"))); + spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name")); + spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, at("fixed_data", "email"))); + spec.withField(new AuthentikFieldSpec("singleUse", "Single Use", QFieldType.BOOLEAN, bool("single_use"))); + spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires"))); + spec.withField(new AuthentikFieldSpec("flowSlug", "Flow", QFieldType.STRING, at("flow_obj", "slug"))); + spec.withField(new AuthentikFieldSpec("createdBy", "Created By", QFieldType.STRING, at("created_by", "username"))); + return (spec); + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java b/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java index b7aaf1f..ee9f34c 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java +++ b/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java @@ -345,7 +345,7 @@ public class AuthentikDriftReport if(admins.isEmpty()) { - String bootstrap = config.getResolvedBootstrapOperators().isEmpty() ? "" : " bootstrapOperators is set, which covers sign-in for now."; + String bootstrap = config.getResolvedBootstrapSubjects().isEmpty() ? "" : " bootstrapOperatorSubjects is set, which covers sign-in for now."; findings.add(DriftFinding.error("identity admins", "-", "Nobody can administer identities from QQQ: no active, unprotected human user holds authentik.memberships.manage and authentik.permissions.manage. Add an administrator to a group carrying authentik.* (e.g. identity-admins) in the Authentik UI." + bootstrap)); } else if(admins.size() == 1) @@ -426,7 +426,7 @@ public class AuthentikDriftReport continue; } String modelName = model.optString("model_name"); - String pk = model.optString("pk"); + String pk = dashed(model.optString("pk")); boolean sensitive = false; if(!"user".equals(modelName) && !"group".equals(modelName)) { @@ -458,4 +458,19 @@ public class AuthentikDriftReport } } + + + /******************************************************************************* + ** Authentik events carry group pks as 32 hex characters without dashes; + ** the graph is keyed by the dashed uuid. + *******************************************************************************/ + static String dashed(String pk) + { + if(pk != null && pk.matches("[0-9a-fA-F]{32}")) + { + return (pk.substring(0, 8) + "-" + pk.substring(8, 12) + "-" + pk.substring(12, 16) + "-" + pk.substring(16, 20) + "-" + pk.substring(20)); + } + return (pk); + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAdminActionTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAdminActionTableProducer.java new file mode 100644 index 0000000..5d8e026 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAdminActionTableProducer.java @@ -0,0 +1,65 @@ +/* + * 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.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; + + +/******************************************************************************* + ** authentik_admin_action: the append-only audit trail of every change made from QQQ. + *******************************************************************************/ +public class AuthentikAdminActionTableProducer extends QBitComponentMetaDataProducer +{ + + /******************************************************************************* + ** Needs the local backend. + *******************************************************************************/ + @Override + public boolean isEnabled() + { + return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QTableMetaData produce(QInstance qInstance) throws QException + { + return (AuthentikLocalTables.adminAction(getQBitConfig())); + } + + + + /******************************************************************************* + ** After the backend. + *******************************************************************************/ + @Override + public int getSortOrder() + { + return (300); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java index 043a124..a0c12bb 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java @@ -18,12 +18,17 @@ package com.kingsrook.qbits.authentik.metadata; import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables; import com.kingsrook.qbits.authentik.client.AuthentikTables; import com.kingsrook.qbits.authentik.security.IdentityPermission; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext; @@ -59,26 +64,54 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer< MetaDataProducerMultiOutput produced = QBitProductionContext.peekMetaDataProducerMultiOutput(); if(produced != null) { - for(String specName : AuthentikTables.all().keySet()) + /////////////////////////////////////////////////////////////////////////// + // the nav lists only the main tables; memberships, sessions, tokens, // + // events, bindings, roles and blueprints are reached from their parent // + // records as child lists // + /////////////////////////////////////////////////////////////////////////// + addSection(app, produced, config, "people", "People", "person", AuthentikTables.USER, AuthentikTables.INVITATION); + addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikTables.APPLICATION); + addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION); + + List widgets = new ArrayList<>(); + for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME))) { - QTableMetaData table = produced.get(QTableMetaData.class, config.applyTablePrefix(specName)); - if(table != null) + if(produced.get(QWidgetMetaData.class, widgetName) != null) { - app.withChild(table); + widgets.add(widgetName); } } - - String driftReport = config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME); - if(produced.get(com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData.class, driftReport) != null) - { - app.withWidgets(java.util.List.of(driftReport)); - } + app.withWidgets(widgets); } return (app); } + /******************************************************************************* + ** Add a nav section with the main tables that were produced. + *******************************************************************************/ + private static void addSection(QAppMetaData app, MetaDataProducerMultiOutput produced, AuthentikQBitConfig config, String name, String label, String icon, String... specNames) + { + List names = new ArrayList<>(); + for(String specName : specNames) + { + String tableName = config.applyTablePrefix(specName); + QTableMetaData table = produced.get(QTableMetaData.class, tableName); + if(table != null) + { + app.withChild(table); + names.add(tableName); + } + } + if(!names.isEmpty()) + { + app.withSection(new QAppSection().withName(name).withLabel(label).withIcon(new QIcon(icon)).withTables(names)); + } + } + + + /******************************************************************************* ** Last, so the tables exist in the output. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikApplicationTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikApplicationTableProducer.java new file mode 100644 index 0000000..83a1b36 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikApplicationTableProducer.java @@ -0,0 +1,38 @@ +/* + * 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.client.AuthentikTables; + + +/******************************************************************************* + ** authentik_application: /core/applications/. + *******************************************************************************/ +public class AuthentikApplicationTableProducer extends AbstractAuthentikTableProducer +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected String getSpecName() + { + return (AuthentikTables.APPLICATION); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikApprovalRequestTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikApprovalRequestTableProducer.java new file mode 100644 index 0000000..9ce0b64 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikApprovalRequestTableProducer.java @@ -0,0 +1,65 @@ +/* + * 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.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; + + +/******************************************************************************* + ** authentik_approval_request: changes waiting for a second person. + *******************************************************************************/ +public class AuthentikApprovalRequestTableProducer extends QBitComponentMetaDataProducer +{ + + /******************************************************************************* + ** Needs the local backend. + *******************************************************************************/ + @Override + public boolean isEnabled() + { + return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QTableMetaData produce(QInstance qInstance) throws QException + { + return (AuthentikLocalTables.approvalRequest(getQBitConfig())); + } + + + + /******************************************************************************* + ** After the backend. + *******************************************************************************/ + @Override + public int getSortOrder() + { + return (300); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikBlueprintTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikBlueprintTableProducer.java new file mode 100644 index 0000000..670100b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikBlueprintTableProducer.java @@ -0,0 +1,38 @@ +/* + * 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.client.AuthentikTables; + + +/******************************************************************************* + ** authentik_blueprint: /managed/blueprints/. + *******************************************************************************/ +public class AuthentikBlueprintTableProducer extends AbstractAuthentikTableProducer +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected String getSpecName() + { + return (AuthentikTables.BLUEPRINT); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikHomeWidgetProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikHomeWidgetProducer.java new file mode 100644 index 0000000..a36b1ed --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikHomeWidgetProducer.java @@ -0,0 +1,71 @@ +/* + * 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.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.app.AuthentikHomeWidget; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer; + + +/******************************************************************************* + ** The Identity home widget: the single dashboard of this qbit (what needs + ** attention, people, groups, recent changes). Other screens only link to it. + *******************************************************************************/ +public class AuthentikHomeWidgetProducer extends QBitComponentMetaDataProducer +{ + public static final String NAME = "home"; + public static final String BACKEND_NAME_KEY = "backendName"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QWidgetMetaData produce(QInstance qInstance) throws QException + { + AuthentikQBitConfig config = getQBitConfig(); + return (new QWidgetMetaData() + .withName(config.applyPrefix(NAME)) + .withLabel("Identity") + .withType(WidgetType.HTML.getType()) + .withGridColumns(12) + .withIsCard(false) + .withCodeReference(new QCodeReference(AuthentikHomeWidget.Renderer.class)) + .withPermissionRules(IdentityPermission.USERS_READ.rules()) + .withDefaultValue(BACKEND_NAME_KEY, config.getApiBackendName())); + } + + + + /******************************************************************************* + ** After the tables, before the app. + *******************************************************************************/ + @Override + public int getSortOrder() + { + return (450); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikInvitationTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikInvitationTableProducer.java new file mode 100644 index 0000000..af66358 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikInvitationTableProducer.java @@ -0,0 +1,48 @@ +/* + * 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.client.AuthentikTables; + + +/******************************************************************************* + ** authentik_invitation: /stages/invitation/invitations/. + *******************************************************************************/ +public class AuthentikInvitationTableProducer extends AbstractAuthentikTableProducer +{ + + /******************************************************************************* + ** Only when invitations are configured (an enrollment flow). + *******************************************************************************/ + @Override + public boolean isEnabled() + { + return (super.isEnabled() && getQBitConfig().getInvitationFlow() != null && !getQBitConfig().getInvitationFlow().isBlank()); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected String getSpecName() + { + return (AuthentikTables.INVITATION); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyBindingTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyBindingTableProducer.java new file mode 100644 index 0000000..07d52fe --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyBindingTableProducer.java @@ -0,0 +1,38 @@ +/* + * 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.client.AuthentikTables; + + +/******************************************************************************* + ** authentik_policy_binding: /policies/bindings/. + *******************************************************************************/ +public class AuthentikPolicyBindingTableProducer extends AbstractAuthentikTableProducer +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected String getSpecName() + { + return (AuthentikTables.POLICY_BINDING); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikRoleTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikRoleTableProducer.java new file mode 100644 index 0000000..52a9864 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikRoleTableProducer.java @@ -0,0 +1,38 @@ +/* + * 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.client.AuthentikTables; + + +/******************************************************************************* + ** authentik_role: /rbac/roles/. + *******************************************************************************/ +public class AuthentikRoleTableProducer extends AbstractAuthentikTableProducer +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected String getSpecName() + { + return (AuthentikTables.ROLE); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenTableProducer.java new file mode 100644 index 0000000..46bbb7e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenTableProducer.java @@ -0,0 +1,38 @@ +/* + * 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.client.AuthentikTables; + + +/******************************************************************************* + ** authentik_token: /core/tokens/ (metadata only, never a key). + *******************************************************************************/ +public class AuthentikTokenTableProducer extends AbstractAuthentikTableProducer +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected String getSpecName() + { + return (AuthentikTables.TOKEN); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java index aec328f..09cfe59 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java @@ -27,6 +27,7 @@ import java.util.TreeSet; import com.kingsrook.qbits.authentik.AuthentikRuntime; import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; import com.kingsrook.qbits.authentik.client.AuthentikRedactor; +import com.kingsrook.qbits.authentik.security.BootstrapOperators; import com.kingsrook.qbits.authentik.security.IdentityPermission; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.logging.QLogger; @@ -49,7 +50,7 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; ** 2. Each group is resolved with its ancestors through the cached group ** graph; the union of qqq.io/permissions becomes session permissions, ** expanded and checked against the instance's permission catalog. - ** 3. Static fallbacks (withGroupPermissions) and bootstrapOperators are added. + ** 3. Static fallbacks (withGroupPermissions) and bootstrapOperatorSubjects are added. ** 4. If Authentik cannot be read, only the static fallbacks (and bootstrap ** operators) apply: it never grants everything. ** @@ -101,9 +102,11 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust return; } List groups = groupNames(claims); - String email = claims.optString("email", "").trim().toLowerCase(); + String email = claims.optString("email", "").trim(); + String sub = claims.optString("sub", "").trim(); + boolean verified = claims.optBoolean("email_verified", false); - Set permissions = new TreeSet<>(resolve(qInstance, groups, email)); + Set permissions = new TreeSet<>(resolve(qInstance, groups, sub, email, verified)); if(qSession.getPermissions() != null) { permissions.addAll(qSession.getPermissions()); @@ -114,9 +117,10 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust /******************************************************************************* - ** The permissions for a user's group names and email. + ** The permissions for a user's group names and token identity (sub, email, + ** email_verified). Bootstrap operators are matched on immutable ids only. *******************************************************************************/ - public Set resolve(QInstance qInstance, Collection groupNames, String email) + public Set resolve(QInstance qInstance, Collection groupNames, String sub, String email, boolean emailVerified) { Set rs = new TreeSet<>(); for(String group : groupNames) @@ -125,9 +129,9 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust } AuthentikRuntime runtime = runtime(); - if(runtime != null && email != null && runtime.getConfig().getResolvedBootstrapOperators().contains(email)) + if(runtime != null && !runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, emailVerified)) { - LOG.warn("bootstrapOperators grants all identity permissions; remove it once an identity administrator is set up in Authentik", logPair("email", email)); + LOG.warn("bootstrapOperatorSubjects grants all identity permissions; empty it once an identity administrator is set up in Authentik", logPair("sub", sub)); for(IdentityPermission permission : IdentityPermission.values()) { rs.add(permission.getSessionPermission()); @@ -164,6 +168,23 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust + /******************************************************************************* + ** + *******************************************************************************/ + private boolean isBootstrap(QInstance qInstance, AuthentikRuntime runtime, String sub, String email, boolean emailVerified) + { + try + { + return (BootstrapOperators.matches(runtime.getConfig().getResolvedBootstrapSubjects(), client(qInstance), sub, email, emailVerified)); + } + catch(Exception e) + { + return (false); + } + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java new file mode 100644 index 0000000..bd4892f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java @@ -0,0 +1,865 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.processes; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.admin.AdminAction; +import com.kingsrook.qbits.authentik.admin.AdminPlan; +import com.kingsrook.qbits.authentik.admin.AdminRequest; +import com.kingsrook.qbits.authentik.admin.AuthentikAdmin; +import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables; +import com.kingsrook.qbits.authentik.admin.AuthentikSafety; +import com.kingsrook.qbits.authentik.client.AuthentikJson; +import com.kingsrook.qbits.authentik.client.AuthentikTables; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +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.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** Every change the qbit makes is a table or record action (DESIGN.md section + ** 6.3); none is ever in the nav. Each is a standard streamed-ETL process with + ** an optional details screen first, a Review step (the dry run: what each + ** target would become, and which are refused and why) and a Result step + ** (summary lines per outcome). The rules live in AuthentikAdmin; these steps + ** only ask it to plan (Review) and to execute (Result). + *******************************************************************************/ +public final class AuthentikProcesses +{ + /** What the process is launched on. */ + public enum Kind + { + /** Selected users (authentik_user). */ + USERS, + /** Selected groups, each with a list of usernames (members actions). */ + GROUP_USERNAMES, + /** Selected groups (authentik_group). */ + GROUPS, + /** No records: a form creates something (table action). */ + CREATE, + /** Selected invitations. */ + INVITATIONS, + /** Selected approval requests. */ + APPROVALS + } + + /******************************************************************************* + ** One process definition. + ** + ** @param key short key; the process is named applyPrefix("<key>") + ** @param label plain label shown in the Actions menu + ** @param action what it does (null for approvals) + ** @param kind what it is launched on + ** @param table unprefixed table name it is bound to + ** @param icon material icon + ** @param prefill whether a prepare step fills the form from the record + ** @param fields details screen fields + ** @param help help text on the details screen + *******************************************************************************/ + public record Def(String key, String label, AdminAction action, Kind kind, String table, String icon, boolean prefill, List fields, String help) + { + } + + private static final Map DEFS_BY_PROCESS = new HashMap<>(); + private static final Map CONFIGS_BY_PROCESS = new HashMap<>(); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AuthentikProcesses() + { + } + + + + /******************************************************************************* + ** The definitions enabled by the config's features. + *******************************************************************************/ + public static List definitions(AuthentikQBitConfig config) + { + List defs = new ArrayList<>(); + QFieldMetaData reason = new QFieldMetaData("reason", QFieldType.TEXT).withLabel("Reason").withIsRequired(true); + QFieldMetaData typed = new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Type the username to confirm").withIsRequired(true); + QFieldMetaData group = new QFieldMetaData("group", QFieldType.STRING).withLabel("Group").withIsRequired(true) + .withPossibleValueSourceName(config.applyTablePrefix(AuthentikTables.GROUP)); + + String users = AuthentikTables.USER; + String groups = AuthentikTables.GROUP; + + 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), + new QFieldMetaData("email", QFieldType.STRING).withLabel("Email").withIsRequired(true), + new QFieldMetaData("groups", QFieldType.TEXT).withLabel("Groups (names, one per line)"), + new QFieldMetaData("sendRecovery", QFieldType.BOOLEAN).withLabel("Email them a link to set their own password").withDefaultValue(true), + reason), "No password is set here. Authentik emails the person a link to set their own.")); + defs.add(new Def("inviteUser", "Invite User", AdminAction.INVITE_USER, Kind.CREATE, users, "mail", false, List.of( + new QFieldMetaData("email", QFieldType.STRING).withLabel("Email").withIsRequired(true), + new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"), + reason), "Authentik emails a single-use sign-up invitation that expires in 7 days.")); + defs.add(new Def("editUser", "Edit User", AdminAction.EDIT_USER, Kind.USERS, users, "edit", true, List.of( + new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"), + new QFieldMetaData("email", QFieldType.STRING).withLabel("Email"), + new QFieldMetaData("path", QFieldType.STRING).withLabel("Path"), + hidden("expectedFingerprint"), reason), "Change a person's name, email or path.")); + defs.add(new Def("deactivateUser", "Deactivate User", AdminAction.DEACTIVATE_USER, Kind.USERS, users, "block", false, List.of( + new QFieldMetaData("revokeAppPasswords", QFieldType.BOOLEAN).withLabel("Also revoke their app passwords").withDefaultValue(false), + typed, reason), "Deactivating ends their sessions immediately and deletes nothing. Type the username to confirm (when you select several people, type the username of the first one).")); + defs.add(new Def("reactivateUser", "Reactivate User", AdminAction.REACTIVATE_USER, Kind.USERS, users, "check_circle", false, List.of(reason), null)); + defs.add(new Def("addToGroup", "Add To Group", AdminAction.ADD_MEMBER, Kind.USERS, users, "group_add", false, List.of(group, + new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), + "You can only add people to a group whose permissions you hold yourself.")); + defs.add(new Def("removeFromGroup", "Remove From Group", AdminAction.REMOVE_MEMBER, Kind.USERS, users, "group_remove", false, List.of(group, + new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null)); + defs.add(new Def("sendPasswordReset", "Send Password Reset Email", AdminAction.SEND_RECOVERY_EMAIL, Kind.USERS, users, "lock_reset", false, List.of(reason), + "Authentik emails the person a link. You never see the link.")); + defs.add(new Def("resetMfa", "Reset MFA", AdminAction.RESET_MFA, Kind.USERS, users, "phonelink_erase", false, List.of(typed, reason), + "Removes every MFA device; the person sets MFA up again at next sign-in.")); + defs.add(new Def("revokeAppPasswords", "Revoke App Passwords", AdminAction.REVOKE_APP_PASSWORDS, Kind.USERS, users, "key_off", false, List.of(typed, reason), + "Revokes app passwords only; API tokens are never touched.")); + defs.add(new Def("revokeSessions", "Revoke Sessions", AdminAction.REVOKE_SESSIONS, Kind.USERS, users, "logout", false, List.of(reason), "Signs the person out everywhere.")); + + defs.add(new Def("createGroup", "Create Group", AdminAction.CREATE_GROUP, Kind.CREATE, groups, "group_add", false, List.of( + new QFieldMetaData("name", QFieldType.STRING).withLabel("Group Name").withIsRequired(true), + new QFieldMetaData("parents", QFieldType.TEXT).withLabel("Parent groups (names, one per line)"), + new QFieldMetaData("permissions", QFieldType.TEXT).withLabel("QQQ permissions (one per line)"), + new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"), + reason), "A group's QQQ permissions apply to everyone in it, and to groups below it.")); + defs.add(new Def("editGroup", "Edit Group", AdminAction.EDIT_GROUP, Kind.GROUPS, groups, "edit", true, List.of( + new QFieldMetaData("name", QFieldType.STRING).withLabel("Group Name"), + new QFieldMetaData("parents", QFieldType.TEXT).withLabel("Parent groups (names, one per line; none to clear)"), + hidden("expectedFingerprint"), reason), "Rename a group or change its parents.")); + defs.add(new Def("editPermissions", "Edit Permissions And App Access", AdminAction.EDIT_PERMISSIONS, Kind.GROUPS, groups, "admin_panel_settings", true, List.of( + new QFieldMetaData("permissions", QFieldType.TEXT).withLabel("QQQ permissions (one per line)"), + new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"), + hidden("expectedFingerprint"), reason), + "You can only add permissions you hold yourself. Adding authentik.* needs a second person to approve.")); + defs.add(new Def("addMembers", "Add Members", AdminAction.ADD_MEMBER, Kind.GROUP_USERNAMES, groups, "person_add", false, List.of( + new QFieldMetaData("usernames", QFieldType.TEXT).withLabel("Usernames (one per line)").withIsRequired(true), + new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null)); + defs.add(new Def("removeMembers", "Remove Members", AdminAction.REMOVE_MEMBER, Kind.GROUP_USERNAMES, groups, "person_remove", false, List.of( + new QFieldMetaData("usernames", QFieldType.TEXT).withLabel("Usernames (one per line)").withIsRequired(true), + new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null)); + + defs.add(new Def("cancelInvitation", "Cancel Invitation", AdminAction.CANCEL_INVITATION, Kind.INVITATIONS, "invitation", "cancel", false, List.of(reason), null)); + defs.add(new Def("approve", "Approve And Apply", null, Kind.APPROVALS, AuthentikLocalTables.APPROVAL_REQUEST, "check", false, List.of(), "A second person applies the change. The change is checked again against live data.")); + defs.add(new Def("reject", "Reject", null, Kind.APPROVALS, AuthentikLocalTables.APPROVAL_REQUEST, "close", false, List.of(), null)); + + defs.removeIf(def -> def.action() != null && !config.hasFeature(def.action().getFeature())); + if(!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS) || !config.getTwoPersonApproval()) + { + defs.removeIf(def -> def.kind() == Kind.APPROVALS); + } + if(StringUtils.hasContent(config.getInvitationFlow())) + { + return (defs); + } + defs.removeIf(def -> def.action() == AdminAction.INVITE_USER || def.action() == AdminAction.CANCEL_INVITATION); + return (defs); + } + + + + /******************************************************************************* + ** A hidden form field (carries a stale-check fingerprint). + *******************************************************************************/ + private static QFieldMetaData hidden(String name) + { + return (new QFieldMetaData(name, QFieldType.STRING).withIsHidden(true)); + } + + + + /******************************************************************************* + ** The process names for a config's definitions (for the app and tests). + *******************************************************************************/ + public static String processName(AuthentikQBitConfig config, Def def) + { + return (config.applyPrefix(def.key())); + } + + + + /******************************************************************************* + ** All processes for a config. + *******************************************************************************/ + public static List processes(AuthentikQBitConfig config) + { + List processes = new ArrayList<>(); + for(Def def : definitions(config)) + { + processes.add(build(config, def)); + } + return (processes); + } + + + + /******************************************************************************* + ** Build one process. + *******************************************************************************/ + static QProcessMetaData build(AuthentikQBitConfig config, Def def) + { + String name = processName(config, def); + String table = config.applyTablePrefix(def.table()); + DEFS_BY_PROCESS.put(name, def); + CONFIGS_BY_PROCESS.put(name, config); + + Map values = new HashMap<>(); + values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table); + values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table); + values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "This is a preview of what will change. Nothing has been sent to Authentik yet."); + + Class extract = def.kind() == Kind.CREATE ? SingleRecordExtractStep.class : ExtractViaQueryStep.class; + QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(extract, ActionTransformStep.class, ActionLoadStep.class, values); + process.setName(name); + process.setLabel(def.label()); + process.setTableName(table); + process.setIcon(new QIcon(def.icon())); + process.setPermissionRules((def.action() == null ? IdentityPermission.APPROVE : def.action().getPermission()).rules()); + switch(def.kind()) + { + case CREATE -> process.setMaxInputRecords(0); + case USERS, GROUP_USERNAMES, GROUPS, INVITATIONS, APPROVALS -> + { + process.setMinInputRecords(1); + process.setMaxInputRecords(def.action() == AdminAction.EDIT_USER || def.action() == AdminAction.EDIT_GROUP || def.action() == AdminAction.EDIT_PERMISSIONS + ? 1 : AuthentikAdmin.MAX_BULK); + } + default -> + { + } + } + + if(!def.fields().isEmpty()) + { + QFrontendStepMetaData details = new QFrontendStepMetaData().withName("details").withLabel(def.label()).withFormFields(def.fields()); + if(StringUtils.hasContent(def.help())) + { + details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.HELP_TEXT).withValue("text", def.help())); + } + details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)); + process.withStep(0, details); + if(def.prefill()) + { + process.withStep(0, new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(PrepareStep.class)) + .withInputData(new QFunctionInputMetaData().withRecordListMetaData(new QRecordListMetaData().withTableName(table)))); + } + } + return (process); + } + + + + /******************************************************************************* + ** The definition behind a process name. + *******************************************************************************/ + static Def defFor(String processName) + { + Def def = DEFS_BY_PROCESS.get(processName); + if(def == null) + { + throw (new IllegalStateException("Not an Authentik qbit process: " + processName)); + } + return (def); + } + + + + /******************************************************************************* + ** The summary-line wording of an action: future (Review) and past (Result). + *******************************************************************************/ + static String[] wording(AdminAction action) + { + if(action == null) + { + return (new String[] { "approval request will be decided", "approval requests will be decided", "approval request was decided", "approval requests were decided" }); + } + return switch(action) + { + case CREATE_USER -> w("user will be created", "users will be created", "user was created", "users were created"); + case INVITE_USER -> w("invitation will be sent", "invitations will be sent", "invitation was sent", "invitations were sent"); + case EDIT_USER -> w("user will be updated", "users will be updated", "user was updated", "users were updated"); + case DEACTIVATE_USER -> w("user will be deactivated", "users will be deactivated", "user was deactivated", "users were deactivated"); + case REACTIVATE_USER -> w("user will be reactivated", "users will be reactivated", "user was reactivated", "users were reactivated"); + case CANCEL_INVITATION -> w("invitation will be cancelled", "invitations will be cancelled", "invitation was cancelled", "invitations were cancelled"); + case ADD_MEMBER -> w("membership will be added", "memberships will be added", "membership was added", "memberships were added"); + case REMOVE_MEMBER -> w("membership will be removed", "memberships will be removed", "membership was removed", "memberships were removed"); + case CREATE_GROUP -> w("group will be created", "groups will be created", "group was created", "groups were created"); + case EDIT_GROUP -> w("group will be updated", "groups will be updated", "group was updated", "groups were updated"); + case EDIT_PERMISSIONS -> w("group's permissions will be updated", "groups' permissions will be updated", "group's permissions were updated", "groups' permissions were updated"); + case 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 REVOKE_SESSIONS -> w("user's sessions will be revoked", "users' sessions will be revoked", "user's sessions were revoked", "users' sessions were revoked"); + }; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String[] w(String a, String b, String c, String d) + { + return (new String[] { a, b, c, d }); + } + + + + /******************************************************************************* + ** A summary line with future (Review) and past (Result) wording. + *******************************************************************************/ + static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast) + { + ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null); + line.setSingularFutureMessage(singularFuture); + line.setPluralFutureMessage(pluralFuture); + line.setSingularPastMessage(singularPast); + line.setPluralPastMessage(pluralPast); + return (line); + } + + + + /******************************************************************************* + ** Build the requests a record stands for, from the process's form values. + *******************************************************************************/ + static List requests(Def def, RunBackendStepInput input, QRecord record, AuthentikAdmin admin) throws QException + { + String reason = input.getValueString("reason"); + List rs = new ArrayList<>(); + switch(def.kind()) + { + case CREATE -> rs.add(withParams(AdminRequest.of(def.action(), "", reason), def, input)); + case USERS -> + { + String userPk = String.valueOf(record.getValue("pk")); + if(def.action() == AdminAction.ADD_MEMBER || def.action() == AdminAction.REMOVE_MEMBER) + { + rs.add(withParams(AdminRequest.of(def.action(), input.getValueString("group") + ":" + userPk, reason), def, input)); + } + else + { + rs.add(withParams(AdminRequest.of(def.action(), userPk, reason), def, input)); + } + } + case GROUP_USERNAMES -> + { + String groupPk = String.valueOf(record.getValue("pk")); + for(String username : AuthentikSafety.parseList(input.getValueString("usernames"))) + { + Integer userPk = admin.userPkByUsername(username); + AdminRequest request = AdminRequest.of(def.action(), groupPk + ":" + (userPk == null ? "0" : userPk), reason); + withParams(request, def, input); + request.with("username", username); + rs.add(request); + } + } + case GROUPS, INVITATIONS -> rs.add(withParams(AdminRequest.of(def.action(), String.valueOf(record.getValue("pk") != null ? record.getValue("pk") : record.getValue("uuid")), reason), def, input)); + default -> + { + } + } + return (rs); + } + + + + /******************************************************************************* + ** Copy the details-screen values onto a request. + *******************************************************************************/ + private static AdminRequest withParams(AdminRequest request, Def def, RunBackendStepInput input) + { + for(QFieldMetaData field : def.fields()) + { + if(field.getName().equals("reason")) + { + continue; + } + Serializable value = input.getValue(field.getName()); + if(value != null) + { + request.with(field.getName(), value); + } + } + return (request); + } + + + + /******************************************************************************* + ** How many records the run covers: counted from the same filter the extract + ** step uses (the preview step's count is not carried into later steps). + *******************************************************************************/ + static Integer countTargets(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + if(defFor(input.getProcessName()).kind() == Kind.CREATE) + { + return (1); + } + ExtractViaQueryStep extract = new ExtractViaQueryStep(); + extract.preRun(input, output); + return (extract.doCount(input)); + } + + + + /******************************************************************************* + ** The admin for the current backend. + *******************************************************************************/ + static AuthentikAdmin admin(RunBackendStepInput input) throws QException + { + AuthentikQBitConfig config = CONFIGS_BY_PROCESS.get(input.getProcessName()); + if(config == null) + { + throw (new QException("The Authentik qbit is not set up in this instance.")); + } + return (AuthentikAdmin.forBackend(config.getApiBackendName())); + } + + + + /******************************************************************************* + ** The details screen is filled from the selected record (edit actions). + *******************************************************************************/ + public static class PrepareStep implements BackendStep + { + /******************************************************************************* + ** Fill the form fields and the stale-check fingerprint. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + Def def = defFor(input.getProcessName()); + if(input.getRecords() == null || input.getRecords().isEmpty()) + { + return; + } + QRecord record = input.getRecords().get(0); + AuthentikAdmin admin = admin(input); + String pk = String.valueOf(record.getValue("pk")); + switch(def.action()) + { + case EDIT_USER -> + { + JSONObject user = admin.getUtil().getObject(AuthentikTables.get(AuthentikTables.USER), pk); + if(user != null) + { + output.addValue("name", user.optString("name")); + output.addValue("email", user.optString("email")); + output.addValue("path", user.optString("path")); + output.addValue("expectedFingerprint", AuthentikSafety.userFingerprint(user)); + } + } + case EDIT_GROUP, EDIT_PERMISSIONS -> + { + JSONObject group = admin.getUtil().getApiJsonOrNull("/core/groups/" + pk + "/?include_users=true&include_parents=true&include_children=true"); + if(group != null) + { + output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group)); + if(def.action() == AdminAction.EDIT_GROUP) + { + output.addValue("name", group.optString("name")); + JSONArray parents = group.optJSONArray("parents_obj"); + List names = new ArrayList<>(); + for(int i = 0; parents != null && i < parents.length(); i++) + { + names.add(parents.getJSONObject(i).optString("name")); + } + output.addValue("parents", String.join("\n", names)); + } + else + { + output.addValue("permissions", String.join("\n", AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS) == null + ? List.of() : AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS))); + output.addValue("appAccess", String.join("\n", AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS) == null + ? List.of() : AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS))); + } + } + } + default -> + { + } + } + } + } + + + + /******************************************************************************* + ** The extract step of actions that create something: one placeholder record + ** (the form values carry the data). + *******************************************************************************/ + public static class SingleRecordExtractStep extends AbstractExtractStep + { + /******************************************************************************* + ** One record, so the transform and load run once. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + getRecordPipe().addRecord(new QRecord().withValue("pk", "new")); + } + + + + /******************************************************************************* + ** One record. + *******************************************************************************/ + @Override + public Integer doCount(RunBackendStepInput input) + { + return (1); + } + } + + + + /******************************************************************************* + ** Review (and the full validation): plan every target against live data. + *******************************************************************************/ + public static class ActionTransformStep extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private final Map lines = new LinkedHashMap<>(); + private final List details = new ArrayList<>(); + private final Map> targetsByLine = new LinkedHashMap<>(); + private final List notes = new ArrayList<>(); + private AuthentikAdmin admin; + private Integer totalTargets; + + + + /******************************************************************************* + ** Remember how many records the run covers (for the more-than-25 rule). + *******************************************************************************/ + @Override + public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + totalTargets = countTargets(input, output); + } + + + + /******************************************************************************* + ** Plan each record's requests. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + Def def = defFor(input.getProcessName()); + if(admin == null) + { + admin = admin(input); + } + + for(QRecord record : input.getRecords()) + { + List requests = def.kind() == Kind.APPROVALS ? List.of() : requests(def, input, record, admin); + if(def.kind() == Kind.APPROVALS) + { + add(Status.OK, "approval request will be decided", "approval requests will be decided", "approval request was decided", "approval requests were decided", "approve"); + output.getRecords().add(record); + continue; + } + int total = requests.size(); + for(AdminRequest request : requests) + { + AdminPlan plan = admin.plan(request); + String problem = bulkProblem(def, request, total, input); + if(problem != null && plan.isRunnable()) + { + plan.refuse(problem); + } + report(def, plan); + output.getRecords().add(record); + } + } + } + + + + /******************************************************************************* + ** More than 25 targets in one run need the count typed. + *******************************************************************************/ + private String bulkProblem(Def def, AdminRequest request, int total, RunBackendStepInput input) + { + Integer count = totalTargets; + int n = Math.max(total, count == null ? 0 : count); + if(n > AuthentikAdmin.TYPED_ABOVE && (def.action() == AdminAction.ADD_MEMBER || def.action() == AdminAction.REMOVE_MEMBER) + && !String.valueOf(n).equals(request.str("confirmText"))) + { + return ("More than " + AuthentikAdmin.TYPED_ABOVE + " people: type " + n + " in the confirmation field."); + } + return (null); + } + + + + /******************************************************************************* + ** Add a plan to the summary. + *******************************************************************************/ + private void report(Def def, AdminPlan plan) + { + String[] w = wording(def.action()); + switch(plan.getState()) + { + case READY -> + { + add(Status.OK, w[0], w[1], w[2], w[3], "ready"); + name("ready", plan); + plan.getNotes().forEach(note -> + { + if(!notes.contains(note)) + { + notes.add(note); + } + }); + } + case NEEDS_APPROVAL -> + { + add(Status.WARNING, "change needs a second person's approval and will be requested", "changes need a second person's approval and will be requested", + "change was sent for approval", "changes were sent for approval", "approval"); + name("approval", plan); + } + case NO_CHANGE -> add(Status.INFO, "target needs no change: " + plan.getMessage(), "targets need no change: " + plan.getMessage(), + "target needed no change: " + plan.getMessage(), "targets needed no change: " + plan.getMessage(), "nochange:" + plan.getMessage()); + default -> { + add(Status.ERROR, "target is refused: " + plan.getMessage(), "targets are refused: " + plan.getMessage(), + "target was refused: " + plan.getMessage(), "targets were refused: " + plan.getMessage(), "refused:" + plan.getMessage()); + name("refused:" + plan.getMessage(), plan); + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void add(Status status, String sf, String pf, String sp, String pp, String key) + { + lines.computeIfAbsent(key, k -> line(status, sf, pf, sp, pp)).incrementCount(); + } + + + + /******************************************************************************* + ** Remember which target a line is about (shown as bullets, first 10). + *******************************************************************************/ + private void name(String key, AdminPlan plan) + { + List names = targetsByLine.computeIfAbsent(key, k -> new ArrayList<>()); + if(names.size() < 10) + { + names.add(plan.getBefore() != null || plan.getAfter() != null ? plan.describe() : (plan.getTargetLabel() == null ? plan.getRequest().targetKey() : plan.getTargetLabel())); + } + } + + + + /******************************************************************************* + ** Review lines: one per outcome, then the before and after of the first + ** targets and any notes. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList rs = new ArrayList<>(); + for(Map.Entry entry : lines.entrySet()) + { + List names = targetsByLine.get(entry.getKey()); + if(names != null && !names.isEmpty() && !isForResultScreen) + { + entry.getValue().setBulletsOfText(new ArrayList<>(names)); + } + entry.getValue().addSelfToListIfAnyCount(rs); + } + for(String note : notes) + { + ProcessSummaryLine noteLine = new ProcessSummaryLine(Status.INFO, note); + noteLine.setCount(null); + rs.add(noteLine); + } + return (rs); + } + } + + + + /******************************************************************************* + ** Result: execute each target (the rules are checked again against live + ** data inside), with a line per outcome. + *******************************************************************************/ + public static class ActionLoadStep extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final Map lines = new LinkedHashMap<>(); + private AuthentikAdmin admin; + private Integer totalTargets; + + + + /******************************************************************************* + ** Remember how many records the run covers (for the more-than-25 rule). + *******************************************************************************/ + @Override + public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + totalTargets = countTargets(input, output); + } + + + + /******************************************************************************* + ** Execute each record's requests. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + Def def = defFor(input.getProcessName()); + if(admin == null) + { + admin = admin(input); + } + String[] w = wording(def.action()); + + for(QRecord record : input.getRecords()) + { + if(def.kind() == Kind.APPROVALS) + { + AuthentikAdmin.AdminResult result = "approve".equals(def.key()) ? admin.approve(record.getValue("id")) : admin.reject(record.getValue("id")); + tally(result, w); + output.getRecords().add(record); + continue; + } + + List requests = requests(def, input, record, admin); + int total = requests.size(); + for(AdminRequest request : requests) + { + Integer count = totalTargets; + int n = Math.max(total, count == null ? 0 : count); + AuthentikAdmin.AdminResult result; + if(n > AuthentikAdmin.TYPED_ABOVE && (def.action() == AdminAction.ADD_MEMBER || def.action() == AdminAction.REMOVE_MEMBER) + && !String.valueOf(n).equals(request.str("confirmText"))) + { + result = new AuthentikAdmin.AdminResult(false, false, false, "More than " + AuthentikAdmin.TYPED_ABOVE + " people: type " + n + " in the confirmation field.", null); + } + else + { + result = admin.execute(request); + } + tally(result, w); + output.getRecords().add(record); + pace(admin); + } + } + } + + + + /******************************************************************************* + ** Pace bulk work (Authentik shares its database with the app). + *******************************************************************************/ + private void pace(AuthentikAdmin admin) + { + Integer millis = admin.getUtil().getRuntime().getConfig().getBulkPacingMillis(); + if(millis != null && millis > 0) + { + com.kingsrook.qqq.backend.core.utils.SleepUtils.sleep(millis, java.util.concurrent.TimeUnit.MILLISECONDS); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void tally(AuthentikAdmin.AdminResult result, String[] w) + { + if(result.ok() && result.pending()) + { + lines.computeIfAbsent("pending", k -> line(Status.WARNING, "change needs approval", "changes need approval", "change was sent for approval", "changes were sent for approval")).incrementCount(); + } + else if(result.ok() && result.noChange()) + { + lines.computeIfAbsent("nochange", k -> line(Status.INFO, "target needed no change", "targets needed no change", "target needed no change", "targets needed no change")).incrementCount(); + } + else if(result.ok()) + { + lines.computeIfAbsent("ok", k -> line(Status.OK, w[0], w[1], w[2], w[3])).incrementCount(); + } + else + { + lines.computeIfAbsent("error:" + result.message(), k -> line(Status.ERROR, "target was not changed: " + result.message(), "targets were not changed: " + result.message(), + "target was not changed: " + result.message(), "targets were not changed: " + result.message())).incrementCount(); + } + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList rs = new ArrayList<>(); + lines.values().forEach(line -> line.addSelfToListIfAnyCount(rs)); + return (rs); + } + } + +} 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 197099f..aa712b3 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java @@ -20,6 +20,7 @@ package com.kingsrook.qbits.authentik.security; import java.util.List; import java.util.Locale; import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.admin.AdminAction; import com.kingsrook.qbits.authentik.AuthentikQBitConfig; import com.kingsrook.qbits.authentik.client.AuthentikJson; import com.kingsrook.qbits.authentik.client.AuthentikTableSpec; @@ -107,6 +108,49 @@ public class AuthentikGuard + /******************************************************************************* + ** Authorize one change to Authentik for the current session: a signed-in + ** user (never a system session), the action's feature on, not read-only, + ** and the action's authentik.* permission. The returned authorization is + ** what AuthentikActionUtil.write requires (DESIGN.md section 5.3). + *******************************************************************************/ + public WriteAuthorization authorize(AdminAction action) throws QPermissionDeniedException + { + QSession session = QContext.getQSession(); + if(session instanceof QSystemUserSession) + { + throw (new QPermissionDeniedException("System sessions may not change Authentik data.")); + } + requireUser(session); + if(Boolean.TRUE.equals(config.getReadOnly())) + { + throw (new QPermissionDeniedException("The Identity screens are in read-only mode.")); + } + if(!config.hasFeature(action.getFeature())) + { + throw (new QPermissionDeniedException(action.getLabel() + " is not enabled (feature " + action.getFeature() + ").")); + } + if(!has(session, action.getPermission())) + { + throw (new QPermissionDeniedException("You do not have permission to do this (" + action.getPermission().getShortName() + ").")); + } + return (new WriteAuthorization(action.name(), operatorEmail())); + } + + + + /******************************************************************************* + ** The current operator: the session user's id reference (an email in apps + ** that sign in through Authentik), or null. + *******************************************************************************/ + public String operatorEmail() + { + QSession session = QContext.getQSession(); + return (session == null || session.getUser() == null ? null : session.getUser().getIdReference()); + } + + + /******************************************************************************* ** Whether the current session may see and filter sensitive fields. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/BootstrapOperators.java b/src/main/java/com/kingsrook/qbits/authentik/security/BootstrapOperators.java new file mode 100644 index 0000000..c4dc89f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/security/BootstrapOperators.java @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.security; + + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import org.json.JSONObject; + + +/******************************************************************************* + ** Decides whether a person is a bootstrap operator (the lockout escape hatch). + ** Users can change their own email in Authentik and emails are not unique, so + ** an email is NEVER enough. Someone is a bootstrap operator only when: + ** - the token sub is a configured subject (a hashed or opaque id; a sub that + ** looks like an email is ignored, since Authentik's user_email sub mode makes + ** it mutable); or + ** - the email is verified, exactly ONE Authentik user has it, that user is + ** active, and that user's pk or uuid is a configured subject. + ** Anything else is denied. + *******************************************************************************/ +public final class BootstrapOperators +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private BootstrapOperators() + { + } + + + + /******************************************************************************* + ** Whether a token (sub, email, email_verified) is a bootstrap operator. + ** + ** @param subjects the configured immutable subjects + ** @param client an Authentik client for the lookup + ** @param sub the sub claim (may be null) + ** @param email the email claim (may be null) + ** @param emailVerified the email_verified claim + *******************************************************************************/ + public static boolean matches(Set subjects, AuthentikActionUtil client, String sub, String email, boolean emailVerified) + { + if(subjects == null || subjects.isEmpty()) + { + return (false); + } + if(sub != null && !sub.isBlank() && !sub.contains("@") && subjects.contains(sub.trim())) + { + return (true); + } + if(!emailVerified || email == null || email.isBlank() || client == null) + { + return (false); + } + return (matchesUniqueUser(subjects, client, email.trim())); + } + + + + /******************************************************************************* + ** The email-based path without the verified claim (an admin session has no + ** claims): still requires exactly one active user with that email whose pk + ** or uuid is a configured subject. + *******************************************************************************/ + public static boolean matchesUniqueUser(Set subjects, AuthentikActionUtil client, String email) + { + if(subjects == null || subjects.isEmpty()) + { + return (false); + } + try + { + JSONObject found = null; + int count = 0; + for(JSONObject user : client.getAllPages("/core/users/?email=" + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&include_groups=false&include_roles=false", 10)) + { + if(email.equalsIgnoreCase(user.optString("email"))) + { + found = user; + count++; + } + } + return (count == 1 && found.optBoolean("is_active", true) + && (subjects.contains(String.valueOf(found.optInt("pk"))) || subjects.contains(found.optString("uuid", "-")))); + } + catch(Exception e) + { + return (false); + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/WriteAuthorization.java b/src/main/java/com/kingsrook/qbits/authentik/security/WriteAuthorization.java new file mode 100644 index 0000000..c44955f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/security/WriteAuthorization.java @@ -0,0 +1,62 @@ +/* + * 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; + + +/******************************************************************************* + ** Proof that {@link AuthentikGuard} authorized one write. AuthentikActionUtil + ** refuses to send any request that changes Authentik data without one, and the + ** constructor is package-private, so only the guard can mint it (DESIGN.md + ** section 5.3: every write path goes through the guard). + *******************************************************************************/ +public final class WriteAuthorization +{ + private final String action; + private final String operator; + + + + /******************************************************************************* + ** Constructor (package-private: only AuthentikGuard authorizes). + *******************************************************************************/ + WriteAuthorization(String action, String operator) + { + this.action = action; + this.operator = operator; + } + + + + /******************************************************************************* + ** Getter for action + *******************************************************************************/ + public String getAction() + { + return (action); + } + + + + /******************************************************************************* + ** Getter for operator + *******************************************************************************/ + public String getOperator() + { + return (operator); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java index 7d9ddce..7d128aa 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java @@ -89,11 +89,11 @@ class AuthentikQBitConfigTest @Test void unavailableFeaturesAndDependencies() { - assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MEMBERSHIP)))) - .anyMatch(e -> e.contains("MEMBERSHIP is not available")); - assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.MIRROR)))).anyMatch(e -> e.contains("READ is required")); - assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MIRROR)))).anyMatch(e -> e.contains("mirrorBackendName")); - assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MIRROR)).withMirrorBackendName("nope"))) + assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.RBAC)))) + .anyMatch(e -> e.contains("RBAC is not available")); + assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.RBAC)))).anyMatch(e -> e.contains("READ is required")); + assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MEMBERSHIP)))).anyMatch(e -> e.contains("localBackendName")); + assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MEMBERSHIP)).withLocalBackendName("nope"))) .anyMatch(e -> e.contains("backend not found")); assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS)))).anyMatch(e -> e.contains("recoveryEmailStage")); assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.WEBHOOK)).withWebhookSecret(null))).anyMatch(e -> e.contains("webhookSecret")); @@ -151,7 +151,19 @@ class AuthentikQBitConfigTest assertThat(new AuthentikQBitConfig().withTableNamePrefix("").applyTablePrefix("user")).isEqualTo("user"); assertThat(new AuthentikQBitConfig().withTableNamePrefix("").applyTablePrefix("groupMember")).isEqualTo("group_member"); assertThat(new AuthentikQBitConfig().withTableNamePrefix("myAuth").applyTablePrefix("groupMember")).isEqualTo("my_auth_group_member"); - assertThat(config.withBootstrapOperators(" A@x.test, ,b@x.test ").getResolvedBootstrapOperators()).containsExactly("a@x.test", "b@x.test"); + assertThat(config.withBootstrapOperatorSubjects(" A1, ,b2 ").getResolvedBootstrapSubjects()).containsExactly("A1", "b2"); + } + + + + /******************************************************************************* + ** Emails are refused as bootstrap subjects: users can change their own. + *******************************************************************************/ + @Test + void bootstrapSubjectsRejectEmails() + { + assertThat(errors(AuthentikTestSupport.unitConfig().withBootstrapOperatorSubjects("owner@example.test"))).anyMatch(e -> e.contains("never emails")); + assertThat(errors(AuthentikTestSupport.unitConfig().withBootstrapOperatorSubjects("42"))).noneMatch(e -> e.contains("never emails")); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java index 49c5b92..d59f97a 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java @@ -65,8 +65,9 @@ class AuthentikQBitProducerTest QAppMetaData app = qInstance.getApp("authentikAdmin"); assertThat(app.getLabel()).isEqualTo("Identity"); - assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("authentik_user", "authentik_group", "authentik_group_member", "authentik_session", "authentik_event"); - assertThat(app.getWidgets()).containsExactly("authentikDriftReport"); + assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("authentik_user", "authentik_group", "authentik_application"); + assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikDriftReport"); + assertThat(qInstance.getTable("authentik_group_member")).isNotNull(); assertThat(qInstance.getTable("authentik_session").getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.audit.read"); assertThat(qInstance.getWidget("authentikDriftReport").getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.admin"); diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/AdminTestSupport.java b/src/test/java/com/kingsrook/qbits/authentik/admin/AdminTestSupport.java new file mode 100644 index 0000000..27a2aed --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/AdminTestSupport.java @@ -0,0 +1,185 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.admin; + + +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.AuthentikQBitProducer; +import com.kingsrook.qbits.authentik.AuthentikTestSupport; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.client.FixtureActionUtil; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +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.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +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; + + +/******************************************************************************* + ** A QInstance with the qbit (all write features, audit on a memory backend), + ** two host tables whose permissions operators can grant, and a FakeAuthentik + ** pre-loaded with a small realistic directory: + ** + **
+ **   identity-admins        authentik.*               app kof22-website-prod
+ **   kof-website-publishers cms.publish.hasAccess     app kof22-website-prod
+ **   kof-website-analysts   kof22.analytics.hasAccess
+ **   authentik Admins       (superuser group)
+ **
+ **   operator (1)  identity-admins, kof-website-publishers   operator@example.test
+ **   second (2)    identity-admins
+ **   sam (3)       -
+ **   breakglass (4) authentik Admins   (superuser, protected)
+ **   qqq-kof22-admin-idadmin (5)  the qbit's service account
+ **   svc-publisher (6) service account
+ **   dormant (7)   inactive
+ ** 
+ *******************************************************************************/ +public final class AdminTestSupport +{ + public static final String LOCAL = "memory"; + + public QInstance qInstance; + public AuthentikQBitConfig config; + public FakeAuthentik authentik; + + + + /******************************************************************************* + ** Build everything and log in as the operator with every authentik.* permission. + *******************************************************************************/ + public static AdminTestSupport create() throws QException + { + return (create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, + AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS))); + } + + + + /******************************************************************************* + ** Build with chosen features. + *******************************************************************************/ + public static AdminTestSupport create(Set features) throws QException + { + AdminTestSupport support = new AdminTestSupport(); + MemoryRecordStore.getInstance().reset(); + + support.authentik = new FakeAuthentik(); + support.authentik + .group("identity-admins", false, List.of("authentik.*"), List.of("kof22-website-prod")) + .group("kof-website-publishers", false, List.of("cms.publish.hasAccess"), List.of("kof22-website-prod")) + .group("kof-website-analysts", false, List.of("kof22.analytics.hasAccess"), List.of()) + .group("authentik Admins", true, List.of(), List.of()) + .user(1, "operator", AuthentikTestSupport.OPERATOR, "internal", true, "identity-admins", "kof-website-publishers") + .user(2, "second", "second@example.test", "internal", true, "identity-admins") + .user(3, "sam", "sam@example.test", "internal", true) + .user(4, "breakglass", "breakglass@example.test", "internal", true, "authentik Admins") + .user(5, "qqq-kof22-admin-idadmin", "", "service_account", true) + .user(6, "svc-publisher", "", "service_account", true) + .user(7, "dormant", "dormant@example.test", "internal", false) + .install(); + + support.config = AuthentikTestSupport.unitConfig() + .withFeatures(features) + .withLocalBackendName(LOCAL) + .withRecoveryEmailStage("kof22-recovery-email") + .withInvitationFlow("kof22-enrollment") + .withBulkPacingMillis(0); + + QInstance instance = new QInstance(); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)); + instance.addBackend(new QBackendMetaData().withName(LOCAL).withBackendType(MemoryBackendModule.class)); + for(String[] table : new String[][] { { "post", "cms.publish" }, { "analytics", "kof22.analytics" } }) + { + instance.addTable(new QTableMetaData().withName(table[0]).withBackendName(LOCAL).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(table[1]))); + } + new AuthentikQBitProducer().withQBitConfig(support.config).produce(instance); + AuthentikTestSupport.useActionUtil(instance, support.config, FixtureActionUtil.class); + support.qInstance = instance; + + support.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.values()); + return (support); + } + + + + /******************************************************************************* + ** Log in as a user with the given authentik.* permissions. + *******************************************************************************/ + public QSession loginAs(String email, IdentityPermission... permissions) + { + Set names = new HashSet<>(); + for(IdentityPermission permission : permissions) + { + names.add(permission.getSessionPermission()); + } + QSession session = new QSession(); + session.setUser(new QUser().withIdReference(email).withFullName(email)); + session.setPermissions(names); + QContext.init(qInstance, session); + return (session); + } + + + + /******************************************************************************* + ** An admin service for the qbit's backend. + *******************************************************************************/ + public AuthentikAdmin admin() throws QException + { + return (new AuthentikAdmin(AuthentikActionUtil.forBackend(config.getApiBackendName()))); + } + + + + /******************************************************************************* + ** The writes sent to Authentik so far, as "METHOD path". + *******************************************************************************/ + public List writes() + { + return (authentik.writes); + } + + + + /******************************************************************************* + ** The writes other than the audit event. + *******************************************************************************/ + public List changes() + { + return (authentik.writes.stream().filter(w -> !w.contains("/events/events/")).toList()); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikAdminTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikAdminTest.java new file mode 100644 index 0000000..1ca24bf --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikAdminTest.java @@ -0,0 +1,886 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.admin; + + +import java.util.EnumSet; +import java.util.List; +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikTestSupport; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** The guard rules of DESIGN.md section 5.4 and the actions of section 6.3, + ** exercised against an in-memory Authentik (FakeAuthentik). Each rule is tested + ** both ways: it refuses what it must, and it allows what it should, and a + ** refusal never reaches Authentik. + *******************************************************************************/ +class AuthentikAdminTest +{ + private AdminTestSupport t; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void setUp() throws QException + { + t = AdminTestSupport.create(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private AuthentikAdmin.AdminResult run(AdminRequest request) throws QException + { + return (t.admin().execute(request)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private AdminRequest add(int userPk, String group) + { + return (AdminRequest.of(AdminAction.ADD_MEMBER, t.authentik.pk(group) + ":" + userPk, "because")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List rows(String table) throws QException + { + QueryInput input = new QueryInput(); + input.setTableName(t.config.applyTablePrefix(table)); + return (new QueryAction().execute(input).getRecords()); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // memberships and the subset (no escalation) rule + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Adding to a group whose permissions the operator holds works, is audited + ** before the call, and posts an event naming the human operator. + *******************************************************************************/ + @Test + void addMemberHeldPermissions() throws QException + { + AuthentikAdmin.AdminResult result = run(add(3, "kof-website-publishers")); + + assertThat(result.ok()).isTrue(); + assertThat(t.authentik.groupNamesOf(3)).containsExactly("kof-website-publishers"); + assertThat(t.changes()).containsExactly("POST /core/groups/" + t.authentik.pk("kof-website-publishers") + "/add_user/"); + assertThat(t.authentik.events).hasSize(1); + assertThat(t.authentik.events.get(0).getString("action")).isEqualTo("custom_qqq_admin_action"); + assertThat(t.authentik.events.get(0).getJSONObject("context").getString("operator")).isEqualTo(AuthentikTestSupport.OPERATOR); + assertThat(t.authentik.events.get(0).getJSONObject("context").getString("reason")).isEqualTo("because"); + + List audit = rows(AuthentikLocalTables.ADMIN_ACTION); + assertThat(audit).hasSize(1); + assertThat(audit.get(0).getValueString("status")).isEqualTo("SUCCEEDED"); + assertThat(audit.get(0).getValueString("actionName")).isEqualTo("ADD_MEMBER"); + assertThat(audit.get(0).getValueString("operator")).isEqualTo(AuthentikTestSupport.OPERATOR); + assertThat(audit.get(0).getValueString("targetLabel")).contains("sam"); + assertThat(audit.get(0).getValueString("authentikEventPk")).isNotBlank(); + } + + + + /******************************************************************************* + ** The subset rule: an operator cannot hand out permissions they do not hold. + *******************************************************************************/ + @Test + void addMemberRefusedWhenGroupGrantsWhatOperatorLacks() throws QException + { + AuthentikAdmin.AdminResult result = run(add(3, "kof-website-analysts")); + + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("kof22.analytics.hasAccess"); + assertThat(t.writes()).isEmpty(); + assertThat(rows(AuthentikLocalTables.ADMIN_ACTION).get(0).getValueString("status")).isEqualTo("REFUSED"); + } + + + + /******************************************************************************* + ** Adding to a group that gives sign-in to an app the operator cannot open is + ** refused too (app-access subset). + *******************************************************************************/ + @Test + void addMemberRefusedWhenGroupGrantsAppAccessOperatorLacks() throws QException + { + t.authentik.group("kof-secret-app", false, List.of(), List.of("secret-app")); + AuthentikAdmin.AdminResult result = run(add(3, "kof-secret-app")); + + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("secret-app"); + assertThat(t.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** The subset rule uses the operator's LIVE groups, not the session claim: + ** an operator whose session says authentik.* but who was removed from the + ** groups in Authentik holds nothing. + *******************************************************************************/ + @Test + void subsetRuleUsesLiveGroupsNotTheSession() throws QException + { + t.authentik.groupByName("kof-website-publishers").put("users", new JSONArray()); + AuthentikAdmin.AdminResult result = run(add(3, "kof-website-publishers")); + + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("cms.publish.hasAccess"); + assertThat(t.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** Superuser groups cannot be managed (adding to one would make a superuser). + *******************************************************************************/ + @Test + void superuserGroupsAreProtected() throws QException + { + AuthentikAdmin.AdminResult result = run(add(3, "authentik Admins")); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).containsIgnoringCase("superuser"); + assertThat(t.writes()).isEmpty(); + + t.authentik.group("kof-under-superuser", false, List.of(), List.of(), "authentik Admins"); + AuthentikAdmin.AdminResult descendant = run(add(3, "kof-under-superuser")); + assertThat(descendant.ok()).isFalse(); + assertThat(descendant.message()).containsIgnoringCase("superuser"); + assertThat(t.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** Protected accounts and self-edits are refused. + *******************************************************************************/ + @Test + void protectedAccountsAndSelfEditsAreRefused() throws QException + { + for(int pk : new int[] { 4, 5, 1 }) + { + AuthentikAdmin.AdminResult result = run(add(pk, "kof-website-publishers")); + assertThat(result.ok()).as("user " + pk).isFalse(); + } + assertThat(run(add(4, "kof-website-publishers")).message()).containsIgnoringCase("protected"); + assertThat(run(add(1, "kof-website-publishers")).message()).contains("your own account"); + assertThat(t.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** Service accounts are listed but can only be deactivated or have app + ** passwords revoked. + *******************************************************************************/ + @Test + void serviceAccountsOnlyDeactivateAndRevoke() throws QException + { + assertThat(run(add(6, "kof-website-publishers")).message()).contains("Service accounts can only"); + assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "6", "because").with("name", "x")).message()).contains("Service accounts can only"); + + AuthentikAdmin.AdminResult deactivate = run(AdminRequest.of(AdminAction.DEACTIVATE_USER, "6", "because").with("confirmText", "svc-publisher")); + assertThat(deactivate.ok()).isTrue(); + assertThat(t.authentik.users.get(6).getBoolean("is_active")).isFalse(); + } + + + + /******************************************************************************* + ** Removing a member works and is audited. + *******************************************************************************/ + @Test + void removeMember() throws QException + { + t.authentik.groupByName("kof-website-publishers").getJSONArray("users").put(3); + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.REMOVE_MEMBER, t.authentik.pk("kof-website-publishers") + ":3", "because")); + + assertThat(result.ok()).isTrue(); + assertThat(t.authentik.groupNamesOf(3)).isEmpty(); + assertThat(t.changes()).containsExactly("POST /core/groups/" + t.authentik.pk("kof-website-publishers") + "/remove_user/"); + } + + + + /******************************************************************************* + ** Removing someone who is not a member, or adding a member again, is a no-op + ** that sends nothing. + *******************************************************************************/ + @Test + void alreadyDoneIsNoChange() throws QException + { + AuthentikAdmin.AdminResult notMember = run(AdminRequest.of(AdminAction.REMOVE_MEMBER, t.authentik.pk("kof-website-publishers") + ":3", "because")); + assertThat(notMember.ok()).isTrue(); + assertThat(notMember.noChange()).isTrue(); + + AuthentikAdmin.AdminResult again = run(add(2, "identity-admins")); + assertThat(again.noChange()).isTrue(); + assertThat(t.writes()).isEmpty(); + assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).isEmpty(); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // last-admin protection + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** The last person who can manage memberships and permissions cannot be + ** removed; with a second administrator the same change is allowed. + *******************************************************************************/ + @Test + void lastAdminCannotBeRemoved() throws QException + { + // acting person: holds the session permissions but is not an administrator in Authentik + t.loginAs("sam@example.test", IdentityPermission.MEMBERSHIPS_MANAGE); + + AuthentikAdmin.AdminResult allowed = run(AdminRequest.of(AdminAction.REMOVE_MEMBER, t.authentik.pk("identity-admins") + ":2", "because")); + assertThat(allowed.ok()).as(allowed.message()).isTrue(); + + AuthentikAdmin.AdminResult refused = run(AdminRequest.of(AdminAction.REMOVE_MEMBER, t.authentik.pk("identity-admins") + ":1", "because")); + assertThat(refused.ok()).isFalse(); + assertThat(refused.message()).contains("last person"); + assertThat(t.authentik.groupNamesOf(1)).contains("identity-admins"); + } + + + + /******************************************************************************* + ** Deactivating the last administrator is refused. + *******************************************************************************/ + @Test + void lastAdminCannotBeDeactivated() throws QException + { + t.authentik.groupByName("identity-admins").put("users", new JSONArray(List.of(1))); + t.loginAs("sam@example.test", IdentityPermission.USERS_MANAGE); + + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.DEACTIVATE_USER, "1", "because").with("confirmText", "operator")); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("last active person"); + assertThat(t.authentik.users.get(1).getBoolean("is_active")).isTrue(); + } + + + + /******************************************************************************* + ** Removing the permissions from the only administrator group is refused. + *******************************************************************************/ + @Test + void lastAdminCannotLosePermissionsByGroupEdit() throws QException + { + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("identity-admins"), "because") + .with("permissions", "").with("appAccess", "kof22-website-prod")); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("nobody able to manage"); + assertThat(t.writes()).isEmpty(); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // permissions and app access + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Adding a permission the operator holds works, and the write merges only the + ** two qqq.io keys into the group's CURRENT attributes (other attributes and + ** edits made in the Authentik UI survive). + *******************************************************************************/ + @Test + void editPermissionsMergesIntoCurrentAttributes() throws QException + { + t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("note", "set in the Authentik UI"); + + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-analysts"), "because") + .with("permissions", "kof22.analytics.hasAccess\ncms.publish.hasAccess").with("appAccess", "")); + + assertThat(result.ok()).as(result.message()).isTrue(); + JSONObject attributes = t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes"); + assertThat(attributes.getString("note")).isEqualTo("set in the Authentik UI"); + assertThat(attributes.getJSONArray("qqq.io/permissions").toList()).containsExactly("kof22.analytics.hasAccess", "cms.publish.hasAccess"); + assertThat(attributes.has("qqq.io/app-access")).isFalse(); + assertThat(t.changes()).containsExactly("PATCH /core/groups/" + t.authentik.pk("kof-website-analysts") + "/"); + } + + + + /******************************************************************************* + ** A permission the operator does not hold cannot be granted; removing one + ** needs no subset; unknown names are rejected. + *******************************************************************************/ + @Test + void editPermissionsSubsetAndUnknownNames() throws QException + { + AuthentikAdmin.AdminResult escalation = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-publishers"), "because") + .with("permissions", "cms.publish.hasAccess\nkof22.analytics.hasAccess").with("appAccess", "kof22-website-prod")); + assertThat(escalation.ok()).isFalse(); + assertThat(escalation.message()).contains("kof22.analytics.hasAccess"); + + AuthentikAdmin.AdminResult unknown = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-publishers"), "because") + .with("permissions", "cms.publish.hasAccess\nnot.a.real.permission").with("appAccess", "kof22-website-prod")); + assertThat(unknown.ok()).isFalse(); + assertThat(unknown.message()).contains("Unknown permission"); + + AuthentikAdmin.AdminResult removal = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-analysts"), "because") + .with("permissions", "").with("appAccess", "")); + assertThat(removal.ok()).isTrue(); + assertThat(t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").has("qqq.io/permissions")).isFalse(); + } + + + + /******************************************************************************* + ** App access: an operator can only give sign-in to apps they can open. + *******************************************************************************/ + @Test + void editAppAccessSubset() throws QException + { + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-analysts"), "because") + .with("permissions", "kof22.analytics.hasAccess").with("appAccess", "secret-app")); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("secret-app"); + + AuthentikAdmin.AdminResult allowed = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-analysts"), "because") + .with("permissions", "kof22.analytics.hasAccess").with("appAccess", "kof22-website-prod")); + assertThat(allowed.ok()).isTrue(); + } + + + + /******************************************************************************* + ** A change made in the Authentik UI after the form was opened is not + ** overwritten (stale check by fingerprint). + *******************************************************************************/ + @Test + void staleGroupIsRefused() throws QException + { + String fingerprint = AuthentikSafety.groupFingerprint(t.authentik.groupByName("kof-website-analysts")); + t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/permissions", new JSONArray(List.of("kof22.analytics.hasAccess", "cms.publish.hasAccess"))); + + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-analysts"), "because") + .with("permissions", "cms.publish.hasAccess").with("appAccess", "").with("expectedFingerprint", fingerprint)); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("changed"); + assertThat(t.writes()).isEmpty(); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // two-person approval + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Granting authentik.* is queued for a second person: nothing is sent to + ** Authentik, the request is stored, and the same person cannot approve it. A + ** different person who holds authentik.approve applies it, and the change is + ** planned again under their own permissions. + *******************************************************************************/ + @Test + void grantingIdentityPermissionsNeedsASecondPerson() throws QException + { + AdminRequest request = AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-publishers"), "give them user read") + .with("permissions", "cms.publish.hasAccess\nauthentik.users.read").with("appAccess", "kof22-website-prod"); + AuthentikAdmin.AdminResult queued = run(request); + + assertThat(queued.ok()).isTrue(); + assertThat(queued.pending()).isTrue(); + assertThat(t.writes()).isEmpty(); + List approvals = rows(AuthentikLocalTables.APPROVAL_REQUEST); + assertThat(approvals).hasSize(1); + assertThat(approvals.get(0).getValueString("status")).isEqualTo("PENDING"); + assertThat(approvals.get(0).getValueString("requester")).isEqualTo(AuthentikTestSupport.OPERATOR); + assertThat(rows(AuthentikLocalTables.ADMIN_ACTION).get(0).getValueString("status")).isEqualTo("PENDING_APPROVAL"); + + java.io.Serializable id = approvals.get(0).getValue("id"); + AuthentikAdmin.AdminResult self = t.admin().approve(id); + assertThat(self.ok()).isFalse(); + assertThat(self.message()).contains("cannot approve your own"); + + t.loginAs("second@example.test", IdentityPermission.APPROVE, IdentityPermission.PERMISSIONS_MANAGE); + AuthentikAdmin.AdminResult approved = t.admin().approve(id); + assertThat(approved.ok()).as(approved.message()).isTrue(); + assertThat(t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").getJSONArray("qqq.io/permissions").toList()) + .containsExactly("cms.publish.hasAccess", "authentik.users.read"); + assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST).get(0).getValueString("status")).isEqualTo("APPROVED"); + assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST).get(0).getValueString("approver")).isEqualTo("second@example.test"); + + AuthentikAdmin.AdminResult twice = t.admin().approve(id); + assertThat(twice.ok()).isFalse(); + assertThat(twice.message()).contains("already decided"); + } + + + + /******************************************************************************* + ** Rejecting stores the decision and changes nothing in Authentik; deciding + ** needs authentik.approve. + *******************************************************************************/ + @Test + void rejectAndApproverPermission() throws QException + { + run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-publishers"), "r") + .with("permissions", "cms.publish.hasAccess\nauthentik.users.read").with("appAccess", "kof22-website-prod")); + java.io.Serializable id = rows(AuthentikLocalTables.APPROVAL_REQUEST).get(0).getValue("id"); + + t.loginAs("second@example.test", IdentityPermission.PERMISSIONS_MANAGE); + assertThat(t.admin().reject(id).message()).contains("authentik.approve"); + + t.loginAs("second@example.test", IdentityPermission.APPROVE); + assertThat(t.admin().reject(id).ok()).isTrue(); + assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST).get(0).getValueString("status")).isEqualTo("REJECTED"); + assertThat(t.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** With two-person approval off, authentik.* grants apply at once. + *******************************************************************************/ + @Test + void approvalCanBeTurnedOff() throws QException + { + t.config.withTwoPersonApproval(false); + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-publishers"), "because") + .with("permissions", "cms.publish.hasAccess\nauthentik.users.read").with("appAccess", "kof22-website-prod")); + assertThat(result.ok()).isTrue(); + assertThat(result.pending()).isFalse(); + assertThat(t.changes()).hasSize(1); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // groups + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Create a group with a parent, permissions and app access. + *******************************************************************************/ + @Test + void createGroup() throws QException + { + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.CREATE_GROUP, "", "because").with("name", "kof-editors") + .with("parents", "kof-website-publishers").with("permissions", "cms.publish.hasAccess").with("appAccess", "kof22-website-prod")); + + assertThat(result.ok()).as(result.message()).isTrue(); + JSONObject group = t.authentik.groupByName("kof-editors"); + assertThat(group.getJSONArray("parents").getString(0)).isEqualTo(t.authentik.pk("kof-website-publishers")); + assertThat(group.getBoolean("is_superuser")).isFalse(); + assertThat(group.getJSONObject("attributes").getJSONArray("qqq.io/permissions").toList()).containsExactly("cms.publish.hasAccess"); + } + + + + /******************************************************************************* + ** Group creation rules: unique names, no superuser parents, the subset rule + ** for parents and permissions. + *******************************************************************************/ + @Test + void createGroupRules() throws QException + { + assertThat(run(AdminRequest.of(AdminAction.CREATE_GROUP, "", "b").with("name", "identity-admins")).message()).contains("already exists"); + assertThat(run(AdminRequest.of(AdminAction.CREATE_GROUP, "", "b").with("name", "x").with("parents", "authentik Admins")).message()).containsIgnoringCase("superuser"); + assertThat(run(AdminRequest.of(AdminAction.CREATE_GROUP, "", "b").with("name", "x").with("parents", "kof-website-analysts")).message()).contains("kof22.analytics.hasAccess"); + assertThat(run(AdminRequest.of(AdminAction.CREATE_GROUP, "", "b").with("name", "x").with("permissions", "kof22.analytics.hasAccess")).message()).contains("do not hold"); + assertThat(run(AdminRequest.of(AdminAction.CREATE_GROUP, "", "b").with("name", "x").with("parents", "nope")).message()).contains("No parent group"); + assertThat(t.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** Re-parenting: loops and superuser parents are refused. + *******************************************************************************/ + @Test + void editGroupParents() throws QException + { + t.authentik.group("kof-child", false, List.of(), List.of(), "kof-website-publishers"); + + AuthentikAdmin.AdminResult loop = run(AdminRequest.of(AdminAction.EDIT_GROUP, t.authentik.pk("kof-website-publishers"), "b").with("parents", "kof-child")); + assertThat(loop.message()).contains("loop"); + + AuthentikAdmin.AdminResult superuser = run(AdminRequest.of(AdminAction.EDIT_GROUP, t.authentik.pk("kof-child"), "b").with("parents", "authentik Admins")); + assertThat(superuser.message()).containsIgnoringCase("superuser"); + + String childPk = t.authentik.pk("kof-child"); + AuthentikAdmin.AdminResult rename = run(AdminRequest.of(AdminAction.EDIT_GROUP, childPk, "b").with("name", "kof-grandchild")); + assertThat(rename.ok()).as(rename.message()).isTrue(); + assertThat(t.authentik.groups.get(childPk).getString("name")).isEqualTo("kof-grandchild"); + + AuthentikAdmin.AdminResult superuserGroup = run(AdminRequest.of(AdminAction.EDIT_GROUP, t.authentik.pk("authentik Admins"), "b").with("name", "renamed")); + assertThat(superuserGroup.message()).containsIgnoringCase("superuser"); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // users + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Create a user: no password is sent, memberships are added, and + ** Authentik emails the recovery link (QQQ never sees it). + *******************************************************************************/ + @Test + void createUserNeverSetsAPassword() throws QException + { + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.CREATE_USER, "", "new hire").with("username", "newbie").with("name", "New Bie") + .with("email", "newbie@example.test").with("groups", "kof-website-publishers")); + + assertThat(result.ok()).as(result.message()).isTrue(); + assertThat(t.changes()).hasSize(3); + assertThat(t.changes().get(0)).isEqualTo("POST /core/users/"); + assertThat(t.changes().get(1)).endsWith("/add_user/"); + assertThat(t.changes().get(2)).isEqualTo("POST /core/users/100/recovery_email/"); + + JSONObject createBody = t.authentik.writeBodies.get(0); + assertThat(createBody.keySet()).doesNotContain("password", "password_hash"); + assertThat(createBody.getString("username")).isEqualTo("newbie"); + assertThat(createBody.getBoolean("is_active")).isTrue(); + assertThat(createBody.getJSONObject("attributes").getString("qqq.io/created-by")).isEqualTo(AuthentikTestSupport.OPERATOR); + JSONObject recovery = t.authentik.writeBodies.get(2); + assertThat(recovery.getString("email_stage")).isEqualTo("11111111-1111-1111-1111-111111111111"); + assertThat(t.authentik.groupNamesOf(100)).containsExactly("kof-website-publishers"); + } + + + + /******************************************************************************* + ** Create-user checks. + *******************************************************************************/ + @Test + void createUserRules() throws QException + { + AdminRequest base = AdminRequest.of(AdminAction.CREATE_USER, "", "b").with("username", "newbie").with("name", "New Bie").with("email", "newbie@example.test"); + assertThat(run(AdminRequest.of(AdminAction.CREATE_USER, "", "b").with("username", "sam").with("name", "n").with("email", "x@example.test")).message()).contains("already exists"); + assertThat(run(AdminRequest.of(AdminAction.CREATE_USER, "", "b").with("username", "x").with("name", "n").with("email", "sam@example.test")).message()).contains("email"); + assertThat(run(AdminRequest.of(AdminAction.CREATE_USER, "", "b").with("username", "x").with("name", "n").with("email", "not-an-email")).message()).contains("valid email"); + assertThat(run(AdminRequest.of(AdminAction.CREATE_USER, "", "b").with("username", "bad name!").with("name", "n").with("email", "a@example.test")).message()).contains("username"); + assertThat(run(base.with("groups", "kof-website-analysts")).message()).contains("do not hold"); + assertThat(run(AdminRequest.of(AdminAction.CREATE_USER, "", "b").with("username", "x").with("name", "n").with("email", "a@example.test").with("groups", "nope")).message()).contains("No group"); + assertThat(t.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** If a step after the user exists fails, the message says the user was + ** created and what to do next. + *******************************************************************************/ + @Test + void createUserPartialFailureIsExplained() throws QException + { + com.kingsrook.qbits.authentik.client.FixtureActionUtil.handler = request -> + { + if(request.getURI().getPath().endsWith("/recovery_email/")) + { + return (com.kingsrook.qbits.authentik.client.FixtureActionUtil.response(500, "{\"detail\":\"boom\"}")); + } + return (t.authentik.handle(request)); + }; + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.CREATE_USER, "", "b").with("username", "newbie").with("name", "New Bie").with("email", "newbie@example.test")); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("was created").contains("Send password reset email"); + assertThat(rows(AuthentikLocalTables.ADMIN_ACTION).get(0).getValueString("status")).isEqualTo("FAILED"); + } + + + + /******************************************************************************* + ** Invite a user: an invitation with the email, and its email. + *******************************************************************************/ + @Test + void inviteAndCancel() throws QException + { + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.INVITE_USER, "", "b").with("email", "guest@example.test")); + assertThat(result.ok()).as(result.message()).isTrue(); + assertThat(t.changes().get(0)).isEqualTo("POST /stages/invitation/invitations/"); + assertThat(t.changes().get(1)).endsWith("/send_email/"); + JSONObject invitation = t.authentik.writeBodies.get(0); + assertThat(invitation.getBoolean("single_use")).isTrue(); + assertThat(invitation.getJSONObject("fixed_data").getString("email")).isEqualTo("guest@example.test"); + assertThat(t.authentik.writeBodies.get(1).getJSONArray("email_addresses").getString(0)).isEqualTo("guest@example.test"); + + String pk = t.authentik.invitations.keySet().iterator().next(); + assertThat(run(AdminRequest.of(AdminAction.CANCEL_INVITATION, pk, "b")).ok()).isTrue(); + assertThat(t.authentik.invitations).isEmpty(); + } + + + + /******************************************************************************* + ** Deactivation is typed-confirmed, protected accounts are untouchable, and + ** app passwords are revoked only when asked (and never API tokens). + *******************************************************************************/ + @Test + void deactivateAndReactivate() throws QException + { + t.authentik.tokens.put("sam", new java.util.ArrayList<>(List.of( + new JSONObject().put("identifier", "sam-app").put("intent", "app_password"), + new JSONObject().put("identifier", "sam-api").put("intent", "api")))); + + AuthentikAdmin.AdminResult untyped = run(AdminRequest.of(AdminAction.DEACTIVATE_USER, "3", "b")); + assertThat(untyped.ok()).isFalse(); + assertThat(untyped.message()).contains("Type the username"); + assertThat(t.writes()).isEmpty(); + + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.DEACTIVATE_USER, "3", "b").with("confirmText", "sam").with("revokeAppPasswords", true)); + assertThat(result.ok()).as(result.message()).isTrue(); + assertThat(t.authentik.users.get(3).getBoolean("is_active")).isFalse(); + assertThat(t.changes()).containsExactly("PATCH /core/users/3/", "DELETE /core/tokens/sam-app/"); + assertThat(t.authentik.tokens.get("sam")).extracting(j -> j.getString("identifier")).containsExactly("sam-api"); + + assertThat(run(AdminRequest.of(AdminAction.DEACTIVATE_USER, "3", "b").with("confirmText", "sam")).noChange()).isTrue(); + assertThat(run(AdminRequest.of(AdminAction.REACTIVATE_USER, "3", "b")).ok()).isTrue(); + assertThat(t.authentik.users.get(3).getBoolean("is_active")).isTrue(); + assertThat(run(AdminRequest.of(AdminAction.DEACTIVATE_USER, "4", "b").with("confirmText", "breakglass")).message()).containsIgnoringCase("protected"); + } + + + + /******************************************************************************* + ** Edit a user: only changed fields are sent; a stale form is refused; + ** invalid input is refused. + *******************************************************************************/ + @Test + void editUser() throws QException + { + String fingerprint = AuthentikSafety.userFingerprint(t.authentik.users.get(3)); + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_USER, "3", "b").with("name", "Samuel").with("email", "sam@example.test") + .with("path", "users").with("expectedFingerprint", fingerprint)); + assertThat(result.ok()).as(result.message()).isTrue(); + assertThat(t.authentik.users.get(3).getString("name")).isEqualTo("Samuel"); + + assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "b").with("name", "Again").with("expectedFingerprint", fingerprint)).message()).contains("was changed"); + assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "b").with("email", "second@example.test")).message()).contains("Another user"); + assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "b").with("name", "")).message()).contains("name cannot be empty"); + assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "b")).noChange()).isTrue(); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // credentials and sessions + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Password reset is Authentik's own email; MFA reset deletes every device + ** (typed confirmation); app passwords only; sessions via bulk delete. + *******************************************************************************/ + @Test + void credentialActions() throws QException + { + assertThat(run(AdminRequest.of(AdminAction.SEND_RECOVERY_EMAIL, "3", "b")).ok()).isTrue(); + assertThat(t.changes().get(0)).isEqualTo("POST /core/users/3/recovery_email/"); + + t.authentik.devices.put(3, new java.util.ArrayList<>(List.of( + new JSONObject().put("pk", "11").put("type", "authentik_stages_authenticator_totp.totpdevice"), + new JSONObject().put("pk", "12").put("type", "authentik_stages_authenticator_webauthn.webauthndevice")))); + assertThat(run(AdminRequest.of(AdminAction.RESET_MFA, "3", "b")).message()).contains("Type the username"); + AuthentikAdmin.AdminResult mfa = run(AdminRequest.of(AdminAction.RESET_MFA, "3", "b").with("confirmText", "sam")); + assertThat(mfa.ok()).as(mfa.message()).isTrue(); + assertThat(t.changes()).contains("DELETE /authenticators/admin/totp/11/", "DELETE /authenticators/admin/webauthn/12/"); + assertThat(run(AdminRequest.of(AdminAction.RESET_MFA, "3", "b").with("confirmText", "sam")).noChange()).isTrue(); + + t.authentik.tokens.put("sam", new java.util.ArrayList<>(List.of(new JSONObject().put("identifier", "sam-api").put("intent", "api")))); + assertThat(run(AdminRequest.of(AdminAction.REVOKE_APP_PASSWORDS, "3", "b").with("confirmText", "sam")).noChange()).isTrue(); + } + + + + /******************************************************************************* + ** Revoke sessions has nothing to do for a user with no sessions (the fake + ** has none), and refuses protected users. + *******************************************************************************/ + @Test + void sessionsRules() throws QException + { + assertThat(run(AdminRequest.of(AdminAction.REVOKE_SESSIONS, "3", "b")).noChange()).isTrue(); + assertThat(run(AdminRequest.of(AdminAction.REVOKE_SESSIONS, "4", "b")).message()).containsIgnoringCase("protected"); + } + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // gates: permission, feature, read-only, reason, audit + ////////////////////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Without the action's authentik.* permission nothing happens. + *******************************************************************************/ + @Test + void permissionIsRequired() throws QException + { + t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ); + AuthentikAdmin.AdminResult result = run(add(3, "kof-website-publishers")); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("authentik.memberships.manage"); + assertThat(t.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** Read-only mode and disabled features refuse every write. + *******************************************************************************/ + @Test + void readOnlyAndFeatureFlags() throws QException + { + t.config.setReadOnly(true); + assertThat(run(add(3, "kof-website-publishers")).message()).contains("read-only"); + t.config.setReadOnly(false); + + AdminTestSupport readOnlyFeatures = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ)); + AuthentikAdmin.AdminResult result = readOnlyFeatures.admin().execute(AdminRequest.of(AdminAction.ADD_MEMBER, readOnlyFeatures.authentik.pk("kof-website-publishers") + ":3", "b")); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("not enabled"); + assertThat(readOnlyFeatures.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** Every change needs a reason. + *******************************************************************************/ + @Test + void reasonIsRequired() throws QException + { + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.ADD_MEMBER, t.authentik.pk("kof-website-publishers") + ":3", " ")); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("reason"); + assertThat(t.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** If the audit row cannot be written, Authentik is not called. + *******************************************************************************/ + @Test + void noAuditRowNoCall() throws QException + { + t.qInstance.getTables().remove(t.config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION)); + assertThatThrownBy(() -> run(add(3, "kof-website-publishers"))).isInstanceOf(Exception.class); + assertThat(t.writes()).isEmpty(); + assertThat(t.authentik.groupNamesOf(3)).isEmpty(); + } + + + + /******************************************************************************* + ** A failed Authentik call is audited as FAILED with the (redacted) reason and + ** reported to the operator. + *******************************************************************************/ + @Test + void failedCallIsAudited() throws QException + { + com.kingsrook.qbits.authentik.client.FixtureActionUtil.handler = request -> + request.getMethod().equals("POST") && request.getURI().getPath().endsWith("/add_user/") + ? com.kingsrook.qbits.authentik.client.FixtureActionUtil.response(403, "{\"detail\":\"no permission " + AuthentikTestSupport.TEST_TOKEN + "\"}") + : t.authentik.handle(request); + AuthentikAdmin.AdminResult result = run(add(3, "kof-website-publishers")); + + assertThat(result.ok()).isFalse(); + assertThat(result.message()).doesNotContain(AuthentikTestSupport.TEST_TOKEN); + QRecord row = rows(AuthentikLocalTables.ADMIN_ACTION).get(0); + assertThat(row.getValueString("status")).isEqualTo("FAILED"); + assertThat(row.getValueString("errorMessage")).doesNotContain(AuthentikTestSupport.TEST_TOKEN); + } + + + + /******************************************************************************* + ** Nothing can change Authentik without the guard's authorization: the util + ** refuses a write with none, and a system session cannot get one. + *******************************************************************************/ + @Test + void writesNeedTheGuard() throws QException + { + assertThatThrownBy(() -> t.admin().getUtil().write(null, "POST", "/core/groups/x/add_user/", new JSONObject().put("pk", 1))) + .hasMessageContaining("through the guard"); + + com.kingsrook.qqq.backend.core.context.QContext.init(t.qInstance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession()); + assertThatThrownBy(() -> t.admin().getUtil().getRuntime().getGuard().authorize(AdminAction.ADD_MEMBER)).hasMessageContaining("System sessions"); + assertThat(t.writes()).isEmpty(); + } + + + + /******************************************************************************* + ** Bootstrap operators hold every authentik.* permission even when their + ** groups are wrong (the lockout escape hatch), but still cannot grant what + ** they do not hold elsewhere. + *******************************************************************************/ + @Test + void bootstrapOperatorSubjectsHoldIdentityPermissions() throws QException + { + t.authentik.groupByName("identity-admins").put("users", new JSONArray()); + t.config.withBootstrapOperatorSubjects("1"); + t.authentik.groupByName("kof-website-publishers").put("users", new JSONArray()); + + AuthentikAdmin.AdminResult grantIdentity = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, t.authentik.pk("kof-website-analysts"), "b") + .with("permissions", "authentik.users.read").with("appAccess", "")); + assertThat(grantIdentity.ok()).as(grantIdentity.message()).isTrue(); + assertThat(run(add(3, "kof-website-publishers")).message()).contains("cms.publish.hasAccess"); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikSafetyTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikSafetyTest.java new file mode 100644 index 0000000..455af12 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikSafetyTest.java @@ -0,0 +1,191 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.admin; + + +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver; +import com.kingsrook.qbits.authentik.permissions.GroupGraph; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The pure rules of DESIGN.md section 5.4. + *******************************************************************************/ +class AuthentikSafetyTest +{ + /******************************************************************************* + ** + *******************************************************************************/ + private static GroupGraph.Node node(String pk, List parents, List permissions, List members) + { + return (new GroupGraph.Node(pk, pk, false, parents, permissions, List.of(), members, List.of())); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void missingIsTheSubsetRule() + { + assertThat(AuthentikSafety.missing(Set.of("a", "b"), Set.of("a", "b", "c"))).isEmpty(); + assertThat(AuthentikSafety.missing(Set.of("a", "b"), Set.of("a"))).containsExactly("b"); + assertThat(AuthentikSafety.missing(Set.of(), Set.of())).isEmpty(); + } + + + + /******************************************************************************* + ** Administrators are active, human, not protected, and hold both manage + ** permissions, directly or through a parent group. + *******************************************************************************/ + @Test + void qualifyingAdmins() + { + GroupGraph graph = new GroupGraph() + .add(node("admins", List.of(), List.of("authentik.*"), List.of(1, 2, 3, 4, 5))) + .add(node("child", List.of("admins"), List.of(), List.of(6))) + .add(node("readers", List.of(), List.of("authentik.users.read"), List.of(7))); + EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null); + + Set admins = AuthentikSafety.qualifyingAdmins(graph, resolver, List.of( + new AuthentikSafety.UserFacts(1, true, true, false), + new AuthentikSafety.UserFacts(2, false, true, false), + new AuthentikSafety.UserFacts(3, true, false, false), + new AuthentikSafety.UserFacts(4, true, true, true), + new AuthentikSafety.UserFacts(5, true, true, false), + new AuthentikSafety.UserFacts(6, true, true, false), + new AuthentikSafety.UserFacts(7, true, true, false))); + assertThat(admins).containsExactlyInAnyOrder(1, 5, 6); + } + + + + /******************************************************************************* + ** Last-admin: only a change that takes the last one away is refused; a + ** state with none already is not blocked again. + *******************************************************************************/ + @Test + void removesLastAdmin() + { + assertThat(AuthentikSafety.removesLastAdmin(Set.of(1), Set.of())).isTrue(); + assertThat(AuthentikSafety.removesLastAdmin(Set.of(1, 2), Set.of(2))).isFalse(); + assertThat(AuthentikSafety.removesLastAdmin(Set.of(), Set.of())).isFalse(); + } + + + + /******************************************************************************* + ** Simulations never change the original graph. + *******************************************************************************/ + @Test + void simulationsCopy() + { + GroupGraph original = new GroupGraph().add(node("g", List.of(), List.of("authentik.*"), List.of(1, 2))); + GroupGraph.Node g = original.byPk("g"); + + GroupGraph removed = AuthentikSafety.withNodes(original, AuthentikSafety.withoutMember(g, 1)); + assertThat(removed.byPk("g").memberPks()).containsExactly(2); + assertThat(original.byPk("g").memberPks()).containsExactly(1, 2); + + GroupGraph stripped = AuthentikSafety.withNodes(original, AuthentikSafety.withPermissions(g, List.of(), List.of())); + assertThat(stripped.byPk("g").permissions()).isEmpty(); + assertThat(original.byPk("g").permissions()).containsExactly("authentik.*"); + + GroupGraph moved = AuthentikSafety.withNodes(original, AuthentikSafety.withParents(g, List.of("x"))); + assertThat(moved.byPk("g").parentPks()).containsExactly("x"); + } + + + + /******************************************************************************* + ** Descendants include grandchildren and survive cycles. + *******************************************************************************/ + @Test + void descendants() + { + GroupGraph graph = new GroupGraph() + .add(node("a", List.of(), List.of(), List.of())) + .add(node("b", List.of("a"), List.of(), List.of())) + .add(node("c", List.of("b"), List.of(), List.of())) + .add(node("d", List.of(), List.of(), List.of())) + .add(node("loop1", List.of("loop2"), List.of(), List.of())) + .add(node("loop2", List.of("loop1"), List.of(), List.of())); + assertThat(AuthentikSafety.descendantPks(graph, "a")).containsExactlyInAnyOrder("b", "c"); + assertThat(AuthentikSafety.descendantPks(graph, "d")).isEmpty(); + assertThat(AuthentikSafety.descendantPks(graph, "loop1")).containsExactly("loop2"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void parseList() + { + assertThat(AuthentikSafety.parseList("a\nb, c;d\r\n\n a ")).containsExactly("a", "b", "c", "d"); + assertThat(AuthentikSafety.parseList("")).isEmpty(); + assertThat(AuthentikSafety.parseList(null)).isEmpty(); + } + + + + /******************************************************************************* + ** The group fingerprint tracks the editable state (name, parents, qqq.io + ** attributes, member count) and ignores everything else. + *******************************************************************************/ + @Test + void groupFingerprint() + { + JSONObject base = new JSONObject().put("name", "g").put("parents", new JSONArray(List.of("p"))).put("users", new JSONArray(List.of(1))) + .put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("a"))).put("other", "x")); + String fingerprint = AuthentikSafety.groupFingerprint(base); + + assertThat(AuthentikSafety.groupFingerprint(new JSONObject(base.toString()).put("pk", "ignored"))).isEqualTo(fingerprint); + assertThat(AuthentikSafety.groupFingerprint(new JSONObject(base.toString()).put("attributes", base.getJSONObject("attributes").put("other", "changed")))).isEqualTo(fingerprint); + assertThat(AuthentikSafety.groupFingerprint(new JSONObject(base.toString()).put("name", "h"))).isNotEqualTo(fingerprint); + assertThat(AuthentikSafety.groupFingerprint(new JSONObject(base.toString()).put("users", new JSONArray(List.of(1, 2))))).isNotEqualTo(fingerprint); + assertThat(AuthentikSafety.groupFingerprint(new JSONObject(base.toString()).put("parents", new JSONArray()))).isNotEqualTo(fingerprint); + assertThat(AuthentikSafety.groupFingerprint(new JSONObject(base.toString()).put("attributes", + new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("b")))))).isNotEqualTo(fingerprint); + } + + + + /******************************************************************************* + ** The user fingerprint changes with any editable field or with last_updated. + *******************************************************************************/ + @Test + void userFingerprint() + { + JSONObject user = new JSONObject().put("username", "u").put("name", "N").put("email", "e@x.test").put("path", "users").put("type", "internal") + .put("is_active", true).put("last_updated", "2026-10-01T00:00:00Z"); + String fingerprint = AuthentikSafety.userFingerprint(user); + assertThat(AuthentikSafety.userFingerprint(new JSONObject(user.toString()))).isEqualTo(fingerprint); + assertThat(AuthentikSafety.userFingerprint(new JSONObject(user.toString()).put("name", "M"))).isNotEqualTo(fingerprint); + assertThat(AuthentikSafety.userFingerprint(new JSONObject(user.toString()).put("is_active", false))).isNotEqualTo(fingerprint); + assertThat(AuthentikSafety.userFingerprint(new JSONObject(user.toString()).put("last_updated", "2026-10-02T00:00:00Z"))).isNotEqualTo(fingerprint); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java b/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java new file mode 100644 index 0000000..1fc5b77 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java @@ -0,0 +1,540 @@ +/* + * 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.IOException; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.kingsrook.qbits.authentik.client.FixtureActionUtil; +import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse; +import org.apache.http.HttpEntityEnclosingRequest; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.util.EntityUtils; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** A small in-memory Authentik for unit tests: users, groups (with parents, + ** attributes and members), tokens, sessions, MFA devices, invitations and the + ** write endpoints the qbit uses, with the same superuser inheritance the real + ** server has. Every write request is recorded so tests can assert what was + ** (and was not) sent. Plug it in with {@link #install()}. + *******************************************************************************/ +public class FakeAuthentik +{ + public final Map users = new LinkedHashMap<>(); + public final Map groups = new LinkedHashMap<>(); + public final Map> tokens = new LinkedHashMap<>(); + public final Map sessions = new LinkedHashMap<>(); + public final Map> devices = new LinkedHashMap<>(); + public final Map invitations = new LinkedHashMap<>(); + public final List writes = new ArrayList<>(); + public final List events = new ArrayList<>(); + public final List writeBodies = new ArrayList<>(); + + private int nextUserPk = 100; + + + + /******************************************************************************* + ** Route the qbit's requests to this fake. + *******************************************************************************/ + public FakeAuthentik install() + { + FixtureActionUtil.reset(); + FixtureActionUtil.handler = this::handle; + FixtureActionUtil.groupGraphHandler = this::handle; + return (this); + } + + + + /******************************************************************************* + ** Add a user (groups by name must already exist). + *******************************************************************************/ + public FakeAuthentik user(int pk, String username, String email, String type, boolean active, String... groupNames) + { + JSONObject user = new JSONObject().put("pk", pk).put("username", username).put("name", username + " Name").put("email", email) + .put("is_active", active).put("type", type).put("path", "users").put("attributes", new JSONObject()).put("last_updated", "2026-10-01T00:00:00Z") + .put("date_joined", "2026-01-01T00:00:00Z").put("last_login", "2026-10-02T00:00:00Z"); + users.put(pk, user); + for(String groupName : groupNames) + { + groupByName(groupName).getJSONArray("users").put(pk); + } + return (this); + } + + + + /******************************************************************************* + ** Add a group with QQQ permissions, app access and parents (names). + *******************************************************************************/ + public FakeAuthentik group(String name, boolean superuser, List permissions, List appAccess, String... parentNames) + { + JSONObject attributes = new JSONObject(); + if(!permissions.isEmpty()) + { + attributes.put("qqq.io/permissions", new JSONArray(permissions)); + } + if(!appAccess.isEmpty()) + { + attributes.put("qqq.io/app-access", new JSONArray(appAccess)); + } + JSONArray parents = new JSONArray(); + for(String parentName : parentNames) + { + parents.put(groupByName(parentName).getString("pk")); + } + String pk = UUID.nameUUIDFromBytes(name.getBytes(StandardCharsets.UTF_8)).toString(); + groups.put(pk, new JSONObject().put("pk", pk).put("name", name).put("is_superuser", superuser).put("parents", parents) + .put("attributes", attributes).put("users", new JSONArray())); + return (this); + } + + + + /******************************************************************************* + ** The group pk for a name. + *******************************************************************************/ + public String pk(String groupName) + { + return (groupByName(groupName).getString("pk")); + } + + + + /******************************************************************************* + ** The group object for a name. + *******************************************************************************/ + public JSONObject groupByName(String name) + { + return (groups.values().stream().filter(g -> g.getString("name").equals(name)).findFirst().orElseThrow(() -> new IllegalStateException("no group " + name))); + } + + + + /******************************************************************************* + ** Names of the groups a user is a direct member of. + *******************************************************************************/ + public List groupNamesOf(int userPk) + { + List rs = new ArrayList<>(); + for(JSONObject group : groups.values()) + { + if(contains(group.getJSONArray("users"), userPk)) + { + rs.add(group.getString("name")); + } + } + return (rs); + } + + + + /******************************************************************************* + ** Whether any group in the user's ancestry is a superuser group. + *******************************************************************************/ + private boolean inheritedSuperuser(String groupPk, int depth) + { + JSONObject group = groups.get(groupPk); + if(group == null || depth > 20) + { + return (false); + } + if(group.getBoolean("is_superuser")) + { + return (true); + } + for(int i = 0; i < group.getJSONArray("parents").length(); i++) + { + if(inheritedSuperuser(group.getJSONArray("parents").getString(i), depth + 1)) + { + return (true); + } + } + return (false); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static boolean contains(JSONArray array, int pk) + { + for(int i = 0; i < array.length(); i++) + { + if(array.getInt(i) == pk) + { + return (true); + } + } + return (false); + } + + + + /******************************************************************************* + ** A user as the API returns it (with groups_obj and derived is_superuser). + *******************************************************************************/ + private JSONObject render(JSONObject user) + { + JSONObject rs = new JSONObject(user.toString()); + JSONArray groupsObj = new JSONArray(); + boolean superuser = false; + for(JSONObject group : groups.values()) + { + if(contains(group.getJSONArray("users"), user.getInt("pk"))) + { + groupsObj.put(new JSONObject().put("pk", group.getString("pk")).put("name", group.getString("name"))); + superuser |= inheritedSuperuser(group.getString("pk"), 0); + } + } + return (rs.put("groups_obj", groupsObj).put("roles_obj", new JSONArray()).put("is_superuser", superuser)); + } + + + + /******************************************************************************* + ** A group as the API returns it. + *******************************************************************************/ + private JSONObject render(JSONObject group, boolean includeUsers) + { + JSONObject rs = new JSONObject(group.toString()); + if(!includeUsers) + { + rs.remove("users"); + } + JSONArray parentsObj = new JSONArray(); + for(int i = 0; i < group.getJSONArray("parents").length(); i++) + { + JSONObject parent = groups.get(group.getJSONArray("parents").getString(i)); + if(parent != null) + { + parentsObj.put(new JSONObject().put("pk", parent.getString("pk")).put("name", parent.getString("name"))); + } + } + return (rs.put("parents_obj", parentsObj).put("children_obj", new JSONArray()).put("roles_obj", new JSONArray())); + } + + + + /******************************************************************************* + ** The fake server. + *******************************************************************************/ + public QHttpResponse handle(HttpRequestBase request) + { + URI uri = request.getURI(); + String path = uri.getPath().replace("/api/v3", ""); + String method = request.getMethod(); + Map> query = parseQuery(uri.getRawQuery()); + JSONObject body = body(request); + + if(!method.equals("GET")) + { + writes.add(method + " " + path + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery())); + writeBodies.add(body == null ? new JSONObject() : body); + } + + if(method.equals("GET")) + { + return (get(path, query)); + } + + if(path.equals("/events/events/")) + { + events.add(body); + return (ok(201, new JSONObject().put("pk", UUID.randomUUID().toString()))); + } + if(path.equals("/core/users/") && method.equals("POST")) + { + int pk = nextUserPk++; + JSONObject user = new JSONObject(body.toString()).put("pk", pk).put("last_updated", "2026-10-12T00:00:00Z"); + if(!user.has("attributes")) + { + user.put("attributes", new JSONObject()); + } + users.put(pk, user); + return (ok(201, render(user))); + } + if(path.equals("/core/groups/") && method.equals("POST")) + { + String pk = UUID.nameUUIDFromBytes(body.getString("name").getBytes(StandardCharsets.UTF_8)).toString(); + JSONObject group = new JSONObject(body.toString()).put("pk", pk).put("users", new JSONArray()); + groups.put(pk, group); + return (ok(201, render(group, true))); + } + if(path.matches("/core/users/\\d+/") && method.equals("PATCH")) + { + JSONObject user = users.get(Integer.parseInt(path.split("/")[3])); + for(String key : body.keySet()) + { + user.put(key, body.get(key)); + } + user.put("last_updated", "2026-10-12T01:00:00Z"); + return (ok(200, render(user))); + } + if(path.matches("/core/groups/[^/]+/") && method.equals("PATCH")) + { + JSONObject group = groups.get(path.split("/")[3]); + for(String key : body.keySet()) + { + group.put(key, body.get(key)); + } + return (ok(200, render(group, true))); + } + if(path.matches("/core/groups/[^/]+/(add_user|remove_user)/")) + { + JSONObject group = groups.get(path.split("/")[3]); + int pk = body.getInt("pk"); + JSONArray users = group.getJSONArray("users"); + if(path.endsWith("add_user/")) + { + if(!contains(users, pk)) + { + users.put(pk); + } + } + else + { + JSONArray kept = new JSONArray(); + for(int i = 0; i < users.length(); i++) + { + if(users.getInt(i) != pk) + { + kept.put(users.getInt(i)); + } + } + group.put("users", kept); + } + return (ok(204, null)); + } + if(path.matches("/core/users/\\d+/recovery_email/")) + { + return (ok(204, null)); + } + if(path.matches("/core/tokens/[^/]+/") && method.equals("DELETE")) + { + String identifier = path.split("/")[3]; + tokens.values().forEach(list -> list.removeIf(t -> t.getString("identifier").equals(identifier))); + return (ok(204, null)); + } + if(path.equals("/core/authenticated_sessions/bulk_delete/")) + { + sessions.remove(first(query, "user_pks")); + return (ok(204, null)); + } + if(path.matches("/authenticators/admin/[a-z]+/[^/]+/") && method.equals("DELETE")) + { + String id = path.split("/")[4]; + devices.values().forEach(list -> list.removeIf(d -> String.valueOf(d.get("pk")).equals(id))); + return (ok(204, null)); + } + if(path.equals("/stages/invitation/invitations/") && method.equals("POST")) + { + String pk = UUID.randomUUID().toString(); + invitations.put(pk, new JSONObject(body.toString()).put("pk", pk)); + return (ok(201, invitations.get(pk))); + } + if(path.matches("/stages/invitation/invitations/[^/]+/send_email/")) + { + return (ok(200, new JSONObject())); + } + if(path.matches("/stages/invitation/invitations/[^/]+/") && method.equals("DELETE")) + { + invitations.remove(path.split("/")[4]); + return (ok(204, null)); + } + return (FixtureActionUtil.response(404, "{\"detail\":\"Not found.\"}")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private QHttpResponse get(String path, Map> query) + { + if(path.equals("/core/users/")) + { + List rs = new ArrayList<>(); + for(JSONObject user : users.values()) + { + JSONObject rendered = render(user); + if(query.containsKey("email") && !first(query, "email").equalsIgnoreCase(user.optString("email"))) + { + continue; + } + if(query.containsKey("username") && !first(query, "username").equalsIgnoreCase(user.optString("username"))) + { + continue; + } + if(query.containsKey("type") && !query.get("type").contains(user.optString("type"))) + { + continue; + } + rs.add(rendered); + } + return (page(rs)); + } + if(path.matches("/core/users/\\d+/")) + { + JSONObject user = users.get(Integer.parseInt(path.split("/")[3])); + return (user == null ? FixtureActionUtil.response(404, "{}") : ok(200, render(user))); + } + if(path.equals("/core/groups/")) + { + boolean includeUsers = "true".equals(first(query, "include_users")); + List rs = new ArrayList<>(); + for(JSONObject group : groups.values()) + { + if(query.containsKey("name") && !first(query, "name").equals(group.getString("name"))) + { + continue; + } + rs.add(render(group, includeUsers)); + } + return (page(rs)); + } + if(path.matches("/core/groups/[^/]+/")) + { + JSONObject group = groups.get(path.split("/")[3]); + return (group == null ? FixtureActionUtil.response(404, "{}") : ok(200, render(group, "true".equals(first(query, "include_users"))))); + } + if(path.equals("/core/tokens/")) + { + List rs = new ArrayList<>(tokens.getOrDefault(first(query, "user__username"), List.of())); + String intent = first(query, "intent"); + if(intent != null) + { + rs.removeIf(t -> !intent.equals(t.optString("intent"))); + } + return (page(rs)); + } + if(path.equals("/core/authenticated_sessions/")) + { + return (page(new ArrayList<>())); + } + if(path.equals("/authenticators/admin/all/")) + { + JSONArray rs = new JSONArray(); + devices.getOrDefault(Integer.parseInt(first(query, "user")), List.of()).forEach(rs::put); + return (ok(200, rs)); + } + if(path.equals("/stages/email/")) + { + return (page(List.of(new JSONObject().put("pk", "11111111-1111-1111-1111-111111111111").put("name", first(query, "name"))))); + } + if(path.equals("/flows/instances/")) + { + return (page(List.of(new JSONObject().put("pk", "22222222-2222-2222-2222-222222222222").put("slug", first(query, "slug"))))); + } + if(path.matches("/stages/invitation/invitations/[^/]+/")) + { + 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/")) + { + return (page(new ArrayList<>())); + } + return (FixtureActionUtil.response(404, "{\"detail\":\"Not found.\"}")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QHttpResponse page(List results) + { + return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", results.size()).put("current", 1).put("total_pages", 1)) + .put("results", new JSONArray(results)))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QHttpResponse ok(int status, Object body) + { + return (FixtureActionUtil.response(status, body == null ? "" : body.toString())); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static JSONObject body(HttpRequestBase request) + { + try + { + if(request instanceof HttpEntityEnclosingRequest entityRequest && entityRequest.getEntity() != null) + { + return (new JSONObject(EntityUtils.toString(entityRequest.getEntity()))); + } + } + catch(IOException e) + { + throw (new IllegalStateException(e)); + } + return (null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String first(Map> query, String name) + { + List values = query.get(name); + return (values == null || values.isEmpty() ? null : values.get(0)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static Map> parseQuery(String rawQuery) + { + Map> rs = new LinkedHashMap<>(); + if(rawQuery == null) + { + return (rs); + } + for(String pair : rawQuery.split("&")) + { + String[] kv = pair.split("=", 2); + rs.computeIfAbsent(URLDecoder.decode(kv[0], StandardCharsets.UTF_8), k -> new ArrayList<>()).add(kv.length > 1 ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8) : ""); + } + return (rs); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java b/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java new file mode 100644 index 0000000..3c366bd --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.app; + + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikTestSupport; +import com.kingsrook.qbits.authentik.admin.AdminTestSupport; +import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The Identity app: nav with only the main tables, child lists on the parents, + ** the local tables, the single home, and the guarantee that nothing but the + ** admin service changes Authentik. + *******************************************************************************/ +class AuthentikAppTest +{ + /******************************************************************************* + ** The nav lists only the main tables; memberships, sessions, tokens, events, + ** bindings, roles and blueprints are reached from their parents. + *******************************************************************************/ + @Test + void navHasOnlyMainTablesAndParentsHaveChildLists() throws QException + { + AdminTestSupport t = AdminTestSupport.create(); + QAppMetaData app = t.qInstance.getApp("authentikAdmin"); + + assertThat(app.getChildren()).extracting(c -> c.getName()) + .containsExactly("authentik_user", "authentik_invitation", "authentik_group", "authentik_application", "authentik_approval_request", "authentik_admin_action"); + assertThat(app.getSections()).extracting(s -> s.getLabel()).containsExactly("People", "Access", "Activity"); + assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikDriftReport"); + + for(String subTable : List.of("authentik_group_member", "authentik_session", "authentik_token", "authentik_event", "authentik_policy_binding", "authentik_role", "authentik_blueprint")) + { + assertThat(t.qInstance.getTable(subTable)).as(subTable).isNotNull(); + assertThat(app.getChildren()).extracting(c -> c.getName()).doesNotContain(subTable); + } + + QTableMetaData user = t.qInstance.getTable("authentik_user"); + assertThat(user.getSections()).extracting(s -> s.getName()).contains("identity", "access", "memberships", "sessions", "tokens", "events"); + assertThat(t.qInstance.getTable("authentik_group").getSections()).extracting(s -> s.getName()).contains("details", "permissions", "members"); + assertThat(t.qInstance.getJoin("authentik_user_sessions").getRightTable()).isEqualTo("authentik_session"); + assertThat(t.qInstance.getWidget("authentik_user_memberships")).isNotNull(); + } + + + + /******************************************************************************* + ** The local tables are standard visible tables on the host's backend, written + ** only by the qbit; with no local backend there are none (read-only app). + *******************************************************************************/ + @Test + void localTables() throws QException + { + AdminTestSupport t = AdminTestSupport.create(); + for(String name : List.of("authentik_admin_action", "authentik_approval_request")) + { + QTableMetaData table = t.qInstance.getTable(name); + assertThat(table.getBackendName()).isEqualTo(AdminTestSupport.LOCAL); + assertThat(table.isCapabilityEnabled(t.qInstance.getBackend(AdminTestSupport.LOCAL), Capability.TABLE_INSERT)).isFalse(); + assertThat(table.isCapabilityEnabled(t.qInstance.getBackend(AdminTestSupport.LOCAL), Capability.TABLE_UPDATE)).isFalse(); + assertThat(table.isCapabilityEnabled(t.qInstance.getBackend(AdminTestSupport.LOCAL), Capability.TABLE_DELETE)).isFalse(); + assertThat(table.isCapabilityEnabled(t.qInstance.getBackend(AdminTestSupport.LOCAL), Capability.TABLE_QUERY)).isTrue(); + } + assertThat(t.qInstance.getTable("authentik_admin_action").getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.audit.read"); + assertThat(t.qInstance.getTable("authentik_approval_request").getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.approve"); + assertThat(t.qInstance.getPossibleValueSource(AuthentikLocalTables.PVS_ACTION_STATUS)).isNotNull(); + + QInstance readOnly = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig()); + assertThat(readOnly.getTable("authentik_admin_action")).isNull(); + assertThat(readOnly.getProcesses()).isEmpty(); + assertThat(readOnly.getApp("authentikAdmin").getChildren()).extracting(c -> c.getName()).containsExactly("authentik_user", "authentik_group", "authentik_application"); + } + + + + /******************************************************************************* + ** Read-only tables carry no key field for tokens and no insert capability. + *******************************************************************************/ + @Test + void tokenTableNeverHasAKey() throws QException + { + AdminTestSupport t = AdminTestSupport.create(); + QTableMetaData token = t.qInstance.getTable("authentik_token"); + assertThat(token.getFields().keySet()).doesNotContain("key", "token", "secret"); + assertThat(token.getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.audit.read"); + } + + + + /******************************************************************************* + ** The home renders every card, escapes values, and shows honest states. + *******************************************************************************/ + @Test + void homeRenders() throws Exception + { + AdminTestSupport t = AdminTestSupport.create(); + t.config.withBootstrapOperatorSubjects("boot"); + com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface widget = t.qInstance.getWidget("authentikHome"); + assertThat(widget.getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.users.read"); + + RenderWidgetInput input = new RenderWidgetInput(); + input.setWidgetMetaData(widget); + RenderWidgetOutput output = ((AbstractWidgetRenderer) new AuthentikHomeWidget.Renderer()).render(input); + String html = ((RawHTML) output.getWidgetData()).getHtml(); + + assertThat(html).contains("Needs attention", "People", "Groups and permissions", "Recent changes", "Connection"); + assertThat(html).contains("Active
4<"); + assertThat(html).contains("Inactive
1<"); + assertThat(html).contains("Service accounts
2<"); + assertThat(html).contains("bootstrapOperatorSubjects is still set"); + assertThat(html).contains("No changes have been made from here yet."); + assertThat(html).contains("href=\"/app/authentik_user\"").contains("href=\"/app/authentik_group\""); + assertThat(html).doesNotContain("boot"); + assertThat(html).doesNotContain(AuthentikTestSupport.TEST_TOKEN); + + t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ); + String withoutAudit = ((RawHTML) new AuthentikHomeWidget.Renderer().render(input).getWidgetData()).getHtml(); + assertThat(withoutAudit).contains("needs the authentik.audit.read permission"); + } + + + + /******************************************************************************* + ** Nothing but AuthentikAdmin may send a change to Authentik: the util's + ** write method is only called from there (the guard's authorization is the + ** required argument; this keeps the call sites to one class). + *******************************************************************************/ + @Test + void onlyTheAdminServiceWrites() throws IOException + { + Path root = Path.of("src/main/java/com/kingsrook/qbits/authentik"); + try(Stream files = Files.walk(root)) + { + List callers = files.filter(p -> p.toString().endsWith(".java")).filter(p -> + { + try + { + return (Files.readString(p).contains("util.write(") || Files.readString(p).contains(".write(auth")); + } + catch(IOException e) + { + throw (new IllegalStateException(e)); + } + }).toList(); + assertThat(callers).extracting(p -> p.getFileName().toString()).containsExactly("AuthentikAdmin.java"); + } + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtilTest.java b/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtilTest.java index e1b762a..a6f564d 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtilTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtilTest.java @@ -339,7 +339,7 @@ class AuthentikActionUtilTest assertThat(identityAdmins.getValueString("permissions")).isEqualTo("authentik.*"); assertThat(identityAdmins.getValueString("appAccess")).isEqualTo("kof22-website-prod"); assertThat(identityAdmins.getValueBoolean("isSuperuser")).isFalse(); - assertThat(param(FixtureActionUtil.REQUESTS.get(0), "include_users", null)).isEqualTo("false"); + assertThat(param(FixtureActionUtil.REQUESTS.get(0), "include_users", null)).isEqualTo("true"); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/client/FixtureActionUtil.java b/src/test/java/com/kingsrook/qbits/authentik/client/FixtureActionUtil.java index 8e69c1e..55aedea 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/client/FixtureActionUtil.java +++ b/src/test/java/com/kingsrook/qbits/authentik/client/FixtureActionUtil.java @@ -42,6 +42,9 @@ public class FixtureActionUtil extends AuthentikActionUtil /** Body served for the group-graph sweep (GET /core/groups/?include_users=false&include_parents=false...). */ public static String groupGraphBody = EMPTY_PAGE; + /** When set, serves group sweeps (include_parents=false) instead of groupGraphBody. */ + public static Function groupGraphHandler = null; + @@ -53,6 +56,7 @@ public class FixtureActionUtil extends AuthentikActionUtil REQUESTS.clear(); handler = r -> response(404, "{\"detail\":\"Not found.\"}"); groupGraphBody = EMPTY_PAGE; + groupGraphHandler = null; AuthentikActionUtil.resetVersionCheck(); } @@ -71,7 +75,7 @@ public class FixtureActionUtil extends AuthentikActionUtil } if(request.getURI().getPath().endsWith("/core/groups/") && String.valueOf(request.getURI().getRawQuery()).contains("include_parents=false")) { - return (response(200, groupGraphBody)); + return (groupGraphHandler != null ? groupGraphHandler.apply(request) : response(200, groupGraphBody)); } return (handler.apply(request)); } 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 439b1f6..42274f9 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java @@ -200,6 +200,30 @@ public final class AuthentikTestStack + /******************************************************************************* + ** Run a command in the Authentik server container (for example `ak shell + ** -c ...`), for setup the API cannot do (creating MFA devices). Returns stdout; + ** throws when the command fails. + *******************************************************************************/ + public String execInServer(String... command) + { + try + { + org.testcontainers.containers.Container.ExecResult result = server.execInContainer(command); + if(result.getExitCode() != 0) + { + throw (new IllegalStateException("Command failed (" + result.getExitCode() + "): " + result.getStderr())); + } + return (result.getStdout()); + } + catch(java.io.IOException | InterruptedException e) + { + throw (new IllegalStateException(e)); + } + } + + + /******************************************************************************* ** Host root, e.g. http://localhost:32768 (the qbit's baseUrl). *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java new file mode 100644 index 0000000..077d79a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java @@ -0,0 +1,482 @@ +/* + * 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.contract; + + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +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.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +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.AfterAll; +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.extension.ExtendWith; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The qbit's WRITE path against a real Authentik 2026.8.3 (DESIGN.md section + ** 10 contract tests a, f and the phase 2/3 actions), as the service account + ** holding the phase 2/3 role permissions (granted additively here and removed + ** again): create user + groups + the recovery email arriving in Mailpit, + ** membership, permission attributes (merged into the current attributes), + ** deactivation, app-password revocation that spares API tokens, the guard + ** refusing superuser groups and protected accounts before Authentik is + ** called, and the audit event naming the human operator. + *******************************************************************************/ +@ExtendWith(RequiresDockerCondition.class) +@Order(70) +class QbitWriteContractIT +{ + private static final String OPERATOR_EMAIL = "wr.operator@example.test"; + + /** The phase 2/3 permissions of DESIGN.md section 5.1 (plus view_flow to resolve the invitation flow by slug). */ + private static final List ROLE_PERMISSIONS = List.of( + "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", "authentik_core.delete_authenticatedsession", + "authentik_stages_invitation.view_invitation", "authentik_stages_invitation.add_invitation", "authentik_stages_invitation.delete_invitation", + "authentik_flows.view_flow", + "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"); + + private static AuthentikTestStack stack; + private static TestApi admin; + private static String roleUuid; + + private QInstance qInstance; + private AuthentikQBitConfig config; + + + + /******************************************************************************* + ** Grant the role permissions, and create an operator who is an identity + ** administrator through a group (so the live subset rule has something to + ** resolve), plus the enrollment flow. + *******************************************************************************/ + @BeforeAll + static void beforeAll() + { + 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))); + + 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))))); + admin.post("/core/groups/", new JSONObject().put("name", "wr-readers").put("attributes", + new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.users.read"))))); + admin.post("/core/users/", new JSONObject().put("username", "wr.operator").put("name", "WR Operator").put("email", OPERATOR_EMAIL).put("type", "internal").put("path", "users")); + admin.post("/core/groups/" + admin.groupPk("wr-admins") + "/add_user/", new JSONObject().put("pk", admin.userPk("wr.operator"))); + admin.post("/core/users/", new JSONObject().put("username", "wr.target").put("name", "WR Target").put("email", "wr.target@example.test").put("type", "internal").put("path", "users")); + admin.post("/flows/instances/", new JSONObject().put("name", "wr enrollment").put("slug", "wr-enrollment").put("title", "Enroll").put("designation", "enrollment")); + + /////////////////////////////////////////////////////////////////////////////// + // recovery_email needs the brand to have a recovery flow ("No recovery flow // + // set." otherwise): an owner prerequisite, documented in the checklist // + /////////////////////////////////////////////////////////////////////////////// + String brand = admin.get("/core/brands/").results().getJSONObject(0).getString("brand_uuid"); + String flow = admin.first("/flows/instances/?slug=kof22-recovery").getString("pk"); + admin.patch("/core/brands/" + brand + "/", new JSONObject().put("flow_recovery", flow)); + } + + + + /******************************************************************************* + ** Remove the test-only grants again. + *******************************************************************************/ + @AfterAll + static void afterAll() + { + if(admin != null && roleUuid != null) + { + admin.patch("/rbac/permissions/assigned_by_roles/" + roleUuid + "/unassign/", new JSONObject().put("permissions", new JSONArray(ROLE_PERMISSIONS))); + } + } + + + + /******************************************************************************* + ** A fresh qbit instance (all write features, audit on the memory backend) + ** and the operator's session. + *******************************************************************************/ + @BeforeEach + void beforeEach() throws QException + { + 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") + .withInvitationFlow("wr-enrollment") + .withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, + AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS)); + + qInstance = new QInstance(); + qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)); + qInstance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance); + + Set permissions = new HashSet<>(); + for(IdentityPermission permission : IdentityPermission.values()) + { + permissions.add(permission.getSessionPermission()); + } + QSession session = new QSession(); + session.setUser(new QUser().withIdReference(OPERATOR_EMAIL).withFullName("WR Operator")); + session.setPermissions(permissions); + QContext.init(qInstance, session); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void afterEach() + { + QContext.clear(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private AuthentikAdmin.AdminResult run(AdminRequest request) throws QException + { + return (new AuthentikAdmin(AuthentikActionUtil.forBackend(config.getApiBackendName())).execute(request)); + } + + + + /******************************************************************************* + ** (a) Create user -> add to group -> recovery email arrives in Mailpit; no + ** password is set; the operator's identity is on the user and in Authentik's + ** event log. + *******************************************************************************/ + @Test + void createUserSendsRecoveryEmailAndAuditsInAuthentik() throws Exception + { + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.CREATE_USER, "", "contract test").with("username", "wr.created").with("name", "WR Created") + .with("email", "wr.created@example.test").with("groups", "wr-readers")); + + assertThat(result.ok()).as(result.message()).isTrue(); + JSONObject user = admin.first("/core/users/?username=wr.created&include_groups=true"); + assertThat(user.getString("email")).isEqualTo("wr.created@example.test"); + assertThat(user.getString("path")).isEqualTo("users"); + assertThat(user.getJSONObject("attributes").getString("qqq.io/created-by")).isEqualTo(OPERATOR_EMAIL); + assertThat(user.getJSONArray("groups_obj").getJSONObject(0).getString("name")).isEqualTo("wr-readers"); + + AuthentikTestStack.waitFor(Duration.ofSeconds(60), "the recovery email in Mailpit", () -> mailpitHas("wr.created@example.test")); + + AuthentikTestStack.waitFor(Duration.ofSeconds(30), "the audit event naming the operator", () -> + { + for(Object event : admin.get("/events/events/?search=" + TestApi.encode(OPERATOR_EMAIL) + "&ordering=-created").results()) + { + JSONObject context = ((JSONObject) event).optJSONObject("context"); + if(((JSONObject) event).getString("action").startsWith("custom_") && context != null && "CREATE_USER".equals(context.optString("action"))) + { + return (OPERATOR_EMAIL.equals(context.optString("operator")) && "contract test".equals(context.optString("reason"))); + } + } + return (false); + }); + } + + + + /******************************************************************************* + ** Memberships both ways, with the subset rule using the operator's live + ** groups (wr-admins holds authentik.*, so adding to wr-readers is allowed). + *******************************************************************************/ + @Test + void addAndRemoveMember() throws Exception + { + int target = admin.userPk("wr.target"); + String group = admin.groupPk("wr-readers"); + + assertThat(run(AdminRequest.of(AdminAction.ADD_MEMBER, group + ":" + target, "contract test")).ok()).isTrue(); + assertThat(memberNames("wr-readers")).contains("wr.target"); + + assertThat(run(AdminRequest.of(AdminAction.REMOVE_MEMBER, group + ":" + target, "contract test")).ok()).isTrue(); + assertThat(memberNames("wr-readers")).doesNotContain("wr.target"); + } + + + + /******************************************************************************* + ** The guard refuses a superuser group and a protected account BEFORE + ** Authentik is called (Authentik itself would refuse the first, and allow the + ** second: contract test (b) and the lockout findings). + *******************************************************************************/ + @Test + void guardRefusesBeforeAuthentikIsCalled() throws Exception + { + int target = admin.userPk("wr.target"); + String superuser = admin.groupPk(AuthentikTestStack.SUPERUSER_GROUP); + + AuthentikAdmin.AdminResult toSuperuser = run(AdminRequest.of(AdminAction.ADD_MEMBER, superuser + ":" + target, "contract test")); + assertThat(toSuperuser.ok()).isFalse(); + assertThat(toSuperuser.message()).containsIgnoringCase("superuser"); + + AuthentikAdmin.AdminResult breakGlass = run(AdminRequest.of(AdminAction.DEACTIVATE_USER, String.valueOf(admin.userPk(AuthentikTestStack.BREAK_GLASS)), "contract test") + .with("confirmText", AuthentikTestStack.BREAK_GLASS)); + assertThat(breakGlass.ok()).isFalse(); + assertThat(breakGlass.message()).containsIgnoringCase("protected"); + assertThat(admin.get("/core/users/" + admin.userPk(AuthentikTestStack.BREAK_GLASS) + "/").json().getBoolean("is_active")).isTrue(); + } + + + + /******************************************************************************* + ** Editing permissions merges only the two qqq.io keys into the group's + ** CURRENT attributes, so something set in the Authentik UI survives. + *******************************************************************************/ + @Test + void editPermissionsKeepsOtherAttributes() throws Exception + { + admin.post("/core/groups/", new JSONObject().put("name", "wr-merge").put("attributes", new JSONObject().put("owner-note", "set in the Authentik UI"))); + String pk = admin.groupPk("wr-merge"); + + config.withTwoPersonApproval(false); + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_PERMISSIONS, pk, "contract test") + .with("permissions", "authentik.users.read").with("appAccess", AuthentikTestStack.APP_SLUG)); + + assertThat(result.ok()).as(result.message()).isTrue(); + JSONObject attributes = admin.get("/core/groups/" + pk + "/").json().getJSONObject("attributes"); + assertThat(attributes.getString("owner-note")).isEqualTo("set in the Authentik UI"); + assertThat(attributes.getJSONArray("qqq.io/permissions").toList()).containsExactly("authentik.users.read"); + assertThat(attributes.getJSONArray("qqq.io/app-access").toList()).containsExactly(AuthentikTestStack.APP_SLUG); + } + + + + /******************************************************************************* + ** Group create and edit (parents), and the superuser parent refused by the + ** guard. + *******************************************************************************/ + @Test + void createAndEditGroup() throws Exception + { + AuthentikAdmin.AdminResult created = run(AdminRequest.of(AdminAction.CREATE_GROUP, "", "contract test").with("name", "wr-child").with("parents", "wr-readers")); + assertThat(created.ok()).as(created.message()).isTrue(); + JSONObject group = admin.get("/core/groups/" + admin.groupPk("wr-child") + "/").json(); + assertThat(group.getJSONArray("parents").getString(0)).isEqualTo(admin.groupPk("wr-readers")); + assertThat(group.getBoolean("is_superuser")).isFalse(); + + AuthentikAdmin.AdminResult renamed = run(AdminRequest.of(AdminAction.EDIT_GROUP, admin.groupPk("wr-child"), "contract test").with("name", "wr-child-renamed")); + assertThat(renamed.ok()).as(renamed.message()).isTrue(); + assertThat(admin.groupPk("wr-child-renamed")).isEqualTo(admin.groupPk("wr-child-renamed")); + + AuthentikAdmin.AdminResult superuserParent = run(AdminRequest.of(AdminAction.EDIT_GROUP, admin.groupPk("wr-child-renamed"), "contract test").with("parents", AuthentikTestStack.SUPERUSER_GROUP)); + assertThat(superuserParent.ok()).isFalse(); + assertThat(superuserParent.message()).containsIgnoringCase("superuser"); + } + + + + /******************************************************************************* + ** (f) Deactivate: the user is inactive; app passwords go when asked, API + ** tokens never do. Reactivate restores. + *******************************************************************************/ + @Test + void deactivateRevokesOnlyAppPasswords() throws Exception + { + admin.post("/core/users/", new JSONObject().put("username", "wr.deact").put("name", "WR Deact").put("email", "wr.deact@example.test").put("type", "internal").put("path", "users")); + int pk = admin.userPk("wr.deact"); + admin.post("/core/tokens/", new JSONObject().put("identifier", "wr-deact-app").put("intent", "app_password").put("user", pk).put("expiring", false)); + admin.post("/core/tokens/", new JSONObject().put("identifier", "wr-deact-api").put("intent", "api").put("user", pk).put("expiring", false)); + + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.DEACTIVATE_USER, String.valueOf(pk), "contract test").with("confirmText", "wr.deact").with("revokeAppPasswords", true)); + assertThat(result.ok()).as(result.message()).isTrue(); + assertThat(admin.get("/core/users/" + pk + "/").json().getBoolean("is_active")).isFalse(); + assertThat(admin.get("/core/tokens/?identifier=wr-deact-app").results().length()).isZero(); + assertThat(admin.get("/core/tokens/?identifier=wr-deact-api").results().length()).isEqualTo(1); + + assertThat(run(AdminRequest.of(AdminAction.REACTIVATE_USER, String.valueOf(pk), "contract test")).ok()).isTrue(); + assertThat(admin.get("/core/users/" + pk + "/").json().getBoolean("is_active")).isTrue(); + } + + + + /******************************************************************************* + ** Edit user and the password-reset email (Authentik's own email; the link is + ** never shown to the operator). + *******************************************************************************/ + @Test + void editUserAndPasswordReset() throws Exception + { + int pk = admin.userPk("wr.target"); + AuthentikAdmin.AdminResult edit = run(AdminRequest.of(AdminAction.EDIT_USER, String.valueOf(pk), "contract test").with("name", "WR Target Renamed") + .with("email", "wr.target@example.test").with("path", "users")); + assertThat(edit.ok()).as(edit.message()).isTrue(); + assertThat(admin.get("/core/users/" + pk + "/").json().getString("name")).isEqualTo("WR Target Renamed"); + + AuthentikAdmin.AdminResult reset = run(AdminRequest.of(AdminAction.SEND_RECOVERY_EMAIL, String.valueOf(pk), "contract test")); + assertThat(reset.ok()).as(reset.message()).isTrue(); + assertThat(reset.message()).doesNotContain("http"); + AuthentikTestStack.waitFor(Duration.ofSeconds(60), "the reset email in Mailpit", () -> mailpitHas("wr.target@example.test")); + } + + + + /******************************************************************************* + ** Invitations: create (with its email through the enrollment flow's slug + ** resolved by the qbit) and cancel. + *******************************************************************************/ + @Test + void inviteAndCancel() throws Exception + { + AuthentikAdmin.AdminResult invite = run(AdminRequest.of(AdminAction.INVITE_USER, "", "contract test").with("email", "wr.invited@example.test")); + assertThat(invite.ok()).as(invite.message()).isTrue(); + + JSONArray invitations = admin.get("/stages/invitation/invitations/").results(); + String pk = null; + for(int i = 0; i < invitations.length(); i++) + { + if("wr.invited@example.test".equals(invitations.getJSONObject(i).getJSONObject("fixed_data").optString("email"))) + { + pk = invitations.getJSONObject(i).getString("pk"); + } + } + assertThat(pk).isNotNull(); + + assertThat(run(AdminRequest.of(AdminAction.CANCEL_INVITATION, pk, "contract test")).ok()).isTrue(); + assertThat(admin.get("/stages/invitation/invitations/" + pk + "/").status()).isEqualTo(404); + } + + + + /******************************************************************************* + ** Reset MFA: the device list is a bare array (not a paged object) whose type + ** strings map to the delete endpoints; both device kinds are removed and a + ** second reset finds nothing to do. (Devices are created in the server + ** container because the admin API cannot create them for another user.) + *******************************************************************************/ + @Test + void resetMfaRemovesEveryDevice() throws Exception + { + admin.post("/core/users/", new JSONObject().put("username", "wr.mfa").put("name", "WR MFA").put("email", "wr.mfa@example.test").put("type", "internal").put("path", "users")); + int pk = admin.userPk("wr.mfa"); + stack.execInServer("ak", "shell", "-c", + "from authentik.core.models import User; from authentik.stages.authenticator_totp.models import TOTPDevice; " + + "from authentik.stages.authenticator_static.models import StaticDevice; u = User.objects.get(username='wr.mfa'); " + + "TOTPDevice.objects.create(user=u, name='wr-totp', confirmed=True); StaticDevice.objects.create(user=u, name='wr-static', confirmed=True)"); + JSONArray before = new JSONArray(admin.get("/authenticators/admin/all/?user=" + pk).body()); + assertThat(before.length()).isEqualTo(2); + for(int i = 0; i < before.length(); i++) + { + assertThat(AuthentikAdmin.devicePath(before.getJSONObject(i))).as(before.getJSONObject(i).toString()).startsWith("/authenticators/admin/"); + } + + AuthentikAdmin.AdminResult untyped = run(AdminRequest.of(AdminAction.RESET_MFA, String.valueOf(pk), "contract test")); + assertThat(untyped.ok()).isFalse(); + + AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.RESET_MFA, String.valueOf(pk), "contract test").with("confirmText", "wr.mfa")); + assertThat(result.ok()).as(result.message()).isTrue(); + assertThat(new JSONArray(admin.get("/authenticators/admin/all/?user=" + pk).body()).length()).isZero(); + assertThat(run(AdminRequest.of(AdminAction.RESET_MFA, String.valueOf(pk), "contract test").with("confirmText", "wr.mfa")).noChange()).isTrue(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List memberNames(String groupName) + { + JSONArray users = admin.get("/core/groups/" + admin.groupPk(groupName) + "/?include_users=true").json().getJSONArray("users_obj"); + java.util.ArrayList rs = new java.util.ArrayList<>(); + for(int i = 0; i < users.length(); i++) + { + rs.add(users.getJSONObject(i).getString("username")); + } + return (rs); + } + + + + /******************************************************************************* + ** Whether Mailpit holds a message addressed to this address. + *******************************************************************************/ + private boolean mailpitHas(String address) + { + try + { + HttpResponse response = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(stack.mailpitRoot() + "/api/v1/messages")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + JSONArray messages = new JSONObject(response.body()).optJSONArray("messages"); + for(int i = 0; messages != null && i < messages.length(); i++) + { + JSONArray to = messages.getJSONObject(i).optJSONArray("To"); + for(int j = 0; to != null && j < to.length(); j++) + { + if(address.equalsIgnoreCase(to.getJSONObject(j).optString("Address"))) + { + return (true); + } + } + } + return (false); + } + catch(Exception e) + { + return (false); + } + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java index 274f0e4..13cea7f 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java @@ -55,7 +55,7 @@ class AuthentikGroupPermissionMapperTest void beforeEach() throws QException { FixtureActionUtil.reset(); - config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0).withBootstrapOperators("Boot@Example.Test"); + config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0).withBootstrapOperatorSubjects("opaque-sub-123, 42"); qInstance = AuthentikTestSupport.produce(config); AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class); QContext.init(qInstance, new QSession()); @@ -88,6 +88,96 @@ class AuthentikGroupPermissionMapperTest + /******************************************************************************* + ** + *******************************************************************************/ + private static Map subjectContext(String sub, String email, boolean verified) + { + Map context = new HashMap<>(); + context.put(AuthentikGroupPermissionMapper.CLAIMS_CONTEXT_KEY, + new JSONObject().put("sub", sub).put("email", email).put("email_verified", verified).put("groups", new JSONArray())); + return (context); + } + + + + /******************************************************************************* + ** Serve /core/users/?email= with these users (everything else as before). + *******************************************************************************/ + private static void usersWithEmail(JSONObject... users) + { + java.util.function.Function previous = FixtureActionUtil.handler; + FixtureActionUtil.handler = request -> + { + if(request.getURI().getPath().endsWith("/core/users/") && String.valueOf(request.getURI().getRawQuery()).contains("email=")) + { + return (FixtureActionUtil.response(200, new JSONObject().put("pagination", new JSONObject().put("count", users.length).put("current", 1).put("total_pages", 1)) + .put("results", new JSONArray(users)).toString())); + } + return (previous.apply(request)); + }; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static JSONObject user(int pk, String email) + { + return (new JSONObject().put("pk", pk).put("uuid", "uuid-" + pk).put("email", email).put("is_active", true).put("username", "u" + pk)); + } + + + + /******************************************************************************* + ** Bootstrap operators are keyed on immutable ids, never on an email: a + ** spoofed email (duplicate, or a different user) is denied, an unverified + ** email is denied, a matching sub or a verified unique user is granted. + *******************************************************************************/ + @Test + void bootstrapIsNotEmailBased() + { + AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper(); + String grant = IdentityPermission.MEMBERSHIPS_MANAGE.getSessionPermission(); + + // the attacker set their email to the owner's: two users share it, sub differs + usersWithEmail(user(42, "owner@example.test"), user(99, "owner@example.test")); + QSession duplicate = new QSession(); + mapper.customizeSession(qInstance, duplicate, subjectContext("attacker-sub", "owner@example.test", true)); + assertThat(duplicate.getPermissions()).doesNotContain(grant); + + // only the attacker has that email, but their pk is not a configured subject + usersWithEmail(user(99, "owner@example.test")); + QSession other = new QSession(); + mapper.customizeSession(qInstance, other, subjectContext("attacker-sub", "owner@example.test", true)); + assertThat(other.getPermissions()).doesNotContain(grant); + + // the right user, but the token's email is not verified + usersWithEmail(user(42, "owner@example.test")); + QSession unverified = new QSession(); + mapper.customizeSession(qInstance, unverified, subjectContext("some-sub", "owner@example.test", false)); + assertThat(unverified.getPermissions()).doesNotContain(grant); + + // an email-looking sub never counts, even if it equals a configured value + QSession emailSub = new QSession(); + mapper.customizeSession(qInstance, emailSub, subjectContext("owner@example.test", "x@example.test", false)); + assertThat(emailSub.getPermissions()).doesNotContain(grant); + + // verified email, exactly one active user, pk is a configured subject + QSession good = new QSession(); + mapper.customizeSession(qInstance, good, subjectContext("some-sub", "owner@example.test", true)); + assertThat(good.getPermissions()).contains(grant); + + // the Authentik lookup failing denies + FixtureActionUtil.handler = request -> FixtureActionUtil.response(500, "boom"); + QSession down = new QSession(); + mapper.customizeSession(qInstance, down, subjectContext("some-sub", "owner@example.test", true)); + assertThat(down.getPermissions()).doesNotContain(grant); + } + + + /******************************************************************************* ** Inherited authentik.* through a child group; existing permissions kept. *******************************************************************************/ @@ -120,7 +210,7 @@ class AuthentikGroupPermissionMapperTest assertThat(admin.getPermissions()).contains("adminEsb.hasAccess").doesNotContain(IdentityPermission.USERS_READ.getSessionPermission()); QSession boot = new QSession(); - mapper.customizeSession(qInstance, boot, context("boot@example.test", new JSONArray())); + mapper.customizeSession(qInstance, boot, subjectContext("opaque-sub-123", "boot@example.test", false)); assertThat(boot.getPermissions()).contains(IdentityPermission.MEMBERSHIPS_MANAGE.getSessionPermission()); QSession none = new QSession(); @@ -151,7 +241,7 @@ class AuthentikGroupPermissionMapperTest return ("nope"); } }; - assertThat(wrongBackend.resolve(qInstance, List.of("phase1-child-of-identity-admins"), "x@example.test")).isEmpty(); + assertThat(wrongBackend.resolve(qInstance, List.of("phase1-child-of-identity-admins"), "s", "x@example.test", false)).isEmpty(); } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/processes/AuthentikProcessesTest.java b/src/test/java/com/kingsrook/qbits/authentik/processes/AuthentikProcessesTest.java new file mode 100644 index 0000000..30a5df7 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/processes/AuthentikProcessesTest.java @@ -0,0 +1,270 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.processes; + + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.admin.AdminTestSupport; +import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables; +import com.kingsrook.qbits.authentik.client.AuthentikTables; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The record and table actions run as standard streamed-ETL processes through + ** QQQ's own RunProcessAction: a details screen, a Review step (the dry run, + ** with refused targets and why) and a Result step, and none is in the nav. + *******************************************************************************/ +class AuthentikProcessesTest +{ + private AdminTestSupport t; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void setUp() throws QException + { + t = AdminTestSupport.create(); + } + + + + /******************************************************************************* + ** What a run produced: the Review lines and the Result lines. + *******************************************************************************/ + private record Outcome(List review, List result) + { + /******************************************************************************* + ** All messages of a list, for readable assertions. + *******************************************************************************/ + static List messages(List lines) + { + return (lines.stream().map(l -> l.getCount() + " " + l.getMessage()).toList()); + } + } + + + + /******************************************************************************* + ** Run a process as the frontend would: start (collecting the first screen), + ** submit the details with a full validation, review, execute. + *******************************************************************************/ + @SuppressWarnings("unchecked") + private Outcome run(String processName, String table, String key, List keys, Map form, boolean execute) throws QException + { + RunProcessInput input = new RunProcessInput(); + input.setProcessName(processName); + if(keys != null) + { + input.addValue("recordIds", keys.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(","))); + } + RunProcessOutput output = new RunProcessAction().execute(input); + input.setProcessUUID(output.getProcessUUID()); + form.forEach(input::addValue); + input.setStartAfterStep(t.qInstance.getProcess(processName).getStepList().stream().filter(s -> s.getName().equals("details")).findFirst().isPresent() ? "details" : null); + output = new RunProcessAction().execute(input); + assertThat(output.getException()).isEmpty(); + + ///////////////////////////////////////////////////////////////////////////// + // the process defaults to full validation, so the Review screen already // + // holds the dry-run lines for every target // + ///////////////////////////////////////////////////////////////////////////// + List review = (List) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY); + assertThat(output.getProcessState().getNextStepName()).contains("review"); + if(!execute) + { + return (new Outcome(review, List.of())); + } + + input.setStartAfterStep("review"); + output = new RunProcessAction().execute(input); + assertThat(output.getException()).isEmpty(); + return (new Outcome(review, (List) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY))); + } + + + + /******************************************************************************* + ** Which processes exist depends on the features; they are table or record + ** actions (bound to a table, hidden from the nav by not being in an app). + *******************************************************************************/ + @Test + void processesFollowFeaturesAndAreNeverInTheNav() throws QException + { + assertThat(t.qInstance.getProcesses().keySet()).contains("authentikCreateUser", "authentikInviteUser", "authentikEditUser", "authentikDeactivateUser", + "authentikReactivateUser", "authentikAddToGroup", "authentikRemoveFromGroup", "authentikSendPasswordReset", "authentikResetMfa", "authentikRevokeAppPasswords", + "authentikRevokeSessions", "authentikCreateGroup", "authentikEditGroup", "authentikEditPermissions", "authentikAddMembers", "authentikRemoveMembers", + "authentikCancelInvitation", "authentikApprove", "authentikReject"); + + for(QProcessMetaData process : t.qInstance.getProcesses().values()) + { + assertThat(process.getTableName()).as(process.getName()).isNotBlank(); + assertThat(t.qInstance.getApp("authentikAdmin").getChildren()).extracting(c -> c.getName()).doesNotContain(process.getName()); + } + assertThat(t.qInstance.getProcess("authentikCreateUser").getMaxInputRecords()).isEqualTo(0); + assertThat(t.qInstance.getProcess("authentikEditUser").getMaxInputRecords()).isEqualTo(1); + assertThat(t.qInstance.getProcess("authentikAddToGroup").getMinInputRecords()).isEqualTo(1); + + AdminTestSupport readOnly = AdminTestSupport.create(java.util.EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MEMBERSHIP)); + assertThat(readOnly.qInstance.getProcesses().keySet()).contains("authentikAddToGroup", "authentikAddMembers").doesNotContain("authentikCreateUser", "authentikEditPermissions", "authentikApprove"); + } + + + + /******************************************************************************* + ** Add To Group: the Review is a dry run (nothing sent), refused people are + ** listed with the reason, the Result applies the rest. + *******************************************************************************/ + @Test + void addToGroupReviewThenResult() throws QException + { + Map form = Map.of("group", t.authentik.pk("kof-website-publishers"), "reason", "onboarding"); + + Outcome review = run("authentikAddToGroup", t.config.applyTablePrefix(AuthentikTables.USER), "pk", List.of(3, 4, 7), form, false); + assertThat(Outcome.messages(review.review())).anyMatch(m -> m.contains("membership will be added") || m.contains("memberships will be added")); + assertThat(review.review()).anyMatch(l -> l.getStatus() == Status.ERROR && l.getMessage().toLowerCase().contains("protected")); + assertThat(t.writes()).as("review is a dry run").isEmpty(); + assertThat(review.review().stream().filter(l -> l.getStatus() == Status.ERROR).flatMap(l -> l.getBulletsOfText().stream())).anyMatch(b -> b.contains("breakglass")); + + Outcome result = run("authentikAddToGroup", t.config.applyTablePrefix(AuthentikTables.USER), "pk", List.of(3, 4, 7), form, true); + assertThat(result.result()).anyMatch(l -> l.getStatus() == Status.OK && l.getCount() == 2); + assertThat(result.result()).anyMatch(l -> l.getStatus() == Status.ERROR); + assertThat(t.authentik.groupNamesOf(3)).contains("kof-website-publishers"); + assertThat(t.authentik.groupNamesOf(7)).contains("kof-website-publishers"); + assertThat(t.authentik.groupNamesOf(4)).doesNotContain("kof-website-publishers"); + } + + + + /******************************************************************************* + ** Create User is a table action with no records: the form carries the data. + *******************************************************************************/ + @Test + void createUserIsATableAction() throws QException + { + Map form = Map.of("username", "newbie", "name", "New Bie", "email", "newbie@example.test", "reason", "new hire", "groups", "", "sendRecovery", true); + Outcome outcome = run("authentikCreateUser", t.config.applyTablePrefix(AuthentikTables.USER), null, null, form, true); + + assertThat(Outcome.messages(outcome.review())).anyMatch(m -> m.contains("user will be created")); + assertThat(Outcome.messages(outcome.result())).anyMatch(m -> m.contains("user was created")); + assertThat(t.authentik.users.values()).anyMatch(u -> u.getString("username").equals("newbie")); + } + + + + /******************************************************************************* + ** Edit Permissions fills its form from the group (prepare step) and sends the + ** stale-check fingerprint back with it. + *******************************************************************************/ + @Test + void editPermissionsPrefillsTheForm() throws QException + { + RunProcessInput input = new RunProcessInput(); + input.setProcessName("authentikEditPermissions"); + input.addValue("recordIds", t.authentik.pk("kof-website-analysts")); + RunProcessOutput output = new RunProcessAction().execute(input); + + assertThat(output.getValues().get("permissions")).isEqualTo("kof22.analytics.hasAccess"); + assertThat(String.valueOf(output.getValues().get("expectedFingerprint"))).hasSize(16); + } + + + + /******************************************************************************* + ** A change that needs a second person shows as a warning in Review and is + ** queued by the Result; Approve then applies it. + *******************************************************************************/ + @Test + void approvalFlowThroughProcesses() throws QException + { + Map form = Map.of("permissions", "cms.publish.hasAccess\nauthentik.users.read", "appAccess", "kof22-website-prod", "reason", "grant"); + String table = t.config.applyTablePrefix(AuthentikTables.GROUP); + Outcome outcome = run("authentikEditPermissions", table, "pk", List.of(t.authentik.pk("kof-website-publishers")), form, true); + + assertThat(outcome.review()).anyMatch(l -> l.getStatus() == Status.WARNING && l.getMessage().contains("second person")); + assertThat(outcome.result()).anyMatch(l -> l.getStatus() == Status.WARNING); + assertThat(t.writes()).isEmpty(); + + t.loginAs("second@example.test", com.kingsrook.qbits.authentik.security.IdentityPermission.values()); + String approvalTable = t.config.applyTablePrefix(AuthentikLocalTables.APPROVAL_REQUEST); + Outcome approved = run("authentikApprove", approvalTable, "id", List.of(1), Map.of(), true); + assertThat(approved.result()).anyMatch(l -> l.getStatus() == Status.OK); + assertThat(t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").getJSONArray("qqq.io/permissions").toList()).contains("authentik.users.read"); + } + + + + /******************************************************************************* + ** More than 25 people in one run needs the count typed. + *******************************************************************************/ + @Test + void bulkAboveTwentyFiveNeedsTypedCount() throws QException + { + for(int pk = 200; pk < 230; pk++) + { + t.authentik.user(pk, "bulk" + pk, "bulk" + pk + "@example.test", "internal", true); + } + List keys = new java.util.ArrayList<>(); + for(int pk = 200; pk < 230; pk++) + { + keys.add(pk); + } + + Map untyped = Map.of("group", t.authentik.pk("kof-website-publishers"), "reason", "bulk"); + Outcome review = run("authentikAddToGroup", t.config.applyTablePrefix(AuthentikTables.USER), "pk", keys, untyped, false); + assertThat(review.review()).anyMatch(l -> l.getStatus() == Status.ERROR && l.getMessage().contains("type 30")); + assertThat(t.writes()).isEmpty(); + + Map typed = Map.of("group", t.authentik.pk("kof-website-publishers"), "reason", "bulk", "confirmText", "30"); + Outcome result = run("authentikAddToGroup", t.config.applyTablePrefix(AuthentikTables.USER), "pk", keys, typed, true); + assertThat(result.result()).anyMatch(l -> l.getStatus() == Status.OK && l.getCount() == 30); + } + + + /******************************************************************************* + ** Records carry their table name (the Next UI rejects a record without one), + ** and the drift report understands undashed group pks in events. + *******************************************************************************/ + @Test + void recordsCarryTheirTableName() throws QException + { + var input = new com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput(t.config.applyTablePrefix(AuthentikTables.USER)); + input.setPrimaryKey(3); + assertThat(new com.kingsrook.qqq.backend.core.actions.tables.GetAction().execute(input).getRecord().getTableName()).isEqualTo("authentik_user"); + assertThat(com.kingsrook.qbits.authentik.drift.AuthentikDriftReport.class.getDeclaredMethods()).anyMatch(m -> m.getName().equals("dashed")); + } + +}