Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 639dc38ca1 feat(policy): phase 5b policy editor and save to Authentik
- Edit Policy record action on groups and policies (feature POLICIES,
  permission authentik.policies.manage): streamed-ETL with the
  AUTHENTIK_POLICY_EDITOR widget on its step; Review parses fail-closed,
  regenerates canonically with the round-trip check, refuses new resources the
  app does not define, applies the subset rule to added permissions and lists
  the changes; Result merge-patches qqq.io/policy into the group's current
  attributes (removed when empty). Optional policyTwoPersonApproval.
- PolicyCatalog: apps, tables, processes (hidden record actions included),
  reports and protected widgets with the actions QQQ checks, grouped by app;
  joins the permission catalog.
- authentik_policy local table (one row per group, field-level audit as the
  change history), nav and child list on the group; DDL in docs/schema.sql.
- Widget authentikPolicyEditor: catalog plus the live document, flagged when
  changed in Authentik since the last QQQ save.
- Tests: catalog, admin rules, process, widget, gating, approval, and a
  savePolicy contract test on Authentik 2026.8.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:47:23 -05:00
james.maesandClaude Opus 5.5 616e3a8135 feat(policy): phase 5a policy model, parser, generator and runtime mapper
- PolicyParser (fail closed), PolicyGenerator (canonical form, round-trip
  check), PolicyFormat and config (policySpec/Version/Attribute/Claim,
  policyTwoPersonApproval).
- AuthentikPolicyClaimMapper and PolicyPermissions for the token claim, with
  an applySecurityKeys hook for record locks; AuthentikGroupPermissionMapper
  adds the claim too.
- GroupGraph and EffectivePermissionResolver read each group's qqq.io/policy,
  so effective permissions, the subset rule and the last-admin check see
  policy grants; an invalid policy is listed as malformed.
- PolicyClaimMappingIT pins the scope-mapping blueprint example on Authentik
  2026.8.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:34:38 -05:00
