Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 2aaedacffd docs(spec): token lifetime cap on the brand, claim bound to profile, separate broker blueprint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:42 -05:00
james.maesandClaude Opus 5.5 d9431e7e6b docs(spec): access tokens as QQQ's personal access tokens (revision 6)
Owner decisions (2026-10-13) 23-30: tokens managed in the app and stored
in Authentik, shown once, self-service through Preferences, on by default
with an opt-out, guardrails (a)-(f), optional per-token scopes enforced
through core QCredentialScope (qqq#1153), reminders, last used, admin
views and bulk revoke.

Decision 30 records the owner-approved mechanism revision: Authentik
2026.8.3 always creates a token for the caller unless it is a superuser,
so people's tokens are created as that person through RFC 8693 token
exchange (token broker with a deny policy), service accounts through
service_account, set_key and PATCH. The qbit role gains change_token and
set_token_key and never holds add_token or view_token_key.

Adds docs/ACCESS-TOKENS.md ("Using access tokens").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:42:28 -05:00
james.maes 7fb96d7d2a Merge pull request #19 from QRun-IO/feature/policy-feature-permissions
feat(policy): grant table feature permissions (analyze, comment, ...)
2026-10-13 15:39:36 -05:00
james.maesandClaude Opus 5.5 d3b740785f feat(policy): grant table feature permissions (analyze, comment, ...)
The catalog lists each table's feature permissions, read generically from
PermissionsHelper.getAllAvailablePermissions (core
TableFeaturePermissionsMetaData), as a features array per table resource.
Policies grant them by name; the * wildcard still means the five
standard actions only, so existing policies grant nothing new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:30:15 -05:00
james.maes 0e981bc4f7 Merge pull request #18 from QRun-IO/feature/policy-cutover
feat(policy): safe cut-over to policies (status, compare, guard, generate)
2026-10-13 14:48:20 -05:00
james.maes 716a40df1b Merge pull request #17 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 14:42:55 -05:00
james.maesandClaude Opus 5.5 0a827955b8 ci: automate RC releases; hotfix/* pushes no longer publish
Removes publish_hotfix_release: a hotfix/* branch push published a GA
release to Maven Central with no tag or review (QRun-IO/qbit-cdn#12,
QRun-IO/qqq#1121). hotfix/* branches now run test_only; a hotfix
release publishes from a reviewed vX.Y.Z tag through publish_release.
Adds .github/workflows/release-rc.yml (reusable QRun-IO/.github
qbit-release-rc): a manual dispatch, or merging a develop PR labelled
release:rc, merges develop into the release line and the release/* push
runs the existing CircleCI RC publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:36:57 -05:00
22 changed files with 886 additions and 71 deletions

No files matched your search

+1 -12
View File
@@ -22,7 +22,7 @@ workflows:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
@@ -58,14 +58,3 @@ workflows:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only: [/hotfix\/.*/]
+25
View File
@@ -0,0 +1,25 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
type: boolean
default: false
pull_request:
types: [closed]
branches: [develop]
permissions:
contents: write
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+3
View File
@@ -4,6 +4,9 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
### Added (table feature permissions)
- Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData
### Added (policy cut-over, DESIGN.md section 12.8)
- `AuthentikHostPermissionSource` SPI (today's access) and config `mapperEnabled`, `cutoverGuardGroups`, `mapperOverride`, `staticGroupPermissions`.
- **Enforcement status**: banner widget `authentikPolicyEnforcement` (first on group and policy records and on the Edit Policy step) and a "Policies at sign-in" row in the home's Connection card: OFF, ACTIVE, OVERRIDDEN or BLOCKED.
+5 -1
View File
@@ -2,7 +2,7 @@
A QQQ QBit to manage [Authentik](https://goauthentik.io) identities from a QQQ app: **the full user lifecycle, and groups with the permissions they grant**. Authentik stays the source of truth. The qbit reads Authentik live and writes through the Authentik API, never its database. Changes made in the Authentik admin UI are equally valid, and the qbit never overwrites them blindly.
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065). Status: [`docs/STATUS.md`](docs/STATUS.md). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Table DDL: [`docs/schema.sql`](docs/schema.sql).
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065). Access tokens (PATs): [`docs/ACCESS-TOKENS.md`](docs/ACCESS-TOKENS.md). Status: [`docs/STATUS.md`](docs/STATUS.md). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Table DDL: [`docs/schema.sql`](docs/schema.sql).
## What you get
@@ -65,6 +65,10 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
`authentik.*` grants all of them.
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
The editor discovers features and labels through `PermissionsHelper.getAllAvailablePermissions`; they appear when the host's core version exposes them. This qbit does not compile against the unreleased metadata hook. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
### Authentik side
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, token-key or impersonate permissions, and holds delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
+65
View File
@@ -0,0 +1,65 @@
# Using access tokens
An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6).
## Create a token
1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**.
2. Choose **Create token** and fill in:
- a description, for example "nightly export script";
- an expiry. It's required: at most 90 days for people, and 365 for service accounts;
- optionally, a **scope**: read only, and/or only some apps and tables. A scoped token can never do more than you can, and loses access as soon as you do.
3. Copy the key from the dialog. **It is shown once.** The app does not keep it, and nobody can show it again. If you lose it, rotate the token.
The dialog also shows ready-to-run examples for this app, with its real URLs.
## Use a token
Tokens are exchanged for a short-lived access token (a JWT) at the Authentik token endpoint. You then call the app with that JWT.
```sh
# 1. Exchange the token for a JWT (valid for the app's access-token lifetime)
JWT=$(curl -s -X POST "https://auth.example.com/application/o/token/" \
-d grant_type=client_credentials \
-d client_id="$APP_CLIENT_ID" \
-d username="$YOUR_USERNAME" \
-d password="$QQQ_TOKEN" \
-d scope="openid profile email" | jq -r .access_token)
# 2. Call the app
curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData"
```
- Keep the token in a secret store or an environment variable. Never commit it.
- When the JWT expires (the API answers 401), repeat step 1.
- Every exchange is recorded as a sign-in in Authentik. The app shows the token's **last used** time from those records.
## Manage your tokens
From **Preferences → Access tokens**:
| Action | What happens |
|---|---|
| **Rotate** | A new key replaces the old one. The description, expiry and scope stay the same. The old key stops working at once. |
| **Extend** | Moves the expiry. The limit counts from today. |
| **Revoke** | Deletes the token in Authentik. Anything using it stops working at once. |
You get a reminder in the notification bell, and by email, 7 days and 1 day before a token expires.
## For administrators
People with `authentik.tokens.manage` see **all** tokens in the Identity app (**Tokens**). From there they can:
- filter by person, intent and expiry, and sort by age and last use;
- revoke tokens one at a time or in bulk, for example the tokens the home page lists as unused for 60 days, or as belonging to deactivated people;
- create a **service account** for an integration (**Users → Create Service Account**). The account gets one app password, shown once, and the groups you choose. Rotate and extend it from its token record.
Administrators can never create, rotate or extend a token for **another person**. Each person manages their own.
Accounts that can administer Authentik, the break-glass account and the app's own service account can't get tokens from the app at all. Use the Authentik admin UI for them if you really need one.
## Security notes
- A token can do what its owner can do, or less if it's scoped. Prefer a scoped token and a short expiry.
- Tokens for people are created as that person, through Authentik. The app's own service account cannot create a token for a person.
- Every create, rotate, extend and revoke is audited in the app (`authentik_admin_action`, with the channel it came from) and in Authentik's event log.
- The key exists only in Authentik and in the one response that showed it to you.
+260 -9
View File
@@ -1,6 +1,6 @@
# Authentik qbit: Design Spec
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)
Status: Approved, revision 6 (adds §13 access tokens; revision 5 added §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
@@ -33,7 +33,8 @@ Seven design rules shape everything below:
- Authentik also enforces a hard floor: the service account cannot touch superuser groups, token keys or impersonation.
6. **Credentials never pass through QQQ.**
- New users and password resets go through Authentik's own emails (enrollment or recovery).
- QQQ never sets passwords, never shows recovery links and never displays token keys.
- QQQ never sets passwords and never shows recovery links.
- Token keys are the one exception (§13): a new or rotated access-token key is shown **once**, in the response that created it, and is never stored, logged or audited in QQQ.
7. **Every change is audited on both sides.** QQQ writes a local append-only `authentik_admin_action` row. Authentik receives a `custom_qqq_admin_action` event naming the human operator.
### 0.1 Owner decisions (2026-10-11)
@@ -60,6 +61,19 @@ Seven design rules shape everything below:
| 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). |
**Owner decisions (2026-10-13): access tokens (revision 6).** The owner asked: "I want to manage tokens in app, like we want to do with users and groups and permissions - all through authentik still, but exposed in app to make it easier", then: "it should be on by default. if this is gonna be our PAT solution, it needs to be first class." Authentik tokens, through this qbit, are **the** personal access token (PAT) solution for QQQ apps that sign in with Authentik. Full design in §13.
| # | Decision |
|---|---|
| 23 | **Tokens are managed in QQQ, stored in Authentik.** The `authentik_token` table lists them (owner, identifier, intent, expiry, last used, description, scope). Record actions: *Create Token*, *Rotate Token*, *Revoke Token*, *Extend Expiry*, and *Create Service Account*. |
| 24 | **The key is shown exactly once**, in the response of the create or rotate, with copy and usage examples, and is never stored in QQQ: not in a table, the audit, logs, process state, ESB, notifications or the assistant (core `OneTimeSecret`, §13.6). |
| 25 | **Self-service.** Every signed-in user manages their **own** tokens in the Preferences section "Access tokens" (core Preferences contract, QRun-IO/qqq#1162), also reachable from the user menu. |
| 26 | **On by default** whenever the qbit runs with writes enabled (feature `TOKENS`). A host can opt out (`tokensEnabled=false`, kof22 env `AUTHENTIK_QBIT_TOKENS=false`). No opt-in flag. |
| 27 | **Guardrails** (§13.4): (a) no minting or rotating for privileged accounts, in the qbit and again in Authentik; (b) self-service is self-only, admins may also target service accounts, never another human; (c) expiry is required, at most 90 days for people and 365 for service accounts (configurable); (d) optional two-person approval for service-account tokens (off by default); (e) an Authentik notification rule fires if the qbit's service account ever touches a protected user's token; (f) everything audited with the client channel. |
| 28 | **Optional per-token scopes** (read only; specific apps and tables), narrower than the user, stored in `authentik_token_scope` and enforced by core scoped credentials (QRun-IO/qqq#1153, `QCredentialScope`) through a signed `qqq_token` claim (§13.5). |
| 29 | **First class:** usage instructions generated from the instance's real URLs; expiry reminders 7 days and 1 day before (bell and email); last used; an unused-token cleanup suggestion; admin views by user and by age with bulk revoke; the doc page [`ACCESS-TOKENS.md`](ACCESS-TOKENS.md). |
| 30 | **Owner-approved revision of the mechanism (2026-10-13, coordinator-approved under the owner's direction).** Authentik 2026.8.3 always creates a token for the *caller* unless the caller is a superuser (`TokenViewSet.perform_create`), so `add_token` cannot mint for anyone else (§13.2). The qbit therefore creates a person's token **as that person**, through RFC 8693 token exchange with a 2-minute API token from the `qqq-<appKey>-token-broker` provider (with a deny policy for protected users). Service accounts use `POST /core/users/service_account/` (create), `set_key` (rotate) and `PATCH` (extend). The qbit role gains `change_token` and `set_token_key`, keeps `delete_token` and `add_user`, and **never** holds `add_token` or `view_token_key`. Limitation: one app password per service account. |
### 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.
@@ -602,13 +616,15 @@ The role `qqq-<appKey>-idadmin` is assigned directly to the service account (boo
| `authentik_core.add_user_to_group`, `remove_user_from_group` | Membership | 2 |
| `authentik_core.delete_authenticatedsession` | Revoke sessions | 3 |
| `authentik_core.delete_token` | Revoke app passwords | 3 |
| `authentik_core.change_token` | Extend a token's expiry (§13); `PATCH` always sends `intent`, `user` and `expires` | 6 |
| `authentik_core.set_token_key` | Rotate a **service account's** app password with a key the qbit generates (§13). Guarded by §13.4 (a) and the detective rule (e) | 6 |
| Device `delete_*` for TOTP, WebAuthn, static, email, SMS | MFA reset | 3 |
| `authentik_stages_invitation.add_invitation`, `change_invitation`, `delete_invitation` | Invites | 3 |
| `authentik_rbac.change_role`, `assign` (object, on non-qbit roles) | Role management | 4 |
**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)
- `view_token_key` and `add_token` (MHP APPS-1462). `set_token_key` is granted since revision 6 for service-account rotation only, behind §13.4 (§13.2 explains why `add_token` would not help anyway)
- `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.
@@ -685,7 +701,8 @@ This is weaker than revision 1's per-group object permissions. That is the cost
| Invite user | Yes (`USERS`) | Invitation (`fixed_data` email and name, `single_use`, expires in 7 days) + `send_email`, through the `kof22-enrollment` flow. Groups are applied by QQQ when the `invitation_used` webhook arrives. |
| Send password-reset email | Yes (`CREDENTIALS`) | `recovery_email` with `recoveryEmailStage`, `token_duration` 30 minutes |
| Show or copy recovery link, set password, set hash | **No** | Never called. A link given to the operator amounts to account takeover. |
| View or set token keys; create tokens | **No** | Not permitted for the service account; not exposed |
| Create, rotate, extend, revoke access tokens | Yes (`TOKENS`, §13) | A person's own tokens: as that person, through the token broker. Service accounts: `service_account`, `set_key`, `PATCH`. The key is shown once and never stored |
| Read token keys (`view_key`) as the service account | **No** | Never granted; a key is read only by its owner, in the owner's own exchange |
| Revoke sessions | Yes (`SESSIONS`) | `bulk_delete?user_pks=` / per-session `DELETE` |
| Reset MFA | Yes (`CREDENTIALS`) | Device list, then per-type `DELETE`, with typed confirmation and a reason |
| Revoke app passwords | Yes (`CREDENTIALS`) | `DELETE /core/tokens/{identifier}/`, intent `app_password` only |
@@ -722,7 +739,7 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace
| `authentik_policy_binding` | `/policies/bindings/?target=` | uuid | Query | Read only. Shows the policy, group or user, plus negate, order and expiry. |
| `authentik_session` | `/core/authenticated_sessions/` | uuid | Query, delete (revoke) | Sensitive |
| `authentik_mfa_device` | `/authenticators/admin/all/?user=` | `type:id` | Query, delete (reset) | Metadata only. Query requires a `user` criterion. |
| `authentik_token` | `/core/tokens/?user__username=` | identifier | Query, delete (revoke) | **No key field.** Only `intent`, `expires`, `description`. |
| `authentik_token` | `/core/tokens/?user__username=` | identifier | Query; changed only by the token actions (§13) | **No key field.** Owner, intent, expires, description, managed, plus computed `lastUsed`, `scopeSummary`, `ownerType`, `isProtectedOwner`. |
| `authentik_event` | `/events/events/` | uuid | Query, get | Sensitive. Ordered `-created`. `context` is rendered as JSON. |
| `authentik_invitation` | `/stages/invitation/invitations/` | uuid | Query; insert via *Invite*; delete (cancel) | |
| `authentik_blueprint` | `/managed/blueprints/` | uuid | Query | Read only. Used by the drift report. |
@@ -927,7 +944,8 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh
| Threat | Control |
|---|---|
| A compromised QQQ backend escalates in Authentik | The service account can't create superusers, touch superuser groups, read or mint tokens, impersonate, delete users, or change flows and policies (§5.1) |
| A compromised QQQ backend escalates in Authentik | The service account can't create superusers, touch superuser groups, read token keys, create tokens for others, impersonate, delete users, or change flows and policies (§5.1). Since revision 6 it can set a token's key (`set_token_key`, for service-account rotation): guard §13.4 (a) plus the detective rule (e) |
| A person's token is minted by someone else | Tokens for people are created only as that person, through token exchange (§13.3.1); Authentik enforces it |
| A QQQ operator escalates themselves or a friend | Subset rule, no self-edits, two-person approval for `authentik.*` grants, protected accounts |
| Lockout (last admin removed or deactivated) | Last-admin guard; break-glass superuser outside QQQ; `bootstrapOperatorSubjects` |
| A blueprint re-apply reverts QQQ edits | No `present` identity entries, no member lists (CI lint); bootstrap as `created` (verified, §4.2); reversion detector |
@@ -1057,7 +1075,9 @@ A **policy** is a JSON document on an Authentik group that grants QQQ permission
}
```
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.
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` = only the five standard actions: `hasAccess`, `read`, `insert`, `edit`, `delete`). 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.
Table feature actions are generic strings registered in the table's permission catalog: for example `analyze` (qbit-data-analysis), or `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration). `{"Effect":"Allow","Action":["analyze"],"Resource":["person"]}` grants `person.analyze`, checked with `session.hasPermission("person.analyze")`. A feature must exist for every resource in its statement. Unknown features reject the document. `"*"` never includes features, so existing policies do not acquire new grants when a feature is registered.
**Fail closed.** Any of these grants **zero** permissions from that document and logs a warning:
- an unknown `PolicySpec` or an unsupported `PolicyVersion`
@@ -1113,11 +1133,14 @@ The feature `POLICIES` turns on the editor, the `authentik_policy` table and the
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
- Table features from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, matched by the table's effective permission base name plus `.`. Their labels come from `AvailablePermission.permissionType`; no dependency on the unreleased `TableFeaturePermissionsMetaData` hook is required. The five standard suffixes are excluded.
The widget JSON keeps `actions` unchanged and adds a `features` array to each table resource, for example `"actions":["read","insert","edit","delete"],"features":[{"name":"analyze","label":"Analyze"},{"name":"comment","label":"Comment"}]`. Tables without features have `"features":[]`. Features also appear for `HAS_ACCESS` and `READ_WRITE` tables. Shared permission base names merge their features. Feature checkboxes must emit explicit action names.
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.
- Tables that use `READ_WRITE` permissions are shown with the standard action `read` and any registered features, and a note about their single write permission. The generic matching rule also exposes their available `write` permission as an explicit named action; `*` does not grant it.
- 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.
@@ -1145,7 +1168,7 @@ Refusals name the reason.
- 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`
- one statement per action, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`, followed by feature names sorted alphabetically
- resources sorted
- a resource with every action is still written as five statements, not `*`, so diffs stay readable
@@ -1247,6 +1270,234 @@ Review shows the generated document, the diff and the subset rule before anythin
---
## 13. Access tokens: QQQ's personal access tokens (revision 6)
Decisions 23–30 (§0.1). Authentik stays the source of truth: every token is an Authentik `Token` row. QQQ keeps only non-secret metadata about the tokens it created: the scope, who created it and when.
### 13.1 What a QQQ access token is
A QQQ access token is an Authentik **app password** (`intent: app_password`) owned by a person or a service account. A client uses it in two steps:
1. **Exchange it for a short-lived JWT** at the app's Authentik token endpoint: `POST {authentikPublicUrl}/application/o/token/` with:
- `grant_type=client_credentials`
- `client_id={app client id}`
- `username={owner username}`
- `password={token}`
- `scope=openid profile email`
Authentik checks that the owner is active, that the token exists with intent `app_password`, and that the app's policies let the owner in. It records a `login` event with `method: token` and the token's identifier.
2. **Call the app** (`/qqq/v1/...` and the published APIs) with `Authorization: Bearer {jwt}`. When the JWT expires (the provider's access-token validity), exchange the token again.
The qbit generates these instructions from the instance's real URLs (§13.7).
API-intent tokens (`intent: api`) call Authentik's own API, not the app. QQQ lists them and can revoke them, but does not create them, because an API token acts with all of its owner's Authentik permissions.
### 13.2 Why the qbit can't just call `POST /core/tokens/` (verified in the 2026.8.3 source)
- **Create belongs to the caller.** `TokenViewSet.perform_create` saves every new token with `user = request.user` unless the caller is a superuser, and `add_token` changes nothing. The qbit's service account is not a superuser and never will be (§5.1), so it **can't** create a token for anyone else.
- **PATCH has dangerous defaults.** `TokenSerializer.validate` runs `attrs.setdefault("intent", "api")` and `attrs.setdefault("user", request.user)`, and forces `expires` to the default for API tokens.
- A `PATCH` without `intent` turns an app password into an API token with the default expiry.
- A `PATCH` without `user` moves the token to the caller.
- So **every `PATCH` the qbit sends carries `intent`, `user` (the current owner's pk) and `expires`**, and the qbit re-reads the token afterwards (the race guard in §13.3).
- **Expiry is capped.** An app password's expiry is capped by `goauthentik.io/user/token-maximum-lifetime` (for example `days=90`) from `User.group_attributes`, which merges the **brand's** attributes, then the user's groups, then the user. Without it the cap is the tenant's default token duration, which is short. The blueprint sets the attribute on the brands (§13.9); the qbit sets `days=365` on each service account it creates.
- **Expired tokens linger.** They stay in the database until Authentik's cleanup task removes them, and the token-endpoint lookup (`Token.objects.filter(key=...)`) doesn't check `expires`. So the qbit never relies on an expired token being unusable. Revoke deletes the token.
### 13.3 How each action works
| Action | Where | Who | Authentik calls |
|---|---|---|---|
| *Create Token* (own) | Preferences "Access tokens"; `authentik_token` table action | Any signed-in user, for themselves | As the user, through token exchange (§13.3.1): `POST /core/tokens/` (`intent: app_password`, identifier `qqq-pat-{username}-{yyyymmdd}-{6 random}`, `description`, `expires`, `expiring: true`), then `GET /core/tokens/{id}/view_key/` (the owner may read their own new key) |
| *Rotate Token* (own) | same | The owner | As the user: create a new token with the same description, expiry and scope, read its key, then `DELETE` the old one. The new identifier replaces the old one in `authentik_token_scope`. If the delete fails, the result says so and the old token stays listed. |
| *Revoke Token* | Preferences (own); table record and bulk action (`authentik.tokens.manage`) | The owner, or an admin for any owner who isn't protected | `DELETE /core/tokens/{identifier}/`. Own tokens: as the user. Admin: as the service account, with `delete_token`. |
| *Extend Expiry* | Preferences (own); table record action | The owner; an admin for service accounts | Own tokens: as the user. Service accounts: as the qbit's service account, with `change_token`. Always `PATCH {intent, user, expires}`, then a re-read. |
| *Create Service Account* | `authentik_user` table action | `authentik.tokens.manage` + `authentik.users.manage` | `POST /core/users/service_account/` (`name`, `create_group: false`, `expiring: true`, `expires`). The response's `token` is the one-time key. Then set `goauthentik.io/user/token-maximum-lifetime: days=365` on the new account (its expiry cap, §13.9) and add it to the groups chosen in the policy editor, subject to the subset rule (§5.4). |
| *Rotate Token* (service account) | `authentik_token` record action | `authentik.tokens.manage` | The qbit generates a key (48 random bytes, URL-safe base64) and calls `POST /core/tokens/{identifier}/set_key/`. The identifier, expiry and scope don't change. |
**Limitation (decision 30).** A service account has exactly one app password: the one `service_account` creates. Only a superuser could add a second. To run two credentials side by side, create a second service account.
**Race guard.** Before an extend or a rotate, the qbit reads the token (owner pk and intent), and it reads the token again after the `PATCH` or `set_key`. If the owner or the intent changed:
- the run fails;
- the audit row is `FAILED` with "token moved";
- an alert is raised (§13.8).
Authentik's `validate_user` refuses a different user, and sending `user` explicitly closes the `setdefault` path.
#### 13.3.1 Acting as the user: token exchange
**The broker.** The blueprint adds a confidential OAuth2 provider and application, `qqq-{appKey}-token-broker`:
- access-token validity `minutes=2`;
- property mappings: only the default `goauthentik.io/api` scope;
- `jwt_federation_providers`: the app's own sign-in provider;
- a **deny policy** bound to the application for superusers, members of `authentik Admins` and `identity-admins`, users with `qqq.io/protected`, and the qbit's own service account (§13.4 a).
**The exchange.** The qbit takes the signed-in user's current access JWT for the app. In kof22 this is the token the OAuth2 module stores in `qqq_user_session`; a host can supply another source with `withSubjectTokenSource(...)`. The qbit posts:
- `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`
- `client_id` and `client_secret` of the broker
- `subject_token={jwt}`
- `subject_token_type=urn:ietf:params:oauth:token-type:jwt`
- `scope=goauthentik.io/api`
**What Authentik does.** It verifies the JWT against the federated provider and applies the broker app's policies, so the deny policy runs inside Authentik. It then returns a 2-minute access token **for that user**. The qbit uses it for the token calls of that one action, then drops it; it is never stored or logged.
**Why this is safe.** The calls are made as the user, so Authentik itself enforces "own tokens only": a user can create, and read the key of, only their own tokens.
**Config.**
- `tokenBrokerClientId`: `${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}`
- `tokenBrokerClientSecret`: `${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}`
- `tokenEndpointUrl`: default `{baseUrl}/application/o/token/`
Without a broker, the self-service actions are hidden, and Review says why.
### 13.4 Guardrails (checked at Review and again at execute)
| | Rule | Where enforced |
|---|---|---|
| (a) | **No minting or rotating for privileged accounts.** Refused for: superusers, including by inheritance; members of `authentik Admins`, `identity-admins` and the configurable `tokenProtectedGroups`; the break-glass account and anything with `qqq.io/protected`; `protectedUsernames`; and the qbit's own service account. Hard-coded for the built-in classes, plus the configurable list. | The `AuthentikTokens` guard; the broker app's deny policy (self-service); for protected owners, `set_key` is refused before the call |
| (b) | **Self-service is self-only.** A person without `authentik.tokens.manage` may target only themselves. An admin may also target service accounts, but **never another human**; this covers create, rotate and extend. Revoke is the exception: for incident response, an admin may revoke any token whose owner isn't protected. | The qbit guard; Authentik itself for the as-user calls |
| (c) | **Expiry is required.** There's no non-expiring option. The maximum is `tokenMaxDaysUser` (default 90) for people and `tokenMaxDaysServiceAccount` (default 365) for service accounts. Extend counts from now. Authentik's group attribute is a second cap. | The qbit (Review refuses); Authentik (`token-maximum-lifetime`) |
| (d) | **Two-person approval** for service-account tokens (create service account, rotate): `tokenTwoPersonApproval`, default **off** while the team is one person. When on, the change is queued in `authentik_approval_request`, and the key is shown to the approver who applies it. | The qbit |
| (e) | **Detective control.** An Authentik notification rule and an expression policy fire whenever the qbit's service account creates or updates a token whose owner is protected, reads its key, or sets its key (`model_created`, `model_updated`, `secret_view` or `secret_rotate` on `authentik_core.token`). They notify `identity-admins` through Authentik notifications. The rule should never fire; if it does, guard (a) was bypassed. | The Authentik blueprint (§13.9). The qbit also raises an `authentik_token_guard_bypass` alert through the Alerts producer when its own re-read finds a protected owner. |
| (f) | **Everything is audited.** Before each call, every action writes an `authentik_admin_action` row with the client channel (`audit.clientChannel`, for example `web`, `api` or `token:{identifier}`), plus an Authentik `custom_qqq_admin_action` event. The assistant can't reach these tables or actions, because the assistant wall denies `authentik_*`. | The qbit; core audit |
### 13.5 Scopes
**What a scope is.** A token may be narrower than its owner: **read only**, and/or limited to a list of **apps** and **tables**. Advanced scopes can also list processes, reports, widgets and operationIds. No scope means the token can do everything its owner can.
**Where scopes are stored.** In the local table `authentik_token_scope`, one row per token the qbit created:
- `id`
- `tokenIdentifier` (unique)
- `ownerUsername`, `ownerPk`
- `readOnly`
- `apps`, `tables`, `processes`, `reports`, `widgets`, `operationIds` (TEXT, one per line)
- `createdBy`, `createdAt`, `rotatedAt`
- `lastReminder7d`, `lastReminder1d`
There is no key, and no hash of a key. Generic insert, edit and delete are off; only the token actions change the table. Record-level audit is on.
**Binding the JWT to its token.** A scope mapping on the app's provider (scope name `profile`) adds `qqq_token: {identifier}` to JWTs minted by `client_credentials` with an app password. It finds the app password the request presented, owned by the same user. Authentik signs the claim. It is bound to `profile` because the app needs the `groups` claim (also `profile`) to accept a token at all, so a client can't drop the claim and keep access.
**Enforcement.** The mapper reads `qqq_token`, loads the scope row (cached 60 s) and sets core's `QCredentialScope` (QRun-IO/qqq#1153) on the session. Core then:
- intersects the scope with the owner's live permissions in `PermissionsHelper`;
- checks `operationIds` in the UI API;
- records the channel `token:{identifier}`.
STAND-IN(core=QRun-IO/qqq#1153, removeBy=the RC that ships #1153, RC.7 at the earliest). Until then the mapper narrows the session's permission set itself:
- read only keeps only `.read` and `.hasAccess`;
- apps and tables keep only permissions whose base name belongs to a listed app or table.
**Fail closed.**
- Scoped tokens are offered only when the host declares the claim mapping installed (`tokenScopesEnabled`; kof22 sets it to `true` once the blueprint is applied).
- A JWT whose `qqq_token` names a token with no scope row is unscoped (the token was created outside the qbit).
- A `qqq_token` naming a token that no longer exists gets **no** permissions.
### 13.6 The key is shown once
**The type.** Core's `com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret` (`label`, `value`, `masked`, `usageText`) from QRun-IO/qqq#1162. Its `toString()` never includes the value.
**Preferences.**
- The provider's `runAction` returns the secret in `UserPreferenceActionResult.secrets`.
- The endpoint responds with `Cache-Control: no-store`.
- Next shows it in a show-once dialog with *Copy* and the usage text.
**Record actions.** These use core's one-time process output values: `RunBackendStepOutput.addOneTimeSecret(...)`, in the QRun-IO/qqq PR `feature/process-one-time-values`.
- The value travels only in the step's HTTP response.
- `ProcessState`, traces and lifecycle listeners (ESB) see only the masked form: `••••` plus the last 4 characters.
- If the run finishes asynchronously, the key is lost by design, and the result says "Rotate the token to get a new key".
**Where the key never goes.** The qbit never passes it to the audit row, the Authentik event context, `AdminResult.message`, notifications, logs or exceptions. `AuthentikRedactor` also masks any `key` or `token` JSON value in error text.
**The test.** A test runs every action, then searches for the key in every QQQ table, the captured log output, the stored process state and the published events (§13.11).
### 13.7 How to use it (generated)
The result screen, the Preferences dialog and `docs/ACCESS-TOKENS.md` show:
- the token endpoint (`{authentikPublicUrl}/application/o/token/`), the app's client id and the owner's username;
- a `curl` that gets a JWT;
- a `curl` to `{appBaseUrl}/qqq/v1/metaData`, and one to a published API (`{appBaseUrl}/api/{version}/...`) when the instance publishes one;
- the token's expiry and scope.
The URLs come from config (`appPublicUrl`, `authentikPublicUrl`, `appClientId`), defaulting to the OAuth2 module's metadata. The key appears in the usage text only inside the one-time response.
### 13.8 Reminders, last used and cleanup
**Last used.** Read from Authentik `login` events with `method: token` (`context.method_args.identifier`), through `/events/events/?action=login`, over a 90-day window, cached for 5 minutes. API tokens show "not tracked".
**Expiry reminders.** A daily scheduled job finds tokens expiring within 7 days and within 1 day, and notifies each owner (bell and email):
- through the Alerts producer SPI when qbit-alerts is installed;
- otherwise through core notifications, if present;
- otherwise not at all.
Reminders are deduplicated through `lastReminder7d` and `lastReminder1d`. Tokens created outside the qbit have no scope row, so they are reminded by owner and identifier.
**Cleanup suggestion.** The app home lists tokens not used for 60 days (`tokenUnusedDays`, configurable) and tokens of deactivated users. It links to the filtered list and the bulk *Revoke Token* action.
### 13.9 Authentik changes (cluster-gitops, kof22)
**The qbit role** `qqq-kof22-admin-idadmin`:
- add `authentik_core.change_token` and `authentik_core.set_token_key`;
- it already has `delete_token`, `view_token` and `add_user`;
- **not** `add_token` or `view_token_key`.
**New Authentik objects:**
- the broker provider and application, with the deny policy (§13.3.1);
- the `qqq_token` scope mapping on the kof22 admin provider (§13.5);
- `goauthentik.io/user/token-maximum-lifetime: days=90` on the brands (people). Groups are `state: created` and can't take new attributes from a blueprint; the qbit sets `days=365` on service accounts it creates;
- the broker in its own blueprint file (`kof22-qqq-tokens.yaml`), so a problem there can't affect sign-in;
- the notification rule and policy (§13.4 e).
**Rollout (kof22).** Seed the Infisical key `/kof22-website-authentik-api/token-broker-client-secret` first, then merge the role-permissions gitops PR, then deploy the backend. `TOKENS` is on by default, so there is no flag PR. The ESO secrets are `AUTHENTIK_TOKEN_BROKER_CLIENT_ID` and `AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET` (Infisical `/kof22-website-authentik-api/`).
### 13.10 Permissions, tables and config
| QQQ permission | Grants |
|---|---|
| (none beyond signing in) | The "Access tokens" Preferences section, for one's own tokens |
| `authentik.tokens.manage` | The `authentik_token` table (all tokens); revoking any token whose owner isn't protected (single and bulk); service-account create, rotate and extend; the admin widgets |
| `authentik.audit.read` | Still reads `authentik_token` (unchanged) |
| Setting | Default | Meaning |
|---|---|---|
| `tokensEnabled` | `true` when any write feature is on | Opt-out (`AUTHENTIK_QBIT_TOKENS=false`) |
| `tokenMaxDaysUser` / `tokenMaxDaysServiceAccount` | 90 / 365 | Guardrail (c) |
| `tokenProtectedGroups` | `authentik Admins`, `identity-admins` | Added to the hard-coded classes of guardrail (a) |
| `tokenTwoPersonApproval` | `false` | Guardrail (d) |
| `tokenScopesEnabled` | `false` | Offer scopes once the claim mapping is installed |
| `tokenBrokerClientId` / `tokenBrokerClientSecret` / `tokenEndpointUrl` | env / env / `{baseUrl}/application/o/token/` | §13.3.1 |
| `appPublicUrl`, `authentikPublicUrl`, `appClientId` | from the OAuth2 module | §13.7 |
| `serviceAccountTokenLifetime` | `days=365` | Set on service accounts the qbit creates |
| `tokenUnusedDays` | 60 | The cleanup suggestion |
### 13.11 Tests
**In-memory Authentik.** `FakeAuthentik` is extended to model the 2026.8.3 behaviour of §13.2: caller-owned create, `setdefault` on `PATCH`, the lifetime cap, `service_account`, `set_key` and token exchange. Tests cover:
- create, rotate, revoke and extend, both for self and for service accounts;
- every guardrail refusal: the protected classes, another human, a missing or too-long expiry, no broker;
- the race guard;
- approval when (d) is on;
- scope rows, reminders and last used.
**The key is never persisted or logged.** After each action, the key is absent from every local table, the audit rows, the captured log, the stored `ProcessState`, the published ESB events and the Authentik event body the qbit posted.
**Contract tests on Authentik 2026.8.3** (Testcontainers):
- the §13.2 facts: a non-superuser create belongs to the caller, a `PATCH` without `intent` changes the token, and the lifetime cap applies;
- token exchange as a user through the broker;
- the deny policy refuses a protected user;
- `service_account`, `set_key` and `PATCH` for a service account;
- the `qqq_token` claim on a `client_credentials` JWT;
- the detective notification rule fires for a protected owner.
### 13.12 Delivery
| Phase | Scope |
|---|---|
| 6a | This spec and `docs/ACCESS-TOKENS.md` |
| 6b | The `TOKENS` feature, the `authentik_token` columns, `authentik_token_scope`, the token actions with guardrails, service accounts, the one-time key (core PR), tests and contract tests |
| 6c | Scopes and the `qqq_token` claim, last used, reminders, admin widgets and bulk revoke |
| 6d | The "Access tokens" Preferences provider (core #1162) and the user-menu entry (Next, by the Preferences agent) |
| 6e | The cluster-gitops role permissions and blueprint objects; kof22 adoption (draft) |
---
## Appendix A: Example API mapping (`authentik_user`)
| QQQ | Authentik |
+11 -5
View File
@@ -31,12 +31,12 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is
| `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[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`, or a table feature name registered for every listed resource |
| `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.
Each statement grants `{resource}.{action}` for every resource and action it lists. `*` stands for the five standard actions only; it never includes table features. The result is the union over all statements, and over all of the user's groups.
| Action | QQQ permission | Applies to |
|---|---|---|
@@ -45,10 +45,14 @@ Each statement grants `{resource}.{action}` for every resource and action it lis
| `insert` | `{name}.insert` | tables |
| `edit` | `{name}.edit` | tables |
| `delete` | `{name}.delete` | tables |
| registered feature (e.g. `analyze`, `comment`) | `{name}.{feature}` | tables that register that feature, including `HAS_ACCESS` and `READ_WRITE` 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`.
- `{"Action": ["analyze", "comment"], "Resource": ["person"]}` grants `person.analyze` and `person.comment` when both features are registered for `person`.
Features are generic strings, discovered by matching `PermissionsHelper.getAllAvailablePermissions(qInstance)` entries to the table's effective permission base name. Examples include `analyze` from qbit-data-analysis and `comment`, `attach`, `tag`, `share`, `task`, `assign` from qbit-collaboration. The feature label comes from `AvailablePermission.permissionType`. Every feature grant is explicit, so registering a new feature never broadens an existing `"*"` policy. This matching rule also exposes the available `write` permission of `READ_WRITE` tables as an explicit named action; it is not part of `*`.
A grant that QQQ never checks (for example `hasAccess` on a table with insert/edit/delete permissions) is harmless.
@@ -63,7 +67,7 @@ A document grants **nothing**, and the parser logs a warning, when any of these
- `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
- an action is neither a standard action, `*`, nor a feature registered for every listed resource
- 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.
@@ -71,7 +75,7 @@ There is no partial credit: one bad statement voids the whole document. When the
## Canonical form
The qbit always writes the canonical form:
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`, then feature names sorted alphabetically (existing documents retain their byte-for-byte canonical form)
- each statement lists its resources sorted
- no `*`
- stored on the group as a compact JSON **string**
@@ -99,7 +103,9 @@ Authentik puts the merged document into the token through a scope mapping. Steps
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())`.
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog continue to accept only standard actions. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the existing available-permission API without compiling against the unreleased `TableFeaturePermissionsMetaData` hook; features become available when the host's core supplies those entries.
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.
@@ -1092,8 +1092,8 @@ public class AuthentikAdmin
try
{
String submitted = request.str(POLICY_DOCUMENT);
newPermissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(newPermissions, format);
newPermissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(newPermissions, format, runtime.getPolicyCatalog());
}
catch(PolicyParseException e)
{
@@ -1107,7 +1107,7 @@ public class AuthentikAdmin
{
try
{
oldPermissions.addAll(PolicyParser.parseStrict(stored, format).permissions());
oldPermissions.addAll(PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).permissions());
}
catch(PolicyParseException e)
{
@@ -1213,7 +1213,7 @@ public class AuthentikAdmin
PolicyFormat format = config.getPolicyFormat();
Set<String> policy = new TreeSet<>();
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
policy.addAll(PolicyParser.parse(stored, format));
policy.addAll(PolicyParser.parse(stored, format, runtime.getPolicyCatalog()));
Set<String> expressible = runtime.getPolicyCatalog().permissionNames();
Set<String> extras = new TreeSet<>();
@@ -1235,7 +1235,7 @@ public class AuthentikAdmin
}
try
{
request.with(POLICY_DOCUMENT, PolicyGenerator.toCompact(PolicyGenerator.generate(policy, format)));
request.with(POLICY_DOCUMENT, PolicyGenerator.toCompact(PolicyGenerator.generate(policy, format, runtime.getPolicyCatalog())));
}
catch(PolicyParseException e)
{
@@ -2011,8 +2011,8 @@ public class AuthentikAdmin
try
{
String submitted = request.str(POLICY_DOCUMENT);
permissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(permissions, format);
permissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(permissions, format, runtime.getPolicyCatalog());
}
catch(PolicyParseException e)
{
@@ -2047,7 +2047,7 @@ public class AuthentikAdmin
try
{
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissions.size(), guard.operatorEmail(),
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format));
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format, runtime.getPolicyCatalog()));
}
catch(Exception e)
{
@@ -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, runtime.getConfig().getPolicyFormat());
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
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, runtime.getConfig().getPolicyFormat());
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
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, config.getPolicyFormat());
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat(), client.getRuntime().getPolicyCatalog());
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
@@ -27,6 +27,7 @@ import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.security.BootstrapOperators;
@@ -116,7 +117,8 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
//////////////////////////////////////////////////////////////////////////
AuthentikRuntime runtime = runtime();
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format));
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format,
runtime == null ? (qInstance == null ? null : PolicyCatalog.of(qInstance)) : runtime.getPolicyCatalog()));
if(runtime != null)
{
@@ -27,6 +27,7 @@ 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.PolicyCatalog;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
@@ -81,6 +82,16 @@ public class GroupGraph
** not parse grants nothing and is listed in the node's malformed entries.
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format)
{
return (fromGroups(groups, format, null));
}
/*******************************************************************************
** Read group policies with the catalog of registered table features.
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format, PolicyCatalog catalog)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
GroupGraph graph = new GroupGraph();
@@ -88,7 +99,7 @@ public class GroupGraph
{
JSONObject attributes = group.optJSONObject("attributes");
List<String> malformed = malformed(attributes);
List<String> policy = policyPermissions(attributes, f, malformed);
List<String> policy = policyPermissions(attributes, f, malformed, catalog);
graph.add(new Node(
group.getString("pk"),
group.optString("name", ""),
@@ -289,7 +300,7 @@ 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)
private static List<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed, PolicyCatalog catalog)
{
Object document = attributes == null ? null : attributes.opt(format.attribute());
if(document == null || JSONObject.NULL.equals(document) || "".equals(document))
@@ -298,7 +309,7 @@ public class GroupGraph
}
try
{
return (List.copyOf(PolicyParser.parseStrict(document, format).permissions()));
return (List.copyOf(PolicyParser.parseStrict(document, format, catalog).permissions()));
}
catch(PolicyParseException e)
{
@@ -95,7 +95,7 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
return;
}
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format));
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format, qInstance == null ? null : PolicyCatalog.of(qInstance)));
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
@@ -21,7 +21,8 @@ import java.util.Optional;
/*******************************************************************************
** The actions a policy statement can grant, in canonical statement order. Each
** The five standard actions, in canonical statement order. Table features use
** their registered string names separately. Each standard action
** maps to the QQQ permission suffix of the same name ({resource}.{action}).
*******************************************************************************/
public enum PolicyAction
@@ -18,6 +18,7 @@ package com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashMap;
@@ -25,7 +26,9 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -74,9 +77,26 @@ public final class PolicyCatalog
** @param label what operators see
** @param kind app, table, process, report or widget
** @param actions the actions QQQ checks, in canonical order
** @param features explicitly grantable table features, sorted by name
** @param note a plain note (e.g. why an action is missing), or null
*******************************************************************************/
public record Resource(String name, String label, Kind kind, List<PolicyAction> actions, String note)
public record Resource(String name, String label, Kind kind, List<PolicyAction> actions, List<Feature> features, String note)
{
/*******************************************************************************
** A resource with only standard actions.
*******************************************************************************/
public Resource(String name, String label, Kind kind, List<PolicyAction> actions, String note)
{
this(name, label, kind, actions, List.of(), note);
}
}
/*******************************************************************************
** A table feature's permission suffix and display label from QQQ.
*******************************************************************************/
public record Feature(String name, String label)
{
}
@@ -118,6 +138,17 @@ public final class PolicyCatalog
** Build the catalog of an instance.
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance)
{
return (of(qInstance, PermissionsHelper.getAllAvailablePermissions(qInstance)));
}
/*******************************************************************************
** Build using an explicit permission source, including features registered
** by core. This needs no dependency on the feature metadata hook itself.
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance, Collection<AvailablePermission> availablePermissions)
{
Map<String, String> appOfObject = appsOfObjects(qInstance);
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
@@ -155,7 +186,7 @@ public final class PolicyCatalog
}
if(rules.getLevel() == PermissionLevel.READ_WRITE_PERMISSIONS)
{
note = "This table uses one write permission, which policies cannot grant yet; only read is offered.";
note = "This table uses one write permission instead of separate insert, edit and delete actions.";
}
else
{
@@ -173,7 +204,20 @@ public final class PolicyCatalog
}
}
}
add(byApp, appOfObject.get("table:" + table.getName()), qInstance, table, Kind.TABLE, label(table.getLabel(), table.getName()), actions, note);
String baseName = StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : table.getName();
Map<String, Feature> features = new TreeMap<>();
for(AvailablePermission permission : availablePermissions)
{
if(permission.getName().startsWith(baseName + "."))
{
String suffix = permission.getName().substring(baseName.length() + 1);
if(!suffix.isEmpty() && PolicyAction.fromJsonName(suffix).isEmpty())
{
features.putIfAbsent(suffix, new Feature(suffix, permission.getPermissionType()));
}
}
}
add(byApp, appOfObject.get("table:" + table.getName()), qInstance, table, Kind.TABLE, label(table.getLabel(), table.getName()), actions, features, note);
}
List<QProcessMetaData> processes = new ArrayList<>(qInstance.getProcesses().values());
@@ -218,7 +262,7 @@ public final class PolicyCatalog
List<Resource> resources = new ArrayList<>();
for(ResourceDraft draft : entry.getValue().values())
{
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), draft.note));
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), List.copyOf(draft.features.values()), draft.note));
}
groups.add(new Group(entry.getKey(), groupLabel, resources));
}
@@ -237,6 +281,7 @@ public final class PolicyCatalog
private final Kind kind;
private final Set<String> labels = new LinkedHashSet<>();
private final Set<PolicyAction> actions = new TreeSet<>();
private final Map<String, Feature> features = new TreeMap<>();
private String note;
@@ -258,9 +303,20 @@ public final class PolicyCatalog
*******************************************************************************/
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
Set<PolicyAction> actions, String note)
{
add(byApp, app, qInstance, object, kind, label, actions, Map.of(), note);
}
/*******************************************************************************
** Add standard and feature actions, merging shared permission base names.
*******************************************************************************/
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
Set<PolicyAction> actions, Map<String, Feature> features, String note)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(object, qInstance);
if(rules == null || rules.getLevel() == null || rules.getLevel() == PermissionLevel.NOT_PROTECTED || rules.getCustomPermissionChecker() != null || actions.isEmpty())
if(rules == null || rules.getLevel() == null || rules.getLevel() == PermissionLevel.NOT_PROTECTED || rules.getCustomPermissionChecker() != null || (actions.isEmpty() && features.isEmpty()))
{
return;
}
@@ -281,6 +337,7 @@ public final class PolicyCatalog
{
existing.labels.add(label);
existing.actions.addAll(actions);
existing.features.putAll(features);
return;
}
}
@@ -288,6 +345,7 @@ public final class PolicyCatalog
ResourceDraft draft = new ResourceDraft(name, kind);
draft.labels.add(label);
draft.actions.addAll(actions);
draft.features.putAll(features);
draft.note = note;
byApp.computeIfAbsent(byApp.containsKey(app) ? app : null, k -> new LinkedHashMap<>()).put(name, draft);
}
@@ -399,6 +457,7 @@ public final class PolicyCatalog
{
rs.add(action.permission(resource.name()));
}
resource.features().forEach(feature -> rs.add(resource.name() + "." + feature.name()));
}
return (rs);
}
@@ -425,6 +484,17 @@ public final class PolicyCatalog
/*******************************************************************************
** Whether this resource has an explicitly registered feature action.
*******************************************************************************/
public boolean hasFeature(String resourceName, String actionName)
{
Resource resource = get(resourceName);
return (resource != null && resource.features().stream().anyMatch(feature -> feature.name().equals(actionName)));
}
/*******************************************************************************
** As JSON for the editor widget.
*******************************************************************************/
@@ -443,6 +513,12 @@ public final class PolicyCatalog
.put("label", resource.label())
.put("kind", resource.kind().name())
.put("actions", actions);
if(resource.kind() == Kind.TABLE || !resource.features().isEmpty())
{
JSONArray features = new JSONArray();
resource.features().forEach(feature -> features.put(new JSONObject().put("name", feature.name()).put("label", feature.label())));
json.put("features", features);
}
if(resource.note() != null)
{
json.put("note", resource.note());
@@ -106,7 +106,7 @@ public final class PolicyEditorWidget
/////////////////////////////////////////////////////////////////
// canonical form of what is stored (unknown resources survive) //
/////////////////////////////////////////////////////////////////
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format)));
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format, runtime.getPolicyCatalog())));
}
catch(PolicyParseException e)
{
@@ -117,7 +117,7 @@ public final class PolicyEditorWidget
QRecord row = PolicyStore.find(config, groupPk);
if(row != null)
{
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format)));
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format, runtime.getPolicyCatalog())));
}
return (data);
}
@@ -21,6 +21,7 @@ import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -29,7 +30,8 @@ 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;
** action in the order hasAccess, read, insert, edit, delete, then features
** sorted by name; 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.
@@ -54,13 +56,24 @@ public final class PolicyGenerator
** round-trip
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format) throws PolicyParseException
{
return (generate(permissions, format, null));
}
/*******************************************************************************
** Generate with the catalog of explicitly grantable table features.
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
Map<PolicyAction, Set<String>> byAction = new LinkedHashMap<>();
Map<String, Set<String>> byAction = new LinkedHashMap<>();
Map<String, Set<String>> byFeature = new TreeMap<>();
for(PolicyAction action : PolicyAction.values())
{
byAction.put(action, new TreeSet<>());
byAction.put(action.getJsonName(), new TreeSet<>());
}
Set<String> expected = new TreeSet<>();
@@ -73,23 +86,28 @@ public final class PolicyGenerator
}
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(PolicyAction.fromJsonName(name).isEmpty() && (catalog == null || !catalog.hasFeature(resource, name)))
{
throw (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);
Map<String, Set<String>> target = byAction.containsKey(name) ? byAction : byFeature;
target.computeIfAbsent(name, key -> new TreeSet<>()).add(resource);
expected.add(permission);
}
JSONArray statements = new JSONArray();
for(Map.Entry<PolicyAction, Set<String>> entry : byAction.entrySet())
byAction.putAll(byFeature);
for(Map.Entry<String, 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("Action", new JSONArray().put(entry.getKey()))
.put("Resource", new JSONArray(entry.getValue())));
}
}
@@ -99,7 +117,7 @@ public final class PolicyGenerator
.put("PolicyVersion", f.version())
.put("Statement", statements);
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f).permissions();
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f, catalog).permissions();
if(!parsed.equals(expected))
{
Set<String> missing = new TreeSet<>(expected);
@@ -118,7 +136,17 @@ public final class PolicyGenerator
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format) throws PolicyParseException
{
return (generate(PolicyParser.parseStrict(document, format).permissions(), format));
return (canonical(document, format, null));
}
/*******************************************************************************
** Canonicalize a document using the registered table features.
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
return (generate(PolicyParser.parseStrict(document, format, catalog).permissions(), format, catalog));
}
@@ -18,7 +18,6 @@ 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;
@@ -80,6 +79,17 @@ public final class PolicyParser
** attribute or a token claim); null grants nothing
*******************************************************************************/
public static Set<String> parse(Object document, PolicyFormat format)
{
return (parse(document, format, null));
}
/*******************************************************************************
** Parse with the catalog of explicitly grantable table features; an invalid
** document still grants nothing.
*******************************************************************************/
public static Set<String> parse(Object document, PolicyFormat format, PolicyCatalog catalog)
{
if(document == null || JSONObject.NULL.equals(document))
{
@@ -87,7 +97,7 @@ public final class PolicyParser
}
try
{
return (parseStrict(document, format).permissions());
return (parseStrict(document, format, catalog).permissions());
}
catch(PolicyParseException e)
{
@@ -102,6 +112,17 @@ public final class PolicyParser
** Parse, throwing a PolicyParseException that names the first problem.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format) throws PolicyParseException
{
return (parseStrict(document, format, null));
}
/*******************************************************************************
** Parse standard actions and features registered for every listed resource.
** Without a catalog only the five standard actions are accepted.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
JSONObject json = asObject(document);
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
@@ -131,7 +152,6 @@ public final class PolicyParser
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"));
@@ -142,29 +162,38 @@ public final class PolicyParser
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);
Set<String> statementResources = strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource");
Set<String> actions = new LinkedHashSet<>();
for(String name : strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action"))
{
if(PolicyAction.WILDCARD.equals(name))
{
actions.addAll(EnumSet.allOf(PolicyAction.class));
for(PolicyAction action : PolicyAction.values())
{
actions.add(action.getJsonName());
}
}
else
{
actions.add(PolicyAction.fromJsonName(name).orElseThrow(() -> new PolicyParseException("Statement " + number + ": unknown action \"" + name + "\"")));
if(PolicyAction.fromJsonName(name).isEmpty()
&& (catalog == null || statementResources.isEmpty() || !statementResources.stream().allMatch(resource -> catalog.hasFeature(resource, name))))
{
throw (new PolicyParseException("Statement " + (i + 1) + ": unknown action \"" + name + "\""));
}
actions.add(name);
}
}
for(String resource : strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource"))
for(String resource : statementResources)
{
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)
for(String action : actions)
{
permissions.add(action.permission(resource));
permissions.add(resource + "." + action);
}
}
}
@@ -51,13 +51,23 @@ public final class PolicyPermissions
** when the claim is missing or invalid (fail closed).
*******************************************************************************/
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format)
{
return (fromClaims(claims, format, null));
}
/*******************************************************************************
** Read a policy claim with the catalog of registered table features.
*******************************************************************************/
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog)
{
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));
return (PolicyParser.parse(claims.opt(f.claim()), f, catalog));
}
@@ -68,6 +68,16 @@ public final class PolicyStore
** text. Null for no policy.
*******************************************************************************/
public static String fingerprint(Object attributeValue, PolicyFormat format)
{
return (fingerprint(attributeValue, format, null));
}
/*******************************************************************************
** Fingerprint including registered table features in the canonical form.
*******************************************************************************/
public static String fingerprint(Object attributeValue, PolicyFormat format, PolicyCatalog catalog)
{
if(attributeValue == null || JSONObject.NULL.equals(attributeValue) || "".equals(attributeValue))
{
@@ -76,7 +86,7 @@ public final class PolicyStore
String text;
try
{
text = PolicyGenerator.toCompact(PolicyGenerator.canonical(attributeValue, format));
text = PolicyGenerator.toCompact(PolicyGenerator.canonical(attributeValue, format, catalog));
}
catch(PolicyParseException e)
{
@@ -18,6 +18,7 @@ package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
@@ -27,8 +28,12 @@ import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -42,10 +47,13 @@ import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -98,6 +106,44 @@ class PolicyEditorTest
/*******************************************************************************
** Save, reload, resolve and compare a feature using simulated core metadata.
** The subset rule still requires the operator to hold the explicit grant.
*******************************************************************************/
@Test
void featurePolicyFlowsThroughEditorAndMapper() throws Exception
{
List<AvailablePermission> available = new ArrayList<>(PermissionsHelper.getAllAvailablePermissions(t.qInstance));
available.add(new AvailablePermission().withName("cms_page.analyze").withObjectType("Table").withObjectName("Pages").withPermissionType("Analyze"));
try(MockedStatic<PermissionsHelper> helper = Mockito.mockStatic(PermissionsHelper.class, Mockito.CALLS_REAL_METHODS))
{
helper.when(() -> PermissionsHelper.getAllAvailablePermissions(t.qInstance)).thenReturn(available);
String document = PAGE_READ_EDIT.replace("\"read\",\"edit\"", "\"analyze\"");
assertThat(save("kof-website-publishers", document).message()).contains("do not hold yourself");
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/policy", document);
t.admin().getUtil().getRuntime().invalidateCache();
AuthentikAdmin.AdminResult saved = save("kof-website-publishers", document);
assertThat(saved.ok()).as(saved.message()).isTrue();
assertThat(policyRows().get(0).getValueInteger("permissionCount")).isEqualTo(1);
PolicyEditorWidget.Data widget = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-publishers"));
assertThat(widget.getDocument()).contains("\"Action\": [\"analyze\"]");
assertThat(widget.getDocumentProblem()).isNull();
assertThat(widget.getChangedInAuthentik()).isFalse();
assertThat(save("kof-website-publishers", document).noChange()).isTrue();
assertThat(t.admin().getUtil().groupGraph().byName("kof-website-publishers").policyPermissions()).containsExactly("cms_page.analyze");
assertThat(AccessComparison.run(t.config, t.admin().getUtil(), t.admin().getUtil().getRuntime().getPermissionCatalog()))
.anyMatch(row -> row.username().equals("operator") && row.after().contains("cms_page.analyze"));
QSession session = new QSession();
JSONObject claims = new JSONObject().put("groups", List.of("kof-website-publishers")).put("sub", "1")
.put("qqq_policy", new JSONObject(document));
new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
assertThat(session.getPermissions()).contains("cms_page.analyze");
}
}
/*******************************************************************************
** Save writes the canonical compact string into the CURRENT attributes, and
** an authentik_policy row with the pretty document.
@@ -0,0 +1,259 @@
/*
* 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.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Table features use generic permission suffixes from core's existing catalog
** API. No test or production code depends on the unreleased metadata hook.
*******************************************************************************/
class PolicyFeaturePermissionsTest
{
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
/*******************************************************************************
** Use an overridden permission base name to catch matching by table name.
*******************************************************************************/
private static QInstance instance()
{
QInstance instance = PolicyCatalogTest.instance();
instance.addTable(new QTableMetaData().withName("person_records").withLabel("People").withBackendName("memory")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withPermissionBaseName("person")));
return (instance);
}
/*******************************************************************************
** The shape returned by PermissionsHelper when core registers a feature.
*******************************************************************************/
private static AvailablePermission feature(String name, String label)
{
return (new AvailablePermission().withName(name).withObjectName("People").withObjectType("Table").withPermissionType(label));
}
/*******************************************************************************
** Deliberately unsorted; duplicates and standard suffixes are not features.
*******************************************************************************/
private static List<AvailablePermission> available()
{
return (List.of(feature("person.comment", "Comment"), feature("person.analyze", "Analyze"), feature("person.analyze", "Analyze"),
feature("person.read", "Read"), feature("person.insert", "Insert"), feature("person.edit", "Edit"), feature("person.delete", "Delete"),
feature("person.hasAccess", "Access"), feature("person_extra.share", "Share"), feature("person.", "Empty"),
feature("gate.analyze", "Analyze"), feature("legacy.comment", "Comment"), feature("legacy.write", "Write"),
feature("shared_base.tag", "Tag"), feature("open_table.analyze", "Analyze")));
}
/*******************************************************************************
** A statement granting these actions on these resources.
*******************************************************************************/
private static JSONObject document(List<String> actions, String... resources)
{
return (PolicyParserTest.document(PolicyParserTest.statement("Allow", actions, List.of(resources))));
}
/*******************************************************************************
** Labels, overridden/shared bases, permission levels and additive widget JSON.
*******************************************************************************/
@Test
void catalogListsFeaturesSeparately()
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
assertThat(catalog.get("person").actions()).containsExactly(PolicyAction.READ, PolicyAction.INSERT, PolicyAction.EDIT, PolicyAction.DELETE);
assertThat(catalog.get("person").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"), new PolicyCatalog.Feature("comment", "Comment"));
assertThat(catalog.get("gate").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"));
assertThat(catalog.get("legacy").features()).containsExactly(new PolicyCatalog.Feature("comment", "Comment"), new PolicyCatalog.Feature("write", "Write"));
assertThat(catalog.get("shared_base").features()).containsExactly(new PolicyCatalog.Feature("tag", "Tag"));
assertThat(catalog.get("open_table")).isNull();
assertThat(catalog.permissionNames()).contains("person.analyze", "person.comment", "gate.analyze", "legacy.comment", "legacy.write", "shared_base.tag")
.doesNotContain("person.share", "person_records.analyze");
assertThat(catalog.unknownPermissions(Set.of("person.analyze", "person.mystery"))).containsExactly("person.mystery");
List<JSONObject> resources = new ArrayList<>();
catalog.toJson().forEach(group -> ((JSONObject) group).getJSONArray("resources").forEach(resource -> resources.add((JSONObject) resource)));
JSONObject person = resources.stream().filter(resource -> resource.getString("name").equals("person")).findFirst().orElseThrow();
assertThat(person.getJSONArray("actions").toList()).containsExactly("read", "insert", "edit", "delete");
assertThat(person.getJSONArray("features").toList()).containsExactly(Map.of("name", "analyze", "label", "Analyze"), Map.of("name", "comment", "label", "Comment"));
assertThat(resources.stream().filter(resource -> resource.getString("kind").equals("TABLE"))).allMatch(resource -> resource.has("features"));
assertThat(resources.stream().filter(resource -> resource.getString("kind").equals("APP"))).noneMatch(resource -> resource.has("features"));
}
/*******************************************************************************
** Feature-only tables are still grantable when CRUD capabilities are absent.
*******************************************************************************/
@Test
void featuresDoNotRequireStandardActions()
{
QInstance instance = instance();
instance.getTable("person_records").withoutCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
PolicyCatalog.Resource resource = PolicyCatalog.of(instance, available()).get("person");
assertThat(resource.actions()).isEmpty();
assertThat(resource.features()).hasSize(2);
}
/*******************************************************************************
** Every resource must register a feature; one bad pair voids the document.
*******************************************************************************/
@Test
void parserValidatesFeaturesPerResource() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
assertThat(PolicyParser.parseStrict(document(List.of("read", "analyze"), "person"), FORMAT, catalog).permissions())
.containsExactly("person.analyze", "person.read");
assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "gate"), FORMAT, catalog)).containsExactly("gate.analyze", "person.analyze");
assertThat(PolicyParser.parse(document(List.of("comment", "write"), "legacy"), FORMAT, catalog)).containsExactly("legacy.comment", "legacy.write");
assertThatThrownBy(() -> PolicyParser.parseStrict(document(List.of("mystery"), "person"), FORMAT, catalog))
.hasMessage("Statement 1: unknown action \"mystery\"");
assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "legacy"), FORMAT, catalog)).isEmpty();
assertThat(PolicyParser.parse(document(List.of("analyze"), "gone"), FORMAT, catalog)).isEmpty();
assertThat(PolicyParser.parse(document(List.of("analyze")), FORMAT, catalog)).isEmpty();
assertThat(PolicyParser.parse(document(List.of("Analyze"), "person"), FORMAT, catalog)).isEmpty();
assertThat(PolicyParser.parse(document(List.of("analyze"), "person"), FORMAT)).isEmpty();
}
/*******************************************************************************
** Existing wildcard grants remain the five standard actions forever.
*******************************************************************************/
@Test
void wildcardDoesNotGrantFeatures()
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
assertThat(PolicyParser.parse(document(List.of("*"), "person"), FORMAT, catalog))
.containsExactlyInAnyOrder("person.hasAccess", "person.read", "person.insert", "person.edit", "person.delete");
assertThat(PolicyParser.parse(document(List.of("*", "analyze"), "person"), FORMAT, catalog)).hasSize(6).contains("person.analyze").doesNotContain("person.comment");
}
/*******************************************************************************
** Standard ordering stays byte-identical; sorted features follow it.
*******************************************************************************/
@Test
void generatorRoundTripsFeatures() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
List<String> permissions = List.of("person.comment", "person.read", "person.analyze", "gate.analyze");
JSONObject generated = PolicyGenerator.generate(permissions, FORMAT, catalog);
String compact = PolicyGenerator.toCompact(generated);
assertThat(compact).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":["
+ "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"person\"]},"
+ "{\"Effect\":\"Allow\",\"Action\":[\"analyze\"],\"Resource\":[\"gate\",\"person\"]},"
+ "{\"Effect\":\"Allow\",\"Action\":[\"comment\"],\"Resource\":[\"person\"]}]}");
assertThat(PolicyParser.parseStrict(compact, FORMAT, catalog).permissions()).containsExactlyInAnyOrderElementsOf(permissions);
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(generated), FORMAT, catalog).permissions()).containsExactlyInAnyOrderElementsOf(permissions);
assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(PolicyGenerator.toPretty(generated), FORMAT, catalog))).isEqualTo(compact);
assertThat(PolicyStore.fingerprint(compact, FORMAT, catalog)).isEqualTo(PolicyStore.fingerprint(PolicyGenerator.toPretty(generated), FORMAT, catalog));
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("person.mystery"), FORMAT, catalog)).hasMessageContaining("unknown action \"mystery\"");
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("legacy.analyze"), FORMAT, catalog)).hasMessageContaining("unknown action \"analyze\"");
List<String> standard = List.of("person.read", "person.edit", "gate.hasAccess");
assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(standard, FORMAT, catalog))).isEqualTo(PolicyGenerator.toCompact(PolicyGenerator.generate(standard, FORMAT)));
}
/*******************************************************************************
** The real mapper discovers simulated core entries and adds their exact names.
*******************************************************************************/
@Test
void claimMapperUsesCoreCatalog()
{
QInstance instance = instance();
try(MockedStatic<PermissionsHelper> helper = Mockito.mockStatic(PermissionsHelper.class, Mockito.CALLS_REAL_METHODS))
{
helper.when(() -> PermissionsHelper.getAllAvailablePermissions(instance)).thenReturn(available());
QSession session = new QSession();
session.setPermissions(Set.of("existing.read"));
JSONObject claims = new JSONObject().put(FORMAT.claim(), document(List.of("analyze"), "person"));
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.read", "person.analyze");
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance))).containsExactly("person.analyze");
}
}
/*******************************************************************************
** Inherited feature grants reach resolution, attribution and access comparison.
*******************************************************************************/
@Test
void comparisonIncludesInheritedFeatures() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
JSONObject parent = new JSONObject().put("pk", "parent").put("name", "Analysts")
.put("attributes", new JSONObject().put(FORMAT.attribute(), document(List.of("analyze"), "person")));
JSONObject child = new JSONObject().put("pk", "child").put("name", "Staff").put("parents", new JSONArray(List.of("parent")))
.put("users", new JSONArray(List.of(1)));
GroupGraph graph = GroupGraph.fromGroups(List.of(parent, child), FORMAT, catalog);
EffectivePermissionResolver.Resolution resolution = new EffectivePermissionResolver(graph, catalog.permissionNames()).resolveUser(1);
assertThat(resolution.sessionPermissions()).containsExactly("person.analyze");
assertThat(resolution.grantedBy().get("person.analyze")).containsExactly("Analysts");
assertThat(resolution.unknown()).isEmpty();
AuthentikActionUtil util = Mockito.mock(AuthentikActionUtil.class);
Mockito.when(util.groupGraphWithMembers()).thenReturn(graph);
Mockito.when(util.allUsers()).thenReturn(List.of(new JSONObject().put("pk", 1).put("username", "person").put("type", "internal")));
AuthentikQBitConfig config = new AuthentikQBitConfig().withCutoverGuardGroups(List.of("Staff"));
AccessComparison.Row added = AccessComparison.run(config, util, catalog.permissionNames()).get(0);
assertThat(added.after()).containsExactly("person.analyze");
assertThat(added.added()).containsExactly("person.analyze");
config.withHostPermissionSource((email, groups) -> Set.of("person.analyze"));
AccessComparison.Row unchanged = AccessComparison.run(config, util, catalog.permissionNames()).get(0);
assertThat(unchanged.before()).isEqualTo(unchanged.after());
assertThat(unchanged.removed()).isEmpty();
assertThat(AccessComparison.guardFailures(List.of(unchanged))).isEmpty();
}
}