From ecad3e2e5b7e71a7cc93dc36f62dde362fb915b4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 12 Oct 2026 14:16:37 -0500 Subject: [PATCH 1/2] fix(naming): snake_case authentik_ table names (owner naming rule) Table names are now lowercase snake_case prefixed with the qbit name: authentik_user, authentik_group, authentik_group_member, authentik_session, authentik_event (were authentikUser, ...). - AuthentikQBitConfig.applyTablePrefix(name) = snake(prefix) + "_" + snake(name), plus a toSnakeCase helper. applyPrefix keeps the camelCase join for apps, widgets, the backend and processes (authentikAdmin, authentikApi), as spec #1065 says. - @Deprecated AuthentikTables.LEGACY_*_TABLE_NAME constants hold the old names for upgrade scripts. - UPGRADE.md covers stored references (permissions, saved views, audit table names, links). API-backed tables, so no DDL. - README, CHANGELOG and docs/DESIGN.md use the new names. - Tests: snake helper, prefix composition (default, empty, custom camelCase prefix), producer and contract tests on the new names. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- README.md | 4 +- UPGRADE.md | 35 ++++++ docs/DESIGN.md | 102 +++++++++--------- .../qbits/authentik/AuthentikQBitConfig.java | 57 +++++++++- .../authentik/client/AuthentikTables.java | 16 +++ .../AbstractAuthentikTableProducer.java | 2 +- .../AuthentikAppMetaDataProducer.java | 2 +- .../metadata/AuthentikEventTableProducer.java | 2 +- .../AuthentikGroupMemberTableProducer.java | 2 +- .../metadata/AuthentikGroupTableProducer.java | 2 +- .../AuthentikSessionTableProducer.java | 2 +- .../metadata/AuthentikUserTableProducer.java | 2 +- .../authentik/AuthentikQBitConfigTest.java | 26 ++++- .../authentik/AuthentikQBitProducerTest.java | 10 +- .../qbits/authentik/ReviewFixesTest.java | 8 +- .../client/AuthentikActionUtilTest.java | 4 +- .../authentik/client/Phase1TablesTest.java | 10 +- .../contract/Phase1ReadContractIT.java | 6 +- .../contract/QbitReadContractIT.java | 4 +- 20 files changed, 216 insertions(+), 85 deletions(-) create mode 100644 UPGRADE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 53f86ab..04beaf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,14 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +### Changed +- **Table names are lowercase snake_case with the `authentik_` prefix** (owner naming rule, 2026-10-12): `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event` (were `authentikUser`, ...). New `AuthentikQBitConfig.applyTablePrefix` and `toSnakeCase`; apps, widgets, the backend and processes keep camelCase names (`authentikAdmin`, `authentikApi`). The old names are kept as `@Deprecated` `AuthentikTables.LEGACY_*_TABLE_NAME` constants. See [UPGRADE.md](UPGRADE.md). + ### Documentation - README: "Adopting in another app" section (coordinates, configuration, secrets, permissions, limitations) linking the QQQ adoption checklist; examples use neutral names instead of one host's (#3). ### Added (phase 1, read-only MVP) -- Tables: `authentikGroupMember` (virtual membership table: by group, by user, by key, or a bounded sweep), `authentikSession` and `authentikEvent` (both need `identity.audit.read`). +- Tables: `authentik_group_member` (virtual membership table: by group, by user, by key, or a bounded sweep), `authentik_session` and `authentik_event` (both need `identity.audit.read`). - `GroupGraph` and `EffectivePermissionResolver`: groups, including inherited parents, become QQQ permissions. `identity.*` and catalog wildcards are expanded; unknown names are reported, never granted. Groups show inherited superuser status and effective permissions; users show effective permissions. - `AuthentikGroupPermissionMapper`: an authentication customizer that turns the token's `groups` claim into QQQ session permissions through the resolver. It supports static fallbacks and `bootstrapOperators`; if Authentik cannot be read it grants only the static fallbacks, never everything. - Review hardening: diff --git a/README.md b/README.md index e2b3d0c..94a6afc 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance); This produces: - an API backend `authentikApi` -- read-only tables `authentikUser`, `authentikGroup`, `authentikGroupMember`, `authentikSession` and `authentikEvent` +- 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 @@ -56,7 +56,7 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo | 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 (`authentikUser`, `authentikGroup`, `authentikGroupMember`, `authentikSession`, `authentikEvent`), the drift report widget, and the `authentikAdmin` ("Identity") app, which the host may nest. | +| 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 | `identity.users.read` (Identity app and tables), `identity.audit.read` (sensitive fields and tables); the full `identity.*` set is in DESIGN.md section 5.2. Granted through the Authentik group attribute `qqq.io/permissions`, read by `AuthentikGroupPermissionMapper`. | diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..5070830 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,35 @@ +# Upgrading qbit-authentik + +## Snake_case table names (unreleased → first release) + +Table names now follow the owner naming rule (2026-10-12): lowercase snake_case, +prefixed with the qbit's name and `_`. + +| Before | After | +|---|---| +| `authentikUser` | `authentik_user` | +| `authentikGroup` | `authentik_group` | +| `authentikGroupMember` | `authentik_group_member` | +| `authentikSession` | `authentik_session` | +| `authentikEvent` | `authentik_event` | + +With a custom `tableNamePrefix` the prefix is snake-cased too: `myAuth` gives +`my_auth_user`. App, widget, backend and process names keep camelCase +(`authentikAdmin`, `authentikDriftReport`, `authentikApi`). Permission names +(`identity.*`) are unchanged. + +The tables are API-backed, so there is no database DDL. Hosts that ran a +pre-release snapshot should update anything that stores a table name: + +- **Permissions:** grants or role definitions that name a table (for example + table-level permission names derived from the table name) and any host code + calling `qInstance.getTable("authentikUser")`. +- **Saved views and reports:** rows whose table name is an old name, for + example `UPDATE saved_view SET table_name = 'authentik_user' WHERE table_name = 'authentikUser';` + (and the same for saved reports and any shared/favorite records). +- **Audit:** `audit_table.name` rows for the old names, if the host audits + these tables (rename them, or leave history under the old name). +- **Frontend links and bookmarks** of the form `/…/authentikUser/…`. + +`AuthentikTables.LEGACY_*_TABLE_NAME` hold the old names (deprecated) so +upgrade scripts can find them. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6c2c313..f5fd8ab 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -34,7 +34,7 @@ Seven design rules shape everything below: 6. **Credentials never pass through QQQ.** - New users and password resets go through Authentik's own emails (enrollment or recovery). - QQQ never sets passwords, never shows recovery links and never displays token keys. -7. **Every change is audited on both sides.** QQQ writes a local append-only `authentikAdminAction` row. Authentik receives a `custom_qqq_admin_action` event naming the human operator. +7. **Every change is audited on both sides.** QQQ writes a local append-only `authentik_admin_action` row. Authentik receives a `custom_qqq_admin_action` event naming the human operator. ### 0.1 Owner decisions (2026-10-11) @@ -328,7 +328,7 @@ 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, processes and widgets | +| `tableNamePrefix` / namespace | `authentik` | Prefix for tables (joined with `_`, snake_case: `authentik_user`), processes and widgets (camelCase: `authentikAdmin`) | | `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` | @@ -696,20 +696,20 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace | Table | Source | Key | Capabilities | Notes | |---|---|---|---|---| -| `authentikUser` | `/core/users/` | `pk` (int) | Query, get. Insert via *Create user*/*Invite*; update via processes only. | Computed: `isProtected`, `isSuperuser` (inherited), `createdByQqq`, `effectivePermissions` (detail), `mfaDeviceCount` (detail). `attributes` beyond `qqq.io/*` is sensitive. | -| `authentikGroup` | `/core/groups/` | `pk` (uuid) | Query, get. Insert via *Create group*; update via *Edit group* / *Edit permissions*. | `parents` as display names; `isSuperuser` (inherited, locked); `permissions` (own `qqq.io/permissions`) and `effectivePermissions` (with ancestors); `appAccess`; member count. | -| `authentikGroupMember` | Virtual: `GET /core/users/?groups_by_pk=` / `GET /core/groups/{uuid}/?include_users=true` | `(groupPk, userPk)` composite string | Query, insert → `add_user`, delete → `remove_user` | The membership association. It supports the generic child-record UI on both user and group. Insert and delete go through the guard, with processes as the preferred UI. | -| `authentikRole` | `/rbac/roles/` | uuid | Query, get | `managed` roles (`ak-managed-role--*`, `ak-migrated-role--*`) are hidden by default | -| `authentikRoleAssignment` | Virtual over `GET /rbac/roles/?users=` / `?groups=` (`inherited=true` for effective) | composite | Query; insert/delete in phase 4 | `assignedVia`: DIRECT or a group name | -| `authentikPermission` | `/rbac/permissions/` + `assigned_by_roles` | id | Query | Catalogue and per-role view (read only) | -| `authentikApplication` | `/core/applications/` | slug | Query, get | Read only. Links to bindings. | -| `authentikPolicyBinding` | `/policies/bindings/?target=` | uuid | Query | Read only. Shows the policy, group or user, plus negate, order and expiry. | -| `authentikSession` | `/core/authenticated_sessions/` | uuid | Query, delete (revoke) | Sensitive | -| `authentikMfaDevice` | `/authenticators/admin/all/?user=` | `type:id` | Query, delete (reset) | Metadata only. Query requires a `user` criterion. | -| `authentikToken` | `/core/tokens/?user__username=` | identifier | Query, delete (revoke) | **No key field.** Only `intent`, `expires`, `description`. | -| `authentikEvent` | `/events/events/` | uuid | Query, get | Sensitive. Ordered `-created`. `context` is rendered as JSON. | -| `authentikInvitation` | `/stages/invitation/invitations/` | uuid | Query; insert via *Invite*; delete (cancel) | | -| `authentikBlueprint` | `/managed/blueprints/` | uuid | Query | Read only. Used by the drift report. | +| `authentik_user` | `/core/users/` | `pk` (int) | Query, get. Insert via *Create user*/*Invite*; update via processes only. | Computed: `isProtected`, `isSuperuser` (inherited), `createdByQqq`, `effectivePermissions` (detail), `mfaDeviceCount` (detail). `attributes` beyond `qqq.io/*` is sensitive. | +| `authentik_group` | `/core/groups/` | `pk` (uuid) | Query, get. Insert via *Create group*; update via *Edit group* / *Edit permissions*. | `parents` as display names; `isSuperuser` (inherited, locked); `permissions` (own `qqq.io/permissions`) and `effectivePermissions` (with ancestors); `appAccess`; member count. | +| `authentik_group_member` | Virtual: `GET /core/users/?groups_by_pk=` / `GET /core/groups/{uuid}/?include_users=true` | `(groupPk, userPk)` composite string | Query, insert → `add_user`, delete → `remove_user` | The membership association. It supports the generic child-record UI on both user and group. Insert and delete go through the guard, with processes as the preferred UI. | +| `authentik_role` | `/rbac/roles/` | uuid | Query, get | `managed` roles (`ak-managed-role--*`, `ak-migrated-role--*`) are hidden by default | +| `authentik_role_assignment` | Virtual over `GET /rbac/roles/?users=` / `?groups=` (`inherited=true` for effective) | composite | Query; insert/delete in phase 4 | `assignedVia`: DIRECT or a group name | +| `authentik_permission` | `/rbac/permissions/` + `assigned_by_roles` | id | Query | Catalogue and per-role view (read only) | +| `authentik_application` | `/core/applications/` | slug | Query, get | Read only. Links to bindings. | +| `authentik_policy_binding` | `/policies/bindings/?target=` | uuid | Query | Read only. Shows the policy, group or user, plus negate, order and expiry. | +| `authentik_session` | `/core/authenticated_sessions/` | uuid | Query, delete (revoke) | Sensitive | +| `authentik_mfa_device` | `/authenticators/admin/all/?user=` | `type:id` | Query, delete (reset) | Metadata only. Query requires a `user` criterion. | +| `authentik_token` | `/core/tokens/?user__username=` | identifier | Query, delete (revoke) | **No key field.** Only `intent`, `expires`, `description`. | +| `authentik_event` | `/events/events/` | uuid | Query, get | Sensitive. Ordered `-created`. `context` is rendered as JSON. | +| `authentik_invitation` | `/stages/invitation/invitations/` | uuid | Query; insert via *Invite*; delete (cancel) | | +| `authentik_blueprint` | `/managed/blueprints/` | uuid | Query | Read only. Used by the drift report. | **`FilterMapper`** maps QQQ criteria to Authentik query params: - `EQUALS` → `field=` @@ -734,12 +734,12 @@ Unsupported criteria (OR across fields, `NOT_EQUALS`, nested filters) are **reje | Table | Purpose | |---|---| -| `authentikAdminAction` | **Append-only audit and outbox** of every write the qbit attempts. Columns: id, correlationId, actionName, operator (QQQ user + Authentik username), reason, targetType, targetKey, targetLabel, beforeJson, afterJson, status (`PENDING_APPROVAL`, `RUNNING`, `SUCCEEDED`, `FAILED`, `REFUSED`), errorMessage, authentikEventPk, approver, timestamps. The row is inserted *before* the API call, following the sql-workbench `ExecutionAuditor` pattern; if that insert fails, the call is not made. Insert, update and delete capabilities are removed for everyone. | -| `authentikApprovalRequest` | Two-person requests: action payload, requester, approver, 24 h expiry, approver ≠ requester. Used for RBAC (phase 4) and, by default, for permission edits that add `identity.*` (§6.3). | -| `authentikUserMirror` | `MIRROR` feature: pk, uuid, username, email, name, type, isActive, lastLogin, isSuperuser, isProtected, groupNames, effectivePermissions (text), mfaDeviceCount, failedLogins24h, refreshedAt. Unique on username and email. Web Users joins here (`qqq_user_session.user_id` = email in kof22). The mapper falls back to it. | -| `authentikGroupMirror` | pk, name, parentPks, attributes `qqq.io/*` only, memberCount, isSuperuser (inherited), refreshedAt. Feeds the resolver and the tree widget. | -| `authentikMembershipMirror` | userPk, groupPk, groupName, refreshedAt | -| `authentikWebhookInbox` | Received webhook payloads, deduplicated by event pk, with processing status. Retained 30 days. | +| `authentik_admin_action` | **Append-only audit and outbox** of every write the qbit attempts. Columns: id, correlationId, actionName, operator (QQQ user + Authentik username), reason, targetType, targetKey, targetLabel, beforeJson, afterJson, status (`PENDING_APPROVAL`, `RUNNING`, `SUCCEEDED`, `FAILED`, `REFUSED`), errorMessage, authentikEventPk, approver, timestamps. The row is inserted *before* the API call, following the sql-workbench `ExecutionAuditor` pattern; if that insert fails, the call is not made. Insert, update and delete capabilities are removed for everyone. | +| `authentik_approval_request` | Two-person requests: action payload, requester, approver, 24 h expiry, approver ≠ requester. Used for RBAC (phase 4) and, by default, for permission edits that add `identity.*` (§6.3). | +| `authentik_user_mirror` | `MIRROR` feature: pk, uuid, username, email, name, type, isActive, lastLogin, isSuperuser, isProtected, groupNames, effectivePermissions (text), mfaDeviceCount, failedLogins24h, refreshedAt. Unique on username and email. Web Users joins here (`qqq_user_session.user_id` = email in kof22). The mapper falls back to it. | +| `authentik_group_mirror` | pk, name, parentPks, attributes `qqq.io/*` only, memberCount, isSuperuser (inherited), refreshedAt. Feeds the resolver and the tree widget. | +| `authentik_membership_mirror` | userPk, groupPk, groupName, refreshedAt | +| `authentik_webhook_inbox` | Received webhook payloads, deduplicated by event pk, with processing status. Retained 30 days. | ### 6.3 Processes (table and record actions only, never left nav) @@ -749,26 +749,26 @@ Each process: | Process | Table / slot | Records | Permission | Default confirmation | Authentik calls | |---|---|---|---|---|---| -| Create user | `authentikUser` query action | 0 | `identity.users.manage` (+ `memberships.manage` if groups given) | CONFIRM | `POST /core/users/` (no password) → `add_user` per group → `recovery_email` | +| Create user | `authentik_user` query action | 0 | `identity.users.manage` (+ `memberships.manage` if groups given) | CONFIRM | `POST /core/users/` (no password) → `add_user` per group → `recovery_email` | | Invite user | same | 0 | `identity.users.manage` | CONFIRM | `POST /stages/invitation/invitations/` + `send_email`; groups applied on `invitation_used` | -| Edit user | `authentikUser` view action | 1 | `identity.users.manage` | CONFIRM | `PATCH /core/users/{id}/` (name, email, path, type internal/external) | -| Deactivate | `authentikUser` | 1–25 | `identity.users.manage` | TYPED | `PATCH is_active=false` (deletes sessions) + optional app-password revoke | +| Edit user | `authentik_user` view action | 1 | `identity.users.manage` | CONFIRM | `PATCH /core/users/{id}/` (name, email, path, type internal/external) | +| Deactivate | `authentik_user` | 1–25 | `identity.users.manage` | TYPED | `PATCH is_active=false` (deletes sessions) + optional app-password revoke | | Reactivate | same | 1–25 | `identity.users.manage` | CONFIRM | `PATCH is_active=true` | -| Add to groups / Remove from groups | `authentikUser` query + view | 1–100 | `identity.memberships.manage` | CONFIRM (TYPED > 25) | `POST /core/groups/{g}/add_user/` / `remove_user/` | -| Add members / Remove members | `authentikGroup` view | 1 group + picker | same | same | same | -| Create group | `authentikGroup` query action | 0 | `identity.groups.manage` (+ `permissions.manage` if permissions given) | CONFIRM | `POST /core/groups/` (name, parents, attributes) | -| Edit group | `authentikGroup` view | 1 | `identity.groups.manage` | CONFIRM | `PATCH` name, parents and non-reserved attributes (merge-patch) | -| Edit permissions and app access | `authentikGroup` view | 1 | `identity.permissions.manage` | CONFIRM; **TWO_PERSON** when adding any `identity.*` permission | `PATCH attributes` (`qqq.io/permissions`, `qqq.io/app-access`), re-read first | -| Check access / Effective permissions | `authentikUser` view | 1 | `identity.users.read` | none | `check_access` per in-scope app; the resolver | -| Send password reset email | `authentikUser` | 1–25 | `identity.credentials.reset` | CONFIRM | `recovery_email` | -| Reset MFA | `authentikUser` | 1 | `identity.credentials.reset` | TYPED | per-device `DELETE` | -| Revoke app passwords | `authentikUser`, `authentikToken` | 1–25 | `identity.credentials.reset` | TYPED | `DELETE /core/tokens/{identifier}/` | -| Revoke sessions | `authentikUser`, `authentikSession` | 1–100 | `identity.sessions.revoke` | CONFIRM | `bulk_delete` / per-session `DELETE` | -| Cancel invitation | `authentikInvitation` | 1–25 | `identity.users.manage` | CONFIRM | `DELETE` | +| Add to groups / Remove from groups | `authentik_user` query + view | 1–100 | `identity.memberships.manage` | CONFIRM (TYPED > 25) | `POST /core/groups/{g}/add_user/` / `remove_user/` | +| Add members / Remove members | `authentik_group` view | 1 group + picker | same | same | same | +| Create group | `authentik_group` query action | 0 | `identity.groups.manage` (+ `permissions.manage` if permissions given) | CONFIRM | `POST /core/groups/` (name, parents, attributes) | +| Edit group | `authentik_group` view | 1 | `identity.groups.manage` | CONFIRM | `PATCH` name, parents and non-reserved attributes (merge-patch) | +| Edit permissions and app access | `authentik_group` view | 1 | `identity.permissions.manage` | CONFIRM; **TWO_PERSON** when adding any `identity.*` permission | `PATCH attributes` (`qqq.io/permissions`, `qqq.io/app-access`), re-read first | +| Check access / Effective permissions | `authentik_user` view | 1 | `identity.users.read` | none | `check_access` per in-scope app; the resolver | +| Send password reset email | `authentik_user` | 1–25 | `identity.credentials.reset` | CONFIRM | `recovery_email` | +| Reset MFA | `authentik_user` | 1 | `identity.credentials.reset` | TYPED | per-device `DELETE` | +| Revoke app passwords | `authentik_user`, `authentik_token` | 1–25 | `identity.credentials.reset` | TYPED | `DELETE /core/tokens/{identifier}/` | +| Revoke sessions | `authentik_user`, `authentik_session` | 1–100 | `identity.sessions.revoke` | CONFIRM | `bulk_delete` / per-session `DELETE` | +| Cancel invitation | `authentik_invitation` | 1–25 | `identity.users.manage` | CONFIRM | `DELETE` | | Assign / unassign role | user / group | 1–25 | `identity.roles.manage` | TWO_PERSON | `/rbac/roles/{r}/add_user/` or group `roles` (phase 4) | -| Resync mirror | `authentikUserMirror` query | 0 | `identity.admin` | CONFIRM | full reconcile | -| Export snapshot now | `authentikUserMirror` query | 0 | `identity.admin` | CONFIRM | reads only (§7.3) | -| View audit trail | every table: view action → filtered `authentikAdminAction` + `authentikEvent?context_model_pk=` | 1 | `identity.audit.read` | none | events query | +| Resync mirror | `authentik_user_mirror` query | 0 | `identity.admin` | CONFIRM | full reconcile | +| Export snapshot now | `authentik_user_mirror` query | 0 | `identity.admin` | CONFIRM | reads only (§7.3) | +| View audit trail | every table: view action → filtered `authentik_admin_action` + `authentik_event?context_model_pk=` | 1 | `identity.audit.read` | none | events query | **Concurrent edits.** If the Authentik UI changes a user or group after a QQQ preview was built, the stale check fails that record and asks for a re-review. Edits made in the Authentik UI are never overwritten blindly. @@ -794,7 +794,7 @@ These objects are infrastructure in Git (`present`): `AuthentikWebhookRouteProvider` does the following: 1. checks the secret with a constant-time compare -2. inserts into `authentikWebhookInbox`, deduplicating on `eventPk` +2. inserts into `authentik_webhook_inbox`, deduplicating on `eventPk` 3. publishes to the ESB destination `authentik.events`, or processes inline 4. returns 204 @@ -823,8 +823,8 @@ Webhooks are hints: delivery is at least once, unordered, and lost if no destina ### 7.4 Web Users integration (kof22) -- *Web Users* gains Users, Groups and Audit (`authentikUser`, `authentikGroup`, `authentikAdminAction`). -- `qqq_user_session` gets a join to `authentikUserMirror` on `user_id = email`, adding groups, active, last login, MFA and effective permissions. +- *Web Users* gains Users, Groups and Audit (`authentik_user`, `authentik_group`, `authentik_admin_action`). +- `qqq_user_session` gets a join to `authentik_user_mirror` on `user_id = email`, adding groups, active, last login, MFA and effective permissions. - The `adminHomeWebUsers` widget replaces its footer link with real counts. --- @@ -839,13 +839,13 @@ The qbit produces an app `authentikAdmin` (label **"Identity"**, icon `badge`). | Screen | Built from | Contents | |---|---|---| -| Users list | generic query on `authentikUser` | Username, name, email, type, active, last login, groups, protected lock. Saved views: "Inactive 90d", "No MFA", "Service accounts", "Superusers", "Created by QQQ". Bulk actions: Add to groups, Remove from groups, Deactivate, Revoke sessions. Table action: Create user, Invite user. | +| Users list | generic query on `authentik_user` | Username, name, email, type, active, last login, groups, protected lock. Saved views: "Inactive 90d", "No MFA", "Service accounts", "Superusers", "Created by QQQ". Bulk actions: Add to groups, Remove from groups, Deactivate, Revoke sessions. Table action: Create user, Invite user. | | User detail | generic view + child widgets | Badges (protected, superuser, service account). Tabs: Groups, **Effective permissions** (each with the granting group), Access (computed per app from `qqq.io/app-access` and the app's bindings; other expression policies shown as "not evaluated", §0.2 iii), Sessions, MFA devices, App passwords, History (QQQ audit + Authentik events, including UI-made changes with their actor) | | 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 (`identity.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 | -| Audit | generic query on `authentikAdminAction` + `authentikEvent` | Filter by operator, target, action, status, and source (QQQ vs Authentik UI) | +| 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) @@ -881,7 +881,7 @@ The qbit produces an app `authentikAdmin` (label **"Identity"**, icon `badge`). ### 9.2 Audit (both sides) -- **QQQ.** The append-only `authentikAdminAction` (§6.2), plus RECORD-level `QAuditRules` on the mirror tables. +- **QQQ.** The append-only `authentik_admin_action` (§6.2), plus RECORD-level `QAuditRules` on the mirror tables. - **Authentik.** - Model events carry `user = qqq--idadmin`. - The qbit also posts `custom_qqq_admin_action` with `{correlationId, operator, operatorEmail, reason, action, targets, appKey}` and stores the returned event pk. @@ -957,7 +957,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 `identity.*`, `authentikAdminAction` + 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 | +| **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 `identity.*`, `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 | | **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 | @@ -1022,14 +1022,14 @@ The decisions taken on 2026-10-11 are in §0.1. The remaining questions are smal --- -## Appendix A: Example API mapping (`authentikUser`) +## Appendix A: Example API mapping (`authentik_user`) | QQQ | Authentik | |---|---| -| `QueryInput(authentikUser, filter: isActive EQUALS false AND lastLogin LESS_THAN 2026-07-13, orderBy username, limit 50)` | `GET /api/v3/core/users/?is_active=false&last_login__lt=2026-07-13T00:00:00Z&ordering=username&include_groups=true&include_roles=true&type=internal&type=external&type=service_account&page=1&page_size=50` | -| `GetInput(authentikUser, 42)` | `GET /api/v3/core/users/42/` (the detail endpoint takes no `include_*` parameters and always returns `groups_obj` and `roles_obj`) | -| `InsertInput(authentikGroupMember, {groupPk: , userPk: 42})` | Guard checks, then `POST /api/v3/core/groups//add_user/` with body `{"pk": 42}`, which returns 204 | -| `DeleteInput(authentikSession, )` | Guard checks, then `DELETE /api/v3/core/authenticated_sessions//` | +| `QueryInput(authentik_user, filter: isActive EQUALS false AND lastLogin LESS_THAN 2026-07-13, orderBy username, limit 50)` | `GET /api/v3/core/users/?is_active=false&last_login__lt=2026-07-13T00:00:00Z&ordering=username&include_groups=true&include_roles=true&type=internal&type=external&type=service_account&page=1&page_size=50` | +| `GetInput(authentik_user, 42)` | `GET /api/v3/core/users/42/` (the detail endpoint takes no `include_*` parameters and always returns `groups_obj` and `roles_obj`) | +| `InsertInput(authentik_group_member, {groupPk: , userPk: 42})` | Guard checks, then `POST /api/v3/core/groups//add_user/` with body `{"pk": 42}`, which returns 204 | +| `DeleteInput(authentik_session, )` | Guard checks, then `DELETE /api/v3/core/authenticated_sessions//` | | Count | `GET …&page_size=1`, reading `pagination.count` | Response shape: `{"pagination": {"next": 2, "previous": 0, "count": 137, "current": 1, "total_pages": 3, "start_index": 1, "end_index": 50}, "results": [...], "autocomplete": {...}}`. diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java index 71229cf..5d5ce3b 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java @@ -218,7 +218,9 @@ public class AuthentikQBitConfig implements QBitConfig /******************************************************************************* - ** Prefix a component name with the tableNamePrefix (camel-cased join). + ** Prefix a non-table component name (app, widget, backend, process) with the + ** tableNamePrefix (camel-cased join), e.g. "authentikAdmin". Table names use + ** applyTablePrefix instead. *******************************************************************************/ public String applyPrefix(String name) { @@ -231,6 +233,59 @@ public class AuthentikQBitConfig implements QBitConfig + /******************************************************************************* + ** Prefix a table name: lowercase snake_case, joined with "_", e.g. "user" -> + ** "authentik_user", "groupMember" -> "authentik_group_member" (owner naming + ** rule, 2026-10-12: table names are lowercase with "_", prefixed with the + ** qbit's name). + *******************************************************************************/ + public String applyTablePrefix(String name) + { + String snakeName = toSnakeCase(name); + if(!StringUtils.hasContent(tableNamePrefix)) + { + return (snakeName); + } + return (toSnakeCase(tableNamePrefix) + "_" + snakeName); + } + + + + /******************************************************************************* + ** camelCase (or PascalCase) to lowercase snake_case: "groupMember" -> + ** "group_member", "myAuth" -> "my_auth". Already-snake input is unchanged. + *******************************************************************************/ + public static String toSnakeCase(String name) + { + if(name == null) + { + return (null); + } + + StringBuilder rs = new StringBuilder(name.length() + 8); + for(int i = 0; i < name.length(); i++) + { + char c = name.charAt(i); + if(Character.isUpperCase(c)) + { + boolean afterLowerOrDigit = i > 0 && (Character.isLowerCase(name.charAt(i - 1)) || Character.isDigit(name.charAt(i - 1))); + boolean endOfAcronym = i > 0 && i + 1 < name.length() && Character.isUpperCase(name.charAt(i - 1)) && Character.isLowerCase(name.charAt(i + 1)); + if((afterLowerOrDigit || endOfAcronym) && rs.length() > 0 && rs.charAt(rs.length() - 1) != '_') + { + rs.append('_'); + } + rs.append(Character.toLowerCase(c)); + } + else + { + rs.append(c); + } + } + return (rs.toString()); + } + + + /******************************************************************************* ** Name of the API backend the qbit produces. *******************************************************************************/ 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 dde2c55..b2dac72 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java @@ -50,6 +50,22 @@ public final class AuthentikTables public static final String SESSION = "session"; public static final String EVENT = "event"; + /******************************************************************************* + ** Table names produced before the owner naming rule (2026-10-12), with the + ** default prefix. Kept only to find stored references (permissions, saved + ** views, audit table names) when upgrading; see UPGRADE.md. + *******************************************************************************/ + @Deprecated + public static final String LEGACY_USER_TABLE_NAME = "authentikUser"; + @Deprecated + public static final String LEGACY_GROUP_TABLE_NAME = "authentikGroup"; + @Deprecated + public static final String LEGACY_GROUP_MEMBER_TABLE_NAME = "authentikGroupMember"; + @Deprecated + public static final String LEGACY_SESSION_TABLE_NAME = "authentikSession"; + @Deprecated + public static final String LEGACY_EVENT_TABLE_NAME = "authentikEvent"; + /** User types the qbit shows by default: everything but outposts' internal service accounts. */ public static final String[] DEFAULT_USER_TYPES = { "internal", "external", "service_account" }; diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AbstractAuthentikTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AbstractAuthentikTableProducer.java index 9ecb626..89d638a 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AbstractAuthentikTableProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AbstractAuthentikTableProducer.java @@ -74,7 +74,7 @@ public abstract class AbstractAuthentikTableProducer extends QBitComponentMetaDa *******************************************************************************/ public static QTableMetaData buildTable(AuthentikQBitConfig config, AuthentikTableSpec spec) { - String tableName = config.applyPrefix(spec.getName()); + String tableName = config.applyTablePrefix(spec.getName()); QTableMetaData table = new QTableMetaData() .withName(tableName) 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 f48642e..76b717e 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java @@ -61,7 +61,7 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer< { for(String specName : AuthentikTables.all().keySet()) { - QTableMetaData table = produced.get(QTableMetaData.class, config.applyPrefix(specName)); + QTableMetaData table = produced.get(QTableMetaData.class, config.applyTablePrefix(specName)); if(table != null) { app.withChild(table); diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java index 73bc1b5..256e2ae 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java @@ -21,7 +21,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTables; /******************************************************************************* - ** authentikEvent: /events/events/ (needs identity.audit.read). + ** authentik_event: /events/events/ (needs identity.audit.read). *******************************************************************************/ public class AuthentikEventTableProducer extends AbstractAuthentikTableProducer { diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupMemberTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupMemberTableProducer.java index f856f20..56891c9 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupMemberTableProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupMemberTableProducer.java @@ -21,7 +21,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTables; /******************************************************************************* - ** authentikGroupMember: virtual membership table (group x user). + ** authentik_group_member: virtual membership table (group x user). *******************************************************************************/ public class AuthentikGroupMemberTableProducer extends AbstractAuthentikTableProducer { diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupTableProducer.java index bc28501..cd08286 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupTableProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupTableProducer.java @@ -21,7 +21,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTables; /******************************************************************************* - ** authentikGroup: /core/groups/. + ** authentik_group: /core/groups/. *******************************************************************************/ public class AuthentikGroupTableProducer extends AbstractAuthentikTableProducer { diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java index 1ffec60..06480df 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java @@ -21,7 +21,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTables; /******************************************************************************* - ** authentikSession: /core/authenticated_sessions/ (needs identity.audit.read). + ** authentik_session: /core/authenticated_sessions/ (needs identity.audit.read). *******************************************************************************/ public class AuthentikSessionTableProducer extends AbstractAuthentikTableProducer { diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikUserTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikUserTableProducer.java index 1ea0c99..ea0c1ca 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikUserTableProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikUserTableProducer.java @@ -21,7 +21,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTables; /******************************************************************************* - ** authentikUser: /core/users/. + ** authentik_user: /core/users/. *******************************************************************************/ public class AuthentikUserTableProducer extends AbstractAuthentikTableProducer { diff --git a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java index 155598d..7d9ddce 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitConfigTest.java @@ -142,12 +142,34 @@ class AuthentikQBitConfigTest void derivedNames() { AuthentikQBitConfig config = AuthentikTestSupport.unitConfig(); - assertThat(config.applyPrefix("user")).isEqualTo("authentikUser"); + assertThat(config.applyPrefix("admin")).isEqualTo("authentikAdmin"); + assertThat(config.applyTablePrefix("user")).isEqualTo("authentik_user"); + assertThat(config.applyTablePrefix("groupMember")).isEqualTo("authentik_group_member"); assertThat(config.getApiBackendName()).isEqualTo("authentikApi"); assertThat(config.getEffectiveServiceAccountUsername()).isEqualTo("qqq-kof22-admin-idadmin"); assertThat(config.withServiceAccountUsername("custom-sa").getEffectiveServiceAccountUsername()).isEqualTo("custom-sa"); - assertThat(new AuthentikQBitConfig().withTableNamePrefix("").applyPrefix("user")).isEqualTo("user"); + 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"); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void toSnakeCase() + { + assertThat(AuthentikQBitConfig.toSnakeCase(null)).isNull(); + assertThat(AuthentikQBitConfig.toSnakeCase("")).isEqualTo(""); + assertThat(AuthentikQBitConfig.toSnakeCase("user")).isEqualTo("user"); + assertThat(AuthentikQBitConfig.toSnakeCase("groupMember")).isEqualTo("group_member"); + assertThat(AuthentikQBitConfig.toSnakeCase("GroupMember")).isEqualTo("group_member"); + assertThat(AuthentikQBitConfig.toSnakeCase("group_member")).isEqualTo("group_member"); + assertThat(AuthentikQBitConfig.toSnakeCase("mfaTOTPDevice")).isEqualTo("mfa_totp_device"); + assertThat(AuthentikQBitConfig.toSnakeCase("auth2Factor")).isEqualTo("auth2_factor"); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java index d26e309..3762189 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java @@ -53,7 +53,7 @@ class AuthentikQBitProducerTest assertThat(backend.getApiKey()).isNull(); assertThat(backend.getActionUtil().getName()).isEqualTo(AuthentikActionUtil.class.getName()); - QTableMetaData users = qInstance.getTable("authentikUser"); + QTableMetaData users = qInstance.getTable("authentik_user"); assertThat(users.getBackendName()).isEqualTo("authentikApi"); assertThat(users.getPrimaryKeyField()).isEqualTo("pk"); assertThat(users.getPermissionRules().getPermissionBaseName()).isEqualTo("identity.users.read"); @@ -61,13 +61,13 @@ class AuthentikQBitProducerTest assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_INSERT)).isFalse(); assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_DELETE)).isFalse(); assertThat(users.getField("uuid").getIsHidden()).isTrue(); - assertThat(qInstance.getTable("authentikGroup")).isNotNull(); + assertThat(qInstance.getTable("authentik_group")).isNotNull(); QAppMetaData app = qInstance.getApp("authentikAdmin"); assertThat(app.getLabel()).isEqualTo("Identity"); - assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("authentikUser", "authentikGroup", "authentikGroupMember", "authentikSession", "authentikEvent"); + 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(qInstance.getTable("authentikSession").getPermissionRules().getPermissionBaseName()).isEqualTo("identity.audit.read"); + assertThat(qInstance.getTable("authentik_session").getPermissionRules().getPermissionBaseName()).isEqualTo("identity.audit.read"); assertThat(qInstance.getWidget("authentikDriftReport").getPermissionRules().getPermissionBaseName()).isEqualTo("identity.admin"); assertThat(PermissionsHelper.getAllAvailablePermissionNames(qInstance)).contains(IdentityPermission.USERS_READ.getSessionPermission()); @@ -85,7 +85,7 @@ class AuthentikQBitProducerTest AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withTableNamePrefix(null); QInstance qInstance = new QInstance(); new AuthentikQBitProducer().withQBitConfig(config).withNamespace("idp").produce(qInstance); - assertThat(qInstance.getTable("idpUser")).isNotNull(); + assertThat(qInstance.getTable("idp_user")).isNotNull(); assertThat(qInstance.getBackend("idpApi")).isNotNull(); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/ReviewFixesTest.java b/src/test/java/com/kingsrook/qbits/authentik/ReviewFixesTest.java index b8b989d..6782a4e 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/ReviewFixesTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/ReviewFixesTest.java @@ -156,17 +156,17 @@ class ReviewFixesTest AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values()); FixtureRouter router = new FixtureRouter().install(); - QueryInput zero = new QueryInput(config.applyPrefix("user")); + QueryInput zero = new QueryInput(config.applyTablePrefix("user")); zero.setFilter(new QQueryFilter().withLimit(0)); assertThat(new QueryAction().execute(zero).getRecords()).isEmpty(); - assertThat(new GetAction().executeForRecord(new GetInput(config.applyPrefix("group")).withPrimaryKey(".."))).isNull(); + assertThat(new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("group")).withPrimaryKey(".."))).isNull(); assertThat(FixtureActionUtil.REQUESTS.stream().noneMatch(r -> r.getURI().getPath().contains(".."))).isTrue(); - QueryInput widen = new QueryInput(config.applyPrefix("user")); + QueryInput widen = new QueryInput(config.applyTablePrefix("user")); widen.setFilter(new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.EQUALS, "internal_service_account"))); assertThatThrownBy(() -> new QueryAction().execute(widen)).hasMessageContaining("outside the users"); - QueryInput otherPath = new QueryInput(config.applyPrefix("user")); + QueryInput otherPath = new QueryInput(config.applyTablePrefix("user")); otherPath.setFilter(new QQueryFilter(new QFilterCriteria("path", QCriteriaOperator.STARTS_WITH, "service-accounts"))); assertThatThrownBy(() -> new QueryAction().execute(otherPath)).hasMessageContaining("outside the users"); 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 9e1a71c..fe350a4 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtilTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtilTest.java @@ -79,8 +79,8 @@ class AuthentikActionUtilTest config = AuthentikTestSupport.unitConfig(); qInstance = AuthentikTestSupport.produce(config); AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class); - userTable = config.applyPrefix("user"); - groupTable = config.applyPrefix("group"); + userTable = config.applyTablePrefix("user"); + groupTable = config.applyTablePrefix("group"); AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java b/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java index 4b8d3b6..010cb22 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java @@ -90,7 +90,7 @@ class Phase1TablesTest *******************************************************************************/ private List query(String table, QQueryFilter filter) throws QException { - QueryInput input = new QueryInput(config.applyPrefix(table)); + QueryInput input = new QueryInput(config.applyTablePrefix(table)); input.setFilter(filter); return (new QueryAction().execute(input).getRecords()); } @@ -134,12 +134,12 @@ class Phase1TablesTest assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "nobody")))).isEmpty(); assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, 999999)))).isEmpty(); - QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyPrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk)); + QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk)); assertThat(byKey.getValueString("username")).isEqualTo("phase1.member"); - assertThat(new GetAction().executeForRecord(new GetInput(config.applyPrefix("groupMember")).withPrimaryKey("no-colon"))).isNull(); + assertThat(new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("groupMember")).withPrimaryKey("no-colon"))).isNull(); List all = query("groupMember", new QQueryFilter()); - int count = new CountAction().execute(new CountInput(config.applyPrefix("groupMember"))).getCount(); + int count = new CountAction().execute(new CountInput(config.applyTablePrefix("groupMember"))).getCount(); assertThat(all).hasSize(count).isNotEmpty(); assertThat(all).extracting(r -> r.getValueString("groupName")).isSortedAccordingTo(String.CASE_INSENSITIVE_ORDER); @@ -183,7 +183,7 @@ class Phase1TablesTest JSONObject member = router.users.stream().filter(u -> u.getString("username").equals("read.probe1")).findFirst().orElseThrow(); FixtureActionUtil.handler = router; - QRecord user = new GetAction().executeForRecord(new GetInput(config.applyPrefix("user")).withPrimaryKey(member.getInt("pk"))); + QRecord user = new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("user")).withPrimaryKey(member.getInt("pk"))); assertThat(user.getValue("effectivePermissions")).isNotNull(); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java index 7cd8647..1dd1f6e 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java @@ -125,7 +125,7 @@ class Phase1ReadContractIT *******************************************************************************/ private List query(String table, QQueryFilter filter) throws QException { - QueryInput input = new QueryInput(config.applyPrefix(table)); + QueryInput input = new QueryInput(config.applyTablePrefix(table)); input.setFilter(filter); return (new QueryAction().execute(input).getRecords()); } @@ -154,10 +154,10 @@ class Phase1ReadContractIT assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, AuthentikTestStack.BREAK_GLASS)))) .extracting(r -> r.getValueString("groupName")).containsExactly(AuthentikTestStack.SUPERUSER_GROUP); - QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyPrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk)); + QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk)); assertThat(byKey.getValueString("username")).isEqualTo("phase1.member"); - int all = new CountAction().execute(new CountInput(config.applyPrefix("groupMember"))).getCount(); + int all = new CountAction().execute(new CountInput(config.applyTablePrefix("groupMember"))).getCount(); assertThat(all).isGreaterThanOrEqualTo(3); FixtureRecorder.record("groups-list-with-users", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/")); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java index 99f6acf..e9282c4 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java @@ -132,8 +132,8 @@ class QbitReadContractIT .withListCacheTtlSeconds(0); qInstance = AuthentikTestSupport.produce(config); AuthentikTestSupport.useActionUtil(qInstance, config, RecordingActionUtil.class); - userTable = config.applyPrefix("user"); - groupTable = config.applyPrefix("group"); + userTable = config.applyTablePrefix("user"); + groupTable = config.applyTablePrefix("group"); AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ); } -- 2.54.0 From 338b591a7f9a0702fc0ee738d089e6845bc69ebd Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 12 Oct 2026 14:29:15 -0500 Subject: [PATCH 2/2] fix(naming): rename identity.* permissions to authentik.* (owner naming rule) Permissions follow the owner's full-name rule (2026-10-12; spec #1065 amended in 8ee607610): identity.users.read -> authentik.users.read, etc., same suffixes; the wildcard is authentik.*. - IdentityPermission names and WILDCARD renamed; @Deprecated LEGACY_PREFIX, LEGACY_WILDCARD and getLegacyShortName() keep the old names for upgrade scripts. Legacy names are never granted: the resolver reports them as unknown and the drift report warns on them. - UPGRADE.md: rename table, update qqq.io/permissions group attributes (UI or blueprint) before deploying, sample SQL for host-stored grants, sign users out afterwards. - Code comments, drift messages, test fixtures, the test bootstrap blueprint, README, CHANGELOG and docs/DESIGN.md use authentik.*. The identity-admins group and the "Identity" app label are unchanged. - Tests: IdentityPermissionTest (names, session form, legacy names not resolved), resolver test that legacy grants are unknown and grant nothing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- README.md | 8 +- UPGRADE.md | 65 ++++++++++- docs/DESIGN.md | 110 +++++++++--------- .../authentik/client/AuthentikFieldSpec.java | 4 +- .../authentik/drift/AuthentikDriftReport.java | 6 +- .../AuthentikAppMetaDataProducer.java | 2 +- .../AuthentikDriftReportWidgetProducer.java | 2 +- .../AuthentikDriftReportWidgetRenderer.java | 2 +- .../metadata/AuthentikEventTableProducer.java | 2 +- .../AuthentikSessionTableProducer.java | 2 +- .../EffectivePermissionResolver.java | 6 +- .../authentik/security/AuthentikGuard.java | 4 +- .../security/IdentityPermission.java | 53 ++++++--- .../authentik/AuthentikQBitProducerTest.java | 10 +- .../client/AuthentikActionUtilTest.java | 2 +- .../client/AuthentikFilterMapperTest.java | 2 +- .../authentik/client/Phase1TablesTest.java | 4 +- .../contract/BlueprintReapplyIT.java | 6 +- .../contract/BootstrapBlueprintIT.java | 2 +- .../contract/Phase1ReadContractIT.java | 8 +- .../contract/QbitReadContractIT.java | 4 +- .../drift/AuthentikDriftReportTest.java | 2 +- .../AuthentikGroupPermissionMapperTest.java | 2 +- .../EffectivePermissionResolverTest.java | 27 ++++- .../security/IdentityPermissionTest.java | 68 +++++++++++ .../blueprints/kof22-identity-bootstrap.yaml | 2 +- .../2026.8.3/groups-list-with-users.json | 2 +- .../fixtures/2026.8.3/groups-list.json | 2 +- 29 files changed, 293 insertions(+), 123 deletions(-) create mode 100644 src/test/java/com/kingsrook/qbits/authentik/security/IdentityPermissionTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 04beaf7..987fdd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,15 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] ### 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). ### Documentation - README: "Adopting in another app" section (coordinates, configuration, secrets, permissions, limitations) linking the QQQ adoption checklist; examples use neutral names instead of one host's (#3). ### Added (phase 1, read-only MVP) -- Tables: `authentik_group_member` (virtual membership table: by group, by user, by key, or a bounded sweep), `authentik_session` and `authentik_event` (both need `identity.audit.read`). -- `GroupGraph` and `EffectivePermissionResolver`: groups, including inherited parents, become QQQ permissions. `identity.*` and catalog wildcards are expanded; unknown names are reported, never granted. Groups show inherited superuser status and effective permissions; users show effective permissions. +- Tables: `authentik_group_member` (virtual membership table: by group, by user, by key, or a bounded sweep), `authentik_session` and `authentik_event` (both need `authentik.audit.read`). +- `GroupGraph` and `EffectivePermissionResolver`: groups, including inherited parents, become QQQ permissions. `authentik.*` and catalog wildcards are expanded; unknown names are reported, never granted. Groups show inherited superuser status and effective permissions; users show effective permissions. - `AuthentikGroupPermissionMapper`: an authentication customizer that turns the token's `groups` claim into QQQ session permissions through the resolver. It supports static fallbacks and `bootstrapOperators`; if Authentik cannot be read it grants only the static fallbacks, never everything. - Review hardening: - `search` accepts plain text only; Authentik's query language could reach sensitive fields. @@ -28,7 +29,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel - https is required (except loopback or `allowInsecureHttp`). - `groupScopeNamePrefix` is refused until implemented. - Drift checks that cannot run report ERROR ("result unknown"); service-account tokens are filtered server-side; deleted targets are flagged; only human users count as identity admins. -- Identity drift report widget (`identity.admin`). It checks the server version, blueprint status, the service account (type, groups, superuser, protection, extra self-minted tokens), break-glass presence, the last identity administrator, `qqq.io/*` attribute validity, and service-account changes to protected or superuser objects (lockout detection). +- Identity drift report widget (`authentik.admin`). It checks the server version, blueprint status, the service account (type, groups, superuser, protection, extra self-minted tokens), break-glass presence, the last identity administrator, `qqq.io/*` attribute validity, and service-account changes to protected or superuser objects (lockout detection). ### Added (phase 0) - Phase 0 foundations: `AuthentikQBitConfig`, `AuthentikQBitProducer`, `AuthentikActionUtil` (bearer auth, page/page_size paging, filter mapping that rejects unsupported filters, 10 rps token-bucket throttle, log redaction), read-only Users and Groups tables, Identity app. diff --git a/README.md b/README.md index 94a6afc..08e34a1 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,10 @@ Access is controlled by QQQ permissions: | Permission (held as `.hasAccess`) | Grants | |---|---| -| `identity.users.read` | the Identity app and its tables | -| `identity.audit.read` | sensitive fields and tables | +| `authentik.users.read` | the Identity app and its tables | +| `authentik.audit.read` | sensitive fields and tables | -The full `identity.*` set is in DESIGN.md section 5.2. +The full `authentik.*` set is in DESIGN.md section 5.2. ### Authentik side @@ -59,7 +59,7 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo | 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 | `identity.users.read` (Identity app and tables), `identity.audit.read` (sensitive fields and tables); the full `identity.*` set is in DESIGN.md section 5.2. Granted through the Authentik group attribute `qqq.io/permissions`, read by `AuthentikGroupPermissionMapper`. | +| 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. | diff --git a/UPGRADE.md b/UPGRADE.md index 5070830..3da3be3 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,67 @@ # Upgrading qbit-authentik +## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release) + +The qbit's permissions follow the same owner naming rule (2026-10-12) and now +carry the qbit's name. The suffixes are unchanged: + +| Before | After | +|---|---| +| `identity.*` | `authentik.*` | +| `identity.users.read` | `authentik.users.read` | +| `identity.users.manage` | `authentik.users.manage` | +| `identity.groups.manage` | `authentik.groups.manage` | +| `identity.memberships.manage` | `authentik.memberships.manage` | +| `identity.permissions.manage` | `authentik.permissions.manage` | +| `identity.roles.manage` | `authentik.roles.manage` | +| `identity.credentials.reset` | `authentik.credentials.reset` | +| `identity.sessions.revoke` | `authentik.sessions.revoke` | +| `identity.audit.read` | `authentik.audit.read` | +| `identity.approve` | `authentik.approve` | +| `identity.admin` | `authentik.admin` | + +The QQQ session form is the new name plus `.hasAccess` +(`authentik.users.read.hasAccess`). + +**Legacy names are not granted.** The resolver lists an old `identity.*` name +among a group's *unknown* permissions, so it grants nothing and fails closed. +Update every stored grant **before** you deploy, or the admins lose the +Identity app until you do: + +- **Authentik group attributes:** edit `qqq.io/permissions` on each group, for + example `identity-admins`, in the Authentik UI (Directory → Groups → + Attributes) or in its blueprint. Replace `identity.` with `authentik.`: + + ```yaml + attributes: + qqq.io/permissions: + - authentik.* # was identity.* + ``` + + To find the groups that still carry old names, run the drift report. Its + "group attributes" check warns about each `identity.*` entry ("grants + nothing in this app"). +- **QQQ-side grants:** these are static fallbacks in + `AuthentikGroupPermissionMapper`, host permission lists and role tables. If + a host stores permission names in a table: + + ```sql + -- example only: use your host's table and column names + UPDATE role_permission + SET permission_name = REPLACE(permission_name, 'identity.', 'authentik.') + WHERE permission_name LIKE 'identity.%'; + ``` + + The qbit itself stores no grants in a database. `qqq_user_session` rows hold + resolved permissions, so sign users out (or let their sessions expire) + after the change. +- **Code** that names `identity.*` strings. Use `IdentityPermission` + instead. `IdentityPermission.LEGACY_PREFIX`, `LEGACY_WILDCARD` and + `getLegacyShortName()` (all deprecated) give the old names for upgrade + scripts. + +The `identity-admins` group name and the "Identity" app label are unchanged. + ## Snake_case table names (unreleased → first release) Table names now follow the owner naming rule (2026-10-12): lowercase snake_case, @@ -15,8 +77,7 @@ prefixed with the qbit's name and `_`. With a custom `tableNamePrefix` the prefix is snake-cased too: `myAuth` gives `my_auth_user`. App, widget, backend and process names keep camelCase -(`authentikAdmin`, `authentikDriftReport`, `authentikApi`). Permission names -(`identity.*`) are unchanged. +(`authentikAdmin`, `authentikDriftReport`, `authentikApi`). The tables are API-backed, so there is no database DDL. Hosts that ran a pre-release snapshot should update anything that stores a table name: diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f5fd8ab..79c9db8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -23,7 +23,7 @@ Seven design rules shape everything below: 3. **QQQ permissions are data, managed in QQQ.** - A group's QQQ permissions live in the group attribute `qqq.io/permissions`. - The reusable `AuthentikGroupPermissionMapper` turns a user's groups, including inherited parents, into QQQ session permissions. - - Who can operate the qbit is decided by those same permissions (`identity.*`). No operator group is hard-coded. + - Who can operate the qbit is decided by those same permissions (`authentik.*`). No operator group is hard-coded. 4. **The qbit checks permissions itself.** QQQ core actions do not enforce permissions (QRun-IO/qqq#1061), and `FieldSecurityLock` is not enforced (#1062). An `AuthentikGuard` in the backend action layer checks every read and write. 5. **No escalation.** - Operators can only grant permissions they hold themselves, whether through memberships or permission attributes. @@ -42,10 +42,10 @@ Seven design rules shape everything below: |---|---| | 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). | | 2 | **v1 scope.** Full user lifecycle: phases 0–3 of §11. Authentik RBAC role management is phase 4. | -| 3 | **Who operates.** Decided by permissions, not a fixed group. Fine-grained `identity.*` QQQ permissions are granted through Authentik groups and the mapper. James holds all of them at bootstrap. All safety guards are kept. | +| 3 | **Who operates.** Decided by permissions, not a fixed group. Fine-grained `authentik.*` QQQ permissions are granted through Authentik groups and the mapper. James holds all of them at bootstrap. All safety guards are kept. | | 4 | **Users.** Direct create is allowed, alongside invitations. The user sets their own password through an Authentik email. No hard delete; deactivate only. | | 5–16 | The recommendations in the first revision are accepted: recovery email only, MFA view and reset, typed confirmation plus reason with two-person approval for role changes, read-through plus webhook→ESB plus mirror, Authentik-specific with neutral names, the mapper, list-and-revoke for service accounts, new repo, cluster-gitops housekeeping, no Enterprise features, and the retention defaults. Decision 11 (attribute-driven permissions) moves **into v1**, because decision 3 depends on it. | -| §11.2 | All six smaller questions take the recommended answer: snapshot repo `KofTwentyTwo/identity-snapshots` (private); two-person approval **on** for `identity.*` grants, with the Authentik UI as break-glass while James is the only identity admin; a new `kof22-breakglass` superuser; all four seed groups; service accounts listed but only deactivate/revoke; `kof-website-drafters` created with **no** app access until the owner decides (one attribute later). | +| §11.2 | All six smaller questions take the recommended answer: snapshot repo `KofTwentyTwo/identity-snapshots` (private); two-person approval **on** for `authentik.*` grants, with the Authentik UI as break-glass while James is the only identity admin; a new `kof22-breakglass` superuser; all four seed groups; service accounts listed but only deactivate/revoke; `kof-website-drafters` created with **no** app access until the owner decides (one attribute later). | ### 0.2 Phase 0 findings (2026-10-11, verified on Authentik 2026.8.3) @@ -97,10 +97,10 @@ Git also *creates* a few starting objects if they don't exist yet: These starting objects use `state: created`. Authentik's blueprint importer skips an object that already exists, so **a blueprint re-apply never changes or reverts it**. Blueprints never list group members, so a re-apply can never remove someone you added. Re-applies happen hourly and on every Git change. **How permissions work.** -- Each group can carry a list of QQQ permissions, for example `kof-website-publishers` → `cms.publish`, or `identity-admins` → `identity.*`. The list is stored on the group in Authentik as the attribute `qqq.io/permissions`, and you edit it in QQQ on the group's page. +- Each group can carry a list of QQQ permissions, for example `kof-website-publishers` → `cms.publish`, or `identity-admins` → `authentik.*`. The list is stored on the group in Authentik as the attribute `qqq.io/permissions`, and you edit it in QQQ on the group's page. - When someone signs in, QQQ looks at their groups, including parent groups, and gives them the union of those permissions. - To give someone a permission, add them to a group that has it. To give a whole group a new permission, edit the group. -- **Who may use the Identity screens is itself just a permission** (`identity.users.manage`, `identity.memberships.manage`, …). At bootstrap you hold all of them through `identity-admins`. +- **Who may use the Identity screens is itself just a permission** (`authentik.users.manage`, `authentik.memberships.manage`, …). At bootstrap you hold all of them through `identity-admins`. **Who can sign in to the admin at all.** The Authentik sign-in policy, kept in Git, changes from "is in `kof22-admins` or `kof-website-publishers`" to "is in any group whose `qqq.io/app-access` attribute lists this app". Access then becomes data you can manage in QQQ: a new group can be given admin sign-in without a Git change. @@ -161,9 +161,9 @@ These starting objects use `state: created`. Authentik's blueprint importer skip | Persona | kof22 example | Typical grants | |---|---|---| -| Identity admin | James | All `identity.*` (bootstrap) | -| Delegated manager | A future editor-in-chief | `identity.users.read`, `identity.memberships.manage`. Can only add people to groups whose permissions they already hold. | -| Auditor | A read-only admin | `identity.users.read`, `identity.audit.read` | +| Identity admin | James | All `authentik.*` (bootstrap) | +| Delegated manager | A future editor-in-chief | `authentik.users.read`, `authentik.memberships.manage`. Can only add people to groups whose permissions they already hold. | +| Auditor | A read-only admin | `authentik.users.read`, `authentik.audit.read` | | Platform owner | Whoever runs cluster-gitops | Owns the infrastructure blueprints; not involved in routine identity changes | ### 1.4 Scenarios (kof22) @@ -335,8 +335,8 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which | `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 `identity.*` 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. | -| `permissionCatalog` | derived | The QQQ permission names that can be granted through `qqq.io/permissions`, taken from `PermissionsHelper.getAllAvailablePermissions` plus the `identity.*` names. Unknown names are rejected. | +| `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. | +| `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` | | `newUserPath` | `users` | Authentik `path` for users QQQ creates | @@ -478,7 +478,7 @@ entries: name: identity-admins attrs: attributes: - qqq.io/permissions: ["identity.*"] + qqq.io/permissions: ["authentik.*"] qqq.io/app-access: ["kof22-website-prod"] - model: authentik_core.group state: created @@ -615,23 +615,23 @@ The role `qqq--idadmin` is assigned directly to the service account (boo This is weaker than revision 1's per-group object permissions. That is the cost of "manage all identities from QQQ" (decision 1). It is partly offset by `groupScope`/`userScope` for multi-app setups, and by the qbit-side guards. -### 5.2 QQQ side: `identity.*` permissions +### 5.2 QQQ side: `authentik.*` permissions | Permission | Grants | |---|---| -| `identity.users.read` | Identity app; users, groups, memberships, effective permissions, applications, bindings, blueprints (read) | -| `identity.users.manage` | Create, invite, edit (name, email, path, type), deactivate, reactivate users; cancel invitations | -| `identity.groups.manage` | Create groups, rename them, set parents (within `groupScope`), edit non-reserved attributes | -| `identity.memberships.manage` | Add and remove members (subject to the escalation rule) | -| `identity.permissions.manage` | Edit a group's `qqq.io/permissions` and `qqq.io/app-access` (subject to the escalation rule) | -| `identity.roles.manage` | Assign and unassign Authentik RBAC roles (phase 4) | -| `identity.credentials.reset` | Send recovery email, reset MFA, revoke app passwords | -| `identity.sessions.revoke` | Revoke sessions | -| `identity.audit.read` | Local audit log, Authentik events, sessions (IPs, user agents), MFA device lists, user attributes | -| `identity.approve` | Approve two-person requests | -| `identity.admin` | Health and drift views, resync mirror, snapshot export, view config | +| `authentik.users.read` | Identity app; users, groups, memberships, effective permissions, applications, bindings, blueprints (read) | +| `authentik.users.manage` | Create, invite, edit (name, email, path, type), deactivate, reactivate users; cancel invitations | +| `authentik.groups.manage` | Create groups, rename them, set parents (within `groupScope`), edit non-reserved attributes | +| `authentik.memberships.manage` | Add and remove members (subject to the escalation rule) | +| `authentik.permissions.manage` | Edit a group's `qqq.io/permissions` and `qqq.io/app-access` (subject to the escalation rule) | +| `authentik.roles.manage` | Assign and unassign Authentik RBAC roles (phase 4) | +| `authentik.credentials.reset` | Send recovery email, reset MFA, revoke app passwords | +| `authentik.sessions.revoke` | Revoke sessions | +| `authentik.audit.read` | Local audit log, Authentik events, sessions (IPs, user agents), MFA device lists, user attributes | +| `authentik.approve` | Approve two-person requests | +| `authentik.admin` | Health and drift views, resync mirror, snapshot export, view config | -`identity.*` in an attribute expands to all of the above. Permissions are granted only through `qqq.io/permissions` on groups, read by the mapper (§5.6). Nothing is hard-coded per app. **Bootstrap:** the `identity-admins` group carries `identity.*`, and James is its first member (§4.3). +`authentik.*` in an attribute expands to all of the above. Permissions are granted only through `qqq.io/permissions` on groups, read by the mapper (§5.6). Nothing is hard-coded per app. **Bootstrap:** the `identity-admins` group carries `authentik.*`, and James is its first member (§4.3). ### 5.3 Enforcement point (re: #1061, #1062) @@ -639,7 +639,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost - A session with no user is refused, except `MirrorSync`'s internal read-only session. - Every qbit process's first backend step re-checks `checkProcessPermissionThrowing` and the per-record rules (§5.4). It does not trust that the middleware ran. - Field-level protection does not use `FieldSecurityLock` (#1062): - - Sensitive columns are removed in `jsonObjectToRecord` without `identity.audit.read`. These are `attributes` (except the `qqq.io/*` keys, shown under `users.read`), `last_ip`, `user_agent` and `geo_ip`. + - Sensitive columns are removed in `jsonObjectToRecord` without `authentik.audit.read`. These are `attributes` (except the `qqq.io/*` keys, shown under `users.read`), `last_ip`, `user_agent` and `geo_ip`. - Filters on those fields are rejected for that session. - Tables also declare `QPermissionRules`, so the UI hides what the user can't use. - An ArchUnit test asserts that every write path goes through the guard. @@ -655,11 +655,11 @@ This is weaker than revision 1's per-group object permissions. That is the cost 4. **No escalation (the subset rule).** - Every grant is checked against **P(operator)**: the operator's live effective QQQ permissions, resolved from their *current* Authentik groups (cached 30 s), not the session claim. - **Adding user U to group G** requires `P(G) ⊆ P(operator)`. P(G) is the permissions G confers, including inherited parents. - - **Editing G's `qqq.io/permissions`** requires every added permission to be in P(operator). Removing a permission requires only `identity.permissions.manage`. + - **Editing G's `qqq.io/permissions`** requires every added permission to be in P(operator). Removing a permission requires only `authentik.permissions.manage`. - **Setting G's parents to H** requires `P(H) ⊆ P(operator)`. - **App access**: adding an app key to `qqq.io/app-access` requires the operator to have access to that app. - - Holders of `identity.*` pass naturally. Delegated managers can only hand out what they hold. -5. **Last-admin protection.** The qbit refuses any change after which no active, non-protected human holds `identity.memberships.manage` **and** `identity.permissions.manage`. That covers removing a membership, deactivating, editing permissions and re-parenting. Break-glass doesn't count, because it can't use QQQ. + - Holders of `authentik.*` pass naturally. Delegated managers can only hand out what they hold. +5. **Last-admin protection.** The qbit refuses any change after which no active, non-protected human holds `authentik.memberships.manage` **and** `authentik.permissions.manage`. That covers removing a membership, deactivating, editing permissions and re-parenting. Break-glass doesn't count, because it can't use QQQ. 6. **Bulk caps.** At most 100 targets per run, `TYPED` confirmation above 25, paced by the throttle. A partial failure stops the run with per-record results. 7. **Stale checks.** Each target's state is re-read at execute: users by `last_updated`; groups (which have no `last_updated` in 2026.8, §0.2 vi) by a fingerprint of name, parents, `qqq.io/*` attributes and member count. A record changed since the preview, for example in the Authentik UI, fails with "changed since preview; review again". This is how the two front doors avoid lost updates: QQQ never blind-overwrites a concurrent UI edit. Attribute edits send a merge-patch of only the changed `qqq.io/*` keys, re-read immediately before the write. @@ -681,7 +681,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replaces kof22's `AuthentikAuthenticationCustomizer` and generalizes MHP's IAM-style group-attribute policies (MHP ADR 0001). 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 `identity.*` and other wildcard prefixes expanded against `permissionCatalog`. +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`. 4. It sets QQQ session permissions, user, email and full name. @@ -735,7 +735,7 @@ Unsupported criteria (OR across fields, `NOT_EQUALS`, nested filters) are **reje | Table | Purpose | |---|---| | `authentik_admin_action` | **Append-only audit and outbox** of every write the qbit attempts. Columns: id, correlationId, actionName, operator (QQQ user + Authentik username), reason, targetType, targetKey, targetLabel, beforeJson, afterJson, status (`PENDING_APPROVAL`, `RUNNING`, `SUCCEEDED`, `FAILED`, `REFUSED`), errorMessage, authentikEventPk, approver, timestamps. The row is inserted *before* the API call, following the sql-workbench `ExecutionAuditor` pattern; if that insert fails, the call is not made. Insert, update and delete capabilities are removed for everyone. | -| `authentik_approval_request` | Two-person requests: action payload, requester, approver, 24 h expiry, approver ≠ requester. Used for RBAC (phase 4) and, by default, for permission edits that add `identity.*` (§6.3). | +| `authentik_approval_request` | Two-person requests: action payload, requester, approver, 24 h expiry, approver ≠ requester. Used for RBAC (phase 4) and, by default, for permission edits that add `authentik.*` (§6.3). | | `authentik_user_mirror` | `MIRROR` feature: pk, uuid, username, email, name, type, isActive, lastLogin, isSuperuser, isProtected, groupNames, effectivePermissions (text), mfaDeviceCount, failedLogins24h, refreshedAt. Unique on username and email. Web Users joins here (`qqq_user_session.user_id` = email in kof22). The mapper falls back to it. | | `authentik_group_mirror` | pk, name, parentPks, attributes `qqq.io/*` only, memberCount, isSuperuser (inherited), refreshedAt. Feeds the resolver and the tree widget. | | `authentik_membership_mirror` | userPk, groupPk, groupName, refreshedAt | @@ -749,26 +749,26 @@ Each process: | Process | Table / slot | Records | Permission | Default confirmation | Authentik calls | |---|---|---|---|---|---| -| Create user | `authentik_user` query action | 0 | `identity.users.manage` (+ `memberships.manage` if groups given) | CONFIRM | `POST /core/users/` (no password) → `add_user` per group → `recovery_email` | -| Invite user | same | 0 | `identity.users.manage` | CONFIRM | `POST /stages/invitation/invitations/` + `send_email`; groups applied on `invitation_used` | -| Edit user | `authentik_user` view action | 1 | `identity.users.manage` | CONFIRM | `PATCH /core/users/{id}/` (name, email, path, type internal/external) | -| Deactivate | `authentik_user` | 1–25 | `identity.users.manage` | TYPED | `PATCH is_active=false` (deletes sessions) + optional app-password revoke | -| Reactivate | same | 1–25 | `identity.users.manage` | CONFIRM | `PATCH is_active=true` | -| Add to groups / Remove from groups | `authentik_user` query + view | 1–100 | `identity.memberships.manage` | CONFIRM (TYPED > 25) | `POST /core/groups/{g}/add_user/` / `remove_user/` | +| Create user | `authentik_user` query action | 0 | `authentik.users.manage` (+ `memberships.manage` if groups given) | CONFIRM | `POST /core/users/` (no password) → `add_user` per group → `recovery_email` | +| Invite user | same | 0 | `authentik.users.manage` | CONFIRM | `POST /stages/invitation/invitations/` + `send_email`; groups applied on `invitation_used` | +| Edit user | `authentik_user` view action | 1 | `authentik.users.manage` | CONFIRM | `PATCH /core/users/{id}/` (name, email, path, type internal/external) | +| Deactivate | `authentik_user` | 1–25 | `authentik.users.manage` | TYPED | `PATCH is_active=false` (deletes sessions) + optional app-password revoke | +| Reactivate | same | 1–25 | `authentik.users.manage` | CONFIRM | `PATCH is_active=true` | +| Add to groups / Remove from groups | `authentik_user` query + view | 1–100 | `authentik.memberships.manage` | CONFIRM (TYPED > 25) | `POST /core/groups/{g}/add_user/` / `remove_user/` | | Add members / Remove members | `authentik_group` view | 1 group + picker | same | same | same | -| Create group | `authentik_group` query action | 0 | `identity.groups.manage` (+ `permissions.manage` if permissions given) | CONFIRM | `POST /core/groups/` (name, parents, attributes) | -| Edit group | `authentik_group` view | 1 | `identity.groups.manage` | CONFIRM | `PATCH` name, parents and non-reserved attributes (merge-patch) | -| Edit permissions and app access | `authentik_group` view | 1 | `identity.permissions.manage` | CONFIRM; **TWO_PERSON** when adding any `identity.*` permission | `PATCH attributes` (`qqq.io/permissions`, `qqq.io/app-access`), re-read first | -| Check access / Effective permissions | `authentik_user` view | 1 | `identity.users.read` | none | `check_access` per in-scope app; the resolver | -| Send password reset email | `authentik_user` | 1–25 | `identity.credentials.reset` | CONFIRM | `recovery_email` | -| Reset MFA | `authentik_user` | 1 | `identity.credentials.reset` | TYPED | per-device `DELETE` | -| Revoke app passwords | `authentik_user`, `authentik_token` | 1–25 | `identity.credentials.reset` | TYPED | `DELETE /core/tokens/{identifier}/` | -| Revoke sessions | `authentik_user`, `authentik_session` | 1–100 | `identity.sessions.revoke` | CONFIRM | `bulk_delete` / per-session `DELETE` | -| Cancel invitation | `authentik_invitation` | 1–25 | `identity.users.manage` | CONFIRM | `DELETE` | -| Assign / unassign role | user / group | 1–25 | `identity.roles.manage` | TWO_PERSON | `/rbac/roles/{r}/add_user/` or group `roles` (phase 4) | -| Resync mirror | `authentik_user_mirror` query | 0 | `identity.admin` | CONFIRM | full reconcile | -| Export snapshot now | `authentik_user_mirror` query | 0 | `identity.admin` | CONFIRM | reads only (§7.3) | -| View audit trail | every table: view action → filtered `authentik_admin_action` + `authentik_event?context_model_pk=` | 1 | `identity.audit.read` | none | events query | +| 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 | +| 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}/` | +| Revoke sessions | `authentik_user`, `authentik_session` | 1–100 | `authentik.sessions.revoke` | CONFIRM | `bulk_delete` / per-session `DELETE` | +| Cancel invitation | `authentik_invitation` | 1–25 | `authentik.users.manage` | CONFIRM | `DELETE` | +| Assign / unassign role | user / group | 1–25 | `authentik.roles.manage` | TWO_PERSON | `/rbac/roles/{r}/add_user/` or group `roles` (phase 4) | +| Resync mirror | `authentik_user_mirror` query | 0 | `authentik.admin` | CONFIRM | full reconcile | +| Export snapshot now | `authentik_user_mirror` query | 0 | `authentik.admin` | CONFIRM | reads only (§7.3) | +| View audit trail | every table: view action → filtered `authentik_admin_action` + `authentik_event?context_model_pk=` | 1 | `authentik.audit.read` | none | events query | **Concurrent edits.** If the Authentik UI changes a user or group after a QQQ preview was built, the stale check fails that record and asks for a re-review. Edits made in the Authentik UI are never overwritten blindly. @@ -842,7 +842,7 @@ The qbit produces an app `authentikAdmin` (label **"Identity"**, icon `badge`). | Users list | generic query on `authentik_user` | Username, name, email, type, active, last login, groups, protected lock. Saved views: "Inactive 90d", "No MFA", "Service accounts", "Superusers", "Created by QQQ". Bulk actions: Add to groups, Remove from groups, Deactivate, Revoke sessions. Table action: Create user, Invite user. | | User detail | generic view + child widgets | Badges (protected, superuser, service account). Tabs: Groups, **Effective permissions** (each with the granting group), Access (computed per app from `qqq.io/app-access` and the app's bindings; other expression policies shown as "not evaluated", §0.2 iii), Sessions, MFA devices, App passwords, History (QQQ audit + Authentik events, including UI-made changes with their actor) | | 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 (`identity.audit.read`) | +| 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 | | Audit | generic query on `authentik_admin_action` + `authentik_event` | Filter by operator, target, action, status, and source (QQQ vs Authentik UI) | @@ -901,7 +901,7 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh ### 9.4 PII and retention - **What is PII.** Emails, names, IPs, user agents, geo/ASN and event contexts. -- **Who sees it.** IPs, user agents, geo and event contexts need `identity.audit.read`. Exports respect the same rule. +- **Who sees it.** IPs, user agents, geo and event contexts need `authentik.audit.read`. Exports respect the same rule. - **What is stored where.** The mirror stores no IPs or user agents. The snapshot export holds only directory data (§7.3). - **Retention.** Webhook inbox 30 days; admin-action audit 2 years. @@ -910,7 +910,7 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh | Threat | Control | |---|---| | A compromised QQQ backend escalates in Authentik | The service account can't create superusers, touch superuser groups, read or mint tokens, impersonate, delete users, or change flows and policies (§5.1) | -| A QQQ operator escalates themselves or a friend | Subset rule, no self-edits, two-person approval for `identity.*` grants, protected accounts | +| 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` | | 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 | @@ -957,7 +957,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 `identity.*`, `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 | +| **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 | | **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 | @@ -988,7 +988,7 @@ 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 `identity.*` | +| A lockout through bad permission edits | Last-admin guard, break-glass, `bootstrapOperators`, 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) | | Load on the shared Postgres | Throttle, caps, mirror | @@ -1005,7 +1005,7 @@ The decisions taken on 2026-10-11 are in §0.1. The remaining questions are smal - **(a) New private `KofTwentyTwo/identity-snapshots` repo.** *(recommended)* - (b) A folder in cluster-gitops (risk of being applied by mistake). - (c) Skip. -2. **Two-person approval for granting `identity.*`.** +2. **Two-person approval for granting `authentik.*`.** - **(a) On**, with a second identity admin required, or break-glass via the Authentik UI while only James exists. *(recommended)* - (b) Off while James is the only admin, with a `TYPED` confirmation instead. 3. **Break-glass admin.** diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikFieldSpec.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikFieldSpec.java index 1fe55bb..18b02ec 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikFieldSpec.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikFieldSpec.java @@ -29,7 +29,7 @@ import org.json.JSONObject; /******************************************************************************* ** One field of an Authentik-backed table: how to read it from the Authentik ** JSON, which QQQ criteria map to which Authentik query parameters, and - ** whether it is sensitive (stripped without identity.audit.read). + ** whether it is sensitive (stripped without authentik.audit.read). *******************************************************************************/ public class AuthentikFieldSpec { @@ -70,7 +70,7 @@ public class AuthentikFieldSpec /******************************************************************************* - ** Mark as sensitive (needs identity.audit.read to see or filter on). + ** Mark as sensitive (needs authentik.audit.read to see or filter on). *******************************************************************************/ public AuthentikFieldSpec withSensitive() { 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 9b6d6ca..b7aaf1f 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java +++ b/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java @@ -50,7 +50,7 @@ import org.json.JSONObject; ** superuser, is protected; exactly one API token (it can mint its own) ** 4. break-glass: at least one active, protected superuser exists ** 5. identity admins: at least one active, unprotected human holds both - ** identity.memberships.manage and identity.permissions.manage (last-admin) + ** authentik.memberships.manage and authentik.permissions.manage (last-admin) ** 6. group attributes: qqq.io/permissions entries that grant nothing known; ** qqq.io/app-access entries outside applicationsInScope; superuser groups ** (or their descendants) carrying qqq.io/permissions @@ -346,11 +346,11 @@ public class AuthentikDriftReport if(admins.isEmpty()) { String bootstrap = config.getResolvedBootstrapOperators().isEmpty() ? "" : " bootstrapOperators 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 identity.memberships.manage and identity.permissions.manage. Add an administrator to a group carrying identity.* (e.g. identity-admins) in the Authentik UI." + bootstrap)); + 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) { - findings.add(DriftFinding.info("identity admins", admins.get(0), "Only one identity administrator. Two-person approval of identity.* grants needs a second one (or the Authentik UI).")); + findings.add(DriftFinding.info("identity admins", admins.get(0), "Only one identity administrator. Two-person approval of authentik.* grants needs a second one (or the Authentik UI).")); } else { 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 76b717e..043a124 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java @@ -32,7 +32,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* ** The "Identity" app (DESIGN.md section 8.1): the qbit's tables (and, from - ** phase 1, its widgets), gated by identity.users.read. Hosts may re-parent it, + ** phase 1, its widgets), gated by authentik.users.read. Hosts may re-parent it, ** for example under an Admin app; its name stays the same. Processes are only ** ever table or record actions, never navigation entries. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetProducer.java index bad4d5c..bbeaa38 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetProducer.java @@ -30,7 +30,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaData /******************************************************************************* ** The "Identity Drift Report" widget: a standard TABLE widget (generic UI, no ** frontend work) listing the AuthentikDriftReport findings. Needs - ** identity.admin. The renderer finds the qbit through the "backendName" + ** authentik.admin. The renderer finds the qbit through the "backendName" ** default value. *******************************************************************************/ public class AuthentikDriftReportWidgetProducer extends QBitComponentMetaDataProducer diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetRenderer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetRenderer.java index 4b33d25..9cd95e2 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetRenderer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetRenderer.java @@ -38,7 +38,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; /******************************************************************************* - ** Renders the drift report as a TableData. Re-checks identity.admin itself + ** Renders the drift report as a TableData. Re-checks authentik.admin itself ** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061). *******************************************************************************/ public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java index 256e2ae..a548d3d 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java @@ -21,7 +21,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTables; /******************************************************************************* - ** authentik_event: /events/events/ (needs identity.audit.read). + ** authentik_event: /events/events/ (needs authentik.audit.read). *******************************************************************************/ public class AuthentikEventTableProducer extends AbstractAuthentikTableProducer { diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java index 06480df..ea42519 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java @@ -21,7 +21,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTables; /******************************************************************************* - ** authentik_session: /core/authenticated_sessions/ (needs identity.audit.read). + ** authentik_session: /core/authenticated_sessions/ (needs authentik.audit.read). *******************************************************************************/ public class AuthentikSessionTableProducer extends AbstractAuthentikTableProducer { diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java index f61ad97..f158822 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java @@ -34,9 +34,9 @@ import com.kingsrook.qbits.authentik.security.IdentityPermission; ** Rules: ** - A group confers the union of qqq.io/permissions on itself and every ** ancestor ("inherited parents"). - ** - "identity.*" expands to every identity permission; an identity short - ** name ("identity.users.read") becomes its session form - ** ("identity.users.read.hasAccess"). + ** - "authentik.*" expands to every identity permission; an identity short + ** name ("authentik.users.read") becomes its session form + ** ("authentik.users.read.hasAccess"). ** - Any other name ending in ".*" expands against the catalog (prefix match); ** with no catalog it is dropped, never granted as a literal wildcard. ** - Names not in the catalog are reported as unknown and NOT granted (when a 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 847bb36..197099f 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java @@ -38,9 +38,9 @@ import org.json.JSONObject; ** The qbit's own enforcement point (DESIGN.md section 5.3). QQQ core actions ** do not enforce permissions for non-HTTP callers (QRun-IO/qqq#1061), so every ** call into AuthentikActionUtil is checked here first: - ** - reads need the table's identity.* permission (system sessions may read, + ** - reads need the table's authentik.* permission (system sessions may read, ** for the mirror sync); - ** - sensitive fields need identity.audit.read; + ** - sensitive fields need authentik.audit.read; ** - writes are refused in this release (read-only MVP), and always refused ** for system sessions, in readOnly mode, or without the feature; ** - records outside userScope are invisible. diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java b/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java index a2255a7..03321fc 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java @@ -25,32 +25,40 @@ import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRule /******************************************************************************* ** The qbit's own QQQ permissions (DESIGN.md section 5.2). ** - ** Operators write the short name (e.g. "identity.users.read") into a group's - ** qqq.io/permissions attribute; "identity.*" means all of them. In a QQQ + ** Operators write the short name (e.g. "authentik.users.read") into a group's + ** qqq.io/permissions attribute; "authentik.*" means all of them. In a QQQ ** session each one is held as its HAS_ACCESS form, the short name plus ** ".hasAccess", which is what QQQ's PermissionsHelper checks for metadata ** guarded by {@link #rules()}. *******************************************************************************/ public enum IdentityPermission { - USERS_READ("identity.users.read"), - USERS_MANAGE("identity.users.manage"), - GROUPS_MANAGE("identity.groups.manage"), - MEMBERSHIPS_MANAGE("identity.memberships.manage"), - PERMISSIONS_MANAGE("identity.permissions.manage"), - ROLES_MANAGE("identity.roles.manage"), - CREDENTIALS_RESET("identity.credentials.reset"), - SESSIONS_REVOKE("identity.sessions.revoke"), - AUDIT_READ("identity.audit.read"), - APPROVE("identity.approve"), - ADMIN("identity.admin"); + USERS_READ("authentik.users.read"), + USERS_MANAGE("authentik.users.manage"), + GROUPS_MANAGE("authentik.groups.manage"), + MEMBERSHIPS_MANAGE("authentik.memberships.manage"), + PERMISSIONS_MANAGE("authentik.permissions.manage"), + ROLES_MANAGE("authentik.roles.manage"), + CREDENTIALS_RESET("authentik.credentials.reset"), + SESSIONS_REVOKE("authentik.sessions.revoke"), + AUDIT_READ("authentik.audit.read"), + APPROVE("authentik.approve"), + ADMIN("authentik.admin"); /** The wildcard operators write for "all identity permissions". */ - public static final String WILDCARD = "identity.*"; + public static final String WILDCARD = "authentik.*"; /** Suffix QQQ uses for HAS_ACCESS permissions. */ public static final String HAS_ACCESS_SUFFIX = ".hasAccess"; + /** Prefix of the permission names before the owner naming rule (2026-10-12); see UPGRADE.md. */ + @Deprecated + public static final String LEGACY_PREFIX = "identity."; + + /** Wildcard before the owner naming rule (2026-10-12); no longer granted. */ + @Deprecated + public static final String LEGACY_WILDCARD = "identity.*"; + private final String shortName; @@ -66,7 +74,7 @@ public enum IdentityPermission /******************************************************************************* - ** The name written in qqq.io/permissions, e.g. "identity.users.read". + ** The name written in qqq.io/permissions, e.g. "authentik.users.read". *******************************************************************************/ public String getShortName() { @@ -76,7 +84,20 @@ public enum IdentityPermission /******************************************************************************* - ** The QQQ session permission, e.g. "identity.users.read.hasAccess". + ** The name this permission had before the owner naming rule (2026-10-12), + ** e.g. "identity.users.read". Only for finding stored grants when upgrading; + ** legacy names are never granted. + *******************************************************************************/ + @Deprecated + public String getLegacyShortName() + { + return (LEGACY_PREFIX + shortName.substring(shortName.indexOf('.') + 1)); + } + + + + /******************************************************************************* + ** The QQQ session permission, e.g. "authentik.users.read.hasAccess". *******************************************************************************/ public String getSessionPermission() { diff --git a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java index 3762189..49c5b92 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java @@ -56,7 +56,7 @@ class AuthentikQBitProducerTest QTableMetaData users = qInstance.getTable("authentik_user"); assertThat(users.getBackendName()).isEqualTo("authentikApi"); assertThat(users.getPrimaryKeyField()).isEqualTo("pk"); - assertThat(users.getPermissionRules().getPermissionBaseName()).isEqualTo("identity.users.read"); + assertThat(users.getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.users.read"); assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_QUERY)).isTrue(); assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_INSERT)).isFalse(); assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_DELETE)).isFalse(); @@ -67,8 +67,8 @@ class AuthentikQBitProducerTest 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(qInstance.getTable("authentik_session").getPermissionRules().getPermissionBaseName()).isEqualTo("identity.audit.read"); - assertThat(qInstance.getWidget("authentikDriftReport").getPermissionRules().getPermissionBaseName()).isEqualTo("identity.admin"); + assertThat(qInstance.getTable("authentik_session").getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.audit.read"); + assertThat(qInstance.getWidget("authentikDriftReport").getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.admin"); assertThat(PermissionsHelper.getAllAvailablePermissionNames(qInstance)).contains(IdentityPermission.USERS_READ.getSessionPermission()); assertThat(qInstance.getQBits()).containsKey("com.kingsrook.qbits:authentik"); @@ -115,8 +115,8 @@ class AuthentikQBitProducerTest producer.setQBitConfig(AuthentikTestSupport.unitConfig()); assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("authentik"); assertThat(AuthentikQBitProducer.getVersion()).isNotBlank(); - assertThat(IdentityPermission.fromName("identity.audit.read")).contains(IdentityPermission.AUDIT_READ); - assertThat(IdentityPermission.fromName("identity.audit.read.hasAccess")).contains(IdentityPermission.AUDIT_READ); + assertThat(IdentityPermission.fromName("authentik.audit.read")).contains(IdentityPermission.AUDIT_READ); + assertThat(IdentityPermission.fromName("authentik.audit.read.hasAccess")).contains(IdentityPermission.AUDIT_READ); assertThat(IdentityPermission.fromName("nope")).isEmpty(); assertThat(IdentityPermission.fromName(null)).isEmpty(); } 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 fe350a4..e1b762a 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtilTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtilTest.java @@ -336,7 +336,7 @@ class AuthentikActionUtilTest QueryInput input = new QueryInput(groupTable); List groups = new QueryAction().execute(input).getRecords(); QRecord identityAdmins = groups.stream().filter(g -> "identity-admins".equals(g.getValueString("name"))).findFirst().orElseThrow(); - assertThat(identityAdmins.getValueString("permissions")).isEqualTo("identity.*"); + 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"); diff --git a/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikFilterMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikFilterMapperTest.java index 4a281e3..e74b12a 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikFilterMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/client/AuthentikFilterMapperTest.java @@ -196,7 +196,7 @@ class AuthentikFilterMapperTest /******************************************************************************* - ** Sensitive fields can't be filtered without identity.audit.read. + ** Sensitive fields can't be filtered without authentik.audit.read. *******************************************************************************/ @Test void sensitiveFieldFilters() throws QException diff --git a/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java b/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java index 010cb22..58380c7 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java @@ -176,7 +176,7 @@ class Phase1TablesTest List groups = query("group", new QQueryFilter()); QRecord child = groups.stream().filter(g -> "phase1-child-of-identity-admins".equals(g.getValueString("name"))).findFirst().orElseThrow(); - assertThat(child.getValueString("effectivePermissions")).contains("identity.memberships.manage.hasAccess"); + assertThat(child.getValueString("effectivePermissions")).contains("authentik.memberships.manage.hasAccess"); assertThat(child.getValueBoolean("isSuperuserInherited")).isFalse(); QRecord admins = groups.stream().filter(g -> "authentik Admins".equals(g.getValueString("name"))).findFirst().orElseThrow(); assertThat(admins.getValueBoolean("isSuperuserInherited")).isTrue(); @@ -190,7 +190,7 @@ class Phase1TablesTest /******************************************************************************* - ** Sessions and events need identity.audit.read and map recorded rows. + ** Sessions and events need authentik.audit.read and map recorded rows. *******************************************************************************/ @Test void sessionsAndEvents() throws QException diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/BlueprintReapplyIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/BlueprintReapplyIT.java index 48e42a5..1938bf2 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/BlueprintReapplyIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/BlueprintReapplyIT.java @@ -87,7 +87,7 @@ class BlueprintReapplyIT assertThat(admin.patch("/core/groups/" + kof22Admins + "/", new JSONObject().put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("adminEsb.hasAccess")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod")))).status()).isEqualTo(200); assertThat(admin.patch("/core/groups/" + identityAdmins + "/", new JSONObject().put("attributes", - new JSONObject().put("qqq.io/permissions", new JSONArray().put("identity.*").put("cms.publish")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod")))).status()).isEqualTo(200); + new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.*").put("cms.publish")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod")))).status()).isEqualTo(200); String roleUuid = admin.first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk"); try @@ -104,7 +104,7 @@ class BlueprintReapplyIT assertThat(memberNames("identity-admins")).contains("reapply.member"); assertThat(memberNames("kof22-admins")).contains("reapply.member"); assertThat(admin.first("/core/groups/?name=kof22-admins").getJSONObject("attributes").getJSONArray("qqq.io/permissions").toList()).containsExactly("adminEsb.hasAccess"); - assertThat(admin.first("/core/groups/?name=identity-admins").getJSONObject("attributes").getJSONArray("qqq.io/permissions").toList()).containsExactly("identity.*", "cms.publish"); + assertThat(admin.first("/core/groups/?name=identity-admins").getJSONObject("attributes").getJSONArray("qqq.io/permissions").toList()).containsExactly("authentik.*", "cms.publish"); //////////////////////////////////////////////////////////////////////// // role permission re-asserted; role assignment and break-glass kept // @@ -121,7 +121,7 @@ class BlueprintReapplyIT admin.post("/core/groups/" + kof22Admins + "/remove_user/", new JSONObject().put("pk", member)); admin.patch("/core/groups/" + kof22Admins + "/", new JSONObject().put("attributes", new JSONObject())); admin.patch("/core/groups/" + identityAdmins + "/", new JSONObject().put("attributes", - new JSONObject().put("qqq.io/permissions", new JSONArray().put("identity.*")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod")))); + new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.*")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod")))); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/BootstrapBlueprintIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/BootstrapBlueprintIT.java index dec238f..79afe92 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/BootstrapBlueprintIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/BootstrapBlueprintIT.java @@ -120,7 +120,7 @@ class BootstrapBlueprintIT } JSONObject identityAdmins = admin.first("/core/groups/?name=identity-admins").getJSONObject("attributes"); - assertThat(identityAdmins.getJSONArray("qqq.io/permissions").toList()).containsExactly("identity.*"); + assertThat(identityAdmins.getJSONArray("qqq.io/permissions").toList()).containsExactly("authentik.*"); assertThat(identityAdmins.getJSONArray("qqq.io/app-access").toList()).containsExactly(AuthentikTestStack.APP_SLUG); JSONObject drafters = admin.first("/core/groups/?name=kof-website-drafters").getJSONObject("attributes"); diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java index 1dd1f6e..ffae214 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java @@ -54,7 +54,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /******************************************************************************* ** Phase 1 read MVP against Authentik 2026.8.3, as the service account with ** the phase 0/1 role: memberships (virtual table), sessions and events - ** (identity.audit.read), effective permissions and inherited superuser on + ** (authentik.audit.read), effective permissions and inherited superuser on ** groups, and the drift report. Runs after SuperuserFloorIT, whose lockout ** probes are exactly what the drift report must flag. Records fixtures. *******************************************************************************/ @@ -70,7 +70,7 @@ class Phase1ReadContractIT /******************************************************************************* - ** A member of a child of identity-admins (inherited identity.*), and a real + ** A member of a child of identity-admins (inherited authentik.*), and a real ** session for akadmin. *******************************************************************************/ @BeforeAll @@ -173,14 +173,14 @@ class Phase1ReadContractIT { QRecord child = query("group", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins"))).get(0); assertThat(child.getValueString("permissions")).isNull(); - assertThat(child.getValueString("effectivePermissions")).contains("identity.memberships.manage.hasAccess", "identity.users.read.hasAccess"); + assertThat(child.getValueString("effectivePermissions")).contains("authentik.memberships.manage.hasAccess", "authentik.users.read.hasAccess"); assertThat(child.getValueBoolean("isSuperuserInherited")).isFalse(); QRecord admins = query("group", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, AuthentikTestStack.SUPERUSER_GROUP))).get(0); assertThat(admins.getValueBoolean("isSuperuserInherited")).isTrue(); QRecord member = query("user", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "phase1.member"))).get(0); - assertThat(member.getValueString("effectivePermissions")).contains("identity.permissions.manage.hasAccess"); + assertThat(member.getValueString("effectivePermissions")).contains("authentik.permissions.manage.hasAccess"); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java index e9282c4..b2ae375 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java @@ -226,7 +226,7 @@ class QbitReadContractIT /******************************************************************************* - ** Sensitive fields need identity.audit.read; reading needs users.read. + ** Sensitive fields need authentik.audit.read; reading needs users.read. *******************************************************************************/ @Test void permissionsAndSensitiveFields() throws QException @@ -258,7 +258,7 @@ class QbitReadContractIT input.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "identity-admins"))); QRecord identityAdmins = new QueryAction().execute(input).getRecords().get(0); - assertThat(identityAdmins.getValueString("permissions")).isEqualTo("identity.*"); + assertThat(identityAdmins.getValueString("permissions")).isEqualTo("authentik.*"); FixtureRecorder.record("groups-list", RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/")); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReportTest.java b/src/test/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReportTest.java index 8d714d3..89159c2 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReportTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReportTest.java @@ -170,7 +170,7 @@ class AuthentikDriftReportTest /******************************************************************************* - ** The widget renders the findings, and needs identity.admin. + ** The widget renders the findings, and needs authentik.admin. *******************************************************************************/ @Test void widgetRendersAndChecksPermission() throws QException 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 758e3b1..274f0e4 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java @@ -89,7 +89,7 @@ class AuthentikGroupPermissionMapperTest /******************************************************************************* - ** Inherited identity.* through a child group; existing permissions kept. + ** Inherited authentik.* through a child group; existing permissions kept. *******************************************************************************/ @Test void groupsBecomePermissions() diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java index c1c27d7..3d2ca2d 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java @@ -41,9 +41,9 @@ class EffectivePermissionResolverTest return (GroupGraph.fromGroups(List.of( group("admins", "authentik Admins", true, List.of(), null, null, List.of(1)), group("under", "under-admins", false, List.of("admins"), List.of("cms.publish"), null, List.of(2)), - group("ida", "identity-admins", false, List.of(), List.of("identity.*"), List.of("kof22-website-prod"), List.of(3)), + group("ida", "identity-admins", false, List.of(), List.of("authentik.*"), List.of("kof22-website-prod"), List.of(3)), group("editors", "editors", false, List.of("ida"), List.of("cms.edit", "cms.*"), null, List.of(4)), - group("interns", "interns", false, List.of("editors"), List.of("identity.users.read", "nope.unknown"), null, List.of(5)), + group("interns", "interns", false, List.of("editors"), List.of("authentik.users.read", "nope.unknown"), null, List.of(5)), new JSONObject().put("pk", "pub").put("name", "publishers").put("is_superuser", false).put("parents", new JSONArray()) .put("attributes", new JSONObject().put("qqq.io/permissions", "cms.publish").put("qqq.io/app-access", "kof22-website-prod")).put("users", new JSONArray().put(6)), group("a", "cycle-a", false, List.of("b"), List.of("cms.edit"), null, List.of()), @@ -123,6 +123,25 @@ class EffectivePermissionResolverTest + /******************************************************************************* + ** Pre-rename identity.* grants (owner naming rule, 2026-10-12) are reported + ** as unknown and grant nothing until the group attribute is updated. + *******************************************************************************/ + @Test + @SuppressWarnings("deprecation") + void legacyIdentityNamesAreUnknown() + { + GroupGraph graph = GroupGraph.fromGroups(List.of( + group("old", "identity-admins", false, List.of(), List.of(IdentityPermission.LEGACY_WILDCARD, IdentityPermission.USERS_READ.getLegacyShortName()), null, List.of(9)))); + + EffectivePermissionResolver.Resolution old = new EffectivePermissionResolver(graph, CATALOG).resolveUser(9); + assertThat(old.sessionPermissions()).isEmpty(); + assertThat(old.unknown()).containsExactlyInAnyOrder("identity.*", "identity.users.read"); + assertThat(EffectivePermissionResolver.holdsAll(old, List.of(IdentityPermission.USERS_READ))).isFalse(); + } + + + /******************************************************************************* ** A single string attribute is a list of one; cycles terminate. *******************************************************************************/ @@ -148,8 +167,8 @@ class EffectivePermissionResolverTest EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), null); assertThat(resolver.expand("anything.goes")).containsExactly("anything.goes"); assertThat(resolver.expand("cms.*")).isEmpty(); - assertThat(resolver.expand(" identity.audit.read ")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission()); - assertThat(resolver.expand("identity.audit.read.hasAccess")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission()); + assertThat(resolver.expand(" authentik.audit.read ")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission()); + assertThat(resolver.expand("authentik.audit.read.hasAccess")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission()); assertThat(resolver.expand("")).isEmpty(); assertThat(resolver.expand(null)).isEmpty(); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/security/IdentityPermissionTest.java b/src/test/java/com/kingsrook/qbits/authentik/security/IdentityPermissionTest.java new file mode 100644 index 0000000..b4e3745 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/security/IdentityPermissionTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.security; + + +import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Unit test for IdentityPermission: the authentik.* names (owner naming rule, + ** 2026-10-12) and the legacy identity.* names. + *******************************************************************************/ +class IdentityPermissionTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void namesUseTheQbitPrefix() + { + assertThat(IdentityPermission.WILDCARD).isEqualTo("authentik.*"); + for(IdentityPermission permission : IdentityPermission.values()) + { + assertThat(permission.getShortName()).startsWith("authentik."); + assertThat(permission.getSessionPermission()).isEqualTo(permission.getShortName() + ".hasAccess"); + } + assertThat(IdentityPermission.USERS_READ.getShortName()).isEqualTo("authentik.users.read"); + assertThat(IdentityPermission.ADMIN.getShortName()).isEqualTo("authentik.admin"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + @SuppressWarnings("deprecation") + void legacyNamesAreKnownButNeverGranted() + { + assertThat(IdentityPermission.LEGACY_WILDCARD).isEqualTo("identity.*"); + assertThat(IdentityPermission.USERS_READ.getLegacyShortName()).isEqualTo("identity.users.read"); + assertThat(IdentityPermission.MEMBERSHIPS_MANAGE.getLegacyShortName()).isEqualTo("identity.memberships.manage"); + assertThat(IdentityPermission.ADMIN.getLegacyShortName()).isEqualTo("identity.admin"); + + assertThat(IdentityPermission.fromName("authentik.users.read")).contains(IdentityPermission.USERS_READ); + assertThat(IdentityPermission.fromName("authentik.users.read.hasAccess")).contains(IdentityPermission.USERS_READ); + assertThat(IdentityPermission.fromName("identity.users.read")).isEmpty(); + assertThat(IdentityPermission.fromName("identity.users.read.hasAccess")).isEmpty(); + } + +} diff --git a/src/test/resources/blueprints/kof22-identity-bootstrap.yaml b/src/test/resources/blueprints/kof22-identity-bootstrap.yaml index 253e6b5..3c8b225 100644 --- a/src/test/resources/blueprints/kof22-identity-bootstrap.yaml +++ b/src/test/resources/blueprints/kof22-identity-bootstrap.yaml @@ -74,7 +74,7 @@ entries: name: identity-admins attrs: attributes: - qqq.io/permissions: ["identity.*"] + qqq.io/permissions: ["authentik.*"] qqq.io/app-access: ["kof22-website-prod"] - model: authentik_core.group state: created diff --git a/src/test/resources/fixtures/2026.8.3/groups-list-with-users.json b/src/test/resources/fixtures/2026.8.3/groups-list-with-users.json index 1dc3223..df3d25e 100644 --- a/src/test/resources/fixtures/2026.8.3/groups-list-with-users.json +++ b/src/test/resources/fixtures/2026.8.3/groups-list-with-users.json @@ -156,7 +156,7 @@ "name": "identity-admins", "attributes": { "qqq.io/app-access": ["kof22-website-prod"], - "qqq.io/permissions": ["identity.*"] + "qqq.io/permissions": ["authentik.*"] }, "pk": "2eab2c0f-14f0-474c-b27f-d02c3bfcb7ed", "children_obj": null, diff --git a/src/test/resources/fixtures/2026.8.3/groups-list.json b/src/test/resources/fixtures/2026.8.3/groups-list.json index 660d9da..0257e5d 100644 --- a/src/test/resources/fixtures/2026.8.3/groups-list.json +++ b/src/test/resources/fixtures/2026.8.3/groups-list.json @@ -45,7 +45,7 @@ "name": "identity-admins", "attributes": { "qqq.io/app-access": ["kof22-website-prod"], - "qqq.io/permissions": ["identity.*"] + "qqq.io/permissions": ["authentik.*"] }, "pk": "2eab2c0f-14f0-474c-b27f-d02c3bfcb7ed", "children_obj": [], -- 2.54.0