Compare commits

..
Author SHA1 Message Date
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
27 changed files with 2279 additions and 14 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:
+5
View File
@@ -4,6 +4,11 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
### 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)
@@ -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;
@@ -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;
@@ -195,6 +201,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 +1117,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));
}
}
@@ -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()));
}
@@ -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);
@@ -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,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);
}
}
@@ -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();
}
/*******************************************************************************
**
*******************************************************************************/
@@ -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();
}
}
@@ -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,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})}