fix(naming): snake_case authentik_ tables and authentik.* permissions (owner naming rule) #6

Merged
KofTwentyTwo merged 3 commits from feature/snake-case-table-names into develop 2026-10-12 20:57:44 +00:00
37 changed files with 485 additions and 184 deletions

No files matched your search

+7 -3
View File
@@ -4,12 +4,16 @@ 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: `authentikGroupMember` (virtual membership table: by group, by user, by key, or a bounded sweep), `authentikSession` and `authentikEvent` (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.
@@ -25,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.
+6 -6
View File
@@ -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
@@ -37,10 +37,10 @@ Access is controlled by QQQ permissions:
| Permission (held as `<name>.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
@@ -56,10 +56,10 @@ 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-<appKey>-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`. |
| 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. |
+96
View File
@@ -0,0 +1,96 @@
# 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,
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`).
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.
+88 -88
View File
@@ -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.
@@ -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)
@@ -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)
@@ -328,15 +328,15 @@ 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` |
| `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: <appKey>`) 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-<appKey>-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.
@@ -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 `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 |
| `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` |
| 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 |
| 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` |
| 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 |
| 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 | `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.
@@ -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`) |
| 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 `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-<appKey>-idadmin`.
- The qbit also posts `custom_qqq_admin_action` with `{correlationId, operator, operatorEmail, reason, action, targets, appKey}` and stores the returned event pk.
@@ -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.*`, `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 `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.**
@@ -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: <uuid>, userPk: 42})` | Guard checks, then `POST /api/v3/core/groups/<uuid>/add_user/` with body `{"pk": 42}`, which returns 204 |
| `DeleteInput(authentikSession, <uuid>)` | Guard checks, then `DELETE /api/v3/core/authenticated_sessions/<uuid>/` |
| `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: <uuid>, userPk: 42})` | Guard checks, then `POST /api/v3/core/groups/<uuid>/add_user/` with body `{"pk": 42}`, which returns 204 |
| `DeleteInput(authentik_session, <uuid>)` | Guard checks, then `DELETE /api/v3/core/authenticated_sessions/<uuid>/` |
| 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": {...}}`.
@@ -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.
*******************************************************************************/
@@ -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()
{
@@ -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" };
@@ -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
{
@@ -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)
@@ -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.
*******************************************************************************/
@@ -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);
@@ -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<QWidgetMetaData, AuthentikQBitConfig>
@@ -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
@@ -21,7 +21,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentikEvent: /events/events/ (needs identity.audit.read).
** authentik_event: /events/events/ (needs authentik.audit.read).
*******************************************************************************/
public class AuthentikEventTableProducer extends AbstractAuthentikTableProducer
{
@@ -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
{
@@ -21,7 +21,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentikGroup: /core/groups/.
** authentik_group: /core/groups/.
*******************************************************************************/
public class AuthentikGroupTableProducer extends AbstractAuthentikTableProducer
{
@@ -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 authentik.audit.read).
*******************************************************************************/
public class AuthentikSessionTableProducer extends AbstractAuthentikTableProducer
{
@@ -21,7 +21,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentikUser: /core/users/.
** authentik_user: /core/users/.
*******************************************************************************/
public class AuthentikUserTableProducer extends AbstractAuthentikTableProducer
{
@@ -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
@@ -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.
@@ -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()
{
@@ -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");
}
}
@@ -53,22 +53,22 @@ 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");
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();
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.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");
@@ -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();
}
@@ -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();
}
@@ -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");
@@ -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);
}
@@ -336,7 +336,7 @@ class AuthentikActionUtilTest
QueryInput input = new QueryInput(groupTable);
List<QRecord> 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");
@@ -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
@@ -90,7 +90,7 @@ class Phase1TablesTest
*******************************************************************************/
private List<QRecord> 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<QRecord> 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);
@@ -176,21 +176,21 @@ class Phase1TablesTest
List<QRecord> 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();
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();
}
/*******************************************************************************
** 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
@@ -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"))));
}
@@ -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");
@@ -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
@@ -125,7 +125,7 @@ class Phase1ReadContractIT
*******************************************************************************/
private List<QRecord> 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/"));
}
@@ -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");
}
@@ -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);
}
@@ -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/"));
}
@@ -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
@@ -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()
@@ -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();
}
@@ -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();
}
}
@@ -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
@@ -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,
@@ -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": [],