james.maesandClaude Opus 5.5 077d9abbe8 docs(spec): policies (IAM-style statements) and full user/group CRUD
DESIGN.md revision 5: owner decisions of 2026-10-13 (17-22), new section 12
(policy model stored on Authentik groups, fail-closed parsing, canonical
generator with round-trip check, claim mapping, the policy editor, delete
user/group behind the DELETE feature, authentik.policies.manage), plus
docs/POLICY-FORMAT.md and the scope-mapping blueprint example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:21:27 -05:00
james.maes 746b5ee5d0 Merge pull request #12 from QRun-IO/feature/v1-lifecycle
feat: v1 - full user lifecycle and group/permission management (phases 2-3)
2026-10-12 18:18:34 -05:00
james.maes 25a8516400 Merge pull request #11 from QRun-IO/feature/ga-only-release-tags
ci: publish only GA tags (vX.Y.Z), never RC tags
2026-10-12 16:04:54 -05:00
james.maesandClaude Opus 5.5 5154cd202a ci: publish only GA tags (vX.Y.Z), never RC tags
An RC tag matched /v.*/ and the orb republished it as the next RC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:58:31 -05:00
46 changed files with 4284 additions and 26 deletions

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ workflows:
branches:
ignore: /.*/
tags:
only: [/v.*/]
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
+11
View File
@@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
### Added (policy editor, phase 5b: DESIGN.md section 12.4)
- **Edit Policy** record action on groups and on policies (feature `POLICIES`, permission `authentik.policies.manage`): a standard streamed-ETL action whose step shows the `AUTHENTIK_POLICY_EDITOR` widget (Next UI). Review parses the submitted document (fail closed), regenerates it in canonical form with the round-trip check, refuses new resources this app does not define, applies the subset rule to the permissions it adds, and lists what is added and removed. Result merge-patches `qqq.io/policy` into the group's current attributes (removed when empty). Optional second-person approval (`policyTwoPersonApproval`).
- **`PolicyCatalog`**: every app, table, process (hidden record actions included), report and protected widget a policy can grant, with the actions QQQ checks, grouped by app. Its permission names join the permission catalog.
- **`authentik_policy`** local table (one row per group; field-level audit is the change history; DDL in `docs/schema.sql`), in the nav under Access and as a child list on the group.
- Widget `authentikPolicyEditor` (type `AUTHENTIK_POLICY_EDITOR`): the catalog and the group's live document, with a flag when it was changed in Authentik since the last save in QQQ.
### Added (policies, phase 5a: DESIGN.md section 12, docs/POLICY-FORMAT.md)
- **Policy documents** (IAM-style statements) on Authentik groups: `PolicyParser` (fails closed: unknown spec or version, non-Allow effect, unknown action, bad resource or malformed JSON grant nothing), `PolicyGenerator` (canonical form with a round-trip check), `PolicyFormat` and the config `policySpec`, `policyVersion`, `policyAttribute`, `policyClaim`, `policyTwoPersonApproval`.
- **Runtime**: `AuthentikPolicyClaimMapper` (reads the `qqq_policy` claim from the ID token or access token; `applySecurityKeys` hook for record locks) and `PolicyPermissions.fromClaims` for a host's own customizer. `AuthentikGroupPermissionMapper` also adds the claim, and the group graph and `EffectivePermissionResolver` read each group's `qqq.io/policy`, so effective permissions, the subset rule and the last-admin check see policy grants. An invalid policy on a group is listed as malformed.
- **Blueprint example** for the scope mapping that puts the merged policies into the token, pinned by `PolicyClaimMappingIT` on Authentik 2026.8.3.
### Added (v1: full user lifecycle and group/permission management, phases 2-3)
- **Writes through the Authentik API**, behind the features `USERS`, `GROUPS`, `MEMBERSHIP`, `CREDENTIALS` and `SESSIONS` (read-only stays the default; `readOnly` is a hard kill switch). Every change goes through `AuthentikAdmin`: it plans against live Authentik data (the guardrails of DESIGN.md 5.4), audits first, calls Authentik, posts a `custom_qqq_admin_action` event naming the operator, and finishes the audit row. `AuthentikActionUtil.write` needs a `WriteAuthorization`, which only `AuthentikGuard.authorize` can mint.
- **Actions** (table and record actions with Review and Result steps; none in the nav): Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject.
+176 -3
View File
@@ -1,6 +1,6 @@
# Authentik qbit: Design Spec
Status: Approved, revision 4 (phase 0 findings applied; review fixes; owner decisions of 2026-10-12) · 2026-10-12 · Author: James Maes (drafted with Claude)
Status: Approved, revision 5 (adds §12 policies and full user/group CRUD; owner decisions of 2026-10-13) · 2026-10-13 · Author: James Maes (drafted with Claude)
Targets: QQQ 4.1.x, QQQ Next UI 1.0.x, Authentik 2026.8.x (researched against the live `auth.kof22.com` schema, `info.version: 2026.8.3`), new repo `QRun-IO/qbit-authentik`
## 0. Summary
@@ -49,6 +49,17 @@ Seven design rules shape everything below:
| 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 `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). |
**Owner decisions (2026-10-13): policies and full CRUD.** The owner asked for create, edit, delete and query of users and groups, and for IAM-style permission statements with a UI to create and edit them (§12). Decided:
| # | Decision |
|---|---|
| 17 | **Policies are edited in QQQ and stored on Authentik groups.** The qbit's screens are the editor; each policy is a QQQ record (`authentik_policy`) with change history. Saving writes the policy document into the group's attributes through the Authentik API. The sign-in token carries the merged document as a claim, so Authentik is the source of truth at sign-in. |
| 18 | **Statement model: the same as the IAM-style documents already used with Authentik in another QQQ app.** `PolicySpec`, `PolicyVersion` (semver), `Statement[]`; `Effect` `Allow`; `Action` from `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`; `Resource` = QQQ table, process, app, report and widget permission names (`[A-Za-z0-9_]+`). Parsing fails closed. No `Deny`, wildcard resources, conditions or field-level rules yet. |
| 19 | **The format belongs to this qbit** (`docs/POLICY-FORMAT.md`). The spec name and version are config with a generic default (`qqq-iam`, `1.0.0`), so a host app can keep its existing spec name. No host-specific names in this repo. |
| 20 | **A policy editor** lists the app's apps, tables, processes, reports and widgets from metadata, grouped by app, with search, a per-resource action matrix (select all per row, per column and per app), a live read-only JSON preview, round-trip validation, and opens existing documents back into the matrix. Saving writes to Authentik. |
| 21 | **Delete is allowed as an explicit action** (supersedes decision 4's "no hard delete"): deactivate stays the default; *Delete User* and *Delete Group* are separate typed-confirmation actions behind the new `DELETE` feature (off by default), which also needs Authentik's `delete_user` / `delete_group`. |
| 22 | **Write permissions split as read / users / groups / policies**: `authentik.users.read`, `authentik.users.manage`, `authentik.groups.manage` (+ `authentik.memberships.manage`), and the new `authentik.policies.manage`. Two-person approval for policy saves is optional (`policyTwoPersonApproval`, off by default). |
### 0.2 Phase 0 findings (2026-10-11, verified on Authentik 2026.8.3)
The four open points of revision 2 were resolved against a disposable Authentik 2026.8.3 (Testcontainers contract tests in `QRun-IO/qbit-authentik`). Six further facts changed the design; each is pinned by a contract test, so an Authentik upgrade that changes it fails CI.
@@ -331,7 +342,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which
| `baseUrl` | required | Authentik host root, e.g. `https://auth.kof22.com`. `/api/v3` is appended. This is separate from the OAuth2 issuer URL, as MHP learned. |
| `apiToken` | required | Usually `${env.AUTHENTIK_API_TOKEN}`. Interpolated by the qbit (because `APIBackendMetaData.enrich()` is disabled) and redacted in `toString()` |
| `tableNamePrefix` / namespace | `authentik` | Prefix for tables (joined with `_`: `authentik_user`), processes and widgets (camel-cased: `authentikResyncMirror`) |
| `features` | `{READ}` | A subset of `READ`, `USERS` (create, invite, edit, deactivate), `GROUPS` (create, edit, permissions and app-access attributes), `MEMBERSHIP`, `CREDENTIALS` (recovery email, MFA reset, app-password revoke), `SESSIONS`, `RBAC` (phase 4), `ACCESS_MATRIX`, `MIRROR`, `WEBHOOK`, `SNAPSHOT_EXPORT`. A feature that is off produces no metadata. |
| `features` | `{READ}` | A subset of `READ`, `USERS` (create, invite, edit, deactivate), `GROUPS` (create, edit, permissions and app-access attributes), `POLICIES` (§12), `DELETE` (§12.5), `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. |
@@ -598,7 +609,7 @@ The role `qqq-<appKey>-idadmin` is assigned directly to the service account (boo
**Never granted:**
- `enable_group_superuser` / `disable_group_superuser`. Authentik then refuses every route into superuser ancestry: membership (direct, bulk, by user or by group), `parents` under a superuser group or any descendant, creating a group under one, and flipping `is_superuser` (verified, §0.2 ii).
- `view_token_key`, `set_token_key`, `add_token` (MHP APPS-1462)
- `impersonate`, `delete_user`
- `impersonate`; `delete_user` and `delete_group` unless the `DELETE` feature is on (§12.5)
- any `change_*` on flows, stages, providers, applications, policies, bindings, brands or blueprints
- any change on its own role. Role permissions are object permissions excluding `qqq-*` roles, and the guard also refuses.
@@ -626,6 +637,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost
| `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.policies.manage` | Create and edit group policies (§12) through *Edit Policy*, 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 |
@@ -739,6 +751,7 @@ Unsupported criteria (OR across fields, `NOT_EQUALS`, nested filters) are **reje
| Table | Purpose |
|---|---|
| `authentik_admin_action` | **Append-only audit and outbox** of every write the qbit attempts. Columns: id, correlationId, actionName, operator (QQQ user + Authentik username), reason, targetType, targetKey, targetLabel, beforeJson, afterJson, status (`PENDING_APPROVAL`, `RUNNING`, `SUCCEEDED`, `FAILED`, `REFUSED`), errorMessage, authentikEventPk, approver, timestamps. The row is inserted *before* the API call, following the sql-workbench `ExecutionAuditor` pattern; if that insert fails, the call is not made. Insert, update and delete capabilities are removed for everyone. |
| `authentik_policy` | One row per group with a policy (§12): groupPk (unique), groupName, policyJson (canonical pretty JSON), statementCount, permissionCount, savedBy, savedAt, authentikFingerprint. Field-level QQQ audit is the change history. Changed only by *Edit Policy*; generic insert, edit and delete are off. |
| `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. |
@@ -862,6 +875,7 @@ The qbit produces an app `authentikAdmin` (label **"Identity"**, icon `badge`).
- Custom widget types `AUTHENTIK_GROUP_TREE`, `AUTHENTIK_ACCESS_MATRIX` and `AUTHENTIK_HEALTH` are added to `WidgetRenderer.tsx` as `React.lazy` cases, following the `SQL_WORKBENCH` and `ESB_OVERVIEW` pattern.
- `src/lib/api/authentik.ts` calls `/qqq/v1/authentik/{tree,matrix,health,permission-catalog}`, all guarded.
- A permission-picker field component for the group permissions editor.
- `AUTHENTIK_POLICY_EDITOR` (§12.4), lazy-loaded and seeded through the standard process widget route, so no qbit route is needed.
- Mocks in `src/mocks/fixtures/authentik.ts`; e2e in `tests/e2e/authentik.spec.ts`.
- Everything else uses the generic UI.
@@ -1026,6 +1040,165 @@ The decisions taken on 2026-10-11 are in §0.1. The remaining questions are smal
---
## 12. Policies: IAM-style permission statements (revision 5)
### 12.1 What a policy is
A **policy** is a JSON document on an Authentik group that grants QQQ permissions to everyone in the group (and in its child groups). The format is defined in [`POLICY-FORMAT.md`](POLICY-FORMAT.md); in short:
```json
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.0.0",
"Statement": [
{ "Effect": "Allow", "Action": ["hasAccess"], "Resource": ["contentApp", "publishPage"] },
{ "Effect": "Allow", "Action": ["read", "edit"], "Resource": ["cms_page"] }
]
}
```
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` = all five actions). Those are QQQ permission names, so a QQQ session that holds them passes QQQ's own permission checks. Policies cover the instance's tables, processes, apps, reports and widgets. Named permissions that are not `{resource}.{action}` (for example `authentik.users.manage`) stay in `qqq.io/permissions` (§5.6); a policy cannot grant them, because a resource cannot contain a dot.
**Fail closed.** Any of these grants **zero** permissions from that document and logs a warning:
- an unknown `PolicySpec` or an unsupported `PolicyVersion`
- a document that is not a JSON object, or a `Statement` that is not an array
- a statement whose `Effect` is not `Allow`
- an unknown action
- a resource that does not match `[A-Za-z0-9_]+`
- any other parse error
This is stricter than the reference implementation in two places. The reference ignores unknown actions, while this qbit rejects the whole document. `Deny` is not supported yet, so a `Deny` statement also rejects the document; ignoring it would grant more than its author intended.
### 12.2 Where it is stored, and how it reaches the session
| Step | Where | Detail |
|---|---|---|
| Edit | QQQ, *Edit Policy* on a group (§12.4) | The editor builds the document; the server validates it and checks the round trip |
| Save | Authentik group attribute `qqq.io/policy` (config `policyAttribute`) | The compact JSON **string** (the reference format; the parser also reads a JSON object). Merge-patch of that one key into the group's current attributes, re-read immediately before the write (§5.4 rule 7). |
| History | QQQ table `authentik_policy`, one row per group | The canonical pretty JSON, counts, who saved it and when, and the fingerprint of what Authentik held. Field-level QQQ audit gives the change history; `authentik_admin_action` holds before/after of every save. |
| Sign-in | Authentik scope mapping (blueprint example in `POLICY-FORMAT.md`) | Merges the statements of all the user's groups, **including inherited parents** (`request.user.all_groups()`), into one document in the claim `qqq_policy` (config `policyClaim`) |
| Session | `AuthentikPolicyClaimMapper`, or `PolicyPermissions.fromClaims` in a host's own customizer | Reads the claim from the ID token, else the access-token payload; parses it fail-closed; adds the permissions to the QQQ session |
`AuthentikGroupPermissionMapper` (§5.6) also reads `qqq.io/policy` from the group graph. So the following all see policy grants:
- `EffectivePermissionResolver`
- the subset rule
- the effective-permissions view
- the last-admin check
An app that uses the API-backed mapper therefore gets policy grants even before the scope mapping is installed. The claim and the graph hold the same Authentik data, and the session gets their union.
**Record locks (extension point).** Row-level security keys are out of scope for now. `AuthentikPolicyClaimMapper` has a protected `applySecurityKeys(QSession, JSONObject claims)` hook, a no-op by default.
- A host overrides it to read its own explicit claims and set security key values.
- It is called after the identity is set and before the permissions.
- It must trust only explicit claims, never group names.
### 12.3 Config
| Setting | Default | Meaning |
|---|---|---|
| `policySpec` | `qqq-iam` | The `PolicySpec` this app accepts and writes. A host that already has documents keeps its own name. |
| `policyVersion` | `1.0.0` | The one supported `PolicyVersion` (exact match, like the reference) |
| `policyAttribute` | `qqq.io/policy` | Group attribute holding the document. A host with existing documents may point it at its own key. |
| `policyClaim` | `qqq_policy` | Token claim holding the merged document |
| `policyTwoPersonApproval` | `false` | When true, every policy save is queued for a second person (`authentik.approve`) |
The feature `POLICIES` turns on the editor, the `authentik_policy` table and the save action. It needs `GROUPS`. Reading policies at sign-in needs no feature.
### 12.4 The policy editor
*Edit Policy* is a record action on `authentik_group` and on `authentik_policy`, never in the nav. It is a standard streamed-ETL process with three steps.
**1. Edit** (frontend step). The step shows the widget `authentikPolicyEditor`, type `AUTHENTIK_POLICY_EDITOR`, a lazy-loaded Next UI widget. The server sends it two things.
The **catalog** lists every app, table, process, report and protected widget in the QInstance. Each entry has its permission base name, label, kind, app, and the actions QQQ actually checks for it:
- `hasAccess` for apps, processes, reports and widgets
- `read`, `insert`, `edit` and `delete` for tables, limited by the table's capabilities and permission level
The catalog also follows these rules:
- Hidden processes are included. Record actions are hidden from the nav but are still permission-checked.
- Objects that are in no app are listed under "Other".
- Tables that use `READ_WRITE` permissions are shown with `read` only, and a note, because `write` is not in the action set yet.
- Objects with a custom permission checker, or `NOT_PROTECTED`, are left out because a policy cannot grant them.
The **current document** is read live from the group in Authentik, the source of truth, together with the group's fingerprint for the stale check. If it differs from the last QQQ save, a notice says it was changed in Authentik.
The screen works like this:
- Resources are grouped by app, with a search box.
- Each row has a checkbox per action, plus *all* for the row. Each action column and each app has *select all*.
- A read-only JSON preview updates live.
- Unknown resources in an opened document (for example a table that was removed) are listed, and kept until the operator removes them.
- The widget writes the generated document into the process value `policyJson`. Clients without the widget post `policyJson` directly.
**2. Review** (dry run). The server:
- parses the submitted document, failing closed
- regenerates it in canonical form and checks that parse(generate(x)) = x (the round-trip invariant)
- flags unknown resources
- shows the permissions added and removed, and how many members are affected
- applies the subset rule (§5.4 rule 4): the operator must hold every **added** permission
Refusals name the reason.
**3. Result.** The qbit:
- writes the attribute (merge-patch)
- inserts or updates the audited `authentik_policy` row
- writes the `authentik_admin_action` row and the Authentik event
- clears the caches
Saving an empty matrix removes the attribute. The canonical form is the reference generator's:
- one statement per action, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`
- resources sorted
- a resource with every action is still written as five statements, not `*`, so diffs stay readable
### 12.5 Full user and group CRUD (revision 5 additions)
| Action | Table / slot | Permission | Feature | Confirmation | Authentik |
|---|---|---|---|---|---|
| Edit user (adds username and non-reserved attributes) | `authentik_user` record | `authentik.users.manage` | `USERS` | CONFIRM | `PATCH /core/users/{pk}/` (`username`, `name`, `email`, `path`; attributes merge-patch, `qqq.io/*` keys refused) |
| Delete user | `authentik_user` record | `authentik.users.manage` | `DELETE` | TYPED (username) | `DELETE /core/users/{pk}/` |
| Edit group (adds non-reserved attributes) | `authentik_group` record | `authentik.groups.manage` | `GROUPS` | CONFIRM | `PATCH /core/groups/{pk}/` |
| Delete group | `authentik_group` record | `authentik.groups.manage` | `DELETE` | TYPED (group name) | `DELETE /core/groups/{pk}/`. Refused while it has members or child groups. |
| Edit policy | `authentik_group`, `authentik_policy` records | `authentik.policies.manage` | `POLICIES` | CONFIRM (TWO_PERSON if configured) | `PATCH /core/groups/{pk}/` attributes |
The existing guardrails apply unchanged:
- protected accounts and superuser groups
- no self-edits (an operator cannot delete or rename themselves)
- last-admin protection (deleting one of the last identity admins is refused)
- a reason for every change
- audit before the call
Deactivate stays the default for people, and *Delete User* says so in its help text.
**Authentik permissions** the service account needs for these writes, on top of §5.1. All are global on the qbit's role:
| Permission | For |
|---|---|
| `authentik_core.change_group` | Saving a policy (already in §5.1 for phase 3) |
| `authentik_core.delete_user` | Delete user (only with `DELETE`) |
| `authentik_core.delete_group` | Delete group (only with `DELETE`) |
The token needs these only when writes are on. A read-only deployment (`readOnly`, or no write features) keeps the phase 1 read set.
### 12.6 Tests
- **Parser:** every fail-closed case (null, blank, not JSON, unknown spec, unsupported version, `Deny`, unknown action, bad resource, non-array fields), `*` expansion, and merging several statements.
- **Generator:** the canonical form, the round trip over generated permission sets, and rejection of anything that cannot round-trip.
- **Catalog:** apps, tables (capabilities, levels, custom base names), hidden processes, reports, widgets, and "Other".
- **Editor process:** review lines, the subset rule, the stale fingerprint, round-trip refusal, and a save that writes both the attribute and the `authentik_policy` row.
- **Runtime:** the claim from the ID token and from the access token, string and object claims, fail closed, a custom spec name, and the security-key hook.
- **CRUD:** edit user (username, attributes, reserved keys refused) and delete user and group with their refusals, on the in-memory Authentik.
- **Contract tests** on Authentik 2026.8.3: policy save, delete, and the scope-mapping example (`propertymappings/all/{uuid}/test/` returns a claim the parser accepts).
- **Permission gating:** each new action is refused without its permission or feature.
### 12.7 Delivery
| Phase | Scope |
|---|---|
| 5a | Policy model, parser, generator, runtime mapper, resolver integration, format doc and blueprint example |
| 5b | Catalog, `authentik_policy`, *Edit Policy* with save to Authentik; the Next UI widget `AUTHENTIK_POLICY_EDITOR` (separate PR to qqq-frontend-next) |
| 5c | The user and group CRUD additions (§12.5) |
---
## Appendix A: Example API mapping (`authentik_user`)
| QQQ | Authentik |
+114
View File
@@ -0,0 +1,114 @@
# QQQ policy format
Version 1.0.0 of the policy documents that `qbit-authentik` reads and writes. The design is in [DESIGN.md §12](DESIGN.md#12-policies-iam-style-permission-statements-revision-5).
A **policy** grants QQQ permissions to the members of an Authentik group. It is stored on the group as an attribute. At sign-in, the policies of all the user's groups, including inherited parent groups, become QQQ session permissions.
## Document
```json
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.0.0",
"Statement": [
{
"Effect": "Allow",
"Action": ["hasAccess"],
"Resource": ["contentApp", "publishPage"]
},
{
"Effect": "Allow",
"Action": ["read", "insert", "edit"],
"Resource": ["cms_page", "cms_media"]
}
]
}
```
| Field | Type | Rule |
|---|---|---|
| `PolicySpec` | string | Must equal the app's configured spec name (`policySpec`, default `qqq-iam`) |
| `PolicyVersion` | string, semver | Must equal the supported version (`policyVersion`, default `1.0.0`) |
| `Statement` | array of statements | May be empty (grants nothing) |
| `Statement[].Effect` | string | `Allow` (the only effect in 1.0.0) |
| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*` |
| `Statement[].Resource` | array of strings | Each one a QQQ permission base name matching `[A-Za-z0-9_]+`: a table, process, app, report or widget name (or its custom `permissionBaseName`) |
## Meaning
Each statement grants `{resource}.{action}` for every resource and action it lists. `*` stands for all five actions. The result is the union over all statements, and over all of the user's groups.
| Action | QQQ permission | Applies to |
|---|---|---|
| `hasAccess` | `{name}.hasAccess` | apps, processes, reports, widgets, and tables with `HAS_ACCESS` permissions |
| `read` | `{name}.read` | tables (query, get) |
| `insert` | `{name}.insert` | tables |
| `edit` | `{name}.edit` | tables |
| `delete` | `{name}.delete` | tables |
Examples:
- `{"Action": ["read"], "Resource": ["cms_page"]}` grants `cms_page.read`.
- `{"Action": ["*"], "Resource": ["cms_page"]}` grants `cms_page.hasAccess`, `cms_page.read`, `cms_page.insert`, `cms_page.edit` and `cms_page.delete`.
A grant that QQQ never checks (for example `hasAccess` on a table with insert/edit/delete permissions) is harmless.
**Not in 1.0.0:** `Deny`, wildcard or prefix resources, conditions, and field-level rules. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots. They stay in the group attribute `qqq.io/permissions`.
## Parsing fails closed
A document grants **nothing**, and the parser logs a warning, when any of these is true:
- it is missing, blank, not JSON, or not a JSON object
- `PolicySpec` is missing or is not the configured name
- `PolicyVersion` is missing or is not the supported version
- `Statement` is present but not an array, or a statement is not an object
- a statement's `Effect` is not `Allow` (including `Deny`, which is not supported yet)
- `Action` or `Resource` is missing or not an array of strings
- an action is not one of the six above
- a resource does not match `[A-Za-z0-9_]+`
There is no partial credit: one bad statement voids the whole document. When the qbit reads policies from the group graph, each group's document is parsed on its own, so one bad group voids only that group. In the merged token claim, one bad statement voids the claim.
## Canonical form
The qbit always writes the canonical form:
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`
- each statement lists its resources sorted
- no `*`
- stored on the group as a compact JSON **string**
Before every save, the qbit checks the round trip: parsing the generated document must give back exactly the permissions it was generated from. A document that fails this check is never written.
## Where it lives
| What | Default | Config |
|---|---|---|
| Group attribute holding a group's document | `qqq.io/policy` | `policyAttribute` |
| Token claim holding the merged document | `qqq_policy` | `policyClaim` |
| Spec name | `qqq-iam` | `policySpec` |
| Version | `1.0.0` | `policyVersion` |
An app that already stores documents of this shape under its own spec name, attribute and claim sets those four values and keeps its data unchanged. The parser accepts the attribute or claim as a JSON string or as a JSON object.
## Getting policies into the token
Authentik puts the merged document into the token through a scope mapping. Steps:
1. Apply the example blueprint, [`src/test/resources/blueprints/qqq-policy-claim.yaml`](../src/test/resources/blueprints/qqq-policy-claim.yaml). Edit its spec, version, attribute and claim name to match your config.
2. Add the mapping to the OAuth2 provider's `property_mappings`. It uses the scope `profile`, which every QQQ sign-in requests.
3. Keep `include_claims_in_id_token: true` on the provider (the default).
4. Register the customizer in your app:
- `AuthentikPolicyClaimMapper` on its own, or
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat())`.
The mapping merges the statements of every group in `request.user.all_groups()` (direct groups and their ancestors) whose document has the right spec and version. It always emits a document, with an empty `Statement` array when the user has no policies.
The contract test `PolicyClaimMappingIT` runs this blueprint on Authentik 2026.8.3. It puts generated policies on a parent and a child group (one as a string, one as a JSON object), then runs the mapping through Authentik's property-mapping test endpoint for a member of the child group. It checks that the parser turns the claim into the union of the groups' permissions, that a document with another spec is skipped, and that a user without policies gets a valid empty document.
### Freshness
The claim is computed when the token is issued, so a policy change reaches a signed-in user at their next token refresh. In kof22 that is within 1 hour. To apply a change at once, revoke the user's sessions (*Revoke Sessions*).
## Record locks (not yet)
Row-level security keys are not part of the format. `AuthentikPolicyClaimMapper.applySecurityKeys(QSession, JSONObject)` is the extension point. A host overrides it to set security key values from its own explicit claims, and must never infer them from group names.
+1
View File
@@ -11,6 +11,7 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated
| 2. Memberships and permission editing | done, except the webhook route, ESB trigger, inbox and session re-evaluation marking |
| 3. Full user lifecycle | done, except the optional snapshot export |
| 4. RBAC role management | post-v1, not started |
| 5. Policies and full CRUD (DESIGN.md section 12) | spec approved 2026-10-13; 5a policy model, 5b editor, 5c CRUD additions in progress |
### Built in this line (0.1)
+19
View File
@@ -49,3 +49,22 @@ CREATE TABLE authentik_approval_request
);
CREATE INDEX idx_authentik_approval_request_status ON authentik_approval_request (status);
-- authentik_policy (DESIGN.md section 12): the QQQ record of each group's last policy save.
-- Authentik holds the live document (group attribute qqq.io/policy); field-level QQQ audit on
-- this table is the policy's change history.
CREATE TABLE authentik_policy
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
group_pk VARCHAR(64) NOT NULL,
group_name VARCHAR(255),
policy_json TEXT,
statement_count INTEGER,
permission_count INTEGER,
saved_by VARCHAR(255),
saved_date TIMESTAMP WITH TIME ZONE,
authentik_fingerprint VARCHAR(64),
CONSTRAINT uk_authentik_policy_group_pk UNIQUE (group_pk)
);
@@ -32,6 +32,12 @@ public enum AuthentikFeature
/** Create and edit groups, permissions and app-access attributes (phase 2/3). */
GROUPS,
/** Edit group policies (IAM-style statements) with the policy editor (DESIGN.md section 12). */
POLICIES,
/** Delete users and groups as an explicit action (DESIGN.md section 12.5). */
DELETE,
/** Add and remove members (phase 2). */
MEMBERSHIP,
@@ -25,6 +25,7 @@ import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
@@ -45,11 +46,11 @@ public class AuthentikQBitConfig implements QBitConfig
{
/** Features implemented in this release. Others are refused by validate(). */
public static final Set<AuthentikFeature> AVAILABLE_FEATURES = EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS);
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES);
/** Features that change Authentik data; they need a local backend for the audit trail. */
public static final Set<AuthentikFeature> WRITE_FEATURES = EnumSet.of(AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS);
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES);
private static final Pattern APP_KEY_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,48}[a-z0-9]");
private static final Pattern PREFIX_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9]{0,30}");
@@ -84,6 +85,11 @@ public class AuthentikQBitConfig implements QBitConfig
private Integer socketTimeoutMillis = 30_000;
private String expectedServerVersion = "2026.8";
private Boolean allowInsecureHttp = false;
private String policySpec = PolicyFormat.DEFAULT_SPEC;
private String policyVersion = PolicyFormat.DEFAULT_VERSION;
private String policyAttribute = PolicyFormat.DEFAULT_ATTRIBUTE;
private String policyClaim = PolicyFormat.DEFAULT_CLAIM;
private Boolean policyTwoPersonApproval = false;
@@ -173,6 +179,11 @@ public class AuthentikQBitConfig implements QBitConfig
errors.add("features USERS and CREDENTIALS need recoveryEmailStage");
}
if(requested.contains(AuthentikFeature.POLICIES) && !requested.contains(AuthentikFeature.GROUPS))
{
errors.add("feature POLICIES needs GROUPS");
}
if(requested.contains(AuthentikFeature.WEBHOOK) && !StringUtils.hasContent(getResolvedWebhookSecret()))
{
errors.add("feature WEBHOOK needs webhookSecret");
@@ -195,6 +206,12 @@ public class AuthentikQBitConfig implements QBitConfig
break;
}
}
String policyProblem = getPolicyFormat().problem();
if(policyProblem != null)
{
errors.add(policyProblem);
}
if(throttleRequestsPerSecond != null && throttleRequestsPerSecond == 0)
{
errors.add("throttleRequestsPerSecond must be at least 1");
@@ -1105,4 +1122,120 @@ public class AuthentikQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** Getter for policySpec: the PolicySpec this app accepts and writes (docs/POLICY-FORMAT.md).
*******************************************************************************/
public String getPolicySpec()
{
return (this.policySpec);
}
/*******************************************************************************
** Fluent setter for policySpec
*******************************************************************************/
public AuthentikQBitConfig withPolicySpec(String policySpec)
{
this.policySpec = policySpec;
return (this);
}
/*******************************************************************************
** Getter for policyVersion: the one supported PolicyVersion (semver).
*******************************************************************************/
public String getPolicyVersion()
{
return (this.policyVersion);
}
/*******************************************************************************
** Fluent setter for policyVersion
*******************************************************************************/
public AuthentikQBitConfig withPolicyVersion(String policyVersion)
{
this.policyVersion = policyVersion;
return (this);
}
/*******************************************************************************
** Getter for policyAttribute: the Authentik group attribute that holds a group's policy document.
*******************************************************************************/
public String getPolicyAttribute()
{
return (this.policyAttribute);
}
/*******************************************************************************
** Fluent setter for policyAttribute
*******************************************************************************/
public AuthentikQBitConfig withPolicyAttribute(String policyAttribute)
{
this.policyAttribute = policyAttribute;
return (this);
}
/*******************************************************************************
** Getter for policyClaim: the token claim that holds the merged policy document.
*******************************************************************************/
public String getPolicyClaim()
{
return (this.policyClaim);
}
/*******************************************************************************
** Fluent setter for policyClaim
*******************************************************************************/
public AuthentikQBitConfig withPolicyClaim(String policyClaim)
{
this.policyClaim = policyClaim;
return (this);
}
/*******************************************************************************
** Getter for policyTwoPersonApproval: whether every policy save needs a second person (authentik.approve).
*******************************************************************************/
public Boolean getPolicyTwoPersonApproval()
{
return (this.policyTwoPersonApproval);
}
/*******************************************************************************
** Fluent setter for policyTwoPersonApproval
*******************************************************************************/
public AuthentikQBitConfig withPolicyTwoPersonApproval(Boolean policyTwoPersonApproval)
{
this.policyTwoPersonApproval = policyTwoPersonApproval;
return (this);
}
/*******************************************************************************
** The policy names (spec, version, attribute, claim) as one value.
*******************************************************************************/
@JsonIgnore
public PolicyFormat getPolicyFormat()
{
return (new PolicyFormat(policySpec, policyVersion, policyAttribute, policyClaim));
}
}
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
@@ -45,6 +46,7 @@ public class AuthentikRuntime
private final Memoization<String, String> responseCache;
private final Memoization<String, GroupGraph> groupGraphCache;
private volatile java.util.Set<String> permissionCatalog;
private volatile PolicyCatalog policyCatalog;
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
@@ -149,14 +151,39 @@ public class AuthentikRuntime
if(permissionCatalog == null)
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
permissionCatalog = qInstance == null ? java.util.Set.of()
: new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
if(qInstance == null)
{
return (java.util.Set.of());
}
java.util.Set<String> catalog = new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
catalog.addAll(getPolicyCatalog().permissionNames());
permissionCatalog = catalog;
}
return (permissionCatalog);
}
/*******************************************************************************
** What a policy can grant in the current instance (built once, lazily: the
** instance is complete by the first request).
*******************************************************************************/
public PolicyCatalog getPolicyCatalog()
{
if(policyCatalog == null)
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(qInstance == null)
{
return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance()));
}
policyCatalog = PolicyCatalog.of(qInstance);
}
return (policyCatalog);
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
@@ -38,6 +38,7 @@ public enum AdminAction
CREATE_GROUP("Create group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_GROUP("Edit group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_PERMISSIONS("Edit permissions and app access", "group", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
SAVE_POLICY("Save policy", "group", IdentityPermission.POLICIES_MANAGE, AuthentikFeature.POLICIES, Confirmation.CONFIRM),
SEND_RECOVERY_EMAIL("Send password reset email", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.CONFIRM),
RESET_MFA("Reset MFA", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
REVOKE_APP_PASSWORDS("Revoke app passwords", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
@@ -39,6 +39,11 @@ import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.policy.PolicyStore;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
@@ -85,6 +90,9 @@ public class AuthentikAdmin
public static final int TYPED_ABOVE = 25;
public static final int APPROVAL_HOURS = 24;
/** Process value / request parameter that carries a submitted policy document. */
public static final String POLICY_DOCUMENT = "policyDocument";
private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
private static final Pattern USERNAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._@+-]{0,149}");
private static final Pattern EMAIL = Pattern.compile("[^@\\s]+@[^@\\s]+\\.[^@\\s]+");
@@ -252,6 +260,7 @@ public class AuthentikAdmin
case CREATE_GROUP -> planCreateGroup(plan, request);
case EDIT_GROUP -> planEditGroup(plan, request);
case EDIT_PERMISSIONS -> planEditPermissions(plan, request);
case SAVE_POLICY -> planSavePolicy(plan, request);
case SEND_RECOVERY_EMAIL -> planRecovery(plan, request);
case RESET_MFA -> planResetMfa(plan, request);
case REVOKE_APP_PASSWORDS -> planAppPasswords(plan, request);
@@ -1002,6 +1011,133 @@ public class AuthentikAdmin
/*******************************************************************************
** Save a group's policy document (DESIGN.md section 12.4): parse it (fail
** closed), regenerate it in canonical form (round-trip checked), refuse new
** resources this app does not know, and apply the subset rule to the
** permissions it adds.
*******************************************************************************/
private AdminPlan planSavePolicy(AdminPlan plan, AdminRequest request) throws QException
{
JSONObject group = targetGroup(plan, request.targetKey());
if(group == null)
{
return (plan);
}
PolicyFormat format = config.getPolicyFormat();
Set<String> newPermissions;
JSONObject canonical;
try
{
String submitted = request.str(POLICY_DOCUMENT);
newPermissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(newPermissions, format);
}
catch(PolicyParseException e)
{
return (plan.refuse("The policy is not valid: " + e.getMessage()));
}
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
Set<String> oldPermissions = new TreeSet<>();
String storedProblem = null;
if(stored != null && !JSONObject.NULL.equals(stored) && !"".equals(stored))
{
try
{
oldPermissions.addAll(PolicyParser.parseStrict(stored, format).permissions());
}
catch(PolicyParseException e)
{
storedProblem = e.getMessage();
}
}
String fingerprint = request.str("expectedFingerprint");
if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.groupFingerprint(group)))
{
return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again."));
}
Set<String> added = new TreeSet<>(newPermissions);
added.removeAll(oldPermissions);
Set<String> removed = new TreeSet<>(oldPermissions);
removed.removeAll(newPermissions);
Set<String> unknownNew = runtime.getPolicyCatalog().unknownPermissions(added);
if(!unknownNew.isEmpty())
{
return (plan.refuse("These are not permissions of this app: " + String.join(", ", unknownNew) + ". Pick them in the editor."));
}
if(added.isEmpty() && removed.isEmpty() && storedProblem == null)
{
return (plan.noChange("Nothing to change."));
}
Set<String> missing = AuthentikSafety.missing(added, operator().permissions());
if(!missing.isEmpty())
{
return (plan.refuse("You cannot grant permissions you do not hold yourself: " + String.join(", ", missing)));
}
plan.withChange(policySummary(oldPermissions), policySummary(newPermissions));
if(!added.isEmpty())
{
plan.getNotes().add("Adds: " + abbreviate(added));
}
if(!removed.isEmpty())
{
plan.getNotes().add("Removes: " + abbreviate(removed));
}
if(storedProblem != null)
{
plan.getNotes().add("The policy stored in Authentik was not valid (" + storedProblem + "); saving replaces it.");
}
Set<String> keptUnknown = runtime.getPolicyCatalog().unknownPermissions(newPermissions);
if(!keptUnknown.isEmpty())
{
plan.getNotes().add("Kept although this app does not define them: " + abbreviate(keptUnknown));
}
JSONArray users = group.optJSONArray("users");
plan.getNotes().add((users == null ? 0 : users.length()) + " member(s), and the members of groups below it, get the change at their next sign-in or token refresh.");
plan.getNotes().add(canonical.getJSONArray("Statement").length() + " statement(s), " + newPermissions.size() + " permission(s); the document round-trips.");
if(Boolean.TRUE.equals(config.getPolicyTwoPersonApproval()) && !request.bool("approved"))
{
plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("policy changes need a second person");
}
return (plan);
}
/*******************************************************************************
** "n permission(s): a, b, ..." for the audit before/after.
*******************************************************************************/
private static String policySummary(Set<String> permissions)
{
return (permissions.isEmpty() ? "no policy" : permissions.size() + " permission(s): " + abbreviate(permissions));
}
/*******************************************************************************
** The first 12 names, then "and n more".
*******************************************************************************/
private static String abbreviate(Set<String> names)
{
List<String> list = new ArrayList<>(names);
if(list.size() <= 12)
{
return (String.join(", ", list));
}
return (String.join(", ", list.subList(0, 12)) + " and " + (list.size() - 12) + " more");
}
/*******************************************************************************
** Send the password-reset email (Authentik's own email; no link is shown).
*******************************************************************************/
@@ -1440,6 +1576,8 @@ public class AuthentikAdmin
util.write(auth, "PATCH", "/core/groups/" + safeKey(request.targetKey()) + "/", new JSONObject().put("attributes", attributes));
return ("Permissions and app access updated.");
}
case SAVE_POLICY:
return (performSavePolicy(plan, auth));
case SEND_RECOVERY_EMAIL:
sendRecovery(auth, request.targetKey());
return ("Password reset email sent by Authentik.");
@@ -1469,6 +1607,62 @@ public class AuthentikAdmin
/*******************************************************************************
** Write the canonical policy into the group's CURRENT attributes (re-read
** right before, so other attributes and UI edits survive), then record the
** save in authentik_policy. Authentik is the source of truth: if only the
** QQQ record fails, the result says so but the save stands.
*******************************************************************************/
private String performSavePolicy(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
PolicyFormat format = config.getPolicyFormat();
Set<String> permissions;
JSONObject canonical;
try
{
String submitted = request.str(POLICY_DOCUMENT);
permissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(permissions, format);
}
catch(PolicyParseException e)
{
throw (new QUserFacingException("The policy is not valid: " + e.getMessage()));
}
runtime.invalidateCache();
JSONObject fresh = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false");
if(fresh == null)
{
throw (new QUserFacingException("That group no longer exists."));
}
JSONObject attributes = fresh.optJSONObject("attributes") == null ? new JSONObject() : new JSONObject(fresh.getJSONObject("attributes").toString());
String compact = PolicyGenerator.toCompact(canonical);
if(permissions.isEmpty())
{
attributes.remove(format.attribute());
}
else
{
attributes.put(format.attribute(), compact);
}
util.write(auth, "PATCH", "/core/groups/" + safeKey(request.targetKey()) + "/", new JSONObject().put("attributes", attributes));
try
{
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissions.size(), guard.operatorEmail(),
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format));
}
catch(Exception e)
{
LOG.warn("The policy was saved to Authentik but its QQQ record could not be written", logPair("group", request.targetKey()), logPair("error", e.getMessage()));
return ("Policy saved to Authentik, but its QQQ record could not be written: " + e.getMessage());
}
return (permissions.isEmpty() ? "Policy removed from the group in Authentik." : "Policy saved to Authentik.");
}
/*******************************************************************************
** Create the user (no password), add them to their groups, send the
** password email. A failure after the user exists says so.
@@ -1834,7 +2028,7 @@ public class AuthentikAdmin
/*******************************************************************************
** A path segment that is a plain key (digits, letters, dashes, dots, colons).
*******************************************************************************/
static String safeKey(String key) throws QUserFacingException
public static String safeKey(String key) throws QUserFacingException
{
if(key == null || !key.matches("[A-Za-z0-9._-]{1,150}"))
{
@@ -20,6 +20,8 @@ package com.kingsrook.qbits.authentik.admin;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
@@ -27,6 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValu
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
@@ -41,6 +44,7 @@ public final class AuthentikLocalTables
{
public static final String ADMIN_ACTION = "admin_action";
public static final String APPROVAL_REQUEST = "approval_request";
public static final String POLICY = "policy";
public static final String PVS_ACTION_STATUS = "authentik_action_status";
public static final String PVS_APPROVAL_STATUS = "authentik_approval_status";
@@ -212,6 +216,30 @@ public final class AuthentikLocalTables
/*******************************************************************************
** authentik_policy: one row per group with a policy (DESIGN.md section 12).
** Authentik holds the live document; this row is the QQQ record of the last
** save, and field-level audit is its change history. Written only by the
** Edit Policy action.
*******************************************************************************/
public static QTableMetaData policy(AuthentikQBitConfig config)
{
return (base(config, POLICY, "Policies", "policy", IdentityPermission.USERS_READ)
.withRecordLabelFormatAndFields("%s", "groupName")
.withUniqueKey(new UniqueKey("groupPk"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withField(ro("groupPk", QFieldType.STRING).withLabel("Group Id").withMaxLength(64))
.withField(ro("groupName", QFieldType.STRING).withLabel("Group").withMaxLength(255))
.withField(ro("policyJson", QFieldType.TEXT).withLabel("Policy Document"))
.withField(ro("statementCount", QFieldType.INTEGER).withLabel("Statements"))
.withField(ro("permissionCount", QFieldType.INTEGER).withLabel("Permissions"))
.withField(ro("savedBy", QFieldType.STRING).withLabel("Saved By").withMaxLength(255))
.withField(ro("savedDate", QFieldType.DATE_TIME).withLabel("Saved"))
.withField(ro("authentikFingerprint", QFieldType.STRING).withLabel("Authentik Fingerprint").withMaxLength(64)));
}
/*******************************************************************************
** Common shape: id, create and modify dates, no generic writes.
*******************************************************************************/
@@ -141,7 +141,7 @@ public final class AuthentikSafety
{
List<Integer> members = new ArrayList<>(node.memberPks());
members.remove(Integer.valueOf(userPk));
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed()));
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed(), node.policyPermissions()));
}
@@ -151,7 +151,7 @@ public final class AuthentikSafety
*******************************************************************************/
public static GroupGraph.Node withPermissions(GroupGraph.Node node, List<String> permissions, List<String> appAccess)
{
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed()));
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed(), node.policyPermissions()));
}
@@ -161,7 +161,7 @@ public final class AuthentikSafety
*******************************************************************************/
public static GroupGraph.Node withParents(GroupGraph.Node node, List<String> parentPks)
{
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed()));
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed(), node.policyPermissions()));
}
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.authentik.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -85,6 +86,7 @@ public final class AuthentikRelations
groupSections.add(new QFieldSection("permissions", "Permissions and app access", new QIcon("admin_panel_settings"), Tier.T2,
List.of("permissions", "effectivePermissions", "appAccess")));
relate(qInstance, groupSections, "members", "Members", "group", group, membership, "pk", "groupPk");
relate(qInstance, groupSections, "policy", "Policy", "policy", group, config.applyTablePrefix(AuthentikLocalTables.POLICY), "pk", "groupPk");
groupSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("attributes")));
applySections(qInstance, group, groupSections);
@@ -765,7 +765,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
throw (new QUserFacingException("More than " + MAX_SWEEP_OBJECTS + " Authentik groups; the group graph is not supported at this size."));
}
GroupGraph graph = GroupGraph.fromGroups(groups);
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat());
if(cache)
{
runtime.getGroupGraphCache().storeResult("all", graph);
@@ -814,7 +814,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
}
List<JSONObject> groups = getAllPages("/core/groups/?include_users=true&include_parents=false&include_children=false&ordering=name", MAX_SWEEP_OBJECTS);
GroupGraph graph = GroupGraph.fromGroups(groups);
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat());
if(cache)
{
runtime.getGroupGraphCache().storeResult("members", graph);
@@ -304,7 +304,7 @@ public class AuthentikDriftReport
void checkIdentityAdmins(List<DriftFinding> findings) throws QException
{
List<JSONObject> groups = client.getAllPages("/core/groups/?include_users=true&ordering=name", AuthentikActionUtil.MAX_SWEEP_OBJECTS);
GroupGraph graph = GroupGraph.fromGroups(groups);
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat());
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
@@ -70,7 +70,7 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
// records as child lists //
///////////////////////////////////////////////////////////////////////////
addSection(app, produced, config, "people", "People", "person", AuthentikTables.USER, AuthentikTables.INVITATION);
addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikTables.APPLICATION);
addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikLocalTables.POLICY, AuthentikTables.APPLICATION);
addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION);
List<String> widgets = new ArrayList<>();
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The policy editor widget (DESIGN.md section 12.4), shown on the Edit Policy
** step. Gated by authentik.policies.manage.
*******************************************************************************/
public class AuthentikPolicyEditorWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyEditorWidget.NAME))
.withLabel("Policy")
.withType(PolicyEditorWidget.TYPE)
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(PolicyEditorWidget.Renderer.class))
.withPermissionRules(IdentityPermission.POLICIES_MANAGE.rules())
.withDefaultValue(PolicyEditorWidget.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (460);
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** authentik_policy: the QQQ record (with change history) of each group policy.
*******************************************************************************/
public class AuthentikPolicyTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend and the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.policy(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -27,6 +27,8 @@ import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.security.BootstrapOperators;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
@@ -107,6 +109,14 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
boolean verified = claims.optBoolean("email_verified", false);
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, sub, email, verified));
//////////////////////////////////////////////////////////////////////////
// the policy claim (docs/POLICY-FORMAT.md) carries the same group data //
// as the graph read above; the union also covers an unreachable API //
//////////////////////////////////////////////////////////////////////////
AuthentikRuntime runtime = runtime();
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format));
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
@@ -17,6 +17,7 @@
package com.kingsrook.qbits.authentik.permissions;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -101,7 +102,9 @@ public class EffectivePermissionResolver
continue;
}
appAccess.addAll(node.appAccess());
for(String raw : node.permissions())
List<String> raws = new ArrayList<>(node.permissions());
raws.addAll(node.policyPermissions());
for(String raw : raws)
{
Set<String> expanded = expand(raw);
if(expanded.isEmpty())
@@ -27,6 +27,9 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -42,8 +45,16 @@ public class GroupGraph
/*******************************************************************************
** One group.
*******************************************************************************/
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed,
List<String> policyPermissions)
{
/*******************************************************************************
** A group without a policy document.
*******************************************************************************/
public Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
{
this(pk, name, ownSuperuser, parentPks, permissions, appAccess, memberPks, malformed, List.of());
}
}
@@ -59,9 +70,25 @@ public class GroupGraph
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups)
{
GroupGraph graph = new GroupGraph();
return (fromGroups(groups, PolicyFormat.DEFAULT));
}
/*******************************************************************************
** Build from Authentik group objects, reading each group's policy document
** (docs/POLICY-FORMAT.md) from the format's attribute. A document that does
** not parse grants nothing and is listed in the node's malformed entries.
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
GroupGraph graph = new GroupGraph();
for(JSONObject group : groups)
{
JSONObject attributes = group.optJSONObject("attributes");
List<String> malformed = malformed(attributes);
List<String> policy = policyPermissions(attributes, f, malformed);
graph.add(new Node(
group.getString("pk"),
group.optString("name", ""),
@@ -70,7 +97,8 @@ public class GroupGraph
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)),
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)),
ints(group.optJSONArray("users")),
malformed(group.optJSONObject("attributes"))));
malformed,
policy));
}
return (graph);
}
@@ -257,6 +285,30 @@ public class GroupGraph
/*******************************************************************************
** The permissions of a group's policy document; none (and a malformed entry)
** when it does not parse.
*******************************************************************************/
private static List<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed)
{
Object document = attributes == null ? null : attributes.opt(format.attribute());
if(document == null || JSONObject.NULL.equals(document) || "".equals(document))
{
return (List.of());
}
try
{
return (List.copyOf(PolicyParser.parseStrict(document, format).permissions()));
}
catch(PolicyParseException e)
{
malformed.add(format.attribute() + ": " + e.getMessage());
return (List.of());
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -0,0 +1,119 @@
/*
* 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.policy;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Authentication customizer that adds the QQQ permissions granted by the
** policy claim of the sign-in token (docs/POLICY-FORMAT.md). It needs no
** Authentik API access: Authentik merges the user's group policies into the
** claim at sign-in (the scope mapping in the format doc).
**
** Order: applySecurityKeys (a no-op hook for hosts), then permissions. The
** permissions are added to whatever the session already holds. A missing or
** invalid claim adds nothing (fail closed).
*******************************************************************************/
public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomizerInterface
{
private static final QLogger LOG = QLogger.getLogger(AuthentikPolicyClaimMapper.class);
private PolicyFormat format = PolicyFormat.DEFAULT;
/*******************************************************************************
** Use these names (the qbit config's getPolicyFormat()) instead of the
** defaults.
*******************************************************************************/
public AuthentikPolicyClaimMapper withFormat(PolicyFormat format)
{
this.format = format == null ? PolicyFormat.DEFAULT : format;
return (this);
}
/*******************************************************************************
** Getter for format.
*******************************************************************************/
public PolicyFormat getFormat()
{
return (format);
}
/*******************************************************************************
** Add the permissions from the policy claim.
*******************************************************************************/
@Override
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
{
JSONObject claims = PolicyPermissions.claimsWithPolicy(context, format);
JSONObject any = claims;
if(any == null && context != null)
{
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ID_TOKEN_CONTEXT_KEY));
if(any == null)
{
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY));
}
}
if(any != null)
{
applySecurityKeys(qSession, any);
}
if(claims == null)
{
LOG.info("No policy claim in the token; no policy permissions added", logPair("claim", format.claim()));
return;
}
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format));
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
}
/*******************************************************************************
** Extension point for record locks (security keys); a no-op here. A host
** overrides it to set security key values from its OWN explicit claims, and
** must never infer them from group names.
*******************************************************************************/
protected void applySecurityKeys(QSession qSession, JSONObject claims)
{
////////////////////////////////////////////////////////
// no record locks in policy format 1.0.0 (by design) //
////////////////////////////////////////////////////////
}
}
@@ -0,0 +1,86 @@
/*
* 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.policy;
import java.util.Optional;
/*******************************************************************************
** The actions a policy statement can grant, in canonical statement order. Each
** maps to the QQQ permission suffix of the same name ({resource}.{action}).
*******************************************************************************/
public enum PolicyAction
{
HAS_ACCESS("hasAccess"),
READ("read"),
INSERT("insert"),
EDIT("edit"),
DELETE("delete");
/** "All five actions" in a statement's Action array. */
public static final String WILDCARD = "*";
private final String jsonName;
/*******************************************************************************
** Constructor.
*******************************************************************************/
PolicyAction(String jsonName)
{
this.jsonName = jsonName;
}
/*******************************************************************************
** The name used in documents and as the QQQ permission suffix.
*******************************************************************************/
public String getJsonName()
{
return (jsonName);
}
/*******************************************************************************
** The QQQ permission this action grants on a resource.
*******************************************************************************/
public String permission(String resource)
{
return (resource + "." + jsonName);
}
/*******************************************************************************
** Look up by document name (case-sensitive, like QQQ permission names).
*******************************************************************************/
public static Optional<PolicyAction> fromJsonName(String name)
{
for(PolicyAction action : values())
{
if(action.jsonName.equals(name))
{
return (Optional.of(action));
}
}
return (Optional.empty());
}
}
@@ -0,0 +1,456 @@
/*
* 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.policy;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** What a policy can grant in a QInstance (DESIGN.md section 12.4): every app,
** table, process, report and protected widget, with its permission base name
** (the policy resource) and the actions QQQ checks for it, grouped by app.
**
** Unlike PermissionsHelper.getAllAvailablePermissions, hidden tables and
** processes are included: record actions are hidden from the nav but still
** permission-checked. Objects with a custom permission checker or that are
** NOT_PROTECTED are left out (a policy cannot grant them).
*******************************************************************************/
public final class PolicyCatalog
{
/** Group label of objects that are in no app. */
public static final String OTHER = "Other";
/** Kinds of resources. */
public enum Kind
{
APP, TABLE, PROCESS, REPORT, WIDGET
}
/*******************************************************************************
** One grantable resource.
**
** @param name the policy resource (permission base name)
** @param label what operators see
** @param kind app, table, process, report or widget
** @param actions the actions QQQ checks, in canonical order
** @param note a plain note (e.g. why an action is missing), or null
*******************************************************************************/
public record Resource(String name, String label, Kind kind, List<PolicyAction> actions, String note)
{
}
/*******************************************************************************
** The resources of one app (or of no app: OTHER).
*******************************************************************************/
public record Group(String app, String label, List<Resource> resources)
{
}
private final List<Group> groups;
private final Map<String, Resource> byName;
/*******************************************************************************
** Constructor.
*******************************************************************************/
private PolicyCatalog(List<Group> groups)
{
this.groups = groups;
this.byName = new LinkedHashMap<>();
for(Group group : groups)
{
for(Resource resource : group.resources())
{
byName.putIfAbsent(resource.name(), resource);
}
}
}
/*******************************************************************************
** Build the catalog of an instance.
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance)
{
Map<String, String> appOfObject = appsOfObjects(qInstance);
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
List<QAppMetaData> apps = new ArrayList<>(qInstance.getApps().values());
apps.sort(Comparator.comparing((QAppMetaData a) -> a.getSortOrder() == null ? 500 : a.getSortOrder()).thenComparing(a -> label(a.getLabel(), a.getName())));
for(QAppMetaData app : apps)
{
byApp.put(app.getName(), new LinkedHashMap<>());
}
byApp.put(null, new LinkedHashMap<>());
for(QAppMetaData app : apps)
{
add(byApp, app.getName(), qInstance, app, Kind.APP, label(app.getLabel(), app.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QTableMetaData> tables = new ArrayList<>(qInstance.getTables().values());
tables.sort(Comparator.comparing(t -> label(t.getLabel(), t.getName())));
for(QTableMetaData table : tables)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(table, qInstance);
Set<PolicyAction> actions = EnumSet.noneOf(PolicyAction.class);
String note = null;
if(rules.getLevel() == PermissionLevel.HAS_ACCESS_PERMISSION)
{
actions.add(PolicyAction.HAS_ACCESS);
}
else
{
QBackendMetaData backend = qInstance.getBackend(table.getBackendName());
if(table.isCapabilityEnabled(backend, Capability.TABLE_QUERY) || table.isCapabilityEnabled(backend, Capability.TABLE_GET))
{
actions.add(PolicyAction.READ);
}
if(rules.getLevel() == PermissionLevel.READ_WRITE_PERMISSIONS)
{
note = "This table uses one write permission, which policies cannot grant yet; only read is offered.";
}
else
{
if(table.isCapabilityEnabled(backend, Capability.TABLE_INSERT))
{
actions.add(PolicyAction.INSERT);
}
if(table.isCapabilityEnabled(backend, Capability.TABLE_UPDATE))
{
actions.add(PolicyAction.EDIT);
}
if(table.isCapabilityEnabled(backend, Capability.TABLE_DELETE))
{
actions.add(PolicyAction.DELETE);
}
}
}
add(byApp, appOfObject.get("table:" + table.getName()), qInstance, table, Kind.TABLE, label(table.getLabel(), table.getName()), actions, note);
}
List<QProcessMetaData> processes = new ArrayList<>(qInstance.getProcesses().values());
processes.sort(Comparator.comparing(p -> label(p.getLabel(), p.getName())));
for(QProcessMetaData process : processes)
{
String app = appOfObject.get("process:" + process.getName());
if(app == null && process.getTableName() != null)
{
app = appOfObject.get("table:" + process.getTableName());
}
String label = label(process.getLabel(), process.getName());
if(process.getTableName() != null && qInstance.getTable(process.getTableName()) != null)
{
label = label + " (" + label(qInstance.getTable(process.getTableName()).getLabel(), process.getTableName()) + " action)";
}
add(byApp, app, qInstance, process, Kind.PROCESS, label, EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QReportMetaData> reports = new ArrayList<>(qInstance.getReports().values());
reports.sort(Comparator.comparing(r -> label(r.getLabel(), r.getName())));
for(QReportMetaData report : reports)
{
add(byApp, appOfObject.get("report:" + report.getName()), qInstance, report, Kind.REPORT, label(report.getLabel(), report.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QWidgetMetaDataInterface> widgets = new ArrayList<>(qInstance.getWidgets().values());
widgets.sort(Comparator.comparing(w -> label(w.getLabel(), w.getName())));
for(QWidgetMetaDataInterface widget : widgets)
{
add(byApp, appOfObject.get("widget:" + widget.getName()), qInstance, widget, Kind.WIDGET, label(widget.getLabel(), widget.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<Group> groups = new ArrayList<>();
for(Map.Entry<String, Map<String, ResourceDraft>> entry : byApp.entrySet())
{
if(entry.getValue().isEmpty())
{
continue;
}
String groupLabel = entry.getKey() == null ? OTHER : label(qInstance.getApp(entry.getKey()).getLabel(), entry.getKey());
List<Resource> resources = new ArrayList<>();
for(ResourceDraft draft : entry.getValue().values())
{
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), draft.note));
}
groups.add(new Group(entry.getKey(), groupLabel, resources));
}
return (new PolicyCatalog(groups));
}
/*******************************************************************************
** Accumulates one resource while building (several objects can share a
** permission base name).
*******************************************************************************/
private static class ResourceDraft
{
private final String name;
private final Kind kind;
private final Set<String> labels = new LinkedHashSet<>();
private final Set<PolicyAction> actions = new TreeSet<>();
private String note;
/*******************************************************************************
** Constructor.
*******************************************************************************/
ResourceDraft(String name, Kind kind)
{
this.name = name;
this.kind = kind;
}
}
/*******************************************************************************
** Add an object under its app, unless a policy cannot grant it.
*******************************************************************************/
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
Set<PolicyAction> actions, String note)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(object, qInstance);
if(rules == null || rules.getLevel() == null || rules.getLevel() == PermissionLevel.NOT_PROTECTED || rules.getCustomPermissionChecker() != null || actions.isEmpty())
{
return;
}
String name = StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : nameOf(object);
if(!PolicyParser.RESOURCE_PATTERN.matcher(name).matches())
{
return;
}
////////////////////////////////////////////////////////////////////
// a base name shared with an object already listed (in any app) //
// joins that entry, so a resource appears once //
////////////////////////////////////////////////////////////////////
for(Map<String, ResourceDraft> resources : byApp.values())
{
ResourceDraft existing = resources.get(name);
if(existing != null)
{
existing.labels.add(label);
existing.actions.addAll(actions);
return;
}
}
ResourceDraft draft = new ResourceDraft(name, kind);
draft.labels.add(label);
draft.actions.addAll(actions);
draft.note = note;
byApp.computeIfAbsent(byApp.containsKey(app) ? app : null, k -> new LinkedHashMap<>()).put(name, draft);
}
/*******************************************************************************
** Which app directly lists each object ("table:x", "process:y", ...): app
** children (sections included) and app widgets. The first app wins.
*******************************************************************************/
private static Map<String, String> appsOfObjects(QInstance qInstance)
{
Map<String, String> rs = new LinkedHashMap<>();
for(QAppMetaData app : qInstance.getApps().values())
{
if(app.getChildren() != null)
{
for(QAppChildMetaData child : app.getChildren())
{
String type = child instanceof QTableMetaData ? "table" : child instanceof QProcessMetaData ? "process" : child instanceof QReportMetaData ? "report" : null;
if(type != null)
{
rs.putIfAbsent(type + ":" + child.getName(), app.getName());
}
}
}
if(app.getWidgets() != null)
{
for(String widget : app.getWidgets())
{
rs.putIfAbsent("widget:" + widget, app.getName());
}
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String nameOf(MetaDataWithPermissionRules object)
{
if(object instanceof QTableMetaData t)
{
return (t.getName());
}
if(object instanceof QProcessMetaData p)
{
return (p.getName());
}
if(object instanceof QAppMetaData a)
{
return (a.getName());
}
if(object instanceof QReportMetaData r)
{
return (r.getName());
}
if(object instanceof QWidgetMetaDataInterface w)
{
return (w.getName());
}
return ("");
}
/*******************************************************************************
**
*******************************************************************************/
private static String label(String label, String name)
{
return (StringUtils.hasContent(label) ? label : name);
}
/*******************************************************************************
** The groups, in app order, "Other" last.
*******************************************************************************/
public List<Group> getGroups()
{
return (groups);
}
/*******************************************************************************
** A resource by policy name, or null.
*******************************************************************************/
public Resource get(String name)
{
return (byName.get(name));
}
/*******************************************************************************
** Every QQQ permission a policy can grant here ({resource}.{action}).
*******************************************************************************/
public Set<String> permissionNames()
{
Set<String> rs = new TreeSet<>();
for(Resource resource : byName.values())
{
for(PolicyAction action : resource.actions())
{
rs.add(action.permission(resource.name()));
}
}
return (rs);
}
/*******************************************************************************
** The resources of a permission set this catalog does not know, or that
** grant an action the resource does not have (sorted, as permission names).
*******************************************************************************/
public Set<String> unknownPermissions(Set<String> permissions)
{
Set<String> known = permissionNames();
Set<String> rs = new TreeSet<>();
for(String permission : permissions)
{
if(!known.contains(permission))
{
rs.add(permission);
}
}
return (rs);
}
/*******************************************************************************
** As JSON for the editor widget.
*******************************************************************************/
public JSONArray toJson()
{
JSONArray rs = new JSONArray();
for(Group group : groups)
{
JSONArray resources = new JSONArray();
for(Resource resource : group.resources())
{
JSONArray actions = new JSONArray();
resource.actions().forEach(a -> actions.put(a.getJsonName()));
JSONObject json = new JSONObject()
.put("name", resource.name())
.put("label", resource.label())
.put("kind", resource.kind().name())
.put("actions", actions);
if(resource.note() != null)
{
json.put("note", resource.note());
}
resources.put(json);
}
rs.put(new JSONObject().put("app", group.app() == null ? JSONObject.NULL : group.app()).put("label", group.label()).put("resources", resources));
}
return (rs);
}
}
@@ -0,0 +1,341 @@
/*
* 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.policy;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONObject;
/*******************************************************************************
** The policy editor widget (type AUTHENTIK_POLICY_EDITOR, DESIGN.md section
** 12.4). It is shown on the Edit Policy step; the Next UI widget of the same
** type draws the matrix and hands the generated document back to the step as
** the process value policyDocument. The data here: the catalog of what a
** policy can grant, and the group's current document read live from
** Authentik.
*******************************************************************************/
public final class PolicyEditorWidget
{
/** The widget type the Next UI renders. */
public static final String TYPE = "AUTHENTIK_POLICY_EDITOR";
/** Short widget name (prefixed with the qbit namespace). */
public static final String NAME = "policyEditor";
/** Widget default value naming the qbit's API backend. */
public static final String BACKEND_NAME_KEY = "backendName";
/** Widget parameter (process value) with the group's pk. */
public static final String GROUP_PK_PARAM = "groupPk";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyEditorWidget()
{
}
/*******************************************************************************
** The data for a group (or for none: an empty document).
*******************************************************************************/
public static Data load(AuthentikQBitConfig config, String groupPk) throws QException
{
AuthentikRuntime runtime = AuthentikRuntime.forBackend(config.getApiBackendName());
PolicyFormat format = config.getPolicyFormat();
Data data = new Data()
.withSpec(format.spec())
.withVersion(format.version())
.withCatalog(runtime.getPolicyCatalog().toJson().toList())
.withOutputFieldName(AuthentikAdmin.POLICY_DOCUMENT);
if(!StringUtils.hasContent(groupPk))
{
return (data);
}
if(!groupPk.matches("[A-Za-z0-9-]+"))
{
throw (new QUserFacingException("Not a group id: " + groupPk));
}
JSONObject group = AuthentikActionUtil.forBackend(config.getApiBackendName()).getApiJsonOrNull("/core/groups/" + groupPk + "/?include_users=false&include_parents=false&include_children=false");
if(group == null)
{
throw (new QUserFacingException("That group no longer exists."));
}
data.withGroupLabel(group.optString("name"));
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
if(stored != null && !JSONObject.NULL.equals(stored) && !"".equals(stored))
{
try
{
/////////////////////////////////////////////////////////////////
// canonical form of what is stored (unknown resources survive) //
/////////////////////////////////////////////////////////////////
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format)));
}
catch(PolicyParseException e)
{
data.withDocumentProblem(e.getMessage());
}
}
QRecord row = PolicyStore.find(config, groupPk);
if(row != null)
{
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format)));
}
return (data);
}
/*******************************************************************************
** Renderer. QQQ checks the widget's permission rules
** (authentik.policies.manage) before calling it.
*******************************************************************************/
public static class Renderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
AuthentikQBitConfig config = AuthentikRuntime.forBackend(backendName).getConfig();
Map<String, String> params = input.getQueryParams();
return (new RenderWidgetOutput(load(config, params == null ? null : params.get(GROUP_PK_PARAM))));
}
}
/*******************************************************************************
** The widget's data (see the Next UI's PolicyEditorPayload).
*******************************************************************************/
public static class Data extends QWidgetData
{
private String spec;
private String version;
private String groupLabel;
private List<Object> catalog;
private String document;
private String documentProblem;
private Boolean changedInAuthentik = false;
private String outputFieldName;
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getType()
{
return (TYPE);
}
/*******************************************************************************
** Getter for spec
*******************************************************************************/
public String getSpec()
{
return (spec);
}
/*******************************************************************************
** Fluent setter for spec
*******************************************************************************/
public Data withSpec(String spec)
{
this.spec = spec;
return (this);
}
/*******************************************************************************
** Getter for version
*******************************************************************************/
public String getVersion()
{
return (version);
}
/*******************************************************************************
** Fluent setter for version
*******************************************************************************/
public Data withVersion(String version)
{
this.version = version;
return (this);
}
/*******************************************************************************
** Getter for groupLabel
*******************************************************************************/
public String getGroupLabel()
{
return (groupLabel);
}
/*******************************************************************************
** Fluent setter for groupLabel
*******************************************************************************/
public Data withGroupLabel(String groupLabel)
{
this.groupLabel = groupLabel;
return (this);
}
/*******************************************************************************
** Getter for catalog (groups of resources, as plain JSON values)
*******************************************************************************/
public List<Object> getCatalog()
{
return (catalog);
}
/*******************************************************************************
** Fluent setter for catalog
*******************************************************************************/
public Data withCatalog(List<Object> catalog)
{
this.catalog = catalog;
return (this);
}
/*******************************************************************************
** Getter for document (the group's current document, pretty; null for none)
*******************************************************************************/
public String getDocument()
{
return (document);
}
/*******************************************************************************
** Fluent setter for document
*******************************************************************************/
public Data withDocument(String document)
{
this.document = document;
return (this);
}
/*******************************************************************************
** Getter for documentProblem (why the stored document is invalid)
*******************************************************************************/
public String getDocumentProblem()
{
return (documentProblem);
}
/*******************************************************************************
** Fluent setter for documentProblem
*******************************************************************************/
public Data withDocumentProblem(String documentProblem)
{
this.documentProblem = documentProblem;
return (this);
}
/*******************************************************************************
** Getter for changedInAuthentik (differs from the last save in QQQ)
*******************************************************************************/
public Boolean getChangedInAuthentik()
{
return (changedInAuthentik);
}
/*******************************************************************************
** Fluent setter for changedInAuthentik
*******************************************************************************/
public Data withChangedInAuthentik(Boolean changedInAuthentik)
{
this.changedInAuthentik = changedInAuthentik;
return (this);
}
/*******************************************************************************
** Getter for outputFieldName (the process value the editor writes)
*******************************************************************************/
public String getOutputFieldName()
{
return (outputFieldName);
}
/*******************************************************************************
** Fluent setter for outputFieldName
*******************************************************************************/
public Data withOutputFieldName(String outputFieldName)
{
this.outputFieldName = outputFieldName;
return (this);
}
}
}
@@ -0,0 +1,85 @@
/*
* 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.policy;
import java.util.regex.Pattern;
/*******************************************************************************
** The names a host uses for its policy documents (docs/POLICY-FORMAT.md): the
** PolicySpec it accepts, the one supported PolicyVersion, the Authentik group
** attribute that holds a group's document, and the token claim that holds the
** merged document.
**
** @param spec PolicySpec, e.g. "qqq-iam"
** @param version PolicyVersion (semver), e.g. "1.0.0"
** @param attribute group attribute key, e.g. "qqq.io/policy"
** @param claim token claim name, e.g. "qqq_policy"
*******************************************************************************/
public record PolicyFormat(String spec, String version, String attribute, String claim)
{
/** Default PolicySpec. */
public static final String DEFAULT_SPEC = "qqq-iam";
/** Default (and only) PolicyVersion of this qbit's format. */
public static final String DEFAULT_VERSION = "1.0.0";
/** Default group attribute. */
public static final String DEFAULT_ATTRIBUTE = "qqq.io/policy";
/** Default token claim. */
public static final String DEFAULT_CLAIM = "qqq_policy";
/** The generic defaults. */
public static final PolicyFormat DEFAULT = new PolicyFormat(DEFAULT_SPEC, DEFAULT_VERSION, DEFAULT_ATTRIBUTE, DEFAULT_CLAIM);
/** A valid spec name: letters, digits, dot, dash and underscore. */
public static final Pattern SPEC_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
/** Semver MAJOR.MINOR.PATCH. */
public static final Pattern VERSION_PATTERN = Pattern.compile("(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)");
/** A valid claim name. */
public static final Pattern CLAIM_PATTERN = Pattern.compile("[A-Za-z_][A-Za-z0-9_.-]{0,63}");
/*******************************************************************************
** Whether every name is well formed (used by the config's validate()).
*******************************************************************************/
public String problem()
{
if(spec == null || !SPEC_PATTERN.matcher(spec).matches())
{
return ("policySpec must be letters, digits, '.', '-' or '_': " + spec);
}
if(version == null || !VERSION_PATTERN.matcher(version).matches())
{
return ("policyVersion must be semver MAJOR.MINOR.PATCH: " + version);
}
if(attribute == null || attribute.isBlank())
{
return ("policyAttribute is required");
}
if(claim == null || !CLAIM_PATTERN.matcher(claim).matches())
{
return ("policyClaim must be a plain claim name: " + claim);
}
return (null);
}
}
@@ -0,0 +1,202 @@
/*
* 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.policy;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Builds the canonical policy document for a set of QQQ permissions
** (docs/POLICY-FORMAT.md, "Canonical form"): one Allow statement per granted
** action in the order hasAccess, read, insert, edit, delete; resources sorted;
** no "*". Every generated document is checked to round-trip through
** PolicyParser to exactly the permissions it came from; one that does not is
** never returned.
*******************************************************************************/
public final class PolicyGenerator
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyGenerator()
{
}
/*******************************************************************************
** The canonical document for these permissions ({resource}.{action} names).
**
** @throws PolicyParseException when a permission is not {resource}.{action}
** with a known action and a valid resource, or the document does not
** round-trip
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format) throws PolicyParseException
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
Map<PolicyAction, Set<String>> byAction = new LinkedHashMap<>();
for(PolicyAction action : PolicyAction.values())
{
byAction.put(action, new TreeSet<>());
}
Set<String> expected = new TreeSet<>();
for(String permission : permissions)
{
int dot = permission == null ? -1 : permission.lastIndexOf('.');
if(dot <= 0 || dot == permission.length() - 1)
{
throw (new PolicyParseException("\"" + permission + "\" is not {resource}.{action}"));
}
String resource = permission.substring(0, dot);
String name = permission.substring(dot + 1);
PolicyAction action = PolicyAction.fromJsonName(name).orElseThrow(() -> new PolicyParseException("\"" + permission + "\": unknown action \"" + name + "\""));
if(!PolicyParser.RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException("\"" + permission + "\": resource must match [A-Za-z0-9_]+"));
}
byAction.get(action).add(resource);
expected.add(permission);
}
JSONArray statements = new JSONArray();
for(Map.Entry<PolicyAction, Set<String>> entry : byAction.entrySet())
{
if(!entry.getValue().isEmpty())
{
statements.put(new JSONObject()
.put("Effect", PolicyParser.ALLOW)
.put("Action", new JSONArray().put(entry.getKey().getJsonName()))
.put("Resource", new JSONArray(entry.getValue())));
}
}
JSONObject document = new JSONObject()
.put("PolicySpec", f.spec())
.put("PolicyVersion", f.version())
.put("Statement", statements);
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f).permissions();
if(!parsed.equals(expected))
{
Set<String> missing = new TreeSet<>(expected);
missing.removeAll(parsed);
Set<String> extra = new TreeSet<>(parsed);
extra.removeAll(expected);
throw (new PolicyParseException("The generated document does not round-trip. Missing: " + missing + "; unexpected: " + extra));
}
return (document);
}
/*******************************************************************************
** Re-generate any valid document in canonical form (parse, then generate).
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format) throws PolicyParseException
{
return (generate(PolicyParser.parseStrict(document, format).permissions(), format));
}
/*******************************************************************************
** Compact JSON in a fixed key order (PolicySpec, PolicyVersion, Statement;
** Effect, Action, Resource), as stored in the group attribute.
*******************************************************************************/
public static String toCompact(JSONObject document)
{
return (write(document, false));
}
/*******************************************************************************
** Pretty JSON (2-space indent, LF, trailing newline) in the fixed key order.
*******************************************************************************/
public static String toPretty(JSONObject document)
{
return (write(document, true));
}
/*******************************************************************************
** Write in the fixed key order (org.json does not keep insertion order).
*******************************************************************************/
private static String write(JSONObject document, boolean pretty)
{
String nl = pretty ? "\n" : "";
String i1 = pretty ? " " : "";
String i2 = pretty ? " " : "";
String i3 = pretty ? " " : "";
String i4 = pretty ? " " : "";
String sep = pretty ? ": " : ":";
StringBuilder sb = new StringBuilder("{").append(nl);
sb.append(i1).append(JSONObject.quote("PolicySpec")).append(sep).append(JSONObject.quote(document.optString("PolicySpec"))).append(",").append(nl);
sb.append(i1).append(JSONObject.quote("PolicyVersion")).append(sep).append(JSONObject.quote(document.optString("PolicyVersion"))).append(",").append(nl);
sb.append(i1).append(JSONObject.quote("Statement")).append(sep).append("[");
JSONArray statements = document.optJSONArray("Statement");
int count = statements == null ? 0 : statements.length();
for(int s = 0; s < count; s++)
{
JSONObject statement = statements.getJSONObject(s);
sb.append(s == 0 ? nl : "," + nl).append(i2).append("{").append(nl);
sb.append(i3).append(JSONObject.quote("Effect")).append(sep).append(JSONObject.quote(statement.optString("Effect"))).append(",").append(nl);
sb.append(i3).append(JSONObject.quote("Action")).append(sep).append(inline(statement.optJSONArray("Action"), pretty)).append(",").append(nl);
sb.append(i3).append(JSONObject.quote("Resource")).append(sep).append("[");
JSONArray resources = statement.optJSONArray("Resource");
int n = resources == null ? 0 : resources.length();
for(int r = 0; r < n; r++)
{
sb.append(r == 0 ? nl : "," + nl).append(i4).append(JSONObject.quote(resources.optString(r)));
}
sb.append(n == 0 ? "" : nl + i3).append("]").append(nl);
sb.append(i2).append("}");
}
sb.append(count == 0 ? "" : nl + i1).append("]").append(nl);
sb.append("}").append(nl);
return (sb.toString());
}
/*******************************************************************************
** A short string array on one line.
*******************************************************************************/
private static String inline(JSONArray array, boolean pretty)
{
StringBuilder sb = new StringBuilder("[");
int n = array == null ? 0 : array.length();
for(int i = 0; i < n; i++)
{
sb.append(i == 0 ? "" : (pretty ? ", " : ",")).append(JSONObject.quote(array.optString(i)));
}
return (sb.append("]").toString());
}
}
@@ -0,0 +1,35 @@
/*
* 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.policy;
/*******************************************************************************
** A policy document that cannot be accepted; the message names the problem
** in plain words (shown to operators in the editor's Review step).
*******************************************************************************/
public class PolicyParseException extends Exception
{
/*******************************************************************************
** Constructor.
*******************************************************************************/
public PolicyParseException(String message)
{
super(message);
}
}
@@ -0,0 +1,247 @@
/*
* 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.policy;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Parses a policy document (docs/POLICY-FORMAT.md) into QQQ permission names.
**
** parse() FAILS CLOSED: any problem (unknown spec, unsupported version, a
** statement that is not Allow, an unknown action, a bad resource, malformed
** JSON, ...) grants zero permissions and logs a warning. parseStrict() throws
** a PolicyParseException naming the problem instead (for the editor).
*******************************************************************************/
public final class PolicyParser
{
private static final QLogger LOG = QLogger.getLogger(PolicyParser.class);
/** A valid resource: a QQQ permission base name. */
public static final Pattern RESOURCE_PATTERN = Pattern.compile("[A-Za-z0-9_]+");
/** The only effect in 1.0.0. */
public static final String ALLOW = "Allow";
/*******************************************************************************
** The result of a successful parse.
**
** @param permissions QQQ permission names ({resource}.{action}), sorted
** @param resources every resource named, in document order
*******************************************************************************/
public record Parsed(Set<String> permissions, Set<String> resources)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyParser()
{
}
/*******************************************************************************
** The permissions a document grants, or none (with a warning) when it is
** missing or invalid in any way.
**
** @param document a JSONObject, a Map, or a JSON string (as stored in a group
** attribute or a token claim); null grants nothing
*******************************************************************************/
public static Set<String> parse(Object document, PolicyFormat format)
{
if(document == null || JSONObject.NULL.equals(document))
{
return (Collections.emptySet());
}
try
{
return (parseStrict(document, format).permissions());
}
catch(PolicyParseException e)
{
LOG.warn("Policy document rejected; it grants no permissions", logPair("reason", e.getMessage()));
return (Collections.emptySet());
}
}
/*******************************************************************************
** Parse, throwing a PolicyParseException that names the first problem.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format) throws PolicyParseException
{
JSONObject json = asObject(document);
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
Object spec = json.opt("PolicySpec");
if(!(spec instanceof String s) || !s.equals(f.spec()))
{
throw (new PolicyParseException("PolicySpec must be \"" + f.spec() + "\" (found " + describe(spec) + ")"));
}
Object version = json.opt("PolicyVersion");
if(!(version instanceof String v) || !v.equals(f.version()))
{
throw (new PolicyParseException("PolicyVersion must be \"" + f.version() + "\" (found " + describe(version) + ")"));
}
Set<String> permissions = new TreeSet<>();
Set<String> resources = new LinkedHashSet<>();
Object statements = json.opt("Statement");
if(statements == null)
{
return (new Parsed(permissions, resources));
}
if(!(statements instanceof JSONArray array))
{
throw (new PolicyParseException("Statement must be an array"));
}
for(int i = 0; i < array.length(); i++)
{
final int number = i + 1;
if(!(array.opt(i) instanceof JSONObject statement))
{
throw (new PolicyParseException("Statement " + (i + 1) + " must be an object"));
}
Object effect = statement.opt("Effect");
if(!ALLOW.equals(effect))
{
throw (new PolicyParseException("Statement " + (i + 1) + ": Effect must be \"Allow\" (found " + describe(effect) + "); Deny is not supported yet"));
}
Set<PolicyAction> actions = EnumSet.noneOf(PolicyAction.class);
for(String name : strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action"))
{
if(PolicyAction.WILDCARD.equals(name))
{
actions.addAll(EnumSet.allOf(PolicyAction.class));
}
else
{
actions.add(PolicyAction.fromJsonName(name).orElseThrow(() -> new PolicyParseException("Statement " + number + ": unknown action \"" + name + "\"")));
}
}
for(String resource : strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource"))
{
if(!RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException("Statement " + (i + 1) + ": resource \"" + resource + "\" must match [A-Za-z0-9_]+"));
}
resources.add(resource);
for(PolicyAction action : actions)
{
permissions.add(action.permission(resource));
}
}
}
return (new Parsed(permissions, resources));
}
/*******************************************************************************
** The document as a JSON object.
*******************************************************************************/
static JSONObject asObject(Object document) throws PolicyParseException
{
if(document instanceof JSONObject object)
{
return (object);
}
if(document instanceof Map<?, ?> map)
{
return (new JSONObject(map));
}
if(document instanceof String text)
{
if(text.isBlank())
{
throw (new PolicyParseException("The document is empty"));
}
try
{
return (new JSONObject(text));
}
catch(JSONException e)
{
throw (new PolicyParseException("The document is not a JSON object"));
}
}
throw (new PolicyParseException("The document is not a JSON object"));
}
/*******************************************************************************
** An array of strings, or a PolicyParseException.
*******************************************************************************/
private static Set<String> strings(Object value, String what) throws PolicyParseException
{
if(!(value instanceof JSONArray array))
{
throw (new PolicyParseException(what + " must be an array of strings"));
}
Set<String> rs = new LinkedHashSet<>();
for(int i = 0; i < array.length(); i++)
{
if(!(array.opt(i) instanceof String text))
{
throw (new PolicyParseException(what + " must be an array of strings"));
}
rs.add(text);
}
return (rs);
}
/*******************************************************************************
** A short description of a found value for messages (never the whole value).
*******************************************************************************/
private static String describe(Object value)
{
if(value == null)
{
return ("nothing");
}
if(value instanceof String text)
{
return ("\"" + (text.length() > 40 ? text.substring(0, 40) + "..." : text) + "\"");
}
return (value.getClass().getSimpleName());
}
}
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import org.json.JSONObject;
/*******************************************************************************
** Helpers a host's own authentication customizer can call to turn the policy
** claim of a sign-in token into QQQ permissions (docs/POLICY-FORMAT.md).
*******************************************************************************/
public final class PolicyPermissions
{
/** Customizer context key of the ID token claims (QQQ OAuth2 module). */
public static final String ID_TOKEN_CONTEXT_KEY = "idToken";
/** Customizer context key of the access token claims. */
public static final String ACCESS_TOKEN_CONTEXT_KEY = "jwtPayloadJsonObject";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyPermissions()
{
}
/*******************************************************************************
** The permissions granted by the policy claim in these token claims; none
** when the claim is missing or invalid (fail closed).
*******************************************************************************/
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(claims == null || !claims.has(f.claim()))
{
return (Collections.emptySet());
}
return (PolicyParser.parse(claims.opt(f.claim()), f));
}
/*******************************************************************************
** The claims that carry the policy in a customizer context: the ID token's
** when it has the claim (Authentik puts scope-mapping claims there), else the
** access token's (the only ones on a session resume). Null when neither has
** the claim.
*******************************************************************************/
public static JSONObject claimsWithPolicy(Map<String, Object> context, PolicyFormat format)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(context == null)
{
return (null);
}
for(String key : new String[] { ID_TOKEN_CONTEXT_KEY, ACCESS_TOKEN_CONTEXT_KEY })
{
JSONObject claims = asClaims(context.get(key));
if(claims != null && claims.has(f.claim()))
{
return (claims);
}
}
return (null);
}
/*******************************************************************************
** Claims as a JSONObject (QQQ passes a JSONObject; tests may pass a Map).
*******************************************************************************/
public static JSONObject asClaims(Object value)
{
if(value instanceof JSONObject object)
{
return (object);
}
if(value instanceof Map<?, ?> map)
{
return (new JSONObject(map));
}
return (null);
}
}
@@ -0,0 +1,160 @@
/*
* 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.policy;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** The authentik_policy rows (DESIGN.md section 12.2): the QQQ record of each
** group's last policy save, plus the fingerprint used to tell when the policy
** was changed in Authentik since.
*******************************************************************************/
public final class PolicyStore
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyStore()
{
}
/*******************************************************************************
** A fingerprint of a group's policy attribute value: of its canonical form
** when it parses (so formatting differences do not count), else of the raw
** text. Null for no policy.
*******************************************************************************/
public static String fingerprint(Object attributeValue, PolicyFormat format)
{
if(attributeValue == null || JSONObject.NULL.equals(attributeValue) || "".equals(attributeValue))
{
return (null);
}
String text;
try
{
text = PolicyGenerator.toCompact(PolicyGenerator.canonical(attributeValue, format));
}
catch(PolicyParseException e)
{
text = String.valueOf(attributeValue);
}
try
{
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))));
}
catch(NoSuchAlgorithmException e)
{
throw (new IllegalStateException(e));
}
}
/*******************************************************************************
** The row of a group, or null (also null when the table is not set up).
*******************************************************************************/
public static QRecord find(AuthentikQBitConfig config, String groupPk) throws QException
{
String table = config.applyTablePrefix(AuthentikLocalTables.POLICY);
if(QContext.getQInstance() == null || QContext.getQInstance().getTable(table) == null)
{
return (null);
}
QueryInput queryInput = new QueryInput();
queryInput.setTableName(table);
queryInput.setFilter(new QQueryFilter(new QFilterCriteria("groupPk", QCriteriaOperator.EQUALS, groupPk)));
QueryOutput output = new QueryAction().execute(queryInput);
return (output.getRecords().isEmpty() ? null : output.getRecords().get(0));
}
/*******************************************************************************
** Insert or update the row of a group after a save to Authentik.
**
** @param document the canonical document that was saved (empty statements
** when the policy was removed)
*******************************************************************************/
public static void save(AuthentikQBitConfig config, String groupPk, String groupName, JSONObject document, int permissionCount, String savedBy, String fingerprint) throws QException
{
String table = config.applyTablePrefix(AuthentikLocalTables.POLICY);
QRecord existing = find(config, groupPk);
QRecord record = new QRecord()
.withValue("groupPk", groupPk)
.withValue("groupName", groupName)
.withValue("policyJson", PolicyGenerator.toPretty(document))
.withValue("statementCount", document.optJSONArray("Statement") == null ? 0 : document.getJSONArray("Statement").length())
.withValue("permissionCount", permissionCount)
.withValue("savedBy", savedBy)
.withValue("savedDate", Instant.now())
.withValue("authentikFingerprint", fingerprint)
.withValue("modifyDate", Instant.now());
if(existing == null)
{
record.setValue("createDate", Instant.now());
InsertInput insertInput = new InsertInput();
insertInput.setTableName(table);
insertInput.setRecords(List.of(record));
InsertOutput output = new InsertAction().execute(insertInput);
if(output.getRecords().isEmpty() || !output.getRecords().get(0).getErrors().isEmpty())
{
throw (new QException("The policy record could not be written: " + (output.getRecords().isEmpty() ? "no record" : output.getRecords().get(0).getErrors())));
}
}
else
{
record.setValue("id", existing.getValue("id"));
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(table);
updateInput.setRecords(List.of(record));
UpdateOutput output = new UpdateAction().execute(updateInput);
if(output.getRecords().isEmpty() || !output.getRecords().get(0).getErrors().isEmpty())
{
throw (new QException("The policy record could not be written: " + (output.getRecords().isEmpty() ? "no record" : output.getRecords().get(0).getErrors())));
}
}
}
}
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.admin.AuthentikSafety;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -88,7 +89,9 @@ public final class AuthentikProcesses
/** Selected invitations. */
INVITATIONS,
/** Selected approval requests. */
APPROVALS
APPROVALS,
/** Selected policy records (authentik_policy); the target is the record's group. */
POLICIES
}
/*******************************************************************************
@@ -184,6 +187,11 @@ public final class AuthentikProcesses
new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"),
hidden("expectedFingerprint"), reason),
"You can only add permissions you hold yourself. Adding authentik.* needs a second person to approve."));
String policyHelp = "Tick what members of this group, and of the groups below it, may do. The document is what is saved to Authentik. "
+ "You can only grant permissions you hold yourself.";
List<QFieldMetaData> policyFields = List.of(hidden("groupPk"), hidden("expectedFingerprint"), reason);
defs.add(new Def("editPolicy", "Edit Policy", AdminAction.SAVE_POLICY, Kind.GROUPS, groups, "policy", true, policyFields, policyHelp));
defs.add(new Def("editPolicyRecord", "Edit Policy", AdminAction.SAVE_POLICY, Kind.POLICIES, AuthentikLocalTables.POLICY, "policy", true, policyFields, policyHelp));
defs.add(new Def("addMembers", "Add Members", AdminAction.ADD_MEMBER, Kind.GROUP_USERNAMES, groups, "person_add", false, List.of(
new QFieldMetaData("usernames", QFieldType.TEXT).withLabel("Usernames (one per line)").withIsRequired(true),
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null));
@@ -272,11 +280,11 @@ public final class AuthentikProcesses
switch(def.kind())
{
case CREATE -> process.setMaxInputRecords(0);
case USERS, GROUP_USERNAMES, GROUPS, INVITATIONS, APPROVALS ->
case USERS, GROUP_USERNAMES, GROUPS, INVITATIONS, APPROVALS, POLICIES ->
{
process.setMinInputRecords(1);
process.setMaxInputRecords(def.action() == AdminAction.EDIT_USER || def.action() == AdminAction.EDIT_GROUP || def.action() == AdminAction.EDIT_PERMISSIONS
? 1 : AuthentikAdmin.MAX_BULK);
|| def.action() == AdminAction.SAVE_POLICY ? 1 : AuthentikAdmin.MAX_BULK);
}
default ->
{
@@ -290,6 +298,10 @@ public final class AuthentikProcesses
{
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.HELP_TEXT).withValue("text", def.help()));
}
if(def.action() == AdminAction.SAVE_POLICY)
{
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", config.applyPrefix(PolicyEditorWidget.NAME)));
}
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
process.withStep(0, details);
if(def.prefill())
@@ -340,6 +352,7 @@ public final class AuthentikProcesses
case CREATE_GROUP -> w("group will be created", "groups will be created", "group was created", "groups were created");
case EDIT_GROUP -> w("group will be updated", "groups will be updated", "group was updated", "groups were updated");
case EDIT_PERMISSIONS -> w("group's permissions will be updated", "groups' permissions will be updated", "group's permissions were updated", "groups' permissions were updated");
case SAVE_POLICY -> w("group's policy will be saved to Authentik", "groups' policies will be saved to Authentik", "group's policy was saved to Authentik", "groups' policies were saved to Authentik");
case SEND_RECOVERY_EMAIL -> w("password reset email will be sent", "password reset emails will be sent", "password reset email was sent", "password reset emails were sent");
case RESET_MFA -> w("user's MFA will be reset", "users' MFA will be reset", "user's MFA was reset", "users' MFA were reset");
case REVOKE_APP_PASSWORDS -> w("user's app passwords will be revoked", "users' app passwords will be revoked", "user's app passwords were revoked", "users' app passwords were revoked");
@@ -409,6 +422,7 @@ public final class AuthentikProcesses
}
}
case GROUPS, INVITATIONS -> rs.add(withParams(AdminRequest.of(def.action(), String.valueOf(record.getValue("pk") != null ? record.getValue("pk") : record.getValue("uuid")), reason), def, input));
case POLICIES -> rs.add(withParams(AdminRequest.of(def.action(), record.getValueString("groupPk"), reason), def, input));
default ->
{
}
@@ -423,6 +437,14 @@ public final class AuthentikProcesses
*******************************************************************************/
private static AdminRequest withParams(AdminRequest request, Def def, RunBackendStepInput input)
{
//////////////////////////////////////////////////////////////////////
// the policy editor widget writes the document as a process value //
// (not a form field), as does an API client //
//////////////////////////////////////////////////////////////////////
if(def.action() == AdminAction.SAVE_POLICY && input.getValue(AuthentikAdmin.POLICY_DOCUMENT) != null)
{
request.with(AuthentikAdmin.POLICY_DOCUMENT, input.getValueString(AuthentikAdmin.POLICY_DOCUMENT));
}
for(QFieldMetaData field : def.fields())
{
if(field.getName().equals("reason"))
@@ -490,9 +512,18 @@ public final class AuthentikProcesses
}
QRecord record = input.getRecords().get(0);
AuthentikAdmin admin = admin(input);
String pk = String.valueOf(record.getValue("pk"));
String pk = def.kind() == Kind.POLICIES ? record.getValueString("groupPk") : String.valueOf(record.getValue("pk"));
switch(def.action())
{
case SAVE_POLICY ->
{
JSONObject group = admin.getUtil().getApiJsonOrNull("/core/groups/" + AuthentikAdmin.safeKey(pk) + "/?include_users=true&include_parents=true&include_children=true");
output.addValue(PolicyEditorWidget.GROUP_PK_PARAM, pk);
if(group != null)
{
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
}
}
case EDIT_USER ->
{
JSONObject user = admin.getUtil().getObject(AuthentikTables.get(AuthentikTables.USER), pk);
@@ -38,6 +38,7 @@ public enum IdentityPermission
GROUPS_MANAGE("authentik.groups.manage"),
MEMBERSHIPS_MANAGE("authentik.memberships.manage"),
PERMISSIONS_MANAGE("authentik.permissions.manage"),
POLICIES_MANAGE("authentik.policies.manage"),
ROLES_MANAGE("authentik.roles.manage"),
CREDENTIALS_RESET("authentik.credentials.reset"),
SESSIONS_REVOKE("authentik.sessions.revoke"),
@@ -55,6 +55,24 @@ class AuthentikQBitConfigTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void policyNames()
{
assertThat(AuthentikTestSupport.unitConfig().getPolicyFormat()).isEqualTo(com.kingsrook.qbits.authentik.policy.PolicyFormat.DEFAULT);
assertThat(AuthentikTestSupport.unitConfig().getPolicyTwoPersonApproval()).isFalse();
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicySpec("acme-iam").withPolicyVersion("2.0.0").withPolicyAttribute("policies").withPolicyClaim("policies"))).isEmpty();
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicyVersion("1"))).anyMatch(e -> e.contains("policyVersion"));
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicySpec(""))).anyMatch(e -> e.contains("policySpec"));
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicyClaim("a b"))).anyMatch(e -> e.contains("policyClaim"));
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicyAttribute(null))).anyMatch(e -> e.contains("policyAttribute"));
assertThat(AuthentikTestSupport.unitConfig().withPolicyTwoPersonApproval(true).getPolicyTwoPersonApproval()).isTrue();
}
/*******************************************************************************
**
*******************************************************************************/
@@ -28,16 +28,23 @@ import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
@@ -125,6 +132,22 @@ public final class AdminTestSupport
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(table[1])));
}
if(features.contains(AuthentikFeature.POLICIES))
{
//////////////////////////////////////////////////////////////////
// things a policy can grant: an app with a table (insert, edit, //
// delete, read) and a hidden record action on it //
//////////////////////////////////////////////////////////////////
QTableMetaData page = new QTableMetaData().withName("cms_page").withLabel("Pages").withBackendName(LOCAL).withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
instance.addTable(page);
instance.addProcess(new QProcessMetaData().withName("publishPage").withLabel("Publish Page").withTableName("cms_page").withIsHidden(true)
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(NoopStep.class)))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addApp(new QAppMetaData().withName("content").withLabel("Content").withChild(page)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
}
new AuthentikQBitProducer().withQBitConfig(support.config).produce(instance);
AuthentikTestSupport.useActionUtil(instance, support.config, FixtureActionUtil.class);
support.qInstance = instance;
@@ -182,4 +205,22 @@ public final class AdminTestSupport
return (authentik.writes.stream().filter(w -> !w.contains("/events/events/")).toList());
}
/*******************************************************************************
** A backend step that does nothing (for fixture processes).
*******************************************************************************/
public static class NoopStep implements BackendStep
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output)
{
///////////////////////////
// nothing; fixture only //
///////////////////////////
}
}
}
@@ -0,0 +1,317 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The policy editor (DESIGN.md section 12.4): saving a policy to Authentik,
** the guardrails, the authentik_policy history row, the process and widget.
*******************************************************************************/
class PolicyEditorTest
{
private static final String PAGE_READ_EDIT = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"cms_page\"]}]}";
private AdminTestSupport t;
/*******************************************************************************
** The operator's own group carries a policy, so the subset rule lets them
** grant cms_page read/edit and content access (but not delete).
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, AuthentikFeature.POLICIES));
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/policy",
PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("cms_page.read", "cms_page.edit", "content.hasAccess", "publishPage.hasAccess"), PolicyFormat.DEFAULT)));
}
/*******************************************************************************
**
*******************************************************************************/
private AuthentikAdmin.AdminResult save(String group, String document) throws QException
{
return (t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(group), "because").with(AuthentikAdmin.POLICY_DOCUMENT, document)));
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> policyRows() throws QException
{
QueryInput input = new QueryInput();
input.setTableName(t.config.applyTablePrefix(AuthentikLocalTables.POLICY));
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
** Save writes the canonical compact string into the CURRENT attributes, and
** an authentik_policy row with the pretty document.
*******************************************************************************/
@Test
void saveWritesCanonicalDocumentAndHistoryRow() throws Exception
{
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("note", "set in the Authentik UI");
AuthentikAdmin.AdminResult result = save("kof-website-analysts", PAGE_READ_EDIT);
assertThat(result.ok()).as(result.message()).isTrue();
assertThat(result.message()).isEqualTo("Policy saved to Authentik.");
JSONObject attributes = t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes");
assertThat(attributes.getString("note")).isEqualTo("set in the Authentik UI");
assertThat(attributes.getString("qqq.io/policy")).isEqualTo(PolicyGenerator.toCompact(PolicyGenerator.canonical(PAGE_READ_EDIT, PolicyFormat.DEFAULT)));
assertThat(PolicyParser.parse(attributes.get("qqq.io/policy"), PolicyFormat.DEFAULT)).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
List<QRecord> rows = policyRows();
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getValueString("groupName")).isEqualTo("kof-website-analysts");
assertThat(rows.get(0).getValueInteger("permissionCount")).isEqualTo(2);
assertThat(rows.get(0).getValueString("policyJson")).contains("\"Action\": [\"read\"]");
assertThat(rows.get(0).getValueString("savedBy")).isEqualTo(com.kingsrook.qbits.authentik.AuthentikTestSupport.OPERATOR);
//////////////////////////////////////////////////////////////
// the effective permissions of the group now include them //
//////////////////////////////////////////////////////////////
t.admin().getUtil().getRuntime().invalidateCache();
assertThat(t.admin().getUtil().groupGraph().byName("kof-website-analysts").policyPermissions()).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
///////////////////////////////////////////////////////////////////
// saving it again is no change; removing it drops the attribute //
///////////////////////////////////////////////////////////////////
assertThat(save("kof-website-analysts", PAGE_READ_EDIT).noChange()).isTrue();
AuthentikAdmin.AdminResult removed = save("kof-website-analysts", "");
assertThat(removed.message()).contains("removed");
assertThat(t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").has("qqq.io/policy")).isFalse();
assertThat(policyRows()).hasSize(1);
assertThat(policyRows().get(0).getValueInteger("permissionCount")).isEqualTo(0);
}
/*******************************************************************************
** The guardrails: invalid documents, unknown resources, the subset rule,
** superuser groups and a stale fingerprint are refused, and nothing is sent.
*******************************************************************************/
@Test
void refusals() throws Exception
{
assertThat(save("kof-website-analysts", "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}]}").message())
.contains("not valid").contains("Deny");
assertThat(save("kof-website-analysts", "{\"PolicySpec\":\"other\",\"PolicyVersion\":\"1.0.0\"}").message()).contains("PolicySpec");
assertThat(save("kof-website-analysts", PAGE_READ_EDIT.replace("cms_page", "no_such_table")).message()).contains("not permissions of this app").contains("no_such_table.read");
assertThat(save("kof-website-analysts", PAGE_READ_EDIT.replace("\"edit\"", "\"delete\"")).message()).contains("do not hold yourself").contains("cms_page.delete");
assertThat(save("authentik Admins", PAGE_READ_EDIT).message()).contains("Superuser groups");
AuthentikAdmin.AdminResult stale = t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because")
.with(AuthentikAdmin.POLICY_DOCUMENT, PAGE_READ_EDIT).with("expectedFingerprint", "not-the-fingerprint"));
assertThat(stale.message()).contains("changed");
assertThat(t.changes()).isEmpty();
assertThat(policyRows()).isEmpty();
}
/*******************************************************************************
** Unknown resources already in the stored policy are kept (with a note); a
** stored document that is invalid is replaced.
*******************************************************************************/
@Test
void keptUnknownsAndInvalidStoredDocuments() throws Exception
{
String withGone = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\"]}]}";
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", withGone);
AdminPlan plan = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because")
.with(AuthentikAdmin.POLICY_DOCUMENT, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\",\"cms_page\"]}]}"));
assertThat(plan.isRunnable()).as(plan.getMessage()).isTrue();
assertThat(plan.getNotes()).anyMatch(n -> n.startsWith("Adds: cms_page.read")).anyMatch(n -> n.contains("Kept although") && n.contains("gone_table.read"));
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", "garbage");
AdminPlan replace = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because").with(AuthentikAdmin.POLICY_DOCUMENT, ""));
assertThat(replace.isRunnable()).isTrue();
assertThat(replace.getNotes()).anyMatch(n -> n.contains("was not valid"));
}
/*******************************************************************************
** Permission gating: without authentik.policies.manage, or without the
** POLICIES feature, there is no save (and no metadata).
*******************************************************************************/
@Test
void gating() throws Exception
{
t.loginAs(com.kingsrook.qbits.authentik.AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ, IdentityPermission.GROUPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
assertThat(save("kof-website-analysts", PAGE_READ_EDIT).ok()).isFalse();
assertThat(t.changes()).isEmpty();
AdminTestSupport off = AdminTestSupport.create();
assertThat(off.qInstance.getProcess("authentikEditPolicy")).isNull();
assertThat(off.qInstance.getWidget("authentikPolicyEditor")).isNull();
assertThat(off.qInstance.getTable("authentik_policy")).isNull();
AuthentikAdmin.AdminResult refused = off.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, off.authentik.pk("kof-website-analysts"), "because").with(AuthentikAdmin.POLICY_DOCUMENT, PAGE_READ_EDIT));
assertThat(refused.ok()).isFalse();
}
/*******************************************************************************
** A second person approves policy saves when policyTwoPersonApproval is on.
*******************************************************************************/
@Test
void twoPersonApproval() throws Exception
{
t.config.withPolicyTwoPersonApproval(true);
AuthentikAdmin.AdminResult result = save("kof-website-analysts", PAGE_READ_EDIT);
assertThat(result.pending()).isTrue();
assertThat(t.changes()).isEmpty();
}
/*******************************************************************************
** The record action: prepare fills the group and fingerprint, the step shows
** the editor widget, Review is a dry run, Result saves.
*******************************************************************************/
@Test
void editPolicyProcess() throws Exception
{
String groupPk = t.authentik.pk("kof-website-analysts");
QFrontendStepMetaData details = (QFrontendStepMetaData) t.qInstance.getProcess("authentikEditPolicy").getStep("details");
assertThat(details.getComponents()).anyMatch(c -> c.getType() == QComponentType.WIDGET && "authentikPolicyEditor".equals(c.getValues().get("widgetName")));
assertThat(t.qInstance.getProcess("authentikEditPolicy").getTableName()).isEqualTo(t.config.applyTablePrefix(AuthentikTables.GROUP));
assertThat(t.qInstance.getProcess("authentikEditPolicyRecord").getTableName()).isEqualTo("authentik_policy");
RunProcessInput input = new RunProcessInput();
input.setProcessName("authentikEditPolicy");
input.addValue("recordIds", groupPk);
RunProcessOutput output = new RunProcessAction().execute(input);
assertThat(output.getValues().get("groupPk")).isEqualTo(groupPk);
assertThat(output.getValues().get("expectedFingerprint")).isNotNull();
input.setProcessUUID(output.getProcessUUID());
Map<String, Serializable> form = Map.of("reason", "editors read pages", AuthentikAdmin.POLICY_DOCUMENT, PAGE_READ_EDIT);
form.forEach(input::addValue);
input.setStartAfterStep("details");
output = new RunProcessAction().execute(input);
assertThat(output.getException()).isEmpty();
@SuppressWarnings("unchecked")
List<ProcessSummaryLine> review = (List<ProcessSummaryLine>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
assertThat(review).anyMatch(l -> l.getStatus() == Status.OK && l.getMessage().contains("policy will be saved"));
assertThat(review).anyMatch(l -> l.getMessage().contains("Adds: cms_page.edit, cms_page.read"));
assertThat(t.changes()).as("review is a dry run").isEmpty();
input.setStartAfterStep("review");
output = new RunProcessAction().execute(input);
assertThat(output.getException()).isEmpty();
assertThat(PolicyParser.parse(t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").get("qqq.io/policy"), PolicyFormat.DEFAULT))
.containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
////////////////////////////////////////////////////////
// the same action from the policy record (its group) //
////////////////////////////////////////////////////////
RunProcessInput again = new RunProcessInput();
again.setProcessName("authentikEditPolicyRecord");
again.addValue("recordIds", String.valueOf(policyRows().get(0).getValue("id")));
RunProcessOutput prepared = new RunProcessAction().execute(again);
assertThat(prepared.getValues().get("groupPk")).isEqualTo(groupPk);
}
/*******************************************************************************
** The widget: catalog plus the group's live document; a change made in
** Authentik after the last QQQ save is flagged; invalid stored documents are
** reported.
*******************************************************************************/
@Test
void widgetData() throws Exception
{
String groupPk = t.authentik.pk("kof-website-analysts");
PolicyEditorWidget.Data empty = PolicyEditorWidget.load(t.config, groupPk);
assertThat(empty.getType()).isEqualTo("AUTHENTIK_POLICY_EDITOR");
assertThat(empty.getSpec()).isEqualTo("qqq-iam");
assertThat(empty.getDocument()).isNull();
assertThat(empty.getGroupLabel()).isEqualTo("kof-website-analysts");
assertThat(empty.getOutputFieldName()).isEqualTo("policyDocument");
assertThat(new JSONObject(Map.of("c", empty.getCatalog())).toString()).contains("\"cms_page\"").contains("\"publishPage\"");
save("kof-website-analysts", PAGE_READ_EDIT);
PolicyEditorWidget.Data saved = PolicyEditorWidget.load(t.config, groupPk);
assertThat(saved.getDocument()).contains("\"cms_page\"");
assertThat(saved.getChangedInAuthentik()).isFalse();
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT.replace("\"edit\"", "\"insert\""));
t.admin().getUtil().getRuntime().invalidateCache();
assertThat(PolicyEditorWidget.load(t.config, groupPk).getChangedInAuthentik()).isTrue();
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", "garbage");
t.admin().getUtil().getRuntime().invalidateCache();
PolicyEditorWidget.Data broken = PolicyEditorWidget.load(t.config, groupPk);
assertThat(broken.getDocumentProblem()).contains("not a JSON object");
assertThat(broken.getDocument()).isNull();
assertThat(PolicyEditorWidget.load(t.config, null).getGroupLabel()).isNull();
assertThatThrownBy(() -> PolicyEditorWidget.load(t.config, "../x")).hasMessageContaining("Not a group id");
RenderWidgetInput input = new RenderWidgetInput();
input.setWidgetMetaData(t.qInstance.getWidget("authentikPolicyEditor"));
input.setQueryParams(new java.util.HashMap<>(Map.of("groupPk", groupPk)));
RenderWidgetOutput output = new RenderWidgetAction().execute(input);
assertThat(output.getWidgetData().getType()).isEqualTo("AUTHENTIK_POLICY_EDITOR");
}
}
@@ -0,0 +1,149 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.contract;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Pins the scope-mapping example of docs/POLICY-FORMAT.md on Authentik
** 2026.8.3: the blueprint applies, and the mapping (run through Authentik's
** property-mapping test endpoint) emits a claim that the qbit's parser turns
** into the union of the user's groups' policies, including inherited parents.
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(46)
class PolicyClaimMappingIT
{
private static TestApi admin;
private static String mappingPk;
/*******************************************************************************
**
*******************************************************************************/
@BeforeAll
static void beforeAll() throws Exception
{
AuthentikTestStack stack = AuthentikTestStack.get();
admin = stack.admin();
try(InputStream in = PolicyClaimMappingIT.class.getResourceAsStream("/blueprints/qqq-policy-claim.yaml"))
{
stack.createContentBlueprint("qqq-policy-claim", new String(in.readAllBytes(), StandardCharsets.UTF_8));
}
mappingPk = admin.first("/propertymappings/provider/scope/?name=qqq-policy-claim").getString("pk");
}
/*******************************************************************************
** Run the mapping for a user; the claim as Authentik would put it in a token.
*******************************************************************************/
private static JSONObject claimsFor(int userPk)
{
TestApi.Response response = admin.post("/propertymappings/all/" + mappingPk + "/test/", new JSONObject().put("user", userPk));
assertThat(response.status()).as(response.body()).isEqualTo(200);
JSONObject result = response.json();
assertThat(result.getBoolean("successful")).as(result.toString()).isTrue();
return (new JSONObject(result.getString("result")));
}
/*******************************************************************************
** A group with a canonical policy for these permissions.
*******************************************************************************/
private static String group(String name, String parentPk, Object policy) throws Exception
{
JSONObject group = new JSONObject().put("name", name).put("attributes", new JSONObject().put("qqq.io/policy", policy));
if(parentPk != null)
{
group.put("parents", new JSONArray().put(parentPk));
}
admin.post("/core/groups/", group);
return (admin.groupPk(name));
}
/*******************************************************************************
**
*******************************************************************************/
private static int user(String username, String groupPk)
{
admin.post("/core/users/", new JSONObject().put("username", username).put("name", username).put("type", "internal").put("path", "users"));
int pk = admin.userPk(username);
if(groupPk != null)
{
admin.post("/core/groups/" + groupPk + "/add_user/", new JSONObject().put("pk", pk));
}
return (pk);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void claimIsTheUnionOfInheritedGroupPolicies() throws Exception
{
PolicyFormat format = PolicyFormat.DEFAULT;
String parent = group("policy-parent", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("contentApp.hasAccess"), format)));
String child = group("policy-child", parent, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("cms_page.read", "cms_page.edit"), format)));
group("policy-object", null, PolicyGenerator.generate(List.of("objectAttr.read"), format));
group("policy-other-spec", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("ignored.read"), new PolicyFormat("other", "1.0.0", "x", "y"))));
int member = user("policy.member", child);
admin.post("/core/groups/" + admin.groupPk("policy-object") + "/add_user/", new JSONObject().put("pk", member));
admin.post("/core/groups/" + admin.groupPk("policy-other-spec") + "/add_user/", new JSONObject().put("pk", member));
JSONObject claims = claimsFor(member);
assertThat(claims.has("qqq_policy")).isTrue();
assertThat(PolicyPermissions.fromClaims(claims, format))
.as("parent (inherited), child, an object-valued attribute; another spec is skipped")
.containsExactlyInAnyOrder("contentApp.hasAccess", "cms_page.read", "cms_page.edit", "objectAttr.read");
}
/*******************************************************************************
** No policies: the claim is still a valid, empty document.
*******************************************************************************/
@Test
void userWithoutPoliciesGetsAnEmptyDocument() throws Exception
{
JSONObject claims = claimsFor(user("policy.nobody", null));
assertThat(PolicyParser.parseStrict(claims.get("qqq_policy"), PolicyFormat.DEFAULT).permissions()).isEmpty();
}
}
@@ -73,6 +73,9 @@ class QbitWriteContractIT
{
private static final String OPERATOR_EMAIL = "wr.operator@example.test";
/** The operator's own policy: they may grant read and edit on wr_page. */
private static final String WR_POLICY = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"wr_page\"]}]}";
/** The phase 2/3 permissions of DESIGN.md section 5.1 (plus view_flow to resolve the invitation flow by slug). */
private static final List<String> ROLE_PERMISSIONS = List.of(
"authentik_core.add_user", "authentik_core.change_user", "authentik_core.reset_user_password",
@@ -110,7 +113,8 @@ class QbitWriteContractIT
admin.post("/rbac/permissions/assigned_by_roles/" + roleUuid + "/assign/", new JSONObject().put("permissions", new JSONArray(ROLE_PERMISSIONS)));
admin.post("/core/groups/", new JSONObject().put("name", "wr-admins").put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.*"))).put("qqq.io/app-access", new JSONArray(List.of(AuthentikTestStack.APP_SLUG)))));
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.*"))).put("qqq.io/app-access", new JSONArray(List.of(AuthentikTestStack.APP_SLUG)))
.put("qqq.io/policy", WR_POLICY)));
admin.post("/core/groups/", new JSONObject().put("name", "wr-readers").put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.users.read")))));
admin.post("/core/users/", new JSONObject().put("username", "wr.operator").put("name", "WR Operator").put("email", OPERATOR_EMAIL).put("type", "internal").put("path", "users"));
@@ -162,11 +166,15 @@ class QbitWriteContractIT
.withRecoveryEmailStage("kof22-recovery-email")
.withInvitationFlow("wr-enrollment")
.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS));
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES));
qInstance = new QInstance();
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
qInstance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
qInstance.addTable(new com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData().withName("wr_page").withBackendName("memory").withPrimaryKeyField("id")
.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("id", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER))
.withPermissionRules(new com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules()
.withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)));
new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
Set<String> permissions = new HashSet<>();
@@ -305,6 +313,38 @@ class QbitWriteContractIT
/*******************************************************************************
** Save a policy on a real Authentik: the canonical compact string lands in
** the group's attributes next to the others, the qbit reads it back, and a
** grant the operator does not hold is refused.
*******************************************************************************/
@Test
void savePolicy() throws Exception
{
admin.post("/core/groups/", new JSONObject().put("name", "wr-policy").put("attributes", new JSONObject().put("owner-note", "set in the Authentik UI")));
String pk = admin.groupPk("wr-policy");
String document = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"delete\"],\"Resource\":[\"wr_page\"]}]}";
AuthentikAdmin.AdminResult escalation = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, document));
assertThat(escalation.ok()).isFalse();
assertThat(escalation.message()).contains("wr_page.delete");
String allowed = document.replace(",\"delete\"", "");
AuthentikAdmin.AdminResult saved = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, allowed));
assertThat(saved.ok()).as(saved.message()).isTrue();
JSONObject attributes = admin.get("/core/groups/" + pk + "/").json().getJSONObject("attributes");
assertThat(attributes.getString("owner-note")).isEqualTo("set in the Authentik UI");
assertThat(attributes.getString("qqq.io/policy")).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"wr_page\"]}]}");
assertThat(AuthentikActionUtil.forBackend(config.getApiBackendName()).groupGraph().byPk(pk).policyPermissions()).containsExactly("wr_page.read");
AuthentikAdmin.AdminResult cleared = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, ""));
assertThat(cleared.ok()).as(cleared.message()).isTrue();
assertThat(admin.get("/core/groups/" + pk + "/").json().getJSONObject("attributes").has("qqq.io/policy")).isFalse();
}
/*******************************************************************************
** Group create and edit (parents), and the superuser parent refused by the
** guard.
@@ -182,6 +182,27 @@ class AuthentikGroupPermissionMapperTest
** Inherited authentik.* through a child group; existing permissions kept.
*******************************************************************************/
@Test
void policyClaimIsAdded()
{
String policy = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}]}";
Map<String, Object> ctx = context("someone@example.test", new JSONArray());
ctx.put("idToken", new JSONObject().put("qqq_policy", policy));
QSession session = new QSession();
new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, ctx);
assertThat(session.getPermissions()).contains("cms_page.read");
ctx.put("idToken", new JSONObject().put("qqq_policy", policy.replace("Allow", "Deny")));
QSession denied = new QSession();
new AuthentikGroupPermissionMapper().customizeSession(qInstance, denied, ctx);
assertThat(denied.getPermissions()).doesNotContain("cms_page.read");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void groupsBecomePermissions()
{
QSession session = new QSession();
@@ -0,0 +1,153 @@
/*
* 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.policy;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.document;
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.statement;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Unit tests for AuthentikPolicyClaimMapper and PolicyPermissions.
*******************************************************************************/
class AuthentikPolicyClaimMapperTest
{
private static final String READ_PAGE = document(statement("Allow", List.of("read"), List.of("cms_page"))).toString();
/*******************************************************************************
**
*******************************************************************************/
private static QSession run(AuthentikPolicyClaimMapper mapper, Map<String, Object> context)
{
QSession session = new QSession();
session.setPermissions(Set.of("existing.hasAccess"));
mapper.customizeSession(null, session, context);
return (session);
}
/*******************************************************************************
** The claim as a string (the scope mapping's json.dumps) in the ID token.
*******************************************************************************/
@Test
void stringClaimInIdToken()
{
QSession session = run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", READ_PAGE)));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.hasAccess", "cms_page.read");
}
/*******************************************************************************
** An object claim in the access token (session resume: no ID token).
*******************************************************************************/
@Test
void objectClaimInAccessToken()
{
QSession session = run(new AuthentikPolicyClaimMapper(), Map.of("jwtPayloadJsonObject", new JSONObject().put("qqq_policy", new JSONObject(READ_PAGE))));
assertThat(session.getPermissions()).contains("cms_page.read");
}
/*******************************************************************************
** The ID token wins when both carry the claim; the access token is used when
** only it has the claim.
*******************************************************************************/
@Test
void idTokenPreferred()
{
String other = document(statement("Allow", List.of("edit"), List.of("other"))).toString();
Map<String, Object> context = new HashMap<>();
context.put("idToken", new JSONObject().put("qqq_policy", READ_PAGE));
context.put("jwtPayloadJsonObject", new JSONObject().put("qqq_policy", other));
assertThat(run(new AuthentikPolicyClaimMapper(), context).getPermissions()).contains("cms_page.read").doesNotContain("other.edit");
context.put("idToken", new JSONObject().put("email", "a@example.test"));
assertThat(run(new AuthentikPolicyClaimMapper(), context).getPermissions()).contains("other.edit");
}
/*******************************************************************************
** Missing or invalid claims add nothing and keep what the session had.
*******************************************************************************/
@Test
void failsClosed()
{
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of()).getPermissions()).containsExactly("existing.hasAccess");
assertThat(run(new AuthentikPolicyClaimMapper(), null).getPermissions()).containsExactly("existing.hasAccess");
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", "{}"))).getPermissions()).containsExactly("existing.hasAccess");
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", "garbage"))).getPermissions()).containsExactly("existing.hasAccess");
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", "not claims")).getPermissions()).containsExactly("existing.hasAccess");
}
/*******************************************************************************
** A host's own names (spec, claim).
*******************************************************************************/
@Test
void customFormat()
{
PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies");
String doc = new JSONObject(READ_PAGE).put("PolicySpec", "acme-iam").toString();
QSession session = run(new AuthentikPolicyClaimMapper().withFormat(custom), Map.of("idToken", Map.of("policies", doc)));
assertThat(session.getPermissions()).contains("cms_page.read");
assertThat(new AuthentikPolicyClaimMapper().withFormat(null).getFormat()).isEqualTo(PolicyFormat.DEFAULT);
assertThat(PolicyPermissions.fromClaims(new JSONObject().put("policies", doc), custom)).containsExactly("cms_page.read");
assertThat(PolicyPermissions.fromClaims(null, custom)).isEmpty();
assertThat(PolicyPermissions.claimsWithPolicy(null, null)).isNull();
}
/*******************************************************************************
** The security-key hook runs before permissions, with the claims, even when
** the token has no policy claim.
*******************************************************************************/
@Test
void securityKeyHook()
{
List<String> calls = new ArrayList<>();
AuthentikPolicyClaimMapper mapper = new AuthentikPolicyClaimMapper()
{
@Override
protected void applySecurityKeys(QSession qSession, JSONObject claims)
{
calls.add(claims.optString("tenant") + ":" + qSession.getPermissions().contains("cms_page.read"));
}
};
run(mapper, Map.of("idToken", new JSONObject().put("tenant", "t1").put("qqq_policy", READ_PAGE)));
run(mapper, Map.of("jwtPayloadJsonObject", new JSONObject().put("tenant", "t2")));
assertThat(calls).containsExactly("t1:false", "t2:false");
}
}
@@ -0,0 +1,175 @@
/*
* 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.policy;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Unit tests for PolicyCatalog: the editor's enumeration of what a policy can
** grant in a QInstance, grouped by app.
*******************************************************************************/
class PolicyCatalogTest
{
/*******************************************************************************
**
*******************************************************************************/
private static QPermissionRules rules(PermissionLevel level)
{
return (new QPermissionRules().withLevel(level));
}
/*******************************************************************************
**
*******************************************************************************/
private static QTableMetaData table(String name, String label, PermissionLevel level)
{
return (new QTableMetaData().withName(name).withLabel(label).withBackendName("memory").withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withPermissionRules(rules(level)));
}
/*******************************************************************************
** An instance with every kind of object and permission shape.
*******************************************************************************/
static QInstance instance()
{
QInstance instance = new QInstance();
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
QTableMetaData page = table("cms_page", "Pages", PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS);
QTableMetaData audit = table("cms_audit", "Audit", PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
QTableMetaData legacy = table("legacy", "Legacy", PermissionLevel.READ_WRITE_PERMISSIONS);
QTableMetaData gate = table("gate", "Gate", PermissionLevel.HAS_ACCESS_PERMISSION);
QTableMetaData shared1 = table("shared_one", "Shared One", PermissionLevel.HAS_ACCESS_PERMISSION);
shared1.getPermissionRules().setPermissionBaseName("shared_base");
QTableMetaData shared2 = table("shared_two", "Shared Two", PermissionLevel.HAS_ACCESS_PERMISSION);
shared2.getPermissionRules().setPermissionBaseName("shared_base");
QTableMetaData open = table("open_table", "Open", PermissionLevel.NOT_PROTECTED);
QTableMetaData dotted = table("dotted", "Dotted", PermissionLevel.HAS_ACCESS_PERMISSION);
dotted.getPermissionRules().setPermissionBaseName("cms.publish");
QTableMetaData orphan = table("orphan", "Orphan", PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS);
for(QTableMetaData t : List.of(page, audit, legacy, gate, shared1, shared2, open, dotted, orphan))
{
instance.addTable(t);
}
instance.addProcess(new QProcessMetaData().withName("publishPage").withLabel("Publish").withTableName("cms_page").withIsHidden(true)
.withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addProcess(new QProcessMetaData().withName("loose").withLabel("Loose").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addReport(new QReportMetaData().withName("pageReport").withLabel("Page Report").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addWidget(new QWidgetMetaData().withName("pageStats").withLabel("Page Stats").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addWidget(new QWidgetMetaData().withName("openWidget").withLabel("Open Widget").withPermissionRules(rules(PermissionLevel.NOT_PROTECTED)));
instance.addApp(new QAppMetaData().withName("content").withLabel("Content").withSortOrder(1)
.withChild(page).withChild(audit).withChild(legacy).withChild(gate).withChild(shared1).withChild(shared2).withChild(open).withChild(dotted)
.withChild(instance.getReport("pageReport")).withWidgets(List.of("pageStats"))
.withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addApp(new QAppMetaData().withName("emptyApp").withLabel("Empty").withSortOrder(2).withPermissionRules(rules(PermissionLevel.NOT_PROTECTED)));
return (instance);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void groupsByAppWithTheActionsQqqChecks()
{
PolicyCatalog catalog = PolicyCatalog.of(instance());
assertThat(catalog.getGroups()).extracting(PolicyCatalog.Group::label).containsExactly("Content", "Other");
PolicyCatalog.Group content = catalog.getGroups().get(0);
assertThat(content.resources()).extracting(PolicyCatalog.Resource::name)
.containsExactly("content", "cms_audit", "gate", "legacy", "cms_page", "shared_base", "publishPage", "pageReport", "pageStats");
assertThat(catalog.get("content").actions()).containsExactly(PolicyAction.HAS_ACCESS);
assertThat(catalog.get("cms_page").actions()).containsExactly(PolicyAction.READ, PolicyAction.INSERT, PolicyAction.EDIT, PolicyAction.DELETE);
assertThat(catalog.get("cms_audit").actions()).as("capabilities limit the actions").containsExactly(PolicyAction.READ);
assertThat(catalog.get("legacy").actions()).containsExactly(PolicyAction.READ);
assertThat(catalog.get("legacy").note()).contains("write permission");
assertThat(catalog.get("gate").actions()).containsExactly(PolicyAction.HAS_ACCESS);
assertThat(catalog.get("publishPage").kind()).isEqualTo(PolicyCatalog.Kind.PROCESS);
assertThat(catalog.get("publishPage").label()).as("a hidden record action, with its table").isEqualTo("Publish (Pages action)");
assertThat(catalog.get("shared_base").label()).as("one entry per base name").isEqualTo("Shared One / Shared Two");
assertThat(catalog.getGroups().get(1).resources()).extracting(PolicyCatalog.Resource::name).containsExactly("orphan", "loose");
assertThat(catalog.get("open_table")).as("not protected: nothing to grant").isNull();
assertThat(catalog.get("openWidget")).isNull();
assertThat(catalog.get("emptyApp")).isNull();
assertThat(catalog.get("cms.publish")).as("a dotted base name cannot be a resource").isNull();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void permissionNamesAndUnknowns()
{
PolicyCatalog catalog = PolicyCatalog.of(instance());
assertThat(catalog.permissionNames()).contains("cms_page.read", "cms_page.delete", "content.hasAccess", "publishPage.hasAccess", "pageReport.hasAccess")
.doesNotContain("cms_page.hasAccess", "cms_audit.edit", "open_table.read");
assertThat(catalog.unknownPermissions(Set.of("cms_page.read", "cms_page.hasAccess", "gone.read"))).containsExactly("cms_page.hasAccess", "gone.read");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void json()
{
JSONArray json = PolicyCatalog.of(instance()).toJson();
JSONObject content = json.getJSONObject(0);
assertThat(content.getString("app")).isEqualTo("content");
assertThat(content.getString("label")).isEqualTo("Content");
JSONObject legacy = content.getJSONArray("resources").getJSONObject(3);
assertThat(legacy.getString("name")).isEqualTo("legacy");
assertThat(legacy.getString("kind")).isEqualTo("TABLE");
assertThat(legacy.getJSONArray("actions").toList()).containsExactly("read");
assertThat(legacy.has("note")).isTrue();
assertThat(json.getJSONObject(1).isNull("app")).isTrue();
assertThat(PolicyCatalog.of(new QInstance()).getGroups()).isEmpty();
}
}
@@ -0,0 +1,174 @@
/*
* 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.policy;
import java.util.List;
import java.util.Random;
import java.util.Set;
import java.util.TreeSet;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Unit tests for PolicyGenerator: canonical form and the round trip.
*******************************************************************************/
class PolicyGeneratorTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void canonicalForm() throws Exception
{
JSONObject doc = PolicyGenerator.generate(List.of("cms_page.edit", "contentApp.hasAccess", "cms_page.read", "cms_media.read"), PolicyFormat.DEFAULT);
assertThat(PolicyGenerator.toCompact(doc)).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":["
+ "{\"Effect\":\"Allow\",\"Action\":[\"hasAccess\"],\"Resource\":[\"contentApp\"]},"
+ "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_media\",\"cms_page\"]},"
+ "{\"Effect\":\"Allow\",\"Action\":[\"edit\"],\"Resource\":[\"cms_page\"]}]}");
assertThat(PolicyGenerator.toPretty(doc)).isEqualTo("""
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.0.0",
"Statement": [
{
"Effect": "Allow",
"Action": ["hasAccess"],
"Resource": [
"contentApp"
]
},
{
"Effect": "Allow",
"Action": ["read"],
"Resource": [
"cms_media",
"cms_page"
]
},
{
"Effect": "Allow",
"Action": ["edit"],
"Resource": [
"cms_page"
]
}
]
}
""");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void emptyDocument() throws Exception
{
JSONObject doc = PolicyGenerator.generate(List.of(), PolicyFormat.DEFAULT);
assertThat(PolicyGenerator.toCompact(doc)).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}\n".trim());
assertThat(PolicyGenerator.toPretty(doc)).isEqualTo("{\n \"PolicySpec\": \"qqq-iam\",\n \"PolicyVersion\": \"1.0.0\",\n \"Statement\": []\n}\n");
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT).permissions()).isEmpty();
}
/*******************************************************************************
** parse(generate(x)) == x for many random permission sets, in both forms,
** and generate(parse(doc)) is stable (canonical).
*******************************************************************************/
@Test
void roundTripRandomSets() throws Exception
{
Random random = new Random(20261013);
List<String> resources = List.of("a", "B_2", "cms_page", "contentApp", "x9", "authentik_user", "publishPage");
for(int run = 0; run < 500; run++)
{
Set<String> permissions = new TreeSet<>();
for(String resource : resources)
{
for(PolicyAction action : PolicyAction.values())
{
if(random.nextInt(3) == 0)
{
permissions.add(action.permission(resource));
}
}
}
JSONObject doc = PolicyGenerator.generate(permissions, PolicyFormat.DEFAULT);
assertThat(PolicyParser.parseStrict(PolicyGenerator.toCompact(doc), PolicyFormat.DEFAULT).permissions()).isEqualTo(permissions);
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT).permissions()).isEqualTo(permissions);
assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT))).isEqualTo(PolicyGenerator.toCompact(doc));
}
}
/*******************************************************************************
** A hand-written document with "*" and repeated statements becomes the
** canonical one with the same grants.
*******************************************************************************/
@Test
void canonicalizesHandWrittenDocuments() throws Exception
{
String hand = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":["
+ "{\"Effect\":\"Allow\",\"Action\":[\"*\"],\"Resource\":[\"b\"]},"
+ "{\"Effect\":\"Allow\",\"Action\":[\"read\",\"read\"],\"Resource\":[\"a\",\"b\"]}]}";
JSONObject canonical = PolicyGenerator.canonical(hand, PolicyFormat.DEFAULT);
assertThat(PolicyParser.parse(canonical, PolicyFormat.DEFAULT)).isEqualTo(PolicyParser.parse(hand, PolicyFormat.DEFAULT));
assertThat(PolicyGenerator.toCompact(canonical)).doesNotContain("\"*\"");
assertThat(canonical.getJSONArray("Statement").length()).isEqualTo(5);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void rejectsWhatCannotRoundTrip()
{
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("noaction"), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class).hasMessageContaining("{resource}.{action}");
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("a."), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class);
assertThatThrownBy(() -> PolicyGenerator.generate(List.of(".read"), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class);
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("a.write"), PolicyFormat.DEFAULT)).hasMessageContaining("unknown action \"write\"");
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("authentik.users.read"), PolicyFormat.DEFAULT)).hasMessageContaining("[A-Za-z0-9_]+");
assertThatThrownBy(() -> PolicyGenerator.generate(java.util.Arrays.asList((String) null), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class);
}
/*******************************************************************************
** The generator writes the host's spec and version.
*******************************************************************************/
@Test
void customFormat() throws Exception
{
PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies");
JSONObject doc = PolicyGenerator.generate(List.of("order.read"), custom);
assertThat(doc.getString("PolicySpec")).isEqualTo("acme-iam");
assertThat(PolicyParser.parse(PolicyGenerator.toCompact(doc), custom)).containsExactly("order.read");
assertThat(PolicyGenerator.generate(List.of("order.read"), null).getString("PolicySpec")).isEqualTo("qqq-iam");
}
}
@@ -0,0 +1,110 @@
/*
* 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.policy;
import java.util.List;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.document;
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.statement;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Group policies in the group graph and the effective-permission resolver:
** inherited from parents, fail closed per group, checked against the catalog.
*******************************************************************************/
class PolicyGraphTest
{
/*******************************************************************************
**
*******************************************************************************/
private static JSONObject group(String pk, List<String> parents, Object policy, String attribute)
{
JSONObject attributes = new JSONObject();
if(policy != null)
{
attributes.put(attribute, policy);
}
return (new JSONObject().put("pk", pk).put("name", pk).put("is_superuser", false).put("parents", new JSONArray(parents)).put("attributes", attributes).put("users", new JSONArray()));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void policiesAreInheritedAndFailClosedPerGroup()
{
String parentPolicy = document(statement("Allow", List.of("hasAccess"), List.of("contentApp"))).toString();
JSONObject childPolicy = document(statement("Allow", List.of("read", "edit"), List.of("cms_page")));
String broken = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"x\"]}]}";
GroupGraph graph = GroupGraph.fromGroups(List.of(
group("parent", List.of(), parentPolicy, "qqq.io/policy"),
group("child", List.of("parent"), childPolicy, "qqq.io/policy"),
group("broken", List.of("parent"), broken, "qqq.io/policy"),
group("empty", List.of(), "", "qqq.io/policy")));
assertThat(graph.byPk("child").policyPermissions()).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
assertThat(graph.byPk("broken").policyPermissions()).isEmpty();
assertThat(graph.byPk("broken").malformed()).anyMatch(m -> m.startsWith("qqq.io/policy: ") && m.contains("Deny"));
assertThat(graph.byPk("empty").malformed()).isEmpty();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
EffectivePermissionResolver.Resolution child = resolver.resolveGroups(List.of("child"));
assertThat(child.sessionPermissions()).containsExactlyInAnyOrder("contentApp.hasAccess", "cms_page.read", "cms_page.edit");
assertThat(child.grantedBy().get("contentApp.hasAccess")).containsExactly("parent");
assertThat(resolver.resolveGroups(List.of("broken")).sessionPermissions()).as("the broken group grants nothing; its parent still does").containsExactly("contentApp.hasAccess");
}
/*******************************************************************************
** Grants outside the app's permission catalog are reported, not granted.
*******************************************************************************/
@Test
void catalogFiltersPolicyGrants()
{
GroupGraph graph = GroupGraph.fromGroups(List.of(group("g", List.of(), document(statement("Allow", List.of("read"), List.of("cms_page", "gone_table"))).toString(), "qqq.io/policy")));
EffectivePermissionResolver.Resolution resolution = new EffectivePermissionResolver(graph, List.of("cms_page.read")).resolveGroups(List.of("g"));
assertThat(resolution.sessionPermissions()).containsExactly("cms_page.read");
assertThat(resolution.unknown()).containsExactly("gone_table.read");
}
/*******************************************************************************
** A host's own attribute and spec.
*******************************************************************************/
@Test
void customFormat()
{
PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies");
String doc = document(statement("Allow", List.of("read"), List.of("order"))).put("PolicySpec", "acme-iam").toString();
GroupGraph graph = GroupGraph.fromGroups(List.of(group("g", List.of(), doc, "policies")), custom);
assertThat(graph.byPk("g").policyPermissions()).containsExactly("order.read");
assertThat(GroupGraph.fromGroups(List.of(group("g", List.of(), doc, "policies"))).byPk("g").policyPermissions()).as("default attribute is qqq.io/policy").isEmpty();
}
}
@@ -0,0 +1,223 @@
/*
* 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.policy;
import java.util.List;
import java.util.Map;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Unit tests for PolicyParser: the format and every fail-closed case.
*******************************************************************************/
class PolicyParserTest
{
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
/*******************************************************************************
** A statement.
*******************************************************************************/
static JSONObject statement(String effect, List<String> actions, List<String> resources)
{
return (new JSONObject().put("Effect", effect).put("Action", new JSONArray(actions)).put("Resource", new JSONArray(resources)));
}
/*******************************************************************************
** A document with the default spec and version.
*******************************************************************************/
static JSONObject document(JSONObject... statements)
{
return (new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0").put("Statement", new JSONArray(List.of(statements))));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void allowStatementsGrantResourceDotAction()
{
JSONObject doc = document(
statement("Allow", List.of("hasAccess"), List.of("contentApp", "publishPage")),
statement("Allow", List.of("read", "edit"), List.of("cms_page")));
assertThat(PolicyParser.parse(doc, FORMAT)).containsExactlyInAnyOrder(
"contentApp.hasAccess", "publishPage.hasAccess", "cms_page.read", "cms_page.edit");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void wildcardActionIsAllFive()
{
assertThat(PolicyParser.parse(document(statement("Allow", List.of("*"), List.of("cms_page"))), FORMAT)).containsExactlyInAnyOrder(
"cms_page.hasAccess", "cms_page.read", "cms_page.insert", "cms_page.edit", "cms_page.delete");
}
/*******************************************************************************
** String (as stored in an attribute or claim), JSONObject and Map all parse.
*******************************************************************************/
@Test
void acceptsStringObjectAndMap()
{
JSONObject doc = document(statement("Allow", List.of("read"), List.of("a")));
assertThat(PolicyParser.parse(doc.toString(), FORMAT)).containsExactly("a.read");
assertThat(PolicyParser.parse(doc, FORMAT)).containsExactly("a.read");
assertThat(PolicyParser.parse(doc.toMap(), FORMAT)).containsExactly("a.read");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void emptyOrMissingStatementGrantsNothingWithoutError() throws Exception
{
assertThat(PolicyParser.parseStrict(document(), FORMAT).permissions()).isEmpty();
assertThat(PolicyParser.parseStrict(new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0"), FORMAT).permissions()).isEmpty();
assertThat(PolicyParser.parseStrict(document(statement("Allow", List.of(), List.of("a"))), FORMAT).permissions()).isEmpty();
}
/*******************************************************************************
** Every malformed document grants nothing (fail closed), and parseStrict
** names the problem.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {
"",
" ",
"not json",
"[]",
"{\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}",
"{\"PolicySpec\":\"other-spec\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}",
"{\"PolicySpec\":\"qqq-iam\",\"Statement\":[]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"2.0.0\",\"Statement\":[]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":{}}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[\"x\"]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"allow\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"read\",\"Resource\":[\"a\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":\"a\"}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"write\"],\"Resource\":[\"a\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"Read\"],\"Resource\":[\"a\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[1],\"Resource\":[\"a\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"*\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"authentik.users\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"\"]}]}",
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_*\"]}]}",
})
void malformedDocumentsFailClosed(String text)
{
assertThat(PolicyParser.parse(text, FORMAT)).isEmpty();
assertThatThrownBy(() -> PolicyParser.parseStrict(text, FORMAT)).isInstanceOf(PolicyParseException.class);
}
/*******************************************************************************
** One bad statement voids the whole document (no partial credit).
*******************************************************************************/
@Test
void oneBadStatementVoidsAll()
{
JSONObject doc = document(
statement("Allow", List.of("read"), List.of("good")),
statement("Deny", List.of("read"), List.of("other")));
assertThat(PolicyParser.parse(doc, FORMAT)).isEmpty();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void nullAndOtherTypesGrantNothing()
{
assertThat(PolicyParser.parse(null, FORMAT)).isEmpty();
assertThat(PolicyParser.parse(JSONObject.NULL, FORMAT)).isEmpty();
assertThat(PolicyParser.parse(42, FORMAT)).isEmpty();
assertThat(PolicyParser.parse(new JSONArray(), FORMAT)).isEmpty();
}
/*******************************************************************************
** A host keeps its own spec name and version.
*******************************************************************************/
@Test
void customSpecName()
{
PolicyFormat custom = new PolicyFormat("acme-iam", "2.1.0", "policies", "policies");
JSONObject doc = new JSONObject().put("PolicySpec", "acme-iam").put("PolicyVersion", "2.1.0")
.put("Statement", new JSONArray().put(statement("Allow", List.of("read"), List.of("order"))));
assertThat(PolicyParser.parse(doc, custom)).containsExactly("order.read");
assertThat(PolicyParser.parse(doc, FORMAT)).as("the default spec rejects another spec").isEmpty();
assertThat(PolicyParser.parse(doc, null)).as("null format means the default").isEmpty();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void messagesNameTheProblem()
{
assertThatThrownBy(() -> PolicyParser.parseStrict(Map.of("PolicySpec", "x"), FORMAT)).hasMessageContaining("PolicySpec must be \"qqq-iam\"");
assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Allow", List.of("write"), List.of("a"))), FORMAT)).hasMessageContaining("unknown action \"write\"");
assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Deny", List.of("read"), List.of("a"))), FORMAT)).hasMessageContaining("Deny is not supported");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void formatProblems()
{
assertThat(PolicyFormat.DEFAULT.problem()).isNull();
assertThat(new PolicyFormat("bad spec", "1.0.0", "a", "c").problem()).contains("policySpec");
assertThat(new PolicyFormat("s", "1.0", "a", "c").problem()).contains("policyVersion");
assertThat(new PolicyFormat("s", "1.0.0", " ", "c").problem()).contains("policyAttribute");
assertThat(new PolicyFormat("s", "1.0.0", "a", "bad claim").problem()).contains("policyClaim");
}
}
@@ -0,0 +1,51 @@
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
#
# Example: put the merged group policies into the sign-in token (POLICY-FORMAT.md,
# "Getting policies into the token"). The contract test PolicyClaimMappingIT loads
# this file and runs the mapping through Authentik's property-mapping test
# endpoint, so the example in the docs is pinned to a real Authentik.
#
# Keep spec, version, attribute and the claim name equal to the qbit config
# (policySpec, policyVersion, policyAttribute, policyClaim). Add the mapping to
# your OAuth2 provider's property_mappings.
version: 1
metadata:
name: qqq-policy-claim
labels:
blueprints.goauthentik.io/description: "QQQ policy claim (qbit-authentik example)"
entries:
- model: authentik_providers_oauth2.scopemapping
id: qqq-policy-claim-mapping
state: present
identifiers:
name: qqq-policy-claim
attrs:
name: qqq-policy-claim
# "profile" is requested by every QQQ sign-in, so no client change is needed.
scope_name: profile
description: "QQQ policies of all the user's groups, including inherited parents"
expression: |
import json
spec = "qqq-iam"
version = "1.0.0"
attribute = "qqq.io/policy"
statements = []
for group in request.user.all_groups():
raw = (group.attributes or {}).get(attribute)
if raw is None or raw == "":
continue
try:
document = json.loads(raw) if isinstance(raw, str) else raw
except (ValueError, TypeError):
continue
if not isinstance(document, dict):
continue
if document.get("PolicySpec") != spec or document.get("PolicyVersion") != version:
continue
found = document.get("Statement")
if isinstance(found, list):
statements.extend(found)
return {"qqq_policy": json.dumps({"PolicySpec": spec, "PolicyVersion": version, "Statement": statements})}