Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aaedacffd
|
||
|
|
d9431e7e6b
|
||
|
|
7fb96d7d2a | ||
|
|
d3b740785f
|
||
|
|
0e981bc4f7 | ||
|
|
716a40df1b | ||
|
|
af970d32db
|
||
|
|
0a827955b8
|
||
|
|
a5a74af0e2 | ||
|
|
a79f2d2d4b
|
||
|
|
a2fbb2516e | ||
|
|
639dc38ca1
|
||
|
|
9751296d6c | ||
|
|
616e3a8135
|
||
|
|
93d3afa869 |
No files matched your search
+1
-12
@@ -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\/.*/]
|
||||
@@ -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 }}
|
||||
@@ -4,6 +4,31 @@ 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.
|
||||
- **Compare Access** widget `authentikCompareAccess` on the app home (`authentik.admin`, read only): per active person, today vs the mapper, with added, removed and unchanged, and the guard result.
|
||||
- **Cut-over guard**: while a member of a guarded group would lose a permission (and no override), the mapper grants today's access plus the static fallback and bootstrap subjects instead of the policies.
|
||||
- **Generate Policy From Current Access** group action: the expressible part of today's access (joined with the current policy) becomes the policy, named permissions go to `qqq.io/permissions`, the rest is listed; Review shows the document and diff.
|
||||
|
||||
### Added (full user and group CRUD, phase 5c: DESIGN.md section 12.5)
|
||||
- **Edit User** also changes the username (validated, unique) and the other attributes (a JSON object; reserved `qqq.io/` and `goauthentik.io/` keys are kept and cannot be set; blank leaves them as they are; editing attributes needs `authentik.audit.read`, which is also what shows them on the form). **Edit Group** edits other attributes the same way.
|
||||
- **Delete User** and **Delete Group** record actions behind the new `DELETE` feature (off by default; needs Authentik's `delete_user` / `delete_group`). Typed confirmation; never yourself, a protected account, a superuser group or the last identity administrator; a group must have no members and no child groups first; the group's `authentik_policy` row goes with it. Deactivate stays the default for people.
|
||||
|
||||
### Added (policy editor, phase 5b: DESIGN.md section 12.4)
|
||||
- **Edit Policy** record action on groups and on policies (feature `POLICIES`, permission `authentik.policies.manage`): a standard streamed-ETL action whose step shows the `AUTHENTIK_POLICY_EDITOR` widget (Next UI). Review parses the submitted document (fail closed), regenerates it in canonical form with the round-trip check, refuses new resources this app does not define, applies the subset rule to the permissions it adds, and lists what is added and removed. Result merge-patches `qqq.io/policy` into the group's current attributes (removed when empty). Optional second-person approval (`policyTwoPersonApproval`).
|
||||
- **`PolicyCatalog`**: every app, table, process (hidden record actions included), report and protected widget a policy can grant, with the actions QQQ checks, grouped by app. Its permission names join the permission catalog.
|
||||
- **`authentik_policy`** local table (one row per group; field-level audit is the change history; DDL in `docs/schema.sql`), in the nav under Access and as a child list on the group.
|
||||
- Widget `authentikPolicyEditor` (type `AUTHENTIK_POLICY_EDITOR`): the catalog and the group's live document, with a flag when it was changed in Authentik since the last save in QQQ.
|
||||
|
||||
### Added (policies, phase 5a: DESIGN.md section 12, docs/POLICY-FORMAT.md)
|
||||
- **Policy documents** (IAM-style statements) on Authentik groups: `PolicyParser` (fails closed: unknown spec or version, non-Allow effect, unknown action, bad resource or malformed JSON grant nothing), `PolicyGenerator` (canonical form with a round-trip check), `PolicyFormat` and the config `policySpec`, `policyVersion`, `policyAttribute`, `policyClaim`, `policyTwoPersonApproval`.
|
||||
- **Runtime**: `AuthentikPolicyClaimMapper` (reads the `qqq_policy` claim from the ID token or access token; `applySecurityKeys` hook for record locks) and `PolicyPermissions.fromClaims` for a host's own customizer. `AuthentikGroupPermissionMapper` also adds the claim, and the group graph and `EffectivePermissionResolver` read each group's `qqq.io/policy`, so effective permissions, the subset rule and the last-admin check see policy grants. An invalid policy on a group is listed as malformed.
|
||||
- **Blueprint example** for the scope mapping that puts the merged policies into the token, pinned by `PolicyClaimMappingIT` on Authentik 2026.8.3.
|
||||
|
||||
### Added (v1: full user lifecycle and group/permission management, phases 2-3)
|
||||
- **Writes through the Authentik API**, behind the features `USERS`, `GROUPS`, `MEMBERSHIP`, `CREDENTIALS` and `SESSIONS` (read-only stays the default; `readOnly` is a hard kill switch). Every change goes through `AuthentikAdmin`: it plans against live Authentik data (the guardrails of DESIGN.md 5.4), audits first, calls Authentik, posts a `custom_qqq_admin_action` event naming the operator, and finishes the audit row. `AuthentikActionUtil.write` needs a `WriteAuthorization`, which only `AuthentikGuard.authorize` can mint.
|
||||
- **Actions** (table and record actions with Review and Result steps; none in the nav): Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject.
|
||||
|
||||
@@ -51,7 +51,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
|
||||
- Nothing but `AuthentikAdmin` may call `AuthentikActionUtil.write`. Every write needs a reason, passes the guard, and is audited first.
|
||||
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
|
||||
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions.
|
||||
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key or impersonate permissions; `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
|
||||
|
||||
## Code Style
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
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
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. No hard delete. |
|
||||
| **Groups and permissions** | Create and edit groups (parents), add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
|
||||
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. Edit covers username, name, email, path and other attributes. Delete is a separate, explicit action behind the `DELETE` feature (off by default); deactivate stays the default. |
|
||||
| **Groups and permissions** | Create, edit (parents, other attributes) and delete groups, add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
|
||||
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
|
||||
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
|
||||
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
|
||||
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, connection), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
|
||||
@@ -23,7 +24,7 @@ AuthentikQBitConfig config = new AuthentikQBitConfig()
|
||||
.withBaseUrl("https://auth.example.com") // host root; /api/v3 is appended
|
||||
.withApiToken("${env.AUTHENTIK_API_TOKEN}") // service-account token, resolved by the qbit
|
||||
.withAppKey("my-app-admin") // service account: qqq-my-app-admin-idadmin
|
||||
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS))
|
||||
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS, POLICIES)) // + DELETE for delete actions
|
||||
.withLocalBackendName("rdbms") // audit trail and approval requests (see docs/schema.sql)
|
||||
.withRecoveryEmailStage("my-recovery-email") // Authentik email stage (name or uuid)
|
||||
.withInvitationFlow("my-enrollment"); // optional: enables invitations
|
||||
@@ -38,10 +39,10 @@ Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill s
|
||||
| | |
|
||||
|---|---|
|
||||
| API backend | `authentikApi` |
|
||||
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
|
||||
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_policy` (with `POLICIES`), `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
|
||||
| Child lists (on their parents, not in the nav) | `authentik_group_member`, `authentik_session`, `authentik_token` (metadata only, never a key), `authentik_event`, `authentik_policy_binding`, `authentik_role`, `authentik_blueprint` |
|
||||
| App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report |
|
||||
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject |
|
||||
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group |
|
||||
|
||||
### Permissions
|
||||
|
||||
@@ -54,6 +55,7 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
|
||||
| `authentik.groups.manage` | create and edit groups |
|
||||
| `authentik.memberships.manage` | add and remove members (subset rule) |
|
||||
| `authentik.permissions.manage` | edit a group's permissions and app access (subset rule) |
|
||||
| `authentik.policies.manage` | edit a group's policy (subset rule) |
|
||||
| `authentik.credentials.reset` | password reset email, reset MFA, revoke app passwords |
|
||||
| `authentik.sessions.revoke` | revoke sessions |
|
||||
| `authentik.audit.read` | sessions, tokens, events, the audit trail, sensitive fields |
|
||||
@@ -63,11 +65,15 @@ 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, impersonate or delete permissions. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
|
||||
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.
|
||||
|
||||
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. The contract tests grant exactly this list.
|
||||
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. Saving a policy uses `change_group`. The `DELETE` feature also needs `delete_user` and `delete_group`. The contract tests grant exactly this list.
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
|
||||
@@ -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.
|
||||
+308
-9
@@ -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
|
||||
|
||||
@@ -1178,6 +1201,54 @@ Deactivate stays the default for people, and *Delete User* says so in its help t
|
||||
|
||||
The token needs these only when writes are on. A read-only deployment (`readOnly`, or no write features) keeps the phase 1 read set.
|
||||
|
||||
### 12.8 Cut-over: making policies take effect safely
|
||||
|
||||
Saved policies do nothing until the host signs people in through the qbit mapper. The qbit makes that state visible, helps seed policies from today's access, and refuses to switch over in a way that would lock administrators out.
|
||||
|
||||
**Host SPI.** `AuthentikHostPermissionSource` returns what a user (or a group's members) gets *today* from the host, typically a hard-coded grant such as "admins get `adminPermissions()`". The host sets it with `withHostPermissionSource(...)`.
|
||||
|
||||
**Config.**
|
||||
|
||||
| Setting | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `mapperEnabled` | `false` | The host's own switch (kof22: `AUTHENTIK_QBIT_MAPPER`). Off means "policies are saved but not enforced". A host that calls the mapper without setting it is treated as on. |
|
||||
| `cutoverGuardGroups` | none | Groups whose members must lose no permission at the cut-over (kof22: `kof22-admins`) |
|
||||
| `mapperOverride` | `false` | The owner's explicit override of a failing guard |
|
||||
| `staticGroupPermissions` | none | Permissions always granted to a group's members (the floor), e.g. today's admin grant |
|
||||
|
||||
**Status** (`AccessComparison.state`, cached 60 s):
|
||||
- `OFF`: the mapper is not in use.
|
||||
- `ACTIVE`: the guard passed, or nothing is guarded.
|
||||
- `OVERRIDDEN`: the owner overrode a failing guard.
|
||||
- `BLOCKED`: the guard failed or could not reach Authentik.
|
||||
|
||||
The status is shown in three places:
|
||||
- the *Policy enforcement* banner, first on group and policy records and on the Edit Policy step. When off it reads "Policies are saved but NOT enforced at sign-in on this instance (mapper off)".
|
||||
- the home's Connection card ("Policies at sign-in")
|
||||
- **Compare Access**
|
||||
|
||||
**Compare Access** is a read-only `TABLE` widget on the app home (`authentik.admin`). For every active person it compares two sets:
|
||||
- **today**: the host source
|
||||
- **with the mapper**: the person's groups' policies and `qqq.io/permissions` including ancestors, the static fallback of their direct groups, and all `authentik.*` for a bootstrap subject
|
||||
|
||||
It shows what is added and removed, and the unchanged count. Guarded people are marked, and the footer says whether the guard passes.
|
||||
|
||||
**Guard.** While the status is `BLOCKED`, the mapper does not apply policies. A sign-in gets:
|
||||
- today's access from the host source
|
||||
- the static fallbacks
|
||||
- the bootstrap subjects
|
||||
|
||||
So switching the mapper on can never take access away from a guarded admin until Compare Access shows no removal for them, or the owner sets `mapperOverride`. The static fallback and the bootstrap subject (the owner's immutable Authentik pk) stay in force in every state, so a lockout is impossible.
|
||||
|
||||
**Generate Policy From Current Access** is a record action on groups that saves a policy. It takes what the group's members get today (`groupPermissions`):
|
||||
- Permissions a policy can express are joined with the group's current policy, so nothing is removed, and become the document.
|
||||
- Named permissions the app defines but a policy cannot express (dotted names, `write`) are merged into `qqq.io/permissions`.
|
||||
- Anything else is listed as skipped.
|
||||
|
||||
Review shows the generated document, the diff and the subset rule before anything is saved. For the subset rule, the operator's own access today (host source plus static fallback) counts.
|
||||
|
||||
**Rollback** is one switch: set the host flag off (kof22: `AUTHENTIK_QBIT_MAPPER=false`). Sign-in returns to the host's own grant immediately. Saved policies stay in Authentik and in `authentik_policy`, unused.
|
||||
|
||||
### 12.6 Tests
|
||||
|
||||
- **Parser:** every fail-closed case (null, blank, not JSON, unknown spec, unsupported version, `Deny`, unknown action, bad resource, non-array fields), `*` expansion, and merging several statements.
|
||||
@@ -1199,6 +1270,234 @@ The token needs these only when writes are on. A read-only deployment (`readOnly
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
+12
-6
@@ -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,11 +103,13 @@ 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.
|
||||
|
||||
The contract test `PolicyClaimMappingIT` runs this blueprint on Authentik 2026.8.3. It saves policies on a parent and a child group through the qbit, then runs the mapping through Authentik's property-mapping test endpoint for a member of the child group. It checks that the parser turns the claim into the union of both groups' permissions.
|
||||
The contract test `PolicyClaimMappingIT` runs this blueprint on Authentik 2026.8.3. It puts generated policies on a parent and a child group (one as a string, one as a JSON object), then runs the mapping through Authentik's property-mapping test endpoint for a member of the child group. It checks that the parser turns the claim into the union of the groups' permissions, that a document with another spec is skipped, and that a user without policies gets a valid empty document.
|
||||
|
||||
### Freshness
|
||||
|
||||
|
||||
+3
-3
@@ -11,14 +11,14 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated
|
||||
| 2. Memberships and permission editing | done, except the webhook route, ESB trigger, inbox and session re-evaluation marking |
|
||||
| 3. Full user lifecycle | done, except the optional snapshot export |
|
||||
| 4. RBAC role management | post-v1, not started |
|
||||
| 5. Policies and full CRUD (DESIGN.md section 12) | spec approved 2026-10-13; 5a policy model, 5b editor, 5c CRUD additions in progress |
|
||||
| 5. Policies and full CRUD (DESIGN.md section 12) | 5a policy model (#14), 5b policy editor (#15), 5c user and group CRUD additions; the Next UI widget is a separate qqq-frontend-next PR |
|
||||
|
||||
### Built in this line (0.1)
|
||||
|
||||
| Area | What |
|
||||
|---|---|
|
||||
| People | Create, invite, edit, deactivate, reactivate, password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
|
||||
| Groups | Create, edit (parents), add and remove members (bulk), edit permissions and app access |
|
||||
| People | Create, invite, edit (username, name, email, path, other attributes), deactivate, reactivate, delete (feature `DELETE`), password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
|
||||
| Groups | Create, edit (parents, other attributes), delete (feature `DELETE`), add and remove members (bulk), edit permissions and app access, edit the group policy (feature `POLICIES`) |
|
||||
| Safety | Subset rule on live groups, protected accounts and superuser groups, no self-edits, last-admin protection, reasons, typed confirmations, bulk caps, stale checks, two-person approval for `authentik.*` |
|
||||
| Audit | `authentik_admin_action` (before the call), Authentik `custom_qqq_admin_action` events, approval requests |
|
||||
| Screens | Home dashboard, main tables in the nav, child lists on parents, every action as a table or record action |
|
||||
|
||||
@@ -49,3 +49,22 @@ CREATE TABLE authentik_approval_request
|
||||
);
|
||||
|
||||
CREATE INDEX idx_authentik_approval_request_status ON authentik_approval_request (status);
|
||||
|
||||
-- authentik_policy (DESIGN.md section 12): the QQQ record of each group's last policy save.
|
||||
-- Authentik holds the live document (group attribute qqq.io/policy); field-level QQQ audit on
|
||||
-- this table is the policy's change history.
|
||||
CREATE TABLE authentik_policy
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
group_pk VARCHAR(64) NOT NULL,
|
||||
group_name VARCHAR(255),
|
||||
policy_json TEXT,
|
||||
statement_count INTEGER,
|
||||
permission_count INTEGER,
|
||||
saved_by VARCHAR(255),
|
||||
saved_date TIMESTAMP WITH TIME ZONE,
|
||||
authentik_fingerprint VARCHAR(64),
|
||||
CONSTRAINT uk_authentik_policy_group_pk UNIQUE (group_pk)
|
||||
);
|
||||
@@ -32,6 +32,12 @@ public enum AuthentikFeature
|
||||
/** Create and edit groups, permissions and app-access attributes (phase 2/3). */
|
||||
GROUPS,
|
||||
|
||||
/** Edit group policies (IAM-style statements) with the policy editor (DESIGN.md section 12). */
|
||||
POLICIES,
|
||||
|
||||
/** Delete users and groups as an explicit action (DESIGN.md section 12.5). */
|
||||
DELETE,
|
||||
|
||||
/** Add and remove members (phase 2). */
|
||||
MEMBERSHIP,
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
@@ -45,11 +46,11 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
{
|
||||
/** Features implemented in this release. Others are refused by validate(). */
|
||||
public static final Set<AuthentikFeature> AVAILABLE_FEATURES = EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS);
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE);
|
||||
|
||||
/** Features that change Authentik data; they need a local backend for the audit trail. */
|
||||
public static final Set<AuthentikFeature> WRITE_FEATURES = EnumSet.of(AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS);
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE);
|
||||
|
||||
private static final Pattern APP_KEY_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,48}[a-z0-9]");
|
||||
private static final Pattern PREFIX_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9]{0,30}");
|
||||
@@ -84,6 +85,17 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
private Integer socketTimeoutMillis = 30_000;
|
||||
private String expectedServerVersion = "2026.8";
|
||||
private Boolean allowInsecureHttp = false;
|
||||
private String policySpec = PolicyFormat.DEFAULT_SPEC;
|
||||
private String policyVersion = PolicyFormat.DEFAULT_VERSION;
|
||||
private String policyAttribute = PolicyFormat.DEFAULT_ATTRIBUTE;
|
||||
private String policyClaim = PolicyFormat.DEFAULT_CLAIM;
|
||||
private Boolean policyTwoPersonApproval = false;
|
||||
private Boolean mapperEnabled = false;
|
||||
private Boolean mapperOverride = false;
|
||||
private List<String> cutoverGuardGroups = new ArrayList<>();
|
||||
private java.util.Map<String, List<String>> staticGroupPermissions = new java.util.LinkedHashMap<>();
|
||||
@JsonIgnore
|
||||
private com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource hostPermissionSource;
|
||||
|
||||
|
||||
|
||||
@@ -173,6 +185,16 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
errors.add("features USERS and CREDENTIALS need recoveryEmailStage");
|
||||
}
|
||||
|
||||
if(requested.contains(AuthentikFeature.DELETE) && !requested.contains(AuthentikFeature.USERS) && !requested.contains(AuthentikFeature.GROUPS))
|
||||
{
|
||||
errors.add("feature DELETE needs USERS or GROUPS");
|
||||
}
|
||||
|
||||
if(requested.contains(AuthentikFeature.POLICIES) && !requested.contains(AuthentikFeature.GROUPS))
|
||||
{
|
||||
errors.add("feature POLICIES needs GROUPS");
|
||||
}
|
||||
|
||||
if(requested.contains(AuthentikFeature.WEBHOOK) && !StringUtils.hasContent(getResolvedWebhookSecret()))
|
||||
{
|
||||
errors.add("feature WEBHOOK needs webhookSecret");
|
||||
@@ -195,6 +217,12 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
break;
|
||||
}
|
||||
}
|
||||
String policyProblem = getPolicyFormat().problem();
|
||||
if(policyProblem != null)
|
||||
{
|
||||
errors.add(policyProblem);
|
||||
}
|
||||
|
||||
if(throttleRequestsPerSecond != null && throttleRequestsPerSecond == 0)
|
||||
{
|
||||
errors.add("throttleRequestsPerSecond must be at least 1");
|
||||
@@ -1105,4 +1133,227 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policySpec: the PolicySpec this app accepts and writes (docs/POLICY-FORMAT.md).
|
||||
*******************************************************************************/
|
||||
public String getPolicySpec()
|
||||
{
|
||||
return (this.policySpec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policySpec
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withPolicySpec(String policySpec)
|
||||
{
|
||||
this.policySpec = policySpec;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyVersion: the one supported PolicyVersion (semver).
|
||||
*******************************************************************************/
|
||||
public String getPolicyVersion()
|
||||
{
|
||||
return (this.policyVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyVersion
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withPolicyVersion(String policyVersion)
|
||||
{
|
||||
this.policyVersion = policyVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyAttribute: the Authentik group attribute that holds a group's policy document.
|
||||
*******************************************************************************/
|
||||
public String getPolicyAttribute()
|
||||
{
|
||||
return (this.policyAttribute);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyAttribute
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withPolicyAttribute(String policyAttribute)
|
||||
{
|
||||
this.policyAttribute = policyAttribute;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyClaim: the token claim that holds the merged policy document.
|
||||
*******************************************************************************/
|
||||
public String getPolicyClaim()
|
||||
{
|
||||
return (this.policyClaim);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyClaim
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withPolicyClaim(String policyClaim)
|
||||
{
|
||||
this.policyClaim = policyClaim;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyTwoPersonApproval: whether every policy save needs a second person (authentik.approve).
|
||||
*******************************************************************************/
|
||||
public Boolean getPolicyTwoPersonApproval()
|
||||
{
|
||||
return (this.policyTwoPersonApproval);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyTwoPersonApproval
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withPolicyTwoPersonApproval(Boolean policyTwoPersonApproval)
|
||||
{
|
||||
this.policyTwoPersonApproval = policyTwoPersonApproval;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The policy names (spec, version, attribute, claim) as one value.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public PolicyFormat getPolicyFormat()
|
||||
{
|
||||
return (new PolicyFormat(policySpec, policyVersion, policyAttribute, policyClaim));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for mapperEnabled: whether the host uses the qbit mapper for sign-in permissions (its own flag, e.g. AUTHENTIK_QBIT_MAPPER); false shows "policies are not enforced" everywhere.
|
||||
*******************************************************************************/
|
||||
public Boolean getMapperEnabled()
|
||||
{
|
||||
return (this.mapperEnabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for mapperEnabled
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withMapperEnabled(Boolean mapperEnabled)
|
||||
{
|
||||
this.mapperEnabled = mapperEnabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for mapperOverride: activate the mapper even when the cut-over guard finds removed permissions (explicit owner override).
|
||||
*******************************************************************************/
|
||||
public Boolean getMapperOverride()
|
||||
{
|
||||
return (this.mapperOverride);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for mapperOverride
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withMapperOverride(Boolean mapperOverride)
|
||||
{
|
||||
this.mapperOverride = mapperOverride;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cutoverGuardGroups: groups whose members must lose no permission at the cut-over (DESIGN.md section 12.8).
|
||||
*******************************************************************************/
|
||||
public List<String> getCutoverGuardGroups()
|
||||
{
|
||||
return (this.cutoverGuardGroups);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cutoverGuardGroups
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withCutoverGuardGroups(List<String> cutoverGuardGroups)
|
||||
{
|
||||
this.cutoverGuardGroups = cutoverGuardGroups;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for staticGroupPermissions: permissions always granted to members of a group (the mapper floor, e.g. today's admin grant), so a cut-over cannot lock admins out.
|
||||
*******************************************************************************/
|
||||
public java.util.Map<String, List<String>> getStaticGroupPermissions()
|
||||
{
|
||||
return (this.staticGroupPermissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for staticGroupPermissions
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withStaticGroupPermissions(java.util.Map<String, List<String>> staticGroupPermissions)
|
||||
{
|
||||
this.staticGroupPermissions = staticGroupPermissions;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hostPermissionSource: the host's current (pre-policy)
|
||||
** permission source, or null (DESIGN.md section 12.8).
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource getHostPermissionSource()
|
||||
{
|
||||
return (this.hostPermissionSource);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hostPermissionSource
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withHostPermissionSource(com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource hostPermissionSource)
|
||||
{
|
||||
this.hostPermissionSource = hostPermissionSource;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,7 +23,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
|
||||
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
|
||||
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
|
||||
|
||||
@@ -45,6 +47,9 @@ public class AuthentikRuntime
|
||||
private final Memoization<String, String> responseCache;
|
||||
private final Memoization<String, GroupGraph> groupGraphCache;
|
||||
private volatile java.util.Set<String> permissionCatalog;
|
||||
private volatile PolicyCatalog policyCatalog;
|
||||
private volatile AccessComparison.State mapperState;
|
||||
private volatile boolean mapperInvoked;
|
||||
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
@@ -149,14 +154,39 @@ public class AuthentikRuntime
|
||||
if(permissionCatalog == null)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
|
||||
permissionCatalog = qInstance == null ? java.util.Set.of()
|
||||
: new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
|
||||
if(qInstance == null)
|
||||
{
|
||||
return (java.util.Set.of());
|
||||
}
|
||||
java.util.Set<String> catalog = new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
|
||||
catalog.addAll(getPolicyCatalog().permissionNames());
|
||||
permissionCatalog = catalog;
|
||||
}
|
||||
return (permissionCatalog);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a policy can grant in the current instance (built once, lazily: the
|
||||
** instance is complete by the first request).
|
||||
*******************************************************************************/
|
||||
public PolicyCatalog getPolicyCatalog()
|
||||
{
|
||||
if(policyCatalog == null)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
|
||||
if(qInstance == null)
|
||||
{
|
||||
return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance()));
|
||||
}
|
||||
policyCatalog = PolicyCatalog.of(qInstance);
|
||||
}
|
||||
return (policyCatalog);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for config
|
||||
*******************************************************************************/
|
||||
@@ -206,4 +236,48 @@ public class AuthentikRuntime
|
||||
return (responseCache);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cached mapper state (DESIGN.md section 12.8), or null.
|
||||
*******************************************************************************/
|
||||
public AccessComparison.State getMapperState()
|
||||
{
|
||||
return (mapperState);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cache a mapper state.
|
||||
*******************************************************************************/
|
||||
public void setMapperState(AccessComparison.State mapperState)
|
||||
{
|
||||
this.mapperState = mapperState;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the host has run the qbit mapper at sign-in (counts as "enabled"
|
||||
** for hosts that call the mapper without setting mapperEnabled).
|
||||
*******************************************************************************/
|
||||
public boolean isMapperInvoked()
|
||||
{
|
||||
return (mapperInvoked);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record that the mapper ran.
|
||||
*******************************************************************************/
|
||||
public void markMapperInvoked()
|
||||
{
|
||||
if(!mapperInvoked)
|
||||
{
|
||||
mapperInvoked = true;
|
||||
mapperState = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,9 @@ public enum AdminAction
|
||||
CREATE_GROUP("Create group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
|
||||
EDIT_GROUP("Edit group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
|
||||
EDIT_PERMISSIONS("Edit permissions and app access", "group", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
|
||||
SAVE_POLICY("Save policy", "group", IdentityPermission.POLICIES_MANAGE, AuthentikFeature.POLICIES, Confirmation.CONFIRM),
|
||||
DELETE_USER("Delete user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
|
||||
DELETE_GROUP("Delete group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
|
||||
SEND_RECOVERY_EMAIL("Send password reset email", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.CONFIRM),
|
||||
RESET_MFA("Reset MFA", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
|
||||
REVOKE_APP_PASSWORDS("Revoke app passwords", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
|
||||
|
||||
@@ -39,6 +39,11 @@ import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParser;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyStore;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
|
||||
@@ -85,6 +90,15 @@ public class AuthentikAdmin
|
||||
public static final int TYPED_ABOVE = 25;
|
||||
public static final int APPROVAL_HOURS = 24;
|
||||
|
||||
/** Process value / request parameter that carries a submitted policy document. */
|
||||
public static final String POLICY_DOCUMENT = "policyDocument";
|
||||
|
||||
/** Request parameter: named permissions (one per line) to merge into qqq.io/permissions with a policy save. */
|
||||
public static final String EXTRA_PERMISSIONS = "extraPermissions";
|
||||
|
||||
/** Request parameter with the non-reserved attributes (JSON object text) of Edit User and Edit Group. */
|
||||
public static final String OTHER_ATTRIBUTES = "otherAttributes";
|
||||
|
||||
private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
|
||||
private static final Pattern USERNAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._@+-]{0,149}");
|
||||
private static final Pattern EMAIL = Pattern.compile("[^@\\s]+@[^@\\s]+\\.[^@\\s]+");
|
||||
@@ -199,6 +213,21 @@ public class AuthentikAdmin
|
||||
EffectivePermissionResolver.Resolution resolution = resolver().resolveGroups(groupPks);
|
||||
permissions.addAll(resolution.sessionPermissions());
|
||||
appAccess.addAll(resolution.appAccess());
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// what the operator holds today through the host (and the static //
|
||||
// fallback) counts for the subset rule too (DESIGN.md 12.8) //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
List<String> groupNames = new ArrayList<>();
|
||||
for(int i = 0; groups != null && i < groups.length(); i++)
|
||||
{
|
||||
groupNames.add(groups.getJSONObject(i).optString("name"));
|
||||
}
|
||||
permissions.addAll(com.kingsrook.qbits.authentik.permissions.AccessComparison.staticPermissions(config, groupNames));
|
||||
if(config.getHostPermissionSource() != null)
|
||||
{
|
||||
permissions.addAll(config.getHostPermissionSource().userPermissions(email, groupNames));
|
||||
}
|
||||
}
|
||||
else if(QContext.getQSession() != null && QContext.getQSession().getPermissions() != null)
|
||||
{
|
||||
@@ -252,6 +281,9 @@ public class AuthentikAdmin
|
||||
case CREATE_GROUP -> planCreateGroup(plan, request);
|
||||
case EDIT_GROUP -> planEditGroup(plan, request);
|
||||
case EDIT_PERMISSIONS -> planEditPermissions(plan, request);
|
||||
case SAVE_POLICY -> planSavePolicy(plan, request);
|
||||
case DELETE_USER -> planDeleteUser(plan, request);
|
||||
case DELETE_GROUP -> planDeleteGroup(plan, request);
|
||||
case SEND_RECOVERY_EMAIL -> planRecovery(plan, request);
|
||||
case RESET_MFA -> planResetMfa(plan, request);
|
||||
case REVOKE_APP_PASSWORDS -> planAppPasswords(plan, request);
|
||||
@@ -656,6 +688,23 @@ public class AuthentikAdmin
|
||||
{
|
||||
return (plan);
|
||||
}
|
||||
String username = request.params().containsKey("username") && StringUtils.hasContent(request.str("username")) ? request.str("username") : user.optString("username");
|
||||
if(!username.equals(user.optString("username")))
|
||||
{
|
||||
if(!USERNAME.matcher(username).matches())
|
||||
{
|
||||
return (plan.refuse("A username is letters, digits and . _ @ + - (up to 150)."));
|
||||
}
|
||||
if(findUserByUsername(username) != null)
|
||||
{
|
||||
return (plan.refuse("Another user already has the username " + username + "."));
|
||||
}
|
||||
}
|
||||
String attributesProblem = otherAttributesProblem(request, user.optJSONObject("attributes"));
|
||||
if(attributesProblem != null)
|
||||
{
|
||||
return (plan.refuse(attributesProblem));
|
||||
}
|
||||
String name = request.params().containsKey("name") ? request.str("name") : user.optString("name");
|
||||
String email = request.params().containsKey("email") ? request.str("email") : user.optString("email");
|
||||
String path = request.params().containsKey("path") ? request.str("path") : user.optString("path");
|
||||
@@ -685,8 +734,14 @@ public class AuthentikAdmin
|
||||
return (plan.refuse("This user was changed (for example in the Authentik admin UI) since you opened it. Review it again."));
|
||||
}
|
||||
|
||||
String before = user.optString("name") + " <" + user.optString("email") + ">, " + user.optString("path");
|
||||
String after = name + " <" + email + ">, " + path;
|
||||
String before = user.optString("username") + ": " + user.optString("name") + " <" + user.optString("email") + ">, " + user.optString("path");
|
||||
String after = username + ": " + name + " <" + email + ">, " + path;
|
||||
String keys = changedAttributeKeys(request, user.optJSONObject("attributes"));
|
||||
if(keys != null)
|
||||
{
|
||||
before = before + "; attributes";
|
||||
after = after + "; attributes (" + keys + ")";
|
||||
}
|
||||
if(before.equals(after))
|
||||
{
|
||||
return (plan.noChange("Nothing to change."));
|
||||
@@ -923,7 +978,13 @@ public class AuthentikAdmin
|
||||
{
|
||||
return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again."));
|
||||
}
|
||||
if(newName.equals(group.optString("name")) && new LinkedHashSet<>(newParents).equals(new LinkedHashSet<>(currentParents)))
|
||||
String attributesProblem = otherAttributesProblem(request, group.optJSONObject("attributes"));
|
||||
if(attributesProblem != null)
|
||||
{
|
||||
return (plan.refuse(attributesProblem));
|
||||
}
|
||||
String keys = changedAttributeKeys(request, group.optJSONObject("attributes"));
|
||||
if(newName.equals(group.optString("name")) && new LinkedHashSet<>(newParents).equals(new LinkedHashSet<>(currentParents)) && keys == null)
|
||||
{
|
||||
return (plan.noChange("Nothing to change."));
|
||||
}
|
||||
@@ -940,7 +1001,8 @@ public class AuthentikAdmin
|
||||
}
|
||||
|
||||
plan.getRequest().params().put("resolvedParentPks", String.join(",", newParents));
|
||||
return (plan.withChange(group.optString("name") + ", parents " + names(currentParents), newName + ", parents " + names(newParents)));
|
||||
return (plan.withChange(group.optString("name") + ", parents " + names(currentParents) + (keys == null ? "" : "; attributes"),
|
||||
newName + ", parents " + names(newParents) + (keys == null ? "" : "; attributes (" + keys + ")")));
|
||||
}
|
||||
|
||||
|
||||
@@ -1002,6 +1064,442 @@ public class AuthentikAdmin
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Save a group's policy document (DESIGN.md section 12.4): parse it (fail
|
||||
** closed), regenerate it in canonical form (round-trip checked), refuse new
|
||||
** resources this app does not know, and apply the subset rule to the
|
||||
** permissions it adds.
|
||||
*******************************************************************************/
|
||||
private AdminPlan planSavePolicy(AdminPlan plan, AdminRequest request) throws QException
|
||||
{
|
||||
JSONObject group = targetGroup(plan, request.targetKey());
|
||||
if(group == null)
|
||||
{
|
||||
return (plan);
|
||||
}
|
||||
if(StringUtils.hasContent(request.str("generateProblem")))
|
||||
{
|
||||
return (plan.refuse(request.str("generateProblem")));
|
||||
}
|
||||
PolicyFormat format = config.getPolicyFormat();
|
||||
|
||||
List<String> oldNamed = nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS));
|
||||
Set<String> extrasNew = new TreeSet<>(AuthentikSafety.parseList(request.str(EXTRA_PERMISSIONS)));
|
||||
extrasNew.removeAll(oldNamed);
|
||||
|
||||
Set<String> newPermissions;
|
||||
JSONObject canonical;
|
||||
try
|
||||
{
|
||||
String submitted = request.str(POLICY_DOCUMENT);
|
||||
newPermissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).permissions() : new TreeSet<>();
|
||||
canonical = PolicyGenerator.generate(newPermissions, format, runtime.getPolicyCatalog());
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
return (plan.refuse("The policy is not valid: " + e.getMessage()));
|
||||
}
|
||||
|
||||
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
|
||||
Set<String> oldPermissions = new TreeSet<>();
|
||||
String storedProblem = null;
|
||||
if(stored != null && !JSONObject.NULL.equals(stored) && !"".equals(stored))
|
||||
{
|
||||
try
|
||||
{
|
||||
oldPermissions.addAll(PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).permissions());
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
storedProblem = e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
String fingerprint = request.str("expectedFingerprint");
|
||||
if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.groupFingerprint(group)))
|
||||
{
|
||||
return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again."));
|
||||
}
|
||||
|
||||
Set<String> added = new TreeSet<>(newPermissions);
|
||||
added.removeAll(oldPermissions);
|
||||
Set<String> removed = new TreeSet<>(oldPermissions);
|
||||
removed.removeAll(newPermissions);
|
||||
|
||||
Set<String> unknownNew = runtime.getPolicyCatalog().unknownPermissions(added);
|
||||
if(!unknownNew.isEmpty())
|
||||
{
|
||||
return (plan.refuse("These are not permissions of this app: " + String.join(", ", unknownNew) + ". Pick them in the editor."));
|
||||
}
|
||||
|
||||
if(added.isEmpty() && removed.isEmpty() && storedProblem == null && extrasNew.isEmpty())
|
||||
{
|
||||
return (plan.noChange("Nothing to change."));
|
||||
}
|
||||
|
||||
Set<String> needed = new TreeSet<>(added);
|
||||
for(String extra : extrasNew)
|
||||
{
|
||||
needed.addAll(resolver().expand(extra));
|
||||
}
|
||||
Set<String> missing = AuthentikSafety.missing(needed, operator().permissions());
|
||||
if(!missing.isEmpty())
|
||||
{
|
||||
return (plan.refuse("You cannot grant permissions you do not hold yourself: " + String.join(", ", missing)));
|
||||
}
|
||||
|
||||
plan.withChange(policySummary(oldPermissions), policySummary(newPermissions));
|
||||
if(request.bool("generated"))
|
||||
{
|
||||
plan.getNotes().add("Generated from current access: " + PolicyGenerator.toCompact(canonical));
|
||||
if(StringUtils.hasContent(request.str("skippedPermissions")))
|
||||
{
|
||||
plan.getNotes().add("Skipped (this app does not define them): " + request.str("skippedPermissions").replace("\n", ", "));
|
||||
}
|
||||
}
|
||||
if(!extrasNew.isEmpty())
|
||||
{
|
||||
plan.getNotes().add("Also added to qqq.io/permissions (not expressible as a policy): " + abbreviate(extrasNew));
|
||||
}
|
||||
if(!added.isEmpty())
|
||||
{
|
||||
plan.getNotes().add("Adds: " + abbreviate(added));
|
||||
}
|
||||
if(!removed.isEmpty())
|
||||
{
|
||||
plan.getNotes().add("Removes: " + abbreviate(removed));
|
||||
}
|
||||
if(storedProblem != null)
|
||||
{
|
||||
plan.getNotes().add("The policy stored in Authentik was not valid (" + storedProblem + "); saving replaces it.");
|
||||
}
|
||||
Set<String> keptUnknown = runtime.getPolicyCatalog().unknownPermissions(newPermissions);
|
||||
if(!keptUnknown.isEmpty())
|
||||
{
|
||||
plan.getNotes().add("Kept although this app does not define them: " + abbreviate(keptUnknown));
|
||||
}
|
||||
JSONArray users = group.optJSONArray("users");
|
||||
plan.getNotes().add((users == null ? 0 : users.length()) + " member(s), and the members of groups below it, get the change at their next sign-in or token refresh.");
|
||||
plan.getNotes().add(canonical.getJSONArray("Statement").length() + " statement(s), " + newPermissions.size() + " permission(s); the document round-trips.");
|
||||
|
||||
if(Boolean.TRUE.equals(config.getPolicyTwoPersonApproval()) && !request.bool("approved"))
|
||||
{
|
||||
plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("policy changes need a second person");
|
||||
}
|
||||
return (plan);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fill a Save Policy request from what members of the group get TODAY from
|
||||
** the host (DESIGN.md section 12.8): the policy-expressible permissions
|
||||
** (joined with the group's current policy, so nothing is removed) become
|
||||
** the document; named permissions this app defines but a policy cannot
|
||||
** express go to qqq.io/permissions; the rest are listed as skipped.
|
||||
*******************************************************************************/
|
||||
public AdminRequest withGeneratedPolicy(AdminRequest request) throws QException
|
||||
{
|
||||
request.with("generated", true);
|
||||
if(config.getHostPermissionSource() == null)
|
||||
{
|
||||
return (request.with("generateProblem", "This app has no host permission source, so there is no current access to generate from."));
|
||||
}
|
||||
JSONObject group = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false");
|
||||
if(group == null)
|
||||
{
|
||||
return (request.with("generateProblem", "That group no longer exists."));
|
||||
}
|
||||
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, runtime.getPolicyCatalog()));
|
||||
|
||||
Set<String> expressible = runtime.getPolicyCatalog().permissionNames();
|
||||
Set<String> extras = new TreeSet<>();
|
||||
Set<String> skipped = new TreeSet<>();
|
||||
for(String permission : config.getHostPermissionSource().groupPermissions(group.optString("name")))
|
||||
{
|
||||
if(expressible.contains(permission))
|
||||
{
|
||||
policy.add(permission);
|
||||
}
|
||||
else if(!resolver().expand(permission).isEmpty())
|
||||
{
|
||||
extras.add(permission);
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped.add(permission);
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
request.with(POLICY_DOCUMENT, PolicyGenerator.toCompact(PolicyGenerator.generate(policy, format, runtime.getPolicyCatalog())));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
return (request.with("generateProblem", "The current access cannot be written as a policy: " + e.getMessage()));
|
||||
}
|
||||
return (request.with(EXTRA_PERMISSIONS, String.join("\n", extras)).with("skippedPermissions", String.join("\n", skipped)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a person (DESIGN.md section 12.5): explicit, typed, never yourself,
|
||||
** a protected account or the last identity administrator.
|
||||
*******************************************************************************/
|
||||
private AdminPlan planDeleteUser(AdminPlan plan, AdminRequest request) throws QException
|
||||
{
|
||||
JSONObject user = targetUser(plan, request.targetKey(), false);
|
||||
if(user == null)
|
||||
{
|
||||
return (plan);
|
||||
}
|
||||
String typed = typedProblem(request, user.optString("username"));
|
||||
if(typed != null)
|
||||
{
|
||||
return (plan.refuse(typed));
|
||||
}
|
||||
Set<Integer> before = adminsNow();
|
||||
if(before.contains(user.optInt("pk")))
|
||||
{
|
||||
List<AuthentikSafety.UserFacts> changed = new ArrayList<>();
|
||||
for(AuthentikSafety.UserFacts facts : userFacts())
|
||||
{
|
||||
if(facts.pk() != user.optInt("pk"))
|
||||
{
|
||||
changed.add(facts);
|
||||
}
|
||||
}
|
||||
if(AuthentikSafety.removesLastAdmin(before, AuthentikSafety.qualifyingAdmins(membersGraph(), new EffectivePermissionResolver(membersGraph(), runtime.getPermissionCatalog()), changed)))
|
||||
{
|
||||
return (plan.refuse("This is the last active person who can manage memberships and permissions. Add another administrator first."));
|
||||
}
|
||||
}
|
||||
plan.getNotes().add("Deleting cannot be undone. Deactivate User keeps the account and its history.");
|
||||
return (plan.withChange(user.optString("username") + " <" + user.optString("email") + ">", "deleted"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a group (DESIGN.md section 12.5): explicit, typed, and only when it
|
||||
** has no members and no child groups (remove them first, so nobody loses
|
||||
** access by surprise).
|
||||
*******************************************************************************/
|
||||
private AdminPlan planDeleteGroup(AdminPlan plan, AdminRequest request) throws QException
|
||||
{
|
||||
JSONObject group = targetGroup(plan, request.targetKey());
|
||||
if(group == null)
|
||||
{
|
||||
return (plan);
|
||||
}
|
||||
String name = group.optString("name");
|
||||
if(!request.str("confirmText").equals(name) && !request.bool("approved"))
|
||||
{
|
||||
return (plan.refuse("Type the group name (" + name + ") to confirm."));
|
||||
}
|
||||
JSONArray users = group.optJSONArray("users");
|
||||
if(users != null && !users.isEmpty())
|
||||
{
|
||||
return (plan.refuse("The group has " + users.length() + " member(s). Remove them first."));
|
||||
}
|
||||
List<String> children = new ArrayList<>();
|
||||
for(GroupGraph.Node node : graph().nodes())
|
||||
{
|
||||
if(node.parentPks().contains(request.targetKey()))
|
||||
{
|
||||
children.add(node.name());
|
||||
}
|
||||
}
|
||||
if(!children.isEmpty())
|
||||
{
|
||||
return (plan.refuse("The group has child groups (" + String.join(", ", children) + "). Change their parents first."));
|
||||
}
|
||||
plan.getNotes().add("Deleting cannot be undone. Its permissions, app access and policy go with it.");
|
||||
return (plan.withChange(name, "deleted"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Why the submitted other-attributes JSON cannot be applied, or null. It must
|
||||
** be a JSON object without reserved keys (qqq.io/ and goauthentik.io/ are
|
||||
** set elsewhere), and editing attributes needs authentik.audit.read
|
||||
** (attributes are sensitive, DESIGN.md section 5.3).
|
||||
*******************************************************************************/
|
||||
private String otherAttributesProblem(AdminRequest request, JSONObject current) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(request.str(OTHER_ATTRIBUTES)))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
JSONObject submitted = parseOtherAttributes(request.str(OTHER_ATTRIBUTES));
|
||||
for(String key : submitted.keySet())
|
||||
{
|
||||
if(isReservedAttribute(key))
|
||||
{
|
||||
return ("The attribute " + key + " is reserved; it is set by Edit Permissions, Edit Policy or Authentik itself.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
return (e.getMessage());
|
||||
}
|
||||
if(changedAttributeKeys(request, current) != null && !operator().permissions().contains(IdentityPermission.AUDIT_READ.getSessionPermission()) && !operator().bootstrap())
|
||||
{
|
||||
return ("Editing attributes needs " + IdentityPermission.AUDIT_READ.getShortName() + ".");
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The keys whose values the submitted attributes change (added, changed or
|
||||
** removed), or null for no change. Blank means "leave them as they are"
|
||||
** ({} removes them all). A null current compares against nothing.
|
||||
*******************************************************************************/
|
||||
private static String changedAttributeKeys(AdminRequest request, JSONObject current) throws QUserFacingException
|
||||
{
|
||||
if(!StringUtils.hasContent(request.str(OTHER_ATTRIBUTES)))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
JSONObject submitted = parseOtherAttributes(request.str(OTHER_ATTRIBUTES));
|
||||
JSONObject others = otherAttributes(current);
|
||||
Set<String> changed = new TreeSet<>();
|
||||
for(String key : submitted.keySet())
|
||||
{
|
||||
if(!others.has(key) || !String.valueOf(others.get(key)).equals(String.valueOf(submitted.get(key))))
|
||||
{
|
||||
changed.add(key);
|
||||
}
|
||||
}
|
||||
for(String key : others.keySet())
|
||||
{
|
||||
if(!submitted.has(key))
|
||||
{
|
||||
changed.add(key);
|
||||
}
|
||||
}
|
||||
return (changed.isEmpty() ? null : String.join(", ", changed));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The current attributes with the non-reserved part replaced by the
|
||||
** submitted object (reserved keys are kept as they are).
|
||||
*******************************************************************************/
|
||||
static JSONObject mergeOtherAttributes(JSONObject current, String submittedText) throws QUserFacingException
|
||||
{
|
||||
JSONObject rs = new JSONObject();
|
||||
if(current != null)
|
||||
{
|
||||
for(String key : current.keySet())
|
||||
{
|
||||
if(isReservedAttribute(key))
|
||||
{
|
||||
rs.put(key, current.get(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
JSONObject submitted = parseOtherAttributes(submittedText);
|
||||
for(String key : submitted.keySet())
|
||||
{
|
||||
if(!isReservedAttribute(key))
|
||||
{
|
||||
rs.put(key, submitted.get(key));
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The non-reserved attributes, as shown on Edit User and Edit Group.
|
||||
*******************************************************************************/
|
||||
public static JSONObject otherAttributes(JSONObject attributes)
|
||||
{
|
||||
JSONObject rs = new JSONObject();
|
||||
if(attributes != null)
|
||||
{
|
||||
for(String key : attributes.keySet())
|
||||
{
|
||||
if(!isReservedAttribute(key))
|
||||
{
|
||||
rs.put(key, attributes.get(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keys QQQ manages elsewhere (qqq.io/) or Authentik owns (goauthentik.io/).
|
||||
*******************************************************************************/
|
||||
static boolean isReservedAttribute(String key)
|
||||
{
|
||||
return (key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX) || key.startsWith("goauthentik.io/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A submitted attributes text as a JSON object (blank means none).
|
||||
*******************************************************************************/
|
||||
private static JSONObject parseOtherAttributes(String text) throws QUserFacingException
|
||||
{
|
||||
if(!StringUtils.hasContent(text))
|
||||
{
|
||||
return (new JSONObject());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new JSONObject(text));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QUserFacingException("Other attributes must be a JSON object, e.g. {\"department\": \"sales\"}."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "n permission(s): a, b, ..." for the audit before/after.
|
||||
*******************************************************************************/
|
||||
private static String policySummary(Set<String> permissions)
|
||||
{
|
||||
return (permissions.isEmpty() ? "no policy" : permissions.size() + " permission(s): " + abbreviate(permissions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first 12 names, then "and n more".
|
||||
*******************************************************************************/
|
||||
private static String abbreviate(Set<String> names)
|
||||
{
|
||||
List<String> list = new ArrayList<>(names);
|
||||
if(list.size() <= 12)
|
||||
{
|
||||
return (String.join(", ", list));
|
||||
}
|
||||
return (String.join(", ", list.subList(0, 12)) + " and " + (list.size() - 12) + " more");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send the password-reset email (Authentik's own email; no link is shown).
|
||||
*******************************************************************************/
|
||||
@@ -1353,13 +1851,18 @@ public class AuthentikAdmin
|
||||
case EDIT_USER:
|
||||
{
|
||||
JSONObject body = new JSONObject();
|
||||
for(String key : List.of("name", "email", "path"))
|
||||
for(String key : List.of("username", "name", "email", "path"))
|
||||
{
|
||||
if(request.params().containsKey(key))
|
||||
if(request.params().containsKey(key) && (!key.equals("username") || StringUtils.hasContent(request.str(key))))
|
||||
{
|
||||
body.put(key, request.str(key));
|
||||
}
|
||||
}
|
||||
if(changedAttributeKeys(request, null) != null)
|
||||
{
|
||||
JSONObject fresh = util.getObject(AuthentikTables.get(AuthentikTables.USER), request.targetKey());
|
||||
body.put("attributes", mergeOtherAttributes(fresh == null ? null : fresh.optJSONObject("attributes"), request.str(OTHER_ATTRIBUTES)));
|
||||
}
|
||||
util.write(auth, "PATCH", "/core/users/" + safeKey(request.targetKey()) + "/", body);
|
||||
return ("User updated.");
|
||||
}
|
||||
@@ -1409,6 +1912,12 @@ public class AuthentikAdmin
|
||||
{
|
||||
body.put("name", request.str("name"));
|
||||
}
|
||||
if(changedAttributeKeys(request, null) != null)
|
||||
{
|
||||
runtime.invalidateCache();
|
||||
JSONObject fresh = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false");
|
||||
body.put("attributes", mergeOtherAttributes(fresh == null ? null : fresh.optJSONObject("attributes"), request.str(OTHER_ATTRIBUTES)));
|
||||
}
|
||||
if(request.params().containsKey("resolvedParentPks"))
|
||||
{
|
||||
JSONArray parents = new JSONArray();
|
||||
@@ -1440,6 +1949,24 @@ public class AuthentikAdmin
|
||||
util.write(auth, "PATCH", "/core/groups/" + safeKey(request.targetKey()) + "/", new JSONObject().put("attributes", attributes));
|
||||
return ("Permissions and app access updated.");
|
||||
}
|
||||
case SAVE_POLICY:
|
||||
return (performSavePolicy(plan, auth));
|
||||
case DELETE_USER:
|
||||
util.write(auth, "DELETE", "/core/users/" + safeKey(request.targetKey()) + "/", null);
|
||||
return ("User deleted in Authentik.");
|
||||
case DELETE_GROUP:
|
||||
{
|
||||
util.write(auth, "DELETE", "/core/groups/" + safeKey(request.targetKey()) + "/", null);
|
||||
try
|
||||
{
|
||||
PolicyStore.delete(config, request.targetKey());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("The group was deleted but its policy record could not be removed", logPair("group", request.targetKey()), logPair("error", e.getMessage()));
|
||||
}
|
||||
return ("Group deleted in Authentik.");
|
||||
}
|
||||
case SEND_RECOVERY_EMAIL:
|
||||
sendRecovery(auth, request.targetKey());
|
||||
return ("Password reset email sent by Authentik.");
|
||||
@@ -1469,6 +1996,69 @@ public class AuthentikAdmin
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write the canonical policy into the group's CURRENT attributes (re-read
|
||||
** right before, so other attributes and UI edits survive), then record the
|
||||
** save in authentik_policy. Authentik is the source of truth: if only the
|
||||
** QQQ record fails, the result says so but the save stands.
|
||||
*******************************************************************************/
|
||||
private String performSavePolicy(AdminPlan plan, WriteAuthorization auth) throws QException
|
||||
{
|
||||
AdminRequest request = plan.getRequest();
|
||||
PolicyFormat format = config.getPolicyFormat();
|
||||
Set<String> permissions;
|
||||
JSONObject canonical;
|
||||
try
|
||||
{
|
||||
String submitted = request.str(POLICY_DOCUMENT);
|
||||
permissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).permissions() : new TreeSet<>();
|
||||
canonical = PolicyGenerator.generate(permissions, format, runtime.getPolicyCatalog());
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
throw (new QUserFacingException("The policy is not valid: " + e.getMessage()));
|
||||
}
|
||||
|
||||
runtime.invalidateCache();
|
||||
JSONObject fresh = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false");
|
||||
if(fresh == null)
|
||||
{
|
||||
throw (new QUserFacingException("That group no longer exists."));
|
||||
}
|
||||
JSONObject attributes = fresh.optJSONObject("attributes") == null ? new JSONObject() : new JSONObject(fresh.getJSONObject("attributes").toString());
|
||||
String compact = PolicyGenerator.toCompact(canonical);
|
||||
List<String> extras = AuthentikSafety.parseList(request.str(EXTRA_PERMISSIONS));
|
||||
if(!extras.isEmpty())
|
||||
{
|
||||
Set<String> named = new java.util.LinkedHashSet<>(nonNull(AuthentikJson.stringList(attributes, AuthentikJson.ATTR_PERMISSIONS)));
|
||||
named.addAll(extras);
|
||||
putList(attributes, AuthentikJson.ATTR_PERMISSIONS, new ArrayList<>(named));
|
||||
}
|
||||
if(permissions.isEmpty())
|
||||
{
|
||||
attributes.remove(format.attribute());
|
||||
}
|
||||
else
|
||||
{
|
||||
attributes.put(format.attribute(), compact);
|
||||
}
|
||||
util.write(auth, "PATCH", "/core/groups/" + safeKey(request.targetKey()) + "/", new JSONObject().put("attributes", attributes));
|
||||
|
||||
try
|
||||
{
|
||||
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissions.size(), guard.operatorEmail(),
|
||||
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format, runtime.getPolicyCatalog()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("The policy was saved to Authentik but its QQQ record could not be written", logPair("group", request.targetKey()), logPair("error", e.getMessage()));
|
||||
return ("Policy saved to Authentik, but its QQQ record could not be written: " + e.getMessage());
|
||||
}
|
||||
return (permissions.isEmpty() ? "Policy removed from the group in Authentik." : "Policy saved to Authentik.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the user (no password), add them to their groups, send the
|
||||
** password email. A failure after the user exists says so.
|
||||
@@ -1834,7 +2424,7 @@ public class AuthentikAdmin
|
||||
/*******************************************************************************
|
||||
** A path segment that is a plain key (digits, letters, dashes, dots, colons).
|
||||
*******************************************************************************/
|
||||
static String safeKey(String key) throws QUserFacingException
|
||||
public static String safeKey(String key) throws QUserFacingException
|
||||
{
|
||||
if(key == null || !key.matches("[A-Za-z0-9._-]{1,150}"))
|
||||
{
|
||||
|
||||
@@ -20,6 +20,8 @@ package com.kingsrook.qbits.authentik.admin;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
@@ -27,6 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValu
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -41,6 +44,7 @@ public final class AuthentikLocalTables
|
||||
{
|
||||
public static final String ADMIN_ACTION = "admin_action";
|
||||
public static final String APPROVAL_REQUEST = "approval_request";
|
||||
public static final String POLICY = "policy";
|
||||
public static final String PVS_ACTION_STATUS = "authentik_action_status";
|
||||
public static final String PVS_APPROVAL_STATUS = "authentik_approval_status";
|
||||
|
||||
@@ -212,6 +216,30 @@ public final class AuthentikLocalTables
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** authentik_policy: one row per group with a policy (DESIGN.md section 12).
|
||||
** Authentik holds the live document; this row is the QQQ record of the last
|
||||
** save, and field-level audit is its change history. Written only by the
|
||||
** Edit Policy action.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData policy(AuthentikQBitConfig config)
|
||||
{
|
||||
return (base(config, POLICY, "Policies", "policy", IdentityPermission.USERS_READ)
|
||||
.withRecordLabelFormatAndFields("%s", "groupName")
|
||||
.withUniqueKey(new UniqueKey("groupPk"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withField(ro("groupPk", QFieldType.STRING).withLabel("Group Id").withMaxLength(64))
|
||||
.withField(ro("groupName", QFieldType.STRING).withLabel("Group").withMaxLength(255))
|
||||
.withField(ro("policyJson", QFieldType.TEXT).withLabel("Policy Document"))
|
||||
.withField(ro("statementCount", QFieldType.INTEGER).withLabel("Statements"))
|
||||
.withField(ro("permissionCount", QFieldType.INTEGER).withLabel("Permissions"))
|
||||
.withField(ro("savedBy", QFieldType.STRING).withLabel("Saved By").withMaxLength(255))
|
||||
.withField(ro("savedDate", QFieldType.DATE_TIME).withLabel("Saved"))
|
||||
.withField(ro("authentikFingerprint", QFieldType.STRING).withLabel("Authentik Fingerprint").withMaxLength(64)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Common shape: id, create and modify dates, no generic writes.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -141,7 +141,7 @@ public final class AuthentikSafety
|
||||
{
|
||||
List<Integer> members = new ArrayList<>(node.memberPks());
|
||||
members.remove(Integer.valueOf(userPk));
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed()));
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed(), node.policyPermissions()));
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ public final class AuthentikSafety
|
||||
*******************************************************************************/
|
||||
public static GroupGraph.Node withPermissions(GroupGraph.Node node, List<String> permissions, List<String> appAccess)
|
||||
{
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed()));
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed(), node.policyPermissions()));
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ public final class AuthentikSafety
|
||||
*******************************************************************************/
|
||||
public static GroupGraph.Node withParents(GroupGraph.Node node, List<String> parentPks)
|
||||
{
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed()));
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed(), node.policyPermissions()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -212,6 +212,22 @@ public final class AuthentikHomeView
|
||||
row(html, "Version", data.getVersion() == null ? "Not reachable" : data.getVersion());
|
||||
row(html, "Service account", data.getConfig().getEffectiveServiceAccountUsername());
|
||||
row(html, "Changes", Boolean.TRUE.equals(data.getConfig().getReadOnly()) ? "Off (read-only)" : "On, every change is audited here and in Authentik");
|
||||
if(data.getConfig().hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.POLICIES))
|
||||
{
|
||||
String state;
|
||||
try
|
||||
{
|
||||
com.kingsrook.qbits.authentik.permissions.AccessComparison.State mapper = com.kingsrook.qbits.authentik.permissions.AccessComparison.state(
|
||||
com.kingsrook.qbits.authentik.AuthentikRuntime.forBackend(data.getConfig().getApiBackendName()),
|
||||
com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(data.getConfig().getApiBackendName()));
|
||||
state = (mapper.enforced() ? "Enforced. " : "Not enforced. ") + mapper.message();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
state = "Unknown (" + e.getMessage() + ")";
|
||||
}
|
||||
row(html, "Policies at sign-in", state);
|
||||
}
|
||||
html.append("</dl>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.authentik.app;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -85,9 +86,22 @@ public final class AuthentikRelations
|
||||
groupSections.add(new QFieldSection("permissions", "Permissions and app access", new QIcon("admin_panel_settings"), Tier.T2,
|
||||
List.of("permissions", "effectivePermissions", "appAccess")));
|
||||
relate(qInstance, groupSections, "members", "Members", "group", group, membership, "pk", "groupPk");
|
||||
enforcement(qInstance, config, groupSections);
|
||||
relate(qInstance, groupSections, "policy", "Policy", "policy", group, config.applyTablePrefix(AuthentikLocalTables.POLICY), "pk", "groupPk");
|
||||
groupSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("attributes")));
|
||||
applySections(qInstance, group, groupSections);
|
||||
|
||||
String policyTable = config.applyTablePrefix(AuthentikLocalTables.POLICY);
|
||||
if(qInstance.getTable(policyTable) != null)
|
||||
{
|
||||
List<QFieldSection> policySections = new ArrayList<>();
|
||||
enforcement(qInstance, config, policySections);
|
||||
policySections.add(new QFieldSection("policy", "Policy", new QIcon("policy"), Tier.T1,
|
||||
List.of("groupName", "statementCount", "permissionCount", "savedBy", "savedDate", "policyJson")));
|
||||
policySections.add(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3, List.of("id", "groupPk", "authentikFingerprint", "createDate", "modifyDate")));
|
||||
applySections(qInstance, policyTable, policySections);
|
||||
}
|
||||
|
||||
List<QFieldSection> appSections = new ArrayList<>();
|
||||
appSections.add(new QFieldSection("details", "Details", new QIcon("apps"), Tier.T1,
|
||||
List.of("name", "slug", "group", "providerName", "policyEngineMode", "launchUrl", "publisher", "hiddenFromLibrary")));
|
||||
@@ -97,6 +111,20 @@ public final class AuthentikRelations
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The policy enforcement banner as the first section (when produced).
|
||||
*******************************************************************************/
|
||||
private static void enforcement(QInstance qInstance, AuthentikQBitConfig config, List<QFieldSection> sections)
|
||||
{
|
||||
String widget = config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.ENFORCEMENT);
|
||||
if(qInstance.getWidget(widget) != null)
|
||||
{
|
||||
sections.add(0, new QFieldSection("enforcement", new QIcon("verified_user"), Tier.T2).withLabel("Policy enforcement").withWidgetName(widget));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One join plus its child-list widget and the section that shows it.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -765,7 +765,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
throw (new QUserFacingException("More than " + MAX_SWEEP_OBJECTS + " Authentik groups; the group graph is not supported at this size."));
|
||||
}
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups);
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat(), 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);
|
||||
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);
|
||||
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);
|
||||
|
||||
+3
-2
@@ -70,11 +70,12 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
|
||||
// records as child lists //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
addSection(app, produced, config, "people", "People", "person", AuthentikTables.USER, AuthentikTables.INVITATION);
|
||||
addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikTables.APPLICATION);
|
||||
addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikLocalTables.POLICY, AuthentikTables.APPLICATION);
|
||||
addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION);
|
||||
|
||||
List<String> widgets = new ArrayList<>();
|
||||
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME)))
|
||||
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME),
|
||||
config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.COMPARE)))
|
||||
{
|
||||
if(produced.get(QWidgetMetaData.class, widgetName) != null)
|
||||
{
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare Access (DESIGN.md section 12.8): today's access against what the policies would grant, per active person. Read only.
|
||||
*******************************************************************************/
|
||||
public class AuthentikPolicyCompareWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Only with the POLICIES feature.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = getQBitConfig();
|
||||
return (new QWidgetMetaData()
|
||||
.withName(config.applyPrefix(PolicyCutoverWidgets.COMPARE))
|
||||
.withLabel("Compare Access")
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(12)
|
||||
.withIsCard(true)
|
||||
.withCodeReference(new QCodeReference(PolicyCutoverWidgets.CompareRenderer.class))
|
||||
.withPermissionRules(IdentityPermission.ADMIN.rules())
|
||||
.withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** After the tables, before the app.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (470);
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The policy editor widget (DESIGN.md section 12.4), shown on the Edit Policy
|
||||
** step. Gated by authentik.policies.manage.
|
||||
*******************************************************************************/
|
||||
public class AuthentikPolicyEditorWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Only with the POLICIES feature.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = getQBitConfig();
|
||||
return (new QWidgetMetaData()
|
||||
.withName(config.applyPrefix(PolicyEditorWidget.NAME))
|
||||
.withLabel("Policy")
|
||||
.withType(PolicyEditorWidget.TYPE)
|
||||
.withGridColumns(12)
|
||||
.withIsCard(false)
|
||||
.withCodeReference(new QCodeReference(PolicyEditorWidget.Renderer.class))
|
||||
.withPermissionRules(IdentityPermission.POLICIES_MANAGE.rules())
|
||||
.withDefaultValue(PolicyEditorWidget.BACKEND_NAME_KEY, config.getApiBackendName()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** After the tables, before the app.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (460);
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The policy enforcement banner (DESIGN.md section 12.8): whether saved policies take effect at sign-in on this instance.
|
||||
*******************************************************************************/
|
||||
public class AuthentikPolicyEnforcementWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Only with the POLICIES feature.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = getQBitConfig();
|
||||
return (new QWidgetMetaData()
|
||||
.withName(config.applyPrefix(PolicyCutoverWidgets.ENFORCEMENT))
|
||||
.withLabel("Policy Enforcement")
|
||||
.withType(WidgetType.HTML.getType())
|
||||
.withGridColumns(12)
|
||||
.withIsCard(false)
|
||||
.withCodeReference(new QCodeReference(PolicyCutoverWidgets.EnforcementRenderer.class))
|
||||
.withPermissionRules(IdentityPermission.USERS_READ.rules())
|
||||
.withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** After the tables, before the app.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (465);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** authentik_policy: the QQQ record (with change history) of each group policy.
|
||||
*******************************************************************************/
|
||||
public class AuthentikPolicyTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Needs the local backend and the POLICIES feature.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (AuthentikLocalTables.policy(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** After the backend.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (300);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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.permissions;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import org.json.JSONObject;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cut-over from a host's current permission source to Authentik group
|
||||
** policies (DESIGN.md section 12.8): for every active person, what they get
|
||||
** today (the host source) against what the mapper would grant (their groups'
|
||||
** policies and permissions, the static fallback and bootstrap subjects), and
|
||||
** the guard that keeps the mapper inactive while a member of a guarded group
|
||||
** would lose a permission.
|
||||
*******************************************************************************/
|
||||
public final class AccessComparison
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AccessComparison.class);
|
||||
|
||||
/** How long a computed mapper state is reused (sign-ins read it). */
|
||||
public static final Duration STATE_TTL = Duration.ofSeconds(60);
|
||||
|
||||
/** Banner shown wherever policies are edited while they are not enforced. */
|
||||
public static final String NOT_ENFORCED = "Policies are saved but NOT enforced at sign-in on this instance (mapper off).";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One person.
|
||||
*******************************************************************************/
|
||||
public record Row(int pk, String username, String email, List<String> groups, Set<String> before, Set<String> after,
|
||||
Set<String> added, Set<String> removed, boolean guarded)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the mapper is enforcing policies, and why not.
|
||||
*******************************************************************************/
|
||||
public enum Status
|
||||
{
|
||||
/** The host does not use the mapper: policies are saved, not enforced. */
|
||||
OFF,
|
||||
/** Enforced; the guard passed (or there is nothing to guard). */
|
||||
ACTIVE,
|
||||
/** Enforced because the owner overrode a failing guard. */
|
||||
OVERRIDDEN,
|
||||
/** Not enforced: the guard failed (or could not run); sign-in uses the host source. */
|
||||
BLOCKED
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A mapper state with a plain message.
|
||||
*******************************************************************************/
|
||||
public record State(Status status, String message, Instant checkedAt)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Whether sign-in permissions come from the policies.
|
||||
*******************************************************************************/
|
||||
public boolean enforced()
|
||||
{
|
||||
return (status == Status.ACTIVE || status == Status.OVERRIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AccessComparison()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare every active person (internal and external users).
|
||||
*******************************************************************************/
|
||||
public static List<Row> run(AuthentikQBitConfig config, AuthentikActionUtil util, Collection<String> catalog) throws QException
|
||||
{
|
||||
GroupGraph graph = util.groupGraphWithMembers();
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, catalog);
|
||||
AuthentikHostPermissionSource source = config.getHostPermissionSource();
|
||||
List<Row> rows = new ArrayList<>();
|
||||
for(JSONObject user : util.allUsers())
|
||||
{
|
||||
String type = user.optString("type");
|
||||
if(!user.optBoolean("is_active", true) || !("internal".equals(type) || "external".equals(type)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int pk = user.optInt("pk");
|
||||
List<String> groupPks = graph.groupsOfUser(pk);
|
||||
List<String> names = new ArrayList<>();
|
||||
for(String groupPk : groupPks)
|
||||
{
|
||||
names.add(graph.byPk(groupPk).name());
|
||||
}
|
||||
String email = user.optString("email", null);
|
||||
Set<String> before = new TreeSet<>(source == null ? Set.of() : source.userPermissions(email, names));
|
||||
Set<String> after = mapperPermissions(config, resolver, groupPks, names, String.valueOf(pk), user.optString("uuid"));
|
||||
Set<String> added = new TreeSet<>(after);
|
||||
added.removeAll(before);
|
||||
Set<String> removed = new TreeSet<>(before);
|
||||
removed.removeAll(after);
|
||||
boolean guarded = names.stream().anyMatch(n -> config.getCutoverGuardGroups() != null && config.getCutoverGuardGroups().contains(n));
|
||||
rows.add(new Row(pk, user.optString("username"), email, names, before, after, added, removed, guarded));
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What the mapper grants for a person's direct groups: the groups' (and
|
||||
** ancestors') qqq.io/permissions and policies, the static fallback of the
|
||||
** direct groups, and every authentik.* permission for a bootstrap subject.
|
||||
*******************************************************************************/
|
||||
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
|
||||
String userPk, String userUuid)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>(resolver.resolveGroups(groupPks).sessionPermissions());
|
||||
rs.addAll(staticPermissions(config, directGroupNames));
|
||||
Set<String> subjects = config.getResolvedBootstrapSubjects();
|
||||
if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid)))
|
||||
{
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
rs.add(permission.getSessionPermission());
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The static fallback permissions of some direct groups.
|
||||
*******************************************************************************/
|
||||
public static Set<String> staticPermissions(AuthentikQBitConfig config, Collection<String> directGroupNames)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
if(config.getStaticGroupPermissions() != null)
|
||||
{
|
||||
for(String name : directGroupNames)
|
||||
{
|
||||
rs.addAll(config.getStaticGroupPermissions().getOrDefault(name, List.of()));
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The people in guarded groups who would lose a permission (the guard fails
|
||||
** when this is not empty).
|
||||
*******************************************************************************/
|
||||
public static List<Row> guardFailures(List<Row> rows)
|
||||
{
|
||||
return (rows.stream().filter(r -> r.guarded() && !r.removed().isEmpty()).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The mapper state of a backend (cached for STATE_TTL; sign-ins read it).
|
||||
*******************************************************************************/
|
||||
public static State state(AuthentikRuntime runtime, AuthentikActionUtil util)
|
||||
{
|
||||
State cached = runtime.getMapperState();
|
||||
if(cached != null && cached.checkedAt().plus(STATE_TTL).isAfter(Instant.now()))
|
||||
{
|
||||
return (cached);
|
||||
}
|
||||
State state = compute(runtime, util);
|
||||
runtime.setMapperState(state);
|
||||
return (state);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compute the mapper state now.
|
||||
*******************************************************************************/
|
||||
static State compute(AuthentikRuntime runtime, AuthentikActionUtil util)
|
||||
{
|
||||
AuthentikQBitConfig config = runtime.getConfig();
|
||||
if(!Boolean.TRUE.equals(config.getMapperEnabled()) && !runtime.isMapperInvoked())
|
||||
{
|
||||
return (new State(Status.OFF, NOT_ENFORCED, Instant.now()));
|
||||
}
|
||||
if(config.getCutoverGuardGroups() == null || config.getCutoverGuardGroups().isEmpty())
|
||||
{
|
||||
return (new State(Status.ACTIVE, "Policies are enforced at sign-in.", Instant.now()));
|
||||
}
|
||||
if(Boolean.TRUE.equals(config.getMapperOverride()))
|
||||
{
|
||||
return (new State(Status.OVERRIDDEN, "Policies are enforced at sign-in; the cut-over guard is overridden by the owner.", Instant.now()));
|
||||
}
|
||||
try
|
||||
{
|
||||
List<Row> failures = guardFailures(run(config, util, runtime.getPermissionCatalog()));
|
||||
if(failures.isEmpty())
|
||||
{
|
||||
return (new State(Status.ACTIVE, "Policies are enforced at sign-in; the cut-over guard passed (no member of "
|
||||
+ String.join(", ", config.getCutoverGuardGroups()) + " loses a permission).", Instant.now()));
|
||||
}
|
||||
List<String> names = failures.stream().map(Row::username).limit(5).toList();
|
||||
return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard found " + failures.size() + " member(s) of "
|
||||
+ String.join(", ", config.getCutoverGuardGroups()) + " who would lose permissions (" + String.join(", ", names)
|
||||
+ "). Sign-in keeps today's access until the policies cover it, or the owner overrides the guard.", Instant.now()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("The cut-over guard could not run; the mapper stays inactive", logPair("error", e.getMessage()));
|
||||
return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard could not check Authentik ("
|
||||
+ e.getMessage() + "). Sign-in keeps today's access.", Instant.now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -27,6 +27,9 @@ 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;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
@@ -107,6 +110,58 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
boolean verified = claims.optBoolean("email_verified", false);
|
||||
|
||||
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, sub, email, verified));
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// the policy claim (docs/POLICY-FORMAT.md) carries the same group data //
|
||||
// as the graph read above; the union also covers an unreachable API //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
AuthentikRuntime runtime = runtime();
|
||||
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
|
||||
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format,
|
||||
runtime == null ? (qInstance == null ? null : PolicyCatalog.of(qInstance)) : runtime.getPolicyCatalog()));
|
||||
|
||||
if(runtime != null)
|
||||
{
|
||||
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// the cut-over guard (DESIGN.md section 12.8): while it blocks, the //
|
||||
// session gets today's access from the host plus the floor (static //
|
||||
// fallbacks, bootstrap subjects), never the policies alone //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
runtime.markMapperInvoked();
|
||||
AccessComparison.State state;
|
||||
try
|
||||
{
|
||||
state = AccessComparison.state(runtime, client(qInstance));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now());
|
||||
}
|
||||
if(!state.enforced())
|
||||
{
|
||||
Set<String> floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups));
|
||||
for(String group : groups)
|
||||
{
|
||||
floor.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
|
||||
}
|
||||
if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified))
|
||||
{
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
floor.add(permission.getSessionPermission());
|
||||
}
|
||||
}
|
||||
AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource();
|
||||
if(source != null)
|
||||
{
|
||||
floor.addAll(source.userPermissions(email, groups));
|
||||
}
|
||||
LOG.warn("Policies are not enforced at this sign-in; granting today's access", logPair("reason", state.message()));
|
||||
permissions = floor;
|
||||
}
|
||||
}
|
||||
if(qSession.getPermissions() != null)
|
||||
{
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.permissions;
|
||||
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SPI: the permissions a host grants TODAY, before Authentik group policies
|
||||
** take over (DESIGN.md section 12.8). Typically a hard-coded grant such as
|
||||
** "members of the admins group get adminPermissions()". The qbit uses it to
|
||||
** - generate a group's policy from current access,
|
||||
** - compare current access with what the mapper would grant, and
|
||||
** - fall back to it while the cut-over guard keeps the mapper inactive.
|
||||
*******************************************************************************/
|
||||
public interface AuthentikHostPermissionSource
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** The QQQ session permissions a user gets today.
|
||||
**
|
||||
** @param email the user's email (may be null)
|
||||
** @param groupNames the user's direct Authentik group names
|
||||
*******************************************************************************/
|
||||
Set<String> userPermissions(String email, Collection<String> groupNames);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permissions members of a group get today because of that group. The
|
||||
** default asks for a user in only that group.
|
||||
*******************************************************************************/
|
||||
default Set<String> groupPermissions(String groupName)
|
||||
{
|
||||
return (userPermissions(null, List.of(groupName)));
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -17,6 +17,7 @@
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -101,7 +102,9 @@ public class EffectivePermissionResolver
|
||||
continue;
|
||||
}
|
||||
appAccess.addAll(node.appAccess());
|
||||
for(String raw : node.permissions())
|
||||
List<String> raws = new ArrayList<>(node.permissions());
|
||||
raws.addAll(node.policyPermissions());
|
||||
for(String raw : raws)
|
||||
{
|
||||
Set<String> expanded = expand(raw);
|
||||
if(expanded.isEmpty())
|
||||
|
||||
@@ -27,6 +27,10 @@ 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;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -42,8 +46,16 @@ public class GroupGraph
|
||||
/*******************************************************************************
|
||||
** One group.
|
||||
*******************************************************************************/
|
||||
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
|
||||
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed,
|
||||
List<String> policyPermissions)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A group without a policy document.
|
||||
*******************************************************************************/
|
||||
public Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
|
||||
{
|
||||
this(pk, name, ownSuperuser, parentPks, permissions, appAccess, memberPks, malformed, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,9 +71,35 @@ public class GroupGraph
|
||||
*******************************************************************************/
|
||||
public static GroupGraph fromGroups(Collection<JSONObject> groups)
|
||||
{
|
||||
GroupGraph graph = new GroupGraph();
|
||||
return (fromGroups(groups, PolicyFormat.DEFAULT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build from Authentik group objects, reading each group's policy document
|
||||
** (docs/POLICY-FORMAT.md) from the format's attribute. A document that does
|
||||
** not parse grants nothing and is listed in the node's malformed entries.
|
||||
*******************************************************************************/
|
||||
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format)
|
||||
{
|
||||
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();
|
||||
for(JSONObject group : groups)
|
||||
{
|
||||
JSONObject attributes = group.optJSONObject("attributes");
|
||||
List<String> malformed = malformed(attributes);
|
||||
List<String> policy = policyPermissions(attributes, f, malformed, catalog);
|
||||
graph.add(new Node(
|
||||
group.getString("pk"),
|
||||
group.optString("name", ""),
|
||||
@@ -70,7 +108,8 @@ public class GroupGraph
|
||||
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)),
|
||||
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)),
|
||||
ints(group.optJSONArray("users")),
|
||||
malformed(group.optJSONObject("attributes"))));
|
||||
malformed,
|
||||
policy));
|
||||
}
|
||||
return (graph);
|
||||
}
|
||||
@@ -257,6 +296,30 @@ public class GroupGraph
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permissions of a group's policy document; none (and a malformed entry)
|
||||
** when it does not parse.
|
||||
*******************************************************************************/
|
||||
private static List<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed, PolicyCatalog catalog)
|
||||
{
|
||||
Object document = attributes == null ? null : attributes.opt(format.attribute());
|
||||
if(document == null || JSONObject.NULL.equals(document) || "".equals(document))
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (List.copyOf(PolicyParser.parseStrict(document, format, catalog).permissions()));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
malformed.add(format.attribute() + ": " + e.getMessage());
|
||||
return (List.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import org.json.JSONObject;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentication customizer that adds the QQQ permissions granted by the
|
||||
** policy claim of the sign-in token (docs/POLICY-FORMAT.md). It needs no
|
||||
** Authentik API access: Authentik merges the user's group policies into the
|
||||
** claim at sign-in (the scope mapping in the format doc).
|
||||
**
|
||||
** Order: applySecurityKeys (a no-op hook for hosts), then permissions. The
|
||||
** permissions are added to whatever the session already holds. A missing or
|
||||
** invalid claim adds nothing (fail closed).
|
||||
*******************************************************************************/
|
||||
public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikPolicyClaimMapper.class);
|
||||
|
||||
private PolicyFormat format = PolicyFormat.DEFAULT;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use these names (the qbit config's getPolicyFormat()) instead of the
|
||||
** defaults.
|
||||
*******************************************************************************/
|
||||
public AuthentikPolicyClaimMapper withFormat(PolicyFormat format)
|
||||
{
|
||||
this.format = format == null ? PolicyFormat.DEFAULT : format;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for format.
|
||||
*******************************************************************************/
|
||||
public PolicyFormat getFormat()
|
||||
{
|
||||
return (format);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add the permissions from the policy claim.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
|
||||
{
|
||||
JSONObject claims = PolicyPermissions.claimsWithPolicy(context, format);
|
||||
JSONObject any = claims;
|
||||
if(any == null && context != null)
|
||||
{
|
||||
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ID_TOKEN_CONTEXT_KEY));
|
||||
if(any == null)
|
||||
{
|
||||
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY));
|
||||
}
|
||||
}
|
||||
if(any != null)
|
||||
{
|
||||
applySecurityKeys(qSession, any);
|
||||
}
|
||||
|
||||
if(claims == null)
|
||||
{
|
||||
LOG.info("No policy claim in the token; no policy permissions added", logPair("claim", format.claim()));
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format, qInstance == null ? null : PolicyCatalog.of(qInstance)));
|
||||
if(qSession.getPermissions() != null)
|
||||
{
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
}
|
||||
qSession.setPermissions(permissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Extension point for record locks (security keys); a no-op here. A host
|
||||
** overrides it to set security key values from its OWN explicit claims, and
|
||||
** must never infer them from group names.
|
||||
*******************************************************************************/
|
||||
protected void applySecurityKeys(QSession qSession, JSONObject claims)
|
||||
{
|
||||
////////////////////////////////////////////////////////
|
||||
// no record locks in policy format 1.0.0 (by design) //
|
||||
////////////////////////////////////////////////////////
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The 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
|
||||
{
|
||||
HAS_ACCESS("hasAccess"),
|
||||
READ("read"),
|
||||
INSERT("insert"),
|
||||
EDIT("edit"),
|
||||
DELETE("delete");
|
||||
|
||||
/** "All five actions" in a statement's Action array. */
|
||||
public static final String WILDCARD = "*";
|
||||
|
||||
private final String jsonName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
PolicyAction(String jsonName)
|
||||
{
|
||||
this.jsonName = jsonName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The name used in documents and as the QQQ permission suffix.
|
||||
*******************************************************************************/
|
||||
public String getJsonName()
|
||||
{
|
||||
return (jsonName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The QQQ permission this action grants on a resource.
|
||||
*******************************************************************************/
|
||||
public String permission(String resource)
|
||||
{
|
||||
return (resource + "." + jsonName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up by document name (case-sensitive, like QQQ permission names).
|
||||
*******************************************************************************/
|
||||
public static Optional<PolicyAction> fromJsonName(String name)
|
||||
{
|
||||
for(PolicyAction action : values())
|
||||
{
|
||||
if(action.jsonName.equals(name))
|
||||
{
|
||||
return (Optional.of(action));
|
||||
}
|
||||
}
|
||||
return (Optional.empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
/*
|
||||
* 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.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a policy can grant in a QInstance (DESIGN.md section 12.4): every app,
|
||||
** table, process, report and protected widget, with its permission base name
|
||||
** (the policy resource) and the actions QQQ checks for it, grouped by app.
|
||||
**
|
||||
** Unlike PermissionsHelper.getAllAvailablePermissions, hidden tables and
|
||||
** processes are included: record actions are hidden from the nav but still
|
||||
** permission-checked. Objects with a custom permission checker or that are
|
||||
** NOT_PROTECTED are left out (a policy cannot grant them).
|
||||
*******************************************************************************/
|
||||
public final class PolicyCatalog
|
||||
{
|
||||
/** Group label of objects that are in no app. */
|
||||
public static final String OTHER = "Other";
|
||||
|
||||
/** Kinds of resources. */
|
||||
public enum Kind
|
||||
{
|
||||
APP, TABLE, PROCESS, REPORT, WIDGET
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One grantable resource.
|
||||
**
|
||||
** @param name the policy resource (permission base name)
|
||||
** @param label what operators see
|
||||
** @param kind app, table, process, report or widget
|
||||
** @param actions the actions QQQ checks, in canonical order
|
||||
** @param 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, 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)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The resources of one app (or of no app: OTHER).
|
||||
*******************************************************************************/
|
||||
public record Group(String app, String label, List<Resource> resources)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
private final List<Group> groups;
|
||||
private final Map<String, Resource> byName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
private PolicyCatalog(List<Group> groups)
|
||||
{
|
||||
this.groups = groups;
|
||||
this.byName = new LinkedHashMap<>();
|
||||
for(Group group : groups)
|
||||
{
|
||||
for(Resource resource : group.resources())
|
||||
{
|
||||
byName.putIfAbsent(resource.name(), resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the catalog of an instance.
|
||||
*******************************************************************************/
|
||||
public static PolicyCatalog of(QInstance qInstance)
|
||||
{
|
||||
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<>();
|
||||
|
||||
List<QAppMetaData> apps = new ArrayList<>(qInstance.getApps().values());
|
||||
apps.sort(Comparator.comparing((QAppMetaData a) -> a.getSortOrder() == null ? 500 : a.getSortOrder()).thenComparing(a -> label(a.getLabel(), a.getName())));
|
||||
for(QAppMetaData app : apps)
|
||||
{
|
||||
byApp.put(app.getName(), new LinkedHashMap<>());
|
||||
}
|
||||
byApp.put(null, new LinkedHashMap<>());
|
||||
|
||||
for(QAppMetaData app : apps)
|
||||
{
|
||||
add(byApp, app.getName(), qInstance, app, Kind.APP, label(app.getLabel(), app.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
|
||||
}
|
||||
|
||||
List<QTableMetaData> tables = new ArrayList<>(qInstance.getTables().values());
|
||||
tables.sort(Comparator.comparing(t -> label(t.getLabel(), t.getName())));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(table, qInstance);
|
||||
Set<PolicyAction> actions = EnumSet.noneOf(PolicyAction.class);
|
||||
String note = null;
|
||||
if(rules.getLevel() == PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
{
|
||||
actions.add(PolicyAction.HAS_ACCESS);
|
||||
}
|
||||
else
|
||||
{
|
||||
QBackendMetaData backend = qInstance.getBackend(table.getBackendName());
|
||||
if(table.isCapabilityEnabled(backend, Capability.TABLE_QUERY) || table.isCapabilityEnabled(backend, Capability.TABLE_GET))
|
||||
{
|
||||
actions.add(PolicyAction.READ);
|
||||
}
|
||||
if(rules.getLevel() == PermissionLevel.READ_WRITE_PERMISSIONS)
|
||||
{
|
||||
note = "This table uses one write permission instead of separate insert, edit and delete actions.";
|
||||
}
|
||||
else
|
||||
{
|
||||
if(table.isCapabilityEnabled(backend, Capability.TABLE_INSERT))
|
||||
{
|
||||
actions.add(PolicyAction.INSERT);
|
||||
}
|
||||
if(table.isCapabilityEnabled(backend, Capability.TABLE_UPDATE))
|
||||
{
|
||||
actions.add(PolicyAction.EDIT);
|
||||
}
|
||||
if(table.isCapabilityEnabled(backend, Capability.TABLE_DELETE))
|
||||
{
|
||||
actions.add(PolicyAction.DELETE);
|
||||
}
|
||||
}
|
||||
}
|
||||
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());
|
||||
processes.sort(Comparator.comparing(p -> label(p.getLabel(), p.getName())));
|
||||
for(QProcessMetaData process : processes)
|
||||
{
|
||||
String app = appOfObject.get("process:" + process.getName());
|
||||
if(app == null && process.getTableName() != null)
|
||||
{
|
||||
app = appOfObject.get("table:" + process.getTableName());
|
||||
}
|
||||
String label = label(process.getLabel(), process.getName());
|
||||
if(process.getTableName() != null && qInstance.getTable(process.getTableName()) != null)
|
||||
{
|
||||
label = label + " (" + label(qInstance.getTable(process.getTableName()).getLabel(), process.getTableName()) + " action)";
|
||||
}
|
||||
add(byApp, app, qInstance, process, Kind.PROCESS, label, EnumSet.of(PolicyAction.HAS_ACCESS), null);
|
||||
}
|
||||
|
||||
List<QReportMetaData> reports = new ArrayList<>(qInstance.getReports().values());
|
||||
reports.sort(Comparator.comparing(r -> label(r.getLabel(), r.getName())));
|
||||
for(QReportMetaData report : reports)
|
||||
{
|
||||
add(byApp, appOfObject.get("report:" + report.getName()), qInstance, report, Kind.REPORT, label(report.getLabel(), report.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
|
||||
}
|
||||
|
||||
List<QWidgetMetaDataInterface> widgets = new ArrayList<>(qInstance.getWidgets().values());
|
||||
widgets.sort(Comparator.comparing(w -> label(w.getLabel(), w.getName())));
|
||||
for(QWidgetMetaDataInterface widget : widgets)
|
||||
{
|
||||
add(byApp, appOfObject.get("widget:" + widget.getName()), qInstance, widget, Kind.WIDGET, label(widget.getLabel(), widget.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
|
||||
}
|
||||
|
||||
List<Group> groups = new ArrayList<>();
|
||||
for(Map.Entry<String, Map<String, ResourceDraft>> entry : byApp.entrySet())
|
||||
{
|
||||
if(entry.getValue().isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String groupLabel = entry.getKey() == null ? OTHER : label(qInstance.getApp(entry.getKey()).getLabel(), entry.getKey());
|
||||
List<Resource> resources = new ArrayList<>();
|
||||
for(ResourceDraft draft : entry.getValue().values())
|
||||
{
|
||||
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), List.copyOf(draft.features.values()), draft.note));
|
||||
}
|
||||
groups.add(new Group(entry.getKey(), groupLabel, resources));
|
||||
}
|
||||
return (new PolicyCatalog(groups));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Accumulates one resource while building (several objects can share a
|
||||
** permission base name).
|
||||
*******************************************************************************/
|
||||
private static class ResourceDraft
|
||||
{
|
||||
private final String name;
|
||||
private final Kind kind;
|
||||
private final Set<String> labels = new LinkedHashSet<>();
|
||||
private final Set<PolicyAction> actions = new TreeSet<>();
|
||||
private final Map<String, Feature> features = new TreeMap<>();
|
||||
private String note;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
ResourceDraft(String name, Kind kind)
|
||||
{
|
||||
this.name = name;
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add an object under its app, unless a policy cannot grant it.
|
||||
*******************************************************************************/
|
||||
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
|
||||
Set<PolicyAction> actions, String note)
|
||||
{
|
||||
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() && features.isEmpty()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String name = StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : nameOf(object);
|
||||
if(!PolicyParser.RESOURCE_PATTERN.matcher(name).matches())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// a base name shared with an object already listed (in any app) //
|
||||
// joins that entry, so a resource appears once //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
for(Map<String, ResourceDraft> resources : byApp.values())
|
||||
{
|
||||
ResourceDraft existing = resources.get(name);
|
||||
if(existing != null)
|
||||
{
|
||||
existing.labels.add(label);
|
||||
existing.actions.addAll(actions);
|
||||
existing.features.putAll(features);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Which app directly lists each object ("table:x", "process:y", ...): app
|
||||
** children (sections included) and app widgets. The first app wins.
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> appsOfObjects(QInstance qInstance)
|
||||
{
|
||||
Map<String, String> rs = new LinkedHashMap<>();
|
||||
for(QAppMetaData app : qInstance.getApps().values())
|
||||
{
|
||||
if(app.getChildren() != null)
|
||||
{
|
||||
for(QAppChildMetaData child : app.getChildren())
|
||||
{
|
||||
String type = child instanceof QTableMetaData ? "table" : child instanceof QProcessMetaData ? "process" : child instanceof QReportMetaData ? "report" : null;
|
||||
if(type != null)
|
||||
{
|
||||
rs.putIfAbsent(type + ":" + child.getName(), app.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
if(app.getWidgets() != null)
|
||||
{
|
||||
for(String widget : app.getWidgets())
|
||||
{
|
||||
rs.putIfAbsent("widget:" + widget, app.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String nameOf(MetaDataWithPermissionRules object)
|
||||
{
|
||||
if(object instanceof QTableMetaData t)
|
||||
{
|
||||
return (t.getName());
|
||||
}
|
||||
if(object instanceof QProcessMetaData p)
|
||||
{
|
||||
return (p.getName());
|
||||
}
|
||||
if(object instanceof QAppMetaData a)
|
||||
{
|
||||
return (a.getName());
|
||||
}
|
||||
if(object instanceof QReportMetaData r)
|
||||
{
|
||||
return (r.getName());
|
||||
}
|
||||
if(object instanceof QWidgetMetaDataInterface w)
|
||||
{
|
||||
return (w.getName());
|
||||
}
|
||||
return ("");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String label(String label, String name)
|
||||
{
|
||||
return (StringUtils.hasContent(label) ? label : name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The groups, in app order, "Other" last.
|
||||
*******************************************************************************/
|
||||
public List<Group> getGroups()
|
||||
{
|
||||
return (groups);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A resource by policy name, or null.
|
||||
*******************************************************************************/
|
||||
public Resource get(String name)
|
||||
{
|
||||
return (byName.get(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every QQQ permission a policy can grant here ({resource}.{action}).
|
||||
*******************************************************************************/
|
||||
public Set<String> permissionNames()
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(Resource resource : byName.values())
|
||||
{
|
||||
for(PolicyAction action : resource.actions())
|
||||
{
|
||||
rs.add(action.permission(resource.name()));
|
||||
}
|
||||
resource.features().forEach(feature -> rs.add(resource.name() + "." + feature.name()));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The resources of a permission set this catalog does not know, or that
|
||||
** grant an action the resource does not have (sorted, as permission names).
|
||||
*******************************************************************************/
|
||||
public Set<String> unknownPermissions(Set<String> permissions)
|
||||
{
|
||||
Set<String> known = permissionNames();
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(String permission : permissions)
|
||||
{
|
||||
if(!known.contains(permission))
|
||||
{
|
||||
rs.add(permission);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
public JSONArray toJson()
|
||||
{
|
||||
JSONArray rs = new JSONArray();
|
||||
for(Group group : groups)
|
||||
{
|
||||
JSONArray resources = new JSONArray();
|
||||
for(Resource resource : group.resources())
|
||||
{
|
||||
JSONArray actions = new JSONArray();
|
||||
resource.actions().forEach(a -> actions.put(a.getJsonName()));
|
||||
JSONObject json = new JSONObject()
|
||||
.put("name", resource.name())
|
||||
.put("label", resource.label())
|
||||
.put("kind", resource.kind().name())
|
||||
.put("actions", actions);
|
||||
if(resource.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());
|
||||
}
|
||||
resources.put(json);
|
||||
}
|
||||
rs.put(new JSONObject().put("app", group.app() == null ? JSONObject.NULL : group.app()).put("label", group.label()).put("resources", resources));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cut-over widgets (DESIGN.md section 12.8): the enforcement banner shown
|
||||
** on group and policy screens and on Edit Policy, and the Compare Access
|
||||
** report (read only).
|
||||
*******************************************************************************/
|
||||
public final class PolicyCutoverWidgets
|
||||
{
|
||||
/** Widget default value naming the qbit's API backend. */
|
||||
public static final String BACKEND_NAME_KEY = "backendName";
|
||||
|
||||
/** Short name of the banner widget. */
|
||||
public static final String ENFORCEMENT = "policyEnforcement";
|
||||
|
||||
/** Short name of the report widget. */
|
||||
public static final String COMPARE = "compareAccess";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PolicyCutoverWidgets()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The mapper state for a widget's backend.
|
||||
*******************************************************************************/
|
||||
static AccessComparison.State state(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
|
||||
return (AccessComparison.state(AuthentikRuntime.forBackend(backendName), AuthentikActionUtil.forBackend(backendName)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The banner as HTML (also used by the home's health card).
|
||||
*******************************************************************************/
|
||||
public static String bannerHtml(AccessComparison.State state)
|
||||
{
|
||||
String style = state.enforced()
|
||||
? "border:1px solid #16a34a;background:rgba(22,163,74,.10);"
|
||||
: "border:1px solid #d97706;background:rgba(217,119,6,.12);";
|
||||
return ("<div role=\"status\" data-qqq-id=\"authentik-policy-enforcement\" data-status=\"" + state.status().name() + "\" style=\"" + style
|
||||
+ "border-radius:.375rem;padding:.5rem .75rem;font-size:.875rem;line-height:1.4;color:inherit;\"><strong>"
|
||||
+ (state.enforced() ? "Enforced. " : "Not enforced. ") + "</strong>" + escape(state.message()) + "</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String escape(String text)
|
||||
{
|
||||
return (text == null ? "" : text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The enforcement banner (authentik.users.read).
|
||||
*******************************************************************************/
|
||||
public static class EnforcementRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, bannerHtml(state(input)))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare Access (authentik.admin): every active person's access today
|
||||
** against what the mapper would grant. Read only.
|
||||
*******************************************************************************/
|
||||
public static class CompareRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN))
|
||||
{
|
||||
throw (new QPermissionDeniedException("You do not have permission to compare access."));
|
||||
}
|
||||
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
|
||||
AuthentikRuntime runtime = AuthentikRuntime.forBackend(backendName);
|
||||
List<AccessComparison.Row> rows = AccessComparison.run(runtime.getConfig(), AuthentikActionUtil.forBackend(backendName), runtime.getPermissionCatalog());
|
||||
|
||||
List<Map<String, Object>> data = new ArrayList<>();
|
||||
int removedPeople = 0;
|
||||
for(AccessComparison.Row row : rows)
|
||||
{
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("user", escape(row.username()) + (row.guarded() ? " (guarded)" : ""));
|
||||
map.put("groups", escape(String.join(", ", row.groups())));
|
||||
map.put("added", row.added().isEmpty() ? "-" : escape(String.join(", ", row.added())));
|
||||
map.put("removed", row.removed().isEmpty() ? "-" : escape(String.join(", ", row.removed())));
|
||||
map.put("unchanged", row.before().size() - row.removed().size());
|
||||
data.add(map);
|
||||
if(!row.removed().isEmpty())
|
||||
{
|
||||
removedPeople++;
|
||||
}
|
||||
}
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "User", "user", "15%", "left"),
|
||||
new TableData.Column("html", "Groups", "groups", "20%", "left"),
|
||||
new TableData.Column("html", "Added", "added", "27%", "left"),
|
||||
new TableData.Column("html", "Removed", "removed", "28%", "left"),
|
||||
new TableData.Column("default", "Unchanged", "unchanged", "10%", "right"));
|
||||
List<AccessComparison.Row> failures = AccessComparison.guardFailures(rows);
|
||||
String summary = rows.size() + " active people; " + removedPeople + " would lose permissions; guard "
|
||||
+ (runtime.getConfig().getCutoverGuardGroups().isEmpty() ? "not configured" : failures.isEmpty() ? "passes" : "FAILS for " + failures.size())
|
||||
+ (runtime.getConfig().getHostPermissionSource() == null ? ". No host permission source is configured, so today's access is shown as empty." : ".");
|
||||
TableData table = new TableData("Compare Access (today vs policies)", columns, data)
|
||||
.withRowsPerPage(25)
|
||||
.withNoRowsFoundHTML("No active people.");
|
||||
table.setFooterHTML("<p style=\"font-size:.875rem;\">" + escape(summary) + "</p>");
|
||||
return (new RenderWidgetOutput(table));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The policy editor widget (type AUTHENTIK_POLICY_EDITOR, DESIGN.md section
|
||||
** 12.4). It is shown on the Edit Policy step; the Next UI widget of the same
|
||||
** type draws the matrix and hands the generated document back to the step as
|
||||
** the process value policyDocument. The data here: the catalog of what a
|
||||
** policy can grant, and the group's current document read live from
|
||||
** Authentik.
|
||||
*******************************************************************************/
|
||||
public final class PolicyEditorWidget
|
||||
{
|
||||
/** The widget type the Next UI renders. */
|
||||
public static final String TYPE = "AUTHENTIK_POLICY_EDITOR";
|
||||
|
||||
/** Short widget name (prefixed with the qbit namespace). */
|
||||
public static final String NAME = "policyEditor";
|
||||
|
||||
/** Widget default value naming the qbit's API backend. */
|
||||
public static final String BACKEND_NAME_KEY = "backendName";
|
||||
|
||||
/** Widget parameter (process value) with the group's pk. */
|
||||
public static final String GROUP_PK_PARAM = "groupPk";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PolicyEditorWidget()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The data for a group (or for none: an empty document).
|
||||
*******************************************************************************/
|
||||
public static Data load(AuthentikQBitConfig config, String groupPk) throws QException
|
||||
{
|
||||
AuthentikRuntime runtime = AuthentikRuntime.forBackend(config.getApiBackendName());
|
||||
PolicyFormat format = config.getPolicyFormat();
|
||||
Data data = new Data()
|
||||
.withSpec(format.spec())
|
||||
.withVersion(format.version())
|
||||
.withCatalog(runtime.getPolicyCatalog().toJson().toList())
|
||||
.withOutputFieldName(AuthentikAdmin.POLICY_DOCUMENT);
|
||||
|
||||
if(!StringUtils.hasContent(groupPk))
|
||||
{
|
||||
return (data);
|
||||
}
|
||||
if(!groupPk.matches("[A-Za-z0-9-]+"))
|
||||
{
|
||||
throw (new QUserFacingException("Not a group id: " + groupPk));
|
||||
}
|
||||
|
||||
JSONObject group = AuthentikActionUtil.forBackend(config.getApiBackendName()).getApiJsonOrNull("/core/groups/" + groupPk + "/?include_users=false&include_parents=false&include_children=false");
|
||||
if(group == null)
|
||||
{
|
||||
throw (new QUserFacingException("That group no longer exists."));
|
||||
}
|
||||
data.withGroupLabel(group.optString("name"));
|
||||
|
||||
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
|
||||
if(stored != null && !JSONObject.NULL.equals(stored) && !"".equals(stored))
|
||||
{
|
||||
try
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// canonical form of what is stored (unknown resources survive) //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format, runtime.getPolicyCatalog())));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
data.withDocumentProblem(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
QRecord row = PolicyStore.find(config, groupPk);
|
||||
if(row != null)
|
||||
{
|
||||
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format, runtime.getPolicyCatalog())));
|
||||
}
|
||||
return (data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer. QQQ checks the widget's permission rules
|
||||
** (authentik.policies.manage) before calling it.
|
||||
*******************************************************************************/
|
||||
public static class Renderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
|
||||
AuthentikQBitConfig config = AuthentikRuntime.forBackend(backendName).getConfig();
|
||||
Map<String, String> params = input.getQueryParams();
|
||||
return (new RenderWidgetOutput(load(config, params == null ? null : params.get(GROUP_PK_PARAM))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The widget's data (see the Next UI's PolicyEditorPayload).
|
||||
*******************************************************************************/
|
||||
public static class Data extends QWidgetData
|
||||
{
|
||||
private String spec;
|
||||
private String version;
|
||||
private String groupLabel;
|
||||
private List<Object> catalog;
|
||||
private String document;
|
||||
private String documentProblem;
|
||||
private Boolean changedInAuthentik = false;
|
||||
private String outputFieldName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for spec
|
||||
*******************************************************************************/
|
||||
public String getSpec()
|
||||
{
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for spec
|
||||
*******************************************************************************/
|
||||
public Data withSpec(String spec)
|
||||
{
|
||||
this.spec = spec;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for version
|
||||
*******************************************************************************/
|
||||
public String getVersion()
|
||||
{
|
||||
return (version);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for version
|
||||
*******************************************************************************/
|
||||
public Data withVersion(String version)
|
||||
{
|
||||
this.version = version;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for groupLabel
|
||||
*******************************************************************************/
|
||||
public String getGroupLabel()
|
||||
{
|
||||
return (groupLabel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for groupLabel
|
||||
*******************************************************************************/
|
||||
public Data withGroupLabel(String groupLabel)
|
||||
{
|
||||
this.groupLabel = groupLabel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for catalog (groups of resources, as plain JSON values)
|
||||
*******************************************************************************/
|
||||
public List<Object> getCatalog()
|
||||
{
|
||||
return (catalog);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for catalog
|
||||
*******************************************************************************/
|
||||
public Data withCatalog(List<Object> catalog)
|
||||
{
|
||||
this.catalog = catalog;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for document (the group's current document, pretty; null for none)
|
||||
*******************************************************************************/
|
||||
public String getDocument()
|
||||
{
|
||||
return (document);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for document
|
||||
*******************************************************************************/
|
||||
public Data withDocument(String document)
|
||||
{
|
||||
this.document = document;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for documentProblem (why the stored document is invalid)
|
||||
*******************************************************************************/
|
||||
public String getDocumentProblem()
|
||||
{
|
||||
return (documentProblem);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for documentProblem
|
||||
*******************************************************************************/
|
||||
public Data withDocumentProblem(String documentProblem)
|
||||
{
|
||||
this.documentProblem = documentProblem;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for changedInAuthentik (differs from the last save in QQQ)
|
||||
*******************************************************************************/
|
||||
public Boolean getChangedInAuthentik()
|
||||
{
|
||||
return (changedInAuthentik);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for changedInAuthentik
|
||||
*******************************************************************************/
|
||||
public Data withChangedInAuthentik(Boolean changedInAuthentik)
|
||||
{
|
||||
this.changedInAuthentik = changedInAuthentik;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outputFieldName (the process value the editor writes)
|
||||
*******************************************************************************/
|
||||
public String getOutputFieldName()
|
||||
{
|
||||
return (outputFieldName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for outputFieldName
|
||||
*******************************************************************************/
|
||||
public Data withOutputFieldName(String outputFieldName)
|
||||
{
|
||||
this.outputFieldName = outputFieldName;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The names a host uses for its policy documents (docs/POLICY-FORMAT.md): the
|
||||
** PolicySpec it accepts, the one supported PolicyVersion, the Authentik group
|
||||
** attribute that holds a group's document, and the token claim that holds the
|
||||
** merged document.
|
||||
**
|
||||
** @param spec PolicySpec, e.g. "qqq-iam"
|
||||
** @param version PolicyVersion (semver), e.g. "1.0.0"
|
||||
** @param attribute group attribute key, e.g. "qqq.io/policy"
|
||||
** @param claim token claim name, e.g. "qqq_policy"
|
||||
*******************************************************************************/
|
||||
public record PolicyFormat(String spec, String version, String attribute, String claim)
|
||||
{
|
||||
/** Default PolicySpec. */
|
||||
public static final String DEFAULT_SPEC = "qqq-iam";
|
||||
|
||||
/** Default (and only) PolicyVersion of this qbit's format. */
|
||||
public static final String DEFAULT_VERSION = "1.0.0";
|
||||
|
||||
/** Default group attribute. */
|
||||
public static final String DEFAULT_ATTRIBUTE = "qqq.io/policy";
|
||||
|
||||
/** Default token claim. */
|
||||
public static final String DEFAULT_CLAIM = "qqq_policy";
|
||||
|
||||
/** The generic defaults. */
|
||||
public static final PolicyFormat DEFAULT = new PolicyFormat(DEFAULT_SPEC, DEFAULT_VERSION, DEFAULT_ATTRIBUTE, DEFAULT_CLAIM);
|
||||
|
||||
/** A valid spec name: letters, digits, dot, dash and underscore. */
|
||||
public static final Pattern SPEC_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
|
||||
|
||||
/** Semver MAJOR.MINOR.PATCH. */
|
||||
public static final Pattern VERSION_PATTERN = Pattern.compile("(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)");
|
||||
|
||||
/** A valid claim name. */
|
||||
public static final Pattern CLAIM_PATTERN = Pattern.compile("[A-Za-z_][A-Za-z0-9_.-]{0,63}");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether every name is well formed (used by the config's validate()).
|
||||
*******************************************************************************/
|
||||
public String problem()
|
||||
{
|
||||
if(spec == null || !SPEC_PATTERN.matcher(spec).matches())
|
||||
{
|
||||
return ("policySpec must be letters, digits, '.', '-' or '_': " + spec);
|
||||
}
|
||||
if(version == null || !VERSION_PATTERN.matcher(version).matches())
|
||||
{
|
||||
return ("policyVersion must be semver MAJOR.MINOR.PATCH: " + version);
|
||||
}
|
||||
if(attribute == null || attribute.isBlank())
|
||||
{
|
||||
return ("policyAttribute is required");
|
||||
}
|
||||
if(claim == null || !CLAIM_PATTERN.matcher(claim).matches())
|
||||
{
|
||||
return ("policyClaim must be a plain claim name: " + claim);
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds the canonical policy document for a set of QQQ permissions
|
||||
** (docs/POLICY-FORMAT.md, "Canonical form"): one Allow statement per granted
|
||||
** action in the order hasAccess, read, insert, edit, delete, 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.
|
||||
*******************************************************************************/
|
||||
public final class PolicyGenerator
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PolicyGenerator()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical document for these permissions ({resource}.{action} names).
|
||||
**
|
||||
** @throws PolicyParseException when a permission is not {resource}.{action}
|
||||
** with a known action and a valid resource, or the document does not
|
||||
** round-trip
|
||||
*******************************************************************************/
|
||||
public static JSONObject generate(Collection<String> permissions, PolicyFormat format) throws PolicyParseException
|
||||
{
|
||||
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<String, Set<String>> byAction = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> byFeature = new TreeMap<>();
|
||||
for(PolicyAction action : PolicyAction.values())
|
||||
{
|
||||
byAction.put(action.getJsonName(), new TreeSet<>());
|
||||
}
|
||||
|
||||
Set<String> expected = new TreeSet<>();
|
||||
for(String permission : permissions)
|
||||
{
|
||||
int dot = permission == null ? -1 : permission.lastIndexOf('.');
|
||||
if(dot <= 0 || dot == permission.length() - 1)
|
||||
{
|
||||
throw (new PolicyParseException("\"" + permission + "\" is not {resource}.{action}"));
|
||||
}
|
||||
String resource = permission.substring(0, dot);
|
||||
String name = permission.substring(dot + 1);
|
||||
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_]+"));
|
||||
}
|
||||
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();
|
||||
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()))
|
||||
.put("Resource", new JSONArray(entry.getValue())));
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject document = new JSONObject()
|
||||
.put("PolicySpec", f.spec())
|
||||
.put("PolicyVersion", f.version())
|
||||
.put("Statement", statements);
|
||||
|
||||
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f, catalog).permissions();
|
||||
if(!parsed.equals(expected))
|
||||
{
|
||||
Set<String> missing = new TreeSet<>(expected);
|
||||
missing.removeAll(parsed);
|
||||
Set<String> extra = new TreeSet<>(parsed);
|
||||
extra.removeAll(expected);
|
||||
throw (new PolicyParseException("The generated document does not round-trip. Missing: " + missing + "; unexpected: " + extra));
|
||||
}
|
||||
return (document);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-generate any valid document in canonical form (parse, then generate).
|
||||
*******************************************************************************/
|
||||
public static JSONObject canonical(Object document, PolicyFormat format) throws PolicyParseException
|
||||
{
|
||||
return (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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compact JSON in a fixed key order (PolicySpec, PolicyVersion, Statement;
|
||||
** Effect, Action, Resource), as stored in the group attribute.
|
||||
*******************************************************************************/
|
||||
public static String toCompact(JSONObject document)
|
||||
{
|
||||
return (write(document, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pretty JSON (2-space indent, LF, trailing newline) in the fixed key order.
|
||||
*******************************************************************************/
|
||||
public static String toPretty(JSONObject document)
|
||||
{
|
||||
return (write(document, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write in the fixed key order (org.json does not keep insertion order).
|
||||
*******************************************************************************/
|
||||
private static String write(JSONObject document, boolean pretty)
|
||||
{
|
||||
String nl = pretty ? "\n" : "";
|
||||
String i1 = pretty ? " " : "";
|
||||
String i2 = pretty ? " " : "";
|
||||
String i3 = pretty ? " " : "";
|
||||
String i4 = pretty ? " " : "";
|
||||
String sep = pretty ? ": " : ":";
|
||||
|
||||
StringBuilder sb = new StringBuilder("{").append(nl);
|
||||
sb.append(i1).append(JSONObject.quote("PolicySpec")).append(sep).append(JSONObject.quote(document.optString("PolicySpec"))).append(",").append(nl);
|
||||
sb.append(i1).append(JSONObject.quote("PolicyVersion")).append(sep).append(JSONObject.quote(document.optString("PolicyVersion"))).append(",").append(nl);
|
||||
sb.append(i1).append(JSONObject.quote("Statement")).append(sep).append("[");
|
||||
|
||||
JSONArray statements = document.optJSONArray("Statement");
|
||||
int count = statements == null ? 0 : statements.length();
|
||||
for(int s = 0; s < count; s++)
|
||||
{
|
||||
JSONObject statement = statements.getJSONObject(s);
|
||||
sb.append(s == 0 ? nl : "," + nl).append(i2).append("{").append(nl);
|
||||
sb.append(i3).append(JSONObject.quote("Effect")).append(sep).append(JSONObject.quote(statement.optString("Effect"))).append(",").append(nl);
|
||||
sb.append(i3).append(JSONObject.quote("Action")).append(sep).append(inline(statement.optJSONArray("Action"), pretty)).append(",").append(nl);
|
||||
sb.append(i3).append(JSONObject.quote("Resource")).append(sep).append("[");
|
||||
JSONArray resources = statement.optJSONArray("Resource");
|
||||
int n = resources == null ? 0 : resources.length();
|
||||
for(int r = 0; r < n; r++)
|
||||
{
|
||||
sb.append(r == 0 ? nl : "," + nl).append(i4).append(JSONObject.quote(resources.optString(r)));
|
||||
}
|
||||
sb.append(n == 0 ? "" : nl + i3).append("]").append(nl);
|
||||
sb.append(i2).append("}");
|
||||
}
|
||||
sb.append(count == 0 ? "" : nl + i1).append("]").append(nl);
|
||||
sb.append("}").append(nl);
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A short string array on one line.
|
||||
*******************************************************************************/
|
||||
private static String inline(JSONArray array, boolean pretty)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
int n = array == null ? 0 : array.length();
|
||||
for(int i = 0; i < n; i++)
|
||||
{
|
||||
sb.append(i == 0 ? "" : (pretty ? ", " : ",")).append(JSONObject.quote(array.optString(i)));
|
||||
}
|
||||
return (sb.append("]").toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A policy document that cannot be accepted; the message names the problem
|
||||
** in plain words (shown to operators in the editor's Review step).
|
||||
*******************************************************************************/
|
||||
public class PolicyParseException extends Exception
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public PolicyParseException(String message)
|
||||
{
|
||||
super(message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parses a policy document (docs/POLICY-FORMAT.md) into QQQ permission names.
|
||||
**
|
||||
** parse() FAILS CLOSED: any problem (unknown spec, unsupported version, a
|
||||
** statement that is not Allow, an unknown action, a bad resource, malformed
|
||||
** JSON, ...) grants zero permissions and logs a warning. parseStrict() throws
|
||||
** a PolicyParseException naming the problem instead (for the editor).
|
||||
*******************************************************************************/
|
||||
public final class PolicyParser
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(PolicyParser.class);
|
||||
|
||||
/** A valid resource: a QQQ permission base name. */
|
||||
public static final Pattern RESOURCE_PATTERN = Pattern.compile("[A-Za-z0-9_]+");
|
||||
|
||||
/** The only effect in 1.0.0. */
|
||||
public static final String ALLOW = "Allow";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The result of a successful parse.
|
||||
**
|
||||
** @param permissions QQQ permission names ({resource}.{action}), sorted
|
||||
** @param resources every resource named, in document order
|
||||
*******************************************************************************/
|
||||
public record Parsed(Set<String> permissions, Set<String> resources)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PolicyParser()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permissions a document grants, or none (with a warning) when it is
|
||||
** missing or invalid in any way.
|
||||
**
|
||||
** @param document a JSONObject, a Map, or a JSON string (as stored in a group
|
||||
** attribute or a token claim); null grants nothing
|
||||
*******************************************************************************/
|
||||
public static Set<String> parse(Object document, PolicyFormat format)
|
||||
{
|
||||
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))
|
||||
{
|
||||
return (Collections.emptySet());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (parseStrict(document, format, catalog).permissions());
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
LOG.warn("Policy document rejected; it grants no permissions", logPair("reason", e.getMessage()));
|
||||
return (Collections.emptySet());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse, throwing a PolicyParseException that names the first problem.
|
||||
*******************************************************************************/
|
||||
public static Parsed parseStrict(Object document, PolicyFormat format) throws PolicyParseException
|
||||
{
|
||||
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;
|
||||
|
||||
Object spec = json.opt("PolicySpec");
|
||||
if(!(spec instanceof String s) || !s.equals(f.spec()))
|
||||
{
|
||||
throw (new PolicyParseException("PolicySpec must be \"" + f.spec() + "\" (found " + describe(spec) + ")"));
|
||||
}
|
||||
Object version = json.opt("PolicyVersion");
|
||||
if(!(version instanceof String v) || !v.equals(f.version()))
|
||||
{
|
||||
throw (new PolicyParseException("PolicyVersion must be \"" + f.version() + "\" (found " + describe(version) + ")"));
|
||||
}
|
||||
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
Set<String> resources = new LinkedHashSet<>();
|
||||
Object statements = json.opt("Statement");
|
||||
if(statements == null)
|
||||
{
|
||||
return (new Parsed(permissions, resources));
|
||||
}
|
||||
if(!(statements instanceof JSONArray array))
|
||||
{
|
||||
throw (new PolicyParseException("Statement must be an array"));
|
||||
}
|
||||
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
if(!(array.opt(i) instanceof JSONObject statement))
|
||||
{
|
||||
throw (new PolicyParseException("Statement " + (i + 1) + " must be an object"));
|
||||
}
|
||||
Object effect = statement.opt("Effect");
|
||||
if(!ALLOW.equals(effect))
|
||||
{
|
||||
throw (new PolicyParseException("Statement " + (i + 1) + ": Effect must be \"Allow\" (found " + describe(effect) + "); Deny is not supported yet"));
|
||||
}
|
||||
|
||||
Set<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))
|
||||
{
|
||||
for(PolicyAction action : PolicyAction.values())
|
||||
{
|
||||
actions.add(action.getJsonName());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
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 : 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(String action : actions)
|
||||
{
|
||||
permissions.add(resource + "." + action);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new Parsed(permissions, resources));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The document as a JSON object.
|
||||
*******************************************************************************/
|
||||
static JSONObject asObject(Object document) throws PolicyParseException
|
||||
{
|
||||
if(document instanceof JSONObject object)
|
||||
{
|
||||
return (object);
|
||||
}
|
||||
if(document instanceof Map<?, ?> map)
|
||||
{
|
||||
return (new JSONObject(map));
|
||||
}
|
||||
if(document instanceof String text)
|
||||
{
|
||||
if(text.isBlank())
|
||||
{
|
||||
throw (new PolicyParseException("The document is empty"));
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new JSONObject(text));
|
||||
}
|
||||
catch(JSONException e)
|
||||
{
|
||||
throw (new PolicyParseException("The document is not a JSON object"));
|
||||
}
|
||||
}
|
||||
throw (new PolicyParseException("The document is not a JSON object"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An array of strings, or a PolicyParseException.
|
||||
*******************************************************************************/
|
||||
private static Set<String> strings(Object value, String what) throws PolicyParseException
|
||||
{
|
||||
if(!(value instanceof JSONArray array))
|
||||
{
|
||||
throw (new PolicyParseException(what + " must be an array of strings"));
|
||||
}
|
||||
Set<String> rs = new LinkedHashSet<>();
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
if(!(array.opt(i) instanceof String text))
|
||||
{
|
||||
throw (new PolicyParseException(what + " must be an array of strings"));
|
||||
}
|
||||
rs.add(text);
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A short description of a found value for messages (never the whole value).
|
||||
*******************************************************************************/
|
||||
private static String describe(Object value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("nothing");
|
||||
}
|
||||
if(value instanceof String text)
|
||||
{
|
||||
return ("\"" + (text.length() > 40 ? text.substring(0, 40) + "..." : text) + "\"");
|
||||
}
|
||||
return (value.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Helpers a host's own authentication customizer can call to turn the policy
|
||||
** claim of a sign-in token into QQQ permissions (docs/POLICY-FORMAT.md).
|
||||
*******************************************************************************/
|
||||
public final class PolicyPermissions
|
||||
{
|
||||
/** Customizer context key of the ID token claims (QQQ OAuth2 module). */
|
||||
public static final String ID_TOKEN_CONTEXT_KEY = "idToken";
|
||||
|
||||
/** Customizer context key of the access token claims. */
|
||||
public static final String ACCESS_TOKEN_CONTEXT_KEY = "jwtPayloadJsonObject";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PolicyPermissions()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permissions granted by the policy claim in these token claims; none
|
||||
** when the claim is missing or invalid (fail closed).
|
||||
*******************************************************************************/
|
||||
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format)
|
||||
{
|
||||
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, catalog));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The claims that carry the policy in a customizer context: the ID token's
|
||||
** when it has the claim (Authentik puts scope-mapping claims there), else the
|
||||
** access token's (the only ones on a session resume). Null when neither has
|
||||
** the claim.
|
||||
*******************************************************************************/
|
||||
public static JSONObject claimsWithPolicy(Map<String, Object> context, PolicyFormat format)
|
||||
{
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
if(context == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(String key : new String[] { ID_TOKEN_CONTEXT_KEY, ACCESS_TOKEN_CONTEXT_KEY })
|
||||
{
|
||||
JSONObject claims = asClaims(context.get(key));
|
||||
if(claims != null && claims.has(f.claim()))
|
||||
{
|
||||
return (claims);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Claims as a JSONObject (QQQ passes a JSONObject; tests may pass a Map).
|
||||
*******************************************************************************/
|
||||
public static JSONObject asClaims(Object value)
|
||||
{
|
||||
if(value instanceof JSONObject object)
|
||||
{
|
||||
return (object);
|
||||
}
|
||||
if(value instanceof Map<?, ?> map)
|
||||
{
|
||||
return (new JSONObject(map));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The authentik_policy rows (DESIGN.md section 12.2): the QQQ record of each
|
||||
** group's last policy save, plus the fingerprint used to tell when the policy
|
||||
** was changed in Authentik since.
|
||||
*******************************************************************************/
|
||||
public final class PolicyStore
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PolicyStore()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fingerprint of a group's policy attribute value: of its canonical form
|
||||
** when it parses (so formatting differences do not count), else of the raw
|
||||
** text. Null for no policy.
|
||||
*******************************************************************************/
|
||||
public static String fingerprint(Object attributeValue, PolicyFormat format)
|
||||
{
|
||||
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))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String text;
|
||||
try
|
||||
{
|
||||
text = PolicyGenerator.toCompact(PolicyGenerator.canonical(attributeValue, format, catalog));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
text = String.valueOf(attributeValue);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The row of a group, or null (also null when the table is not set up).
|
||||
*******************************************************************************/
|
||||
public static QRecord find(AuthentikQBitConfig config, String groupPk) throws QException
|
||||
{
|
||||
String table = config.applyTablePrefix(AuthentikLocalTables.POLICY);
|
||||
if(QContext.getQInstance() == null || QContext.getQInstance().getTable(table) == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QueryInput queryInput = new QueryInput();
|
||||
queryInput.setTableName(table);
|
||||
queryInput.setFilter(new QQueryFilter(new QFilterCriteria("groupPk", QCriteriaOperator.EQUALS, groupPk)));
|
||||
QueryOutput output = new QueryAction().execute(queryInput);
|
||||
return (output.getRecords().isEmpty() ? null : output.getRecords().get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove the row of a deleted group (no-op without one).
|
||||
*******************************************************************************/
|
||||
public static void delete(AuthentikQBitConfig config, String groupPk) throws QException
|
||||
{
|
||||
QRecord existing = find(config, groupPk);
|
||||
if(existing != null)
|
||||
{
|
||||
DeleteInput deleteInput = new DeleteInput();
|
||||
deleteInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.POLICY));
|
||||
deleteInput.setPrimaryKeys(List.of(existing.getValue("id")));
|
||||
new DeleteAction().execute(deleteInput);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert or update the row of a group after a save to Authentik.
|
||||
**
|
||||
** @param document the canonical document that was saved (empty statements
|
||||
** when the policy was removed)
|
||||
*******************************************************************************/
|
||||
public static void save(AuthentikQBitConfig config, String groupPk, String groupName, JSONObject document, int permissionCount, String savedBy, String fingerprint) throws QException
|
||||
{
|
||||
String table = config.applyTablePrefix(AuthentikLocalTables.POLICY);
|
||||
QRecord existing = find(config, groupPk);
|
||||
QRecord record = new QRecord()
|
||||
.withValue("groupPk", groupPk)
|
||||
.withValue("groupName", groupName)
|
||||
.withValue("policyJson", PolicyGenerator.toPretty(document))
|
||||
.withValue("statementCount", document.optJSONArray("Statement") == null ? 0 : document.getJSONArray("Statement").length())
|
||||
.withValue("permissionCount", permissionCount)
|
||||
.withValue("savedBy", savedBy)
|
||||
.withValue("savedDate", Instant.now())
|
||||
.withValue("authentikFingerprint", fingerprint)
|
||||
.withValue("modifyDate", Instant.now());
|
||||
|
||||
if(existing == null)
|
||||
{
|
||||
record.setValue("createDate", Instant.now());
|
||||
InsertInput insertInput = new InsertInput();
|
||||
insertInput.setTableName(table);
|
||||
insertInput.setRecords(List.of(record));
|
||||
InsertOutput output = new InsertAction().execute(insertInput);
|
||||
if(output.getRecords().isEmpty() || !output.getRecords().get(0).getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("The policy record could not be written: " + (output.getRecords().isEmpty() ? "no record" : output.getRecords().get(0).getErrors())));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
record.setValue("id", existing.getValue("id"));
|
||||
UpdateInput updateInput = new UpdateInput();
|
||||
updateInput.setTableName(table);
|
||||
updateInput.setRecords(List.of(record));
|
||||
UpdateOutput output = new UpdateAction().execute(updateInput);
|
||||
if(output.getRecords().isEmpty() || !output.getRecords().get(0).getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("The policy record could not be written: " + (output.getRecords().isEmpty() ? "no record" : output.getRecords().get(0).getErrors())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikSafety;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -88,7 +89,9 @@ public final class AuthentikProcesses
|
||||
/** Selected invitations. */
|
||||
INVITATIONS,
|
||||
/** Selected approval requests. */
|
||||
APPROVALS
|
||||
APPROVALS,
|
||||
/** Selected policy records (authentik_policy); the target is the record's group. */
|
||||
POLICIES
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -147,14 +150,20 @@ public final class AuthentikProcesses
|
||||
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email").withIsRequired(true),
|
||||
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"),
|
||||
reason), "Authentik emails a single-use sign-up invitation that expires in 7 days."));
|
||||
QFieldMetaData otherAttributes = new QFieldMetaData(AuthentikAdmin.OTHER_ATTRIBUTES, QFieldType.TEXT).withLabel("Other attributes (JSON; leave blank to keep them)");
|
||||
defs.add(new Def("editUser", "Edit User", AdminAction.EDIT_USER, Kind.USERS, users, "edit", true, List.of(
|
||||
new QFieldMetaData("username", QFieldType.STRING).withLabel("Username"),
|
||||
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"),
|
||||
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email"),
|
||||
new QFieldMetaData("path", QFieldType.STRING).withLabel("Path"),
|
||||
hidden("expectedFingerprint"), reason), "Change a person's name, email or path."));
|
||||
otherAttributes,
|
||||
hidden("expectedFingerprint"), reason), "Change a person's username, name, email, path or other attributes. "
|
||||
+ "Attributes starting with qqq.io/ or goauthentik.io/ are set elsewhere; editing attributes needs authentik.audit.read."));
|
||||
defs.add(new Def("deactivateUser", "Deactivate User", AdminAction.DEACTIVATE_USER, Kind.USERS, users, "block", false, List.of(
|
||||
new QFieldMetaData("revokeAppPasswords", QFieldType.BOOLEAN).withLabel("Also revoke their app passwords").withDefaultValue(false),
|
||||
typed, reason), "Deactivating ends their sessions immediately and deletes nothing. Type the username to confirm (when you select several people, type the username of the first one)."));
|
||||
defs.add(new Def("deleteUser", "Delete User", AdminAction.DELETE_USER, Kind.USERS, users, "delete_forever", false, List.of(typed, reason),
|
||||
"Deletes the account in Authentik permanently. To keep the account and its history, use Deactivate User instead."));
|
||||
defs.add(new Def("reactivateUser", "Reactivate User", AdminAction.REACTIVATE_USER, Kind.USERS, users, "check_circle", false, List.of(reason), null));
|
||||
defs.add(new Def("addToGroup", "Add To Group", AdminAction.ADD_MEMBER, Kind.USERS, users, "group_add", false, List.of(group,
|
||||
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason),
|
||||
@@ -178,12 +187,24 @@ public final class AuthentikProcesses
|
||||
defs.add(new Def("editGroup", "Edit Group", AdminAction.EDIT_GROUP, Kind.GROUPS, groups, "edit", true, List.of(
|
||||
new QFieldMetaData("name", QFieldType.STRING).withLabel("Group Name"),
|
||||
new QFieldMetaData("parents", QFieldType.TEXT).withLabel("Parent groups (names, one per line; none to clear)"),
|
||||
hidden("expectedFingerprint"), reason), "Rename a group or change its parents."));
|
||||
otherAttributes,
|
||||
hidden("expectedFingerprint"), reason), "Rename a group, change its parents or its other attributes."));
|
||||
defs.add(new Def("deleteGroup", "Delete Group", AdminAction.DELETE_GROUP, Kind.GROUPS, groups, "delete_forever", false, List.of(
|
||||
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Type the group name to confirm").withIsRequired(true), reason),
|
||||
"Deletes the group in Authentik permanently, with its permissions, app access and policy. Remove its members and child groups first."));
|
||||
defs.add(new Def("editPermissions", "Edit Permissions And App Access", AdminAction.EDIT_PERMISSIONS, Kind.GROUPS, groups, "admin_panel_settings", true, List.of(
|
||||
new QFieldMetaData("permissions", QFieldType.TEXT).withLabel("QQQ permissions (one per line)"),
|
||||
new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"),
|
||||
hidden("expectedFingerprint"), reason),
|
||||
"You can only add permissions you hold yourself. Adding authentik.* needs a second person to approve."));
|
||||
String policyHelp = "Tick what members of this group, and of the groups below it, may do. The document is what is saved to Authentik. "
|
||||
+ "You can only grant permissions you hold yourself.";
|
||||
List<QFieldMetaData> policyFields = List.of(hidden("groupPk"), hidden("expectedFingerprint"), reason);
|
||||
defs.add(new Def("editPolicy", "Edit Policy", AdminAction.SAVE_POLICY, Kind.GROUPS, groups, "policy", true, policyFields, policyHelp));
|
||||
defs.add(new Def("generatePolicy", "Generate Policy From Current Access", AdminAction.SAVE_POLICY, Kind.GROUPS, groups, "auto_fix_high", false, List.of(reason),
|
||||
"Builds this group's policy from what its members get today from the app (joined with its current policy, so nothing is removed). "
|
||||
+ "Review shows the generated document and what changes before anything is saved."));
|
||||
defs.add(new Def("editPolicyRecord", "Edit Policy", AdminAction.SAVE_POLICY, Kind.POLICIES, AuthentikLocalTables.POLICY, "policy", true, policyFields, policyHelp));
|
||||
defs.add(new Def("addMembers", "Add Members", AdminAction.ADD_MEMBER, Kind.GROUP_USERNAMES, groups, "person_add", false, List.of(
|
||||
new QFieldMetaData("usernames", QFieldType.TEXT).withLabel("Usernames (one per line)").withIsRequired(true),
|
||||
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null));
|
||||
@@ -196,6 +217,8 @@ public final class AuthentikProcesses
|
||||
defs.add(new Def("reject", "Reject", null, Kind.APPROVALS, AuthentikLocalTables.APPROVAL_REQUEST, "close", false, List.of(), null));
|
||||
|
||||
defs.removeIf(def -> def.action() != null && !config.hasFeature(def.action().getFeature()));
|
||||
defs.removeIf(def -> (def.action() == AdminAction.DELETE_USER && !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.USERS))
|
||||
|| (def.action() == AdminAction.DELETE_GROUP && !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS)));
|
||||
if(!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS) || !config.getTwoPersonApproval())
|
||||
{
|
||||
defs.removeIf(def -> def.kind() == Kind.APPROVALS);
|
||||
@@ -272,11 +295,11 @@ public final class AuthentikProcesses
|
||||
switch(def.kind())
|
||||
{
|
||||
case CREATE -> process.setMaxInputRecords(0);
|
||||
case USERS, GROUP_USERNAMES, GROUPS, INVITATIONS, APPROVALS ->
|
||||
case USERS, GROUP_USERNAMES, GROUPS, INVITATIONS, APPROVALS, POLICIES ->
|
||||
{
|
||||
process.setMinInputRecords(1);
|
||||
process.setMaxInputRecords(def.action() == AdminAction.EDIT_USER || def.action() == AdminAction.EDIT_GROUP || def.action() == AdminAction.EDIT_PERMISSIONS
|
||||
? 1 : AuthentikAdmin.MAX_BULK);
|
||||
|| def.action() == AdminAction.SAVE_POLICY || def.action() == AdminAction.DELETE_USER || def.action() == AdminAction.DELETE_GROUP ? 1 : AuthentikAdmin.MAX_BULK);
|
||||
}
|
||||
default ->
|
||||
{
|
||||
@@ -290,6 +313,14 @@ public final class AuthentikProcesses
|
||||
{
|
||||
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.HELP_TEXT).withValue("text", def.help()));
|
||||
}
|
||||
if(def.action() == AdminAction.SAVE_POLICY)
|
||||
{
|
||||
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.ENFORCEMENT)));
|
||||
}
|
||||
if(def.action() == AdminAction.SAVE_POLICY && !def.key().equals("generatePolicy"))
|
||||
{
|
||||
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", config.applyPrefix(PolicyEditorWidget.NAME)));
|
||||
}
|
||||
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
|
||||
process.withStep(0, details);
|
||||
if(def.prefill())
|
||||
@@ -340,6 +371,9 @@ public final class AuthentikProcesses
|
||||
case CREATE_GROUP -> w("group will be created", "groups will be created", "group was created", "groups were created");
|
||||
case EDIT_GROUP -> w("group will be updated", "groups will be updated", "group was updated", "groups were updated");
|
||||
case EDIT_PERMISSIONS -> w("group's permissions will be updated", "groups' permissions will be updated", "group's permissions were updated", "groups' permissions were updated");
|
||||
case DELETE_USER -> w("user will be deleted", "users will be deleted", "user was deleted", "users were deleted");
|
||||
case DELETE_GROUP -> w("group will be deleted", "groups will be deleted", "group was deleted", "groups were deleted");
|
||||
case SAVE_POLICY -> w("group's policy will be saved to Authentik", "groups' policies will be saved to Authentik", "group's policy was saved to Authentik", "groups' policies were saved to Authentik");
|
||||
case SEND_RECOVERY_EMAIL -> w("password reset email will be sent", "password reset emails will be sent", "password reset email was sent", "password reset emails were sent");
|
||||
case RESET_MFA -> w("user's MFA will be reset", "users' MFA will be reset", "user's MFA was reset", "users' MFA were reset");
|
||||
case REVOKE_APP_PASSWORDS -> w("user's app passwords will be revoked", "users' app passwords will be revoked", "user's app passwords were revoked", "users' app passwords were revoked");
|
||||
@@ -408,7 +442,12 @@ public final class AuthentikProcesses
|
||||
rs.add(request);
|
||||
}
|
||||
}
|
||||
case GROUPS, INVITATIONS -> rs.add(withParams(AdminRequest.of(def.action(), String.valueOf(record.getValue("pk") != null ? record.getValue("pk") : record.getValue("uuid")), reason), def, input));
|
||||
case GROUPS, INVITATIONS ->
|
||||
{
|
||||
AdminRequest request = withParams(AdminRequest.of(def.action(), String.valueOf(record.getValue("pk") != null ? record.getValue("pk") : record.getValue("uuid")), reason), def, input);
|
||||
rs.add("generatePolicy".equals(def.key()) ? admin.withGeneratedPolicy(request) : request);
|
||||
}
|
||||
case POLICIES -> rs.add(withParams(AdminRequest.of(def.action(), record.getValueString("groupPk"), reason), def, input));
|
||||
default ->
|
||||
{
|
||||
}
|
||||
@@ -423,6 +462,14 @@ public final class AuthentikProcesses
|
||||
*******************************************************************************/
|
||||
private static AdminRequest withParams(AdminRequest request, Def def, RunBackendStepInput input)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// the policy editor widget writes the document as a process value //
|
||||
// (not a form field), as does an API client //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
if(def.action() == AdminAction.SAVE_POLICY && !"generatePolicy".equals(def.key()) && input.getValue(AuthentikAdmin.POLICY_DOCUMENT) != null)
|
||||
{
|
||||
request.with(AuthentikAdmin.POLICY_DOCUMENT, input.getValueString(AuthentikAdmin.POLICY_DOCUMENT));
|
||||
}
|
||||
for(QFieldMetaData field : def.fields())
|
||||
{
|
||||
if(field.getName().equals("reason"))
|
||||
@@ -472,6 +519,18 @@ public final class AuthentikProcesses
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Attributes are sensitive (DESIGN.md section 5.3): they are shown on the
|
||||
** edit forms only with authentik.audit.read; left blank, they are unchanged.
|
||||
*******************************************************************************/
|
||||
static boolean canSeeAttributes()
|
||||
{
|
||||
return (QContext.getQSession() != null && QContext.getQSession().getPermissions() != null
|
||||
&& QContext.getQSession().getPermissions().contains(IdentityPermission.AUDIT_READ.getSessionPermission()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The details screen is filled from the selected record (edit actions).
|
||||
*******************************************************************************/
|
||||
@@ -490,14 +549,28 @@ public final class AuthentikProcesses
|
||||
}
|
||||
QRecord record = input.getRecords().get(0);
|
||||
AuthentikAdmin admin = admin(input);
|
||||
String pk = String.valueOf(record.getValue("pk"));
|
||||
String pk = def.kind() == Kind.POLICIES ? record.getValueString("groupPk") : String.valueOf(record.getValue("pk"));
|
||||
switch(def.action())
|
||||
{
|
||||
case SAVE_POLICY ->
|
||||
{
|
||||
JSONObject group = admin.getUtil().getApiJsonOrNull("/core/groups/" + AuthentikAdmin.safeKey(pk) + "/?include_users=true&include_parents=true&include_children=true");
|
||||
output.addValue(PolicyEditorWidget.GROUP_PK_PARAM, pk);
|
||||
if(group != null)
|
||||
{
|
||||
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
|
||||
}
|
||||
}
|
||||
case EDIT_USER ->
|
||||
{
|
||||
JSONObject user = admin.getUtil().getObject(AuthentikTables.get(AuthentikTables.USER), pk);
|
||||
if(user != null)
|
||||
{
|
||||
output.addValue("username", user.optString("username"));
|
||||
if(canSeeAttributes())
|
||||
{
|
||||
output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(user.optJSONObject("attributes")).toString(2));
|
||||
}
|
||||
output.addValue("name", user.optString("name"));
|
||||
output.addValue("email", user.optString("email"));
|
||||
output.addValue("path", user.optString("path"));
|
||||
@@ -512,6 +585,10 @@ public final class AuthentikProcesses
|
||||
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
|
||||
if(def.action() == AdminAction.EDIT_GROUP)
|
||||
{
|
||||
if(canSeeAttributes())
|
||||
{
|
||||
output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(group.optJSONObject("attributes")).toString(2));
|
||||
}
|
||||
output.addValue("name", group.optString("name"));
|
||||
JSONArray parents = group.optJSONArray("parents_obj");
|
||||
List<String> names = new ArrayList<>();
|
||||
|
||||
@@ -38,6 +38,7 @@ public enum IdentityPermission
|
||||
GROUPS_MANAGE("authentik.groups.manage"),
|
||||
MEMBERSHIPS_MANAGE("authentik.memberships.manage"),
|
||||
PERMISSIONS_MANAGE("authentik.permissions.manage"),
|
||||
POLICIES_MANAGE("authentik.policies.manage"),
|
||||
ROLES_MANAGE("authentik.roles.manage"),
|
||||
CREDENTIALS_RESET("authentik.credentials.reset"),
|
||||
SESSIONS_REVOKE("authentik.sessions.revoke"),
|
||||
|
||||
@@ -55,6 +55,24 @@ class AuthentikQBitConfigTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void policyNames()
|
||||
{
|
||||
assertThat(AuthentikTestSupport.unitConfig().getPolicyFormat()).isEqualTo(com.kingsrook.qbits.authentik.policy.PolicyFormat.DEFAULT);
|
||||
assertThat(AuthentikTestSupport.unitConfig().getPolicyTwoPersonApproval()).isFalse();
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicySpec("acme-iam").withPolicyVersion("2.0.0").withPolicyAttribute("policies").withPolicyClaim("policies"))).isEmpty();
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicyVersion("1"))).anyMatch(e -> e.contains("policyVersion"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicySpec(""))).anyMatch(e -> e.contains("policySpec"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicyClaim("a b"))).anyMatch(e -> e.contains("policyClaim"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicyAttribute(null))).anyMatch(e -> e.contains("policyAttribute"));
|
||||
assertThat(AuthentikTestSupport.unitConfig().withPolicyTwoPersonApproval(true).getPolicyTwoPersonApproval()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -28,16 +28,23 @@ import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
@@ -125,6 +132,22 @@ public final class AdminTestSupport
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(table[1])));
|
||||
}
|
||||
if(features.contains(AuthentikFeature.POLICIES))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// things a policy can grant: an app with a table (insert, edit, //
|
||||
// delete, read) and a hidden record action on it //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
QTableMetaData page = new QTableMetaData().withName("cms_page").withLabel("Pages").withBackendName(LOCAL).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
|
||||
instance.addTable(page);
|
||||
instance.addProcess(new QProcessMetaData().withName("publishPage").withLabel("Publish Page").withTableName("cms_page").withIsHidden(true)
|
||||
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(NoopStep.class)))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
instance.addApp(new QAppMetaData().withName("content").withLabel("Content").withChild(page)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
}
|
||||
new AuthentikQBitProducer().withQBitConfig(support.config).produce(instance);
|
||||
AuthentikTestSupport.useActionUtil(instance, support.config, FixtureActionUtil.class);
|
||||
support.qInstance = instance;
|
||||
@@ -182,4 +205,22 @@ public final class AdminTestSupport
|
||||
return (authentik.writes.stream().filter(w -> !w.contains("/events/events/")).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A backend step that does nothing (for fixture processes).
|
||||
*******************************************************************************/
|
||||
public static class NoopStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output)
|
||||
{
|
||||
///////////////////////////
|
||||
// nothing; fixture only //
|
||||
///////////////////////////
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,29 @@ public class FakeAuthentik
|
||||
user.put("last_updated", "2026-10-12T01:00:00Z");
|
||||
return (ok(200, render(user)));
|
||||
}
|
||||
if(path.matches("/core/users/[0-9]+/") && method.equals("DELETE"))
|
||||
{
|
||||
int pk = Integer.parseInt(path.split("/")[3]);
|
||||
users.remove(pk);
|
||||
for(JSONObject group : groups.values())
|
||||
{
|
||||
JSONArray kept = new JSONArray();
|
||||
for(int i = 0; i < group.getJSONArray("users").length(); i++)
|
||||
{
|
||||
if(group.getJSONArray("users").getInt(i) != pk)
|
||||
{
|
||||
kept.put(group.getJSONArray("users").getInt(i));
|
||||
}
|
||||
}
|
||||
group.put("users", kept);
|
||||
}
|
||||
return (ok(204, null));
|
||||
}
|
||||
if(path.matches("/core/groups/[^/]+/") && method.equals("DELETE"))
|
||||
{
|
||||
groups.remove(path.split("/")[3]);
|
||||
return (ok(204, null));
|
||||
}
|
||||
if(path.matches("/core/groups/[^/]+/") && method.equals("PATCH"))
|
||||
{
|
||||
JSONObject group = groups.get(path.split("/")[3]);
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
|
||||
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
|
||||
import com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParser;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cut-over to policies (DESIGN.md section 12.8): enforcement status, the
|
||||
** compare report, the guard (and its fallback to today's access), and
|
||||
** Generate Policy From Current Access.
|
||||
*******************************************************************************/
|
||||
class PolicyCutoverTest
|
||||
{
|
||||
/** Today's grant: publishers read pages and publish; analysts see analytics. */
|
||||
static final AuthentikHostPermissionSource SOURCE = new AuthentikHostPermissionSource()
|
||||
{
|
||||
@Override
|
||||
public Set<String> userPermissions(String email, Collection<String> groupNames)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
if(groupNames.contains("kof-website-publishers") || groupNames.contains("identity-admins"))
|
||||
{
|
||||
rs.addAll(List.of("cms_page.read", "cms_page.edit", "cms.publish.hasAccess", "nothing.here.hasAccess"));
|
||||
}
|
||||
if(groupNames.contains("kof-website-analysts") || groupNames.contains("identity-admins"))
|
||||
{
|
||||
rs.addAll(List.of("kof22.analytics.hasAccess", "nothing.here.hasAccess"));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
};
|
||||
|
||||
private AdminTestSupport t;
|
||||
private AuthentikRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, AuthentikFeature.POLICIES));
|
||||
t.config.withHostPermissionSource(SOURCE).withCutoverGuardGroups(List.of("kof-website-publishers"));
|
||||
runtime = AuthentikRuntime.forBackend(t.config.getApiBackendName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AccessComparison.State state() throws Exception
|
||||
{
|
||||
runtime.setMapperState(null);
|
||||
return (AccessComparison.state(runtime, AuthentikActionUtil.forBackend(t.config.getApiBackendName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** OFF until the host turns the mapper on; then the guard decides; the
|
||||
** owner can override.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void statusAndGuard() throws Exception
|
||||
{
|
||||
assertThat(state().status()).isEqualTo(AccessComparison.Status.OFF);
|
||||
assertThat(state().message()).isEqualTo("Policies are saved but NOT enforced at sign-in on this instance (mapper off).");
|
||||
|
||||
t.config.withMapperEnabled(true);
|
||||
AccessComparison.State blocked = state();
|
||||
assertThat(blocked.status()).isEqualTo(AccessComparison.Status.BLOCKED);
|
||||
assertThat(blocked.message()).contains("1 member(s) of kof-website-publishers").contains("operator");
|
||||
|
||||
t.config.withMapperOverride(true);
|
||||
assertThat(state().status()).isEqualTo(AccessComparison.Status.OVERRIDDEN);
|
||||
t.config.withMapperOverride(false);
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// the static fallback for the guarded group covers today's grant //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
t.config.withStaticGroupPermissions(Map.of("identity-admins", List.copyOf(SOURCE.userPermissions(null, List.of("identity-admins")))));
|
||||
assertThat(state().status()).isEqualTo(AccessComparison.Status.ACTIVE);
|
||||
assertThat(state().enforced()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare Access: today against the mapper, per active person.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void compareReport() throws Exception
|
||||
{
|
||||
List<AccessComparison.Row> rows = AccessComparison.run(t.config, AuthentikActionUtil.forBackend(t.config.getApiBackendName()), runtime.getPermissionCatalog());
|
||||
AccessComparison.Row operator = rows.stream().filter(r -> r.username().equals("operator")).findFirst().orElseThrow();
|
||||
assertThat(operator.guarded()).isTrue();
|
||||
assertThat(operator.removed()).contains("cms_page.read", "cms_page.edit", "kof22.analytics.hasAccess").doesNotContain("cms.publish.hasAccess");
|
||||
assertThat(operator.added()).contains("authentik.users.read.hasAccess");
|
||||
assertThat(rows).noneMatch(r -> r.username().equals("dormant") || r.username().equals("svc-publisher"));
|
||||
assertThat(AccessComparison.guardFailures(rows)).extracting(AccessComparison.Row::username).containsExactly("operator");
|
||||
|
||||
RenderWidgetInput input = new RenderWidgetInput();
|
||||
input.setWidgetMetaData(t.qInstance.getWidget("authentikCompareAccess"));
|
||||
TableData table = (TableData) new RenderWidgetAction().execute(input).getWidgetData();
|
||||
assertThat(table.getRows()).anyMatch(r -> String.valueOf(r.get("user")).equals("operator (guarded)") && String.valueOf(r.get("removed")).contains("cms_page.edit"));
|
||||
assertThat(table.getFooterHTML()).contains("guard FAILS for 1");
|
||||
assertThat(t.qInstance.getApp("authentikAdmin").getWidgets()).contains("authentikCompareAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The banner widget, on the group and policy screens and the Edit Policy
|
||||
** step, and the home's health row.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void enforcementBanner() throws Exception
|
||||
{
|
||||
RenderWidgetInput input = new RenderWidgetInput();
|
||||
input.setWidgetMetaData(t.qInstance.getWidget("authentikPolicyEnforcement"));
|
||||
String html = ((RawHTML) new RenderWidgetAction().execute(input).getWidgetData()).getHtml();
|
||||
assertThat(html).contains("Not enforced.").contains("NOT enforced at sign-in on this instance (mapper off)").contains("data-status=\"OFF\"");
|
||||
|
||||
assertThat(t.qInstance.getTable("authentik_group").getSections()).anyMatch(s -> "authentikPolicyEnforcement".equals(s.getWidgetName()));
|
||||
assertThat(t.qInstance.getTable("authentik_policy").getSections().get(0).getWidgetName()).isEqualTo("authentikPolicyEnforcement");
|
||||
assertThat(PolicyCutoverWidgets.bannerHtml(new AccessComparison.State(AccessComparison.Status.ACTIVE, "ok <b>", java.time.Instant.now()))).contains("Enforced.").contains("ok <b>");
|
||||
|
||||
String home = com.kingsrook.qbits.authentik.app.AuthentikHomeView.render(com.kingsrook.qbits.authentik.app.AuthentikHomeData.load(t.config));
|
||||
assertThat(home).contains("Policies at sign-in").contains("mapper off");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** While the guard blocks, a sign-in through the mapper gets today's access
|
||||
** (and the floor); once it passes, the policies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mapperFallsBackWhileBlocked() throws Exception
|
||||
{
|
||||
t.config.withMapperEnabled(true);
|
||||
Map<String, Object> context = new HashMap<>();
|
||||
context.put(AuthentikGroupPermissionMapper.CLAIMS_CONTEXT_KEY, new JSONObject().put("email", "sam@example.test").put("groups", new JSONArray().put("kof-website-publishers")));
|
||||
|
||||
runtime.setMapperState(null);
|
||||
QSession blocked = new QSession();
|
||||
new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, blocked, context);
|
||||
assertThat(blocked.getPermissions()).contains("cms_page.read", "cms_page.edit", "cms.publish.hasAccess");
|
||||
|
||||
t.config.withStaticGroupPermissions(Map.of("kof-website-publishers", List.of("cms_page.read", "cms_page.edit", "cms.publish.hasAccess")));
|
||||
t.config.withCutoverGuardGroups(List.of());
|
||||
t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").put("qqq.io/policy",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"delete\"],\"Resource\":[\"cms_page\"]}]}");
|
||||
t.config.withStaticGroupPermissions(Map.of());
|
||||
runtime.invalidateCache();
|
||||
runtime.setMapperState(null);
|
||||
QSession active = new QSession();
|
||||
new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, active, context);
|
||||
assertThat(active.getPermissions()).contains("cms_page.delete", "cms.publish.hasAccess").doesNotContain("cms_page.edit");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Generate Policy From Current Access: the expressible part becomes the
|
||||
** policy, named permissions go to qqq.io/permissions, unknown ones are
|
||||
** skipped; Review shows the document before anything is saved.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void generatePolicy() throws Exception
|
||||
{
|
||||
String analysts = t.authentik.pk("kof-website-analysts");
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("authentikGeneratePolicy");
|
||||
input.addValue("recordIds", t.authentik.pk("kof-website-publishers"));
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
input.setProcessUUID(output.getProcessUUID());
|
||||
input.addValue("reason", "seed from today's access");
|
||||
input.setStartAfterStep("details");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
@SuppressWarnings("unchecked")
|
||||
List<ProcessSummaryLine> review = (List<ProcessSummaryLine>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
assertThat(review).anyMatch(l -> l.getMessage().contains("Generated from current access: {\"PolicySpec\":\"qqq-iam\""));
|
||||
assertThat(review).anyMatch(l -> l.getMessage().contains("Skipped (this app does not define them): nothing.here.hasAccess"));
|
||||
assertThat(t.changes()).isEmpty();
|
||||
|
||||
AuthentikAdmin.AdminResult result = t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-publishers"), "seed")));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
JSONObject attributes = t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes");
|
||||
assertThat(PolicyParser.parse(attributes.get("qqq.io/policy"), PolicyFormat.DEFAULT)).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
|
||||
assertThat(attributes.getJSONArray("qqq.io/permissions").toList()).contains("cms.publish.hasAccess");
|
||||
|
||||
assertThat(t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-publishers"), "again"))).noChange()).isTrue();
|
||||
|
||||
t.config.withHostPermissionSource(null);
|
||||
assertThat(t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, analysts, "x"))).message()).contains("no host permission source");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParser;
|
||||
import com.kingsrook.qbits.authentik.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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The policy editor (DESIGN.md section 12.4): saving a policy to Authentik,
|
||||
** the guardrails, the authentik_policy history row, the process and widget.
|
||||
*******************************************************************************/
|
||||
class PolicyEditorTest
|
||||
{
|
||||
private static final String PAGE_READ_EDIT = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"cms_page\"]}]}";
|
||||
|
||||
private AdminTestSupport t;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The operator's own group carries a policy, so the subset rule lets them
|
||||
** grant cms_page read/edit and content access (but not delete).
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, AuthentikFeature.POLICIES));
|
||||
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/policy",
|
||||
PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("cms_page.read", "cms_page.edit", "content.hasAccess", "publishPage.hasAccess"), PolicyFormat.DEFAULT)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AuthentikAdmin.AdminResult save(String group, String document) throws QException
|
||||
{
|
||||
return (t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(group), "because").with(AuthentikAdmin.POLICY_DOCUMENT, document)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> policyRows() throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput();
|
||||
input.setTableName(t.config.applyTablePrefix(AuthentikLocalTables.POLICY));
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Save, 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.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void saveWritesCanonicalDocumentAndHistoryRow() throws Exception
|
||||
{
|
||||
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("note", "set in the Authentik UI");
|
||||
|
||||
AuthentikAdmin.AdminResult result = save("kof-website-analysts", PAGE_READ_EDIT);
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
assertThat(result.message()).isEqualTo("Policy saved to Authentik.");
|
||||
|
||||
JSONObject attributes = t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes");
|
||||
assertThat(attributes.getString("note")).isEqualTo("set in the Authentik UI");
|
||||
assertThat(attributes.getString("qqq.io/policy")).isEqualTo(PolicyGenerator.toCompact(PolicyGenerator.canonical(PAGE_READ_EDIT, PolicyFormat.DEFAULT)));
|
||||
assertThat(PolicyParser.parse(attributes.get("qqq.io/policy"), PolicyFormat.DEFAULT)).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
|
||||
|
||||
List<QRecord> rows = policyRows();
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getValueString("groupName")).isEqualTo("kof-website-analysts");
|
||||
assertThat(rows.get(0).getValueInteger("permissionCount")).isEqualTo(2);
|
||||
assertThat(rows.get(0).getValueString("policyJson")).contains("\"Action\": [\"read\"]");
|
||||
assertThat(rows.get(0).getValueString("savedBy")).isEqualTo(com.kingsrook.qbits.authentik.AuthentikTestSupport.OPERATOR);
|
||||
|
||||
//////////////////////////////////////////////////////////////
|
||||
// the effective permissions of the group now include them //
|
||||
//////////////////////////////////////////////////////////////
|
||||
t.admin().getUtil().getRuntime().invalidateCache();
|
||||
assertThat(t.admin().getUtil().groupGraph().byName("kof-website-analysts").policyPermissions()).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// saving it again is no change; removing it drops the attribute //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
assertThat(save("kof-website-analysts", PAGE_READ_EDIT).noChange()).isTrue();
|
||||
AuthentikAdmin.AdminResult removed = save("kof-website-analysts", "");
|
||||
assertThat(removed.message()).contains("removed");
|
||||
assertThat(t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").has("qqq.io/policy")).isFalse();
|
||||
assertThat(policyRows()).hasSize(1);
|
||||
assertThat(policyRows().get(0).getValueInteger("permissionCount")).isEqualTo(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The guardrails: invalid documents, unknown resources, the subset rule,
|
||||
** superuser groups and a stale fingerprint are refused, and nothing is sent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusals() throws Exception
|
||||
{
|
||||
assertThat(save("kof-website-analysts", "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}]}").message())
|
||||
.contains("not valid").contains("Deny");
|
||||
assertThat(save("kof-website-analysts", "{\"PolicySpec\":\"other\",\"PolicyVersion\":\"1.0.0\"}").message()).contains("PolicySpec");
|
||||
assertThat(save("kof-website-analysts", PAGE_READ_EDIT.replace("cms_page", "no_such_table")).message()).contains("not permissions of this app").contains("no_such_table.read");
|
||||
assertThat(save("kof-website-analysts", PAGE_READ_EDIT.replace("\"edit\"", "\"delete\"")).message()).contains("do not hold yourself").contains("cms_page.delete");
|
||||
assertThat(save("authentik Admins", PAGE_READ_EDIT).message()).contains("Superuser groups");
|
||||
|
||||
AuthentikAdmin.AdminResult stale = t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because")
|
||||
.with(AuthentikAdmin.POLICY_DOCUMENT, PAGE_READ_EDIT).with("expectedFingerprint", "not-the-fingerprint"));
|
||||
assertThat(stale.message()).contains("changed");
|
||||
|
||||
assertThat(t.changes()).isEmpty();
|
||||
assertThat(policyRows()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unknown resources already in the stored policy are kept (with a note); a
|
||||
** stored document that is invalid is replaced.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void keptUnknownsAndInvalidStoredDocuments() throws Exception
|
||||
{
|
||||
String withGone = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\"]}]}";
|
||||
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", withGone);
|
||||
AdminPlan plan = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because")
|
||||
.with(AuthentikAdmin.POLICY_DOCUMENT, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\",\"cms_page\"]}]}"));
|
||||
assertThat(plan.isRunnable()).as(plan.getMessage()).isTrue();
|
||||
assertThat(plan.getNotes()).anyMatch(n -> n.startsWith("Adds: cms_page.read")).anyMatch(n -> n.contains("Kept although") && n.contains("gone_table.read"));
|
||||
|
||||
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", "garbage");
|
||||
AdminPlan replace = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because").with(AuthentikAdmin.POLICY_DOCUMENT, ""));
|
||||
assertThat(replace.isRunnable()).isTrue();
|
||||
assertThat(replace.getNotes()).anyMatch(n -> n.contains("was not valid"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission gating: without authentik.policies.manage, or without the
|
||||
** POLICIES feature, there is no save (and no metadata).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void gating() throws Exception
|
||||
{
|
||||
t.loginAs(com.kingsrook.qbits.authentik.AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ, IdentityPermission.GROUPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
|
||||
assertThat(save("kof-website-analysts", PAGE_READ_EDIT).ok()).isFalse();
|
||||
assertThat(t.changes()).isEmpty();
|
||||
|
||||
AdminTestSupport off = AdminTestSupport.create();
|
||||
assertThat(off.qInstance.getProcess("authentikEditPolicy")).isNull();
|
||||
assertThat(off.qInstance.getWidget("authentikPolicyEditor")).isNull();
|
||||
assertThat(off.qInstance.getTable("authentik_policy")).isNull();
|
||||
AuthentikAdmin.AdminResult refused = off.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, off.authentik.pk("kof-website-analysts"), "because").with(AuthentikAdmin.POLICY_DOCUMENT, PAGE_READ_EDIT));
|
||||
assertThat(refused.ok()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A second person approves policy saves when policyTwoPersonApproval is on.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void twoPersonApproval() throws Exception
|
||||
{
|
||||
t.config.withPolicyTwoPersonApproval(true);
|
||||
AuthentikAdmin.AdminResult result = save("kof-website-analysts", PAGE_READ_EDIT);
|
||||
assertThat(result.pending()).isTrue();
|
||||
assertThat(t.changes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record action: prepare fills the group and fingerprint, the step shows
|
||||
** the editor widget, Review is a dry run, Result saves.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void editPolicyProcess() throws Exception
|
||||
{
|
||||
String groupPk = t.authentik.pk("kof-website-analysts");
|
||||
QFrontendStepMetaData details = (QFrontendStepMetaData) t.qInstance.getProcess("authentikEditPolicy").getStep("details");
|
||||
assertThat(details.getComponents()).anyMatch(c -> c.getType() == QComponentType.WIDGET && "authentikPolicyEditor".equals(c.getValues().get("widgetName")));
|
||||
assertThat(t.qInstance.getProcess("authentikEditPolicy").getTableName()).isEqualTo(t.config.applyTablePrefix(AuthentikTables.GROUP));
|
||||
assertThat(t.qInstance.getProcess("authentikEditPolicyRecord").getTableName()).isEqualTo("authentik_policy");
|
||||
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("authentikEditPolicy");
|
||||
input.addValue("recordIds", groupPk);
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getValues().get("groupPk")).isEqualTo(groupPk);
|
||||
assertThat(output.getValues().get("expectedFingerprint")).isNotNull();
|
||||
|
||||
input.setProcessUUID(output.getProcessUUID());
|
||||
Map<String, Serializable> form = Map.of("reason", "editors read pages", AuthentikAdmin.POLICY_DOCUMENT, PAGE_READ_EDIT);
|
||||
form.forEach(input::addValue);
|
||||
input.setStartAfterStep("details");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
@SuppressWarnings("unchecked")
|
||||
List<ProcessSummaryLine> review = (List<ProcessSummaryLine>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
assertThat(review).anyMatch(l -> l.getStatus() == Status.OK && l.getMessage().contains("policy will be saved"));
|
||||
assertThat(review).anyMatch(l -> l.getMessage().contains("Adds: cms_page.edit, cms_page.read"));
|
||||
assertThat(t.changes()).as("review is a dry run").isEmpty();
|
||||
|
||||
input.setStartAfterStep("review");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(PolicyParser.parse(t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").get("qqq.io/policy"), PolicyFormat.DEFAULT))
|
||||
.containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// the same action from the policy record (its group) //
|
||||
////////////////////////////////////////////////////////
|
||||
RunProcessInput again = new RunProcessInput();
|
||||
again.setProcessName("authentikEditPolicyRecord");
|
||||
again.addValue("recordIds", String.valueOf(policyRows().get(0).getValue("id")));
|
||||
RunProcessOutput prepared = new RunProcessAction().execute(again);
|
||||
assertThat(prepared.getValues().get("groupPk")).isEqualTo(groupPk);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The widget: catalog plus the group's live document; a change made in
|
||||
** Authentik after the last QQQ save is flagged; invalid stored documents are
|
||||
** reported.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void widgetData() throws Exception
|
||||
{
|
||||
String groupPk = t.authentik.pk("kof-website-analysts");
|
||||
PolicyEditorWidget.Data empty = PolicyEditorWidget.load(t.config, groupPk);
|
||||
assertThat(empty.getType()).isEqualTo("AUTHENTIK_POLICY_EDITOR");
|
||||
assertThat(empty.getSpec()).isEqualTo("qqq-iam");
|
||||
assertThat(empty.getDocument()).isNull();
|
||||
assertThat(empty.getGroupLabel()).isEqualTo("kof-website-analysts");
|
||||
assertThat(empty.getOutputFieldName()).isEqualTo("policyDocument");
|
||||
assertThat(new JSONObject(Map.of("c", empty.getCatalog())).toString()).contains("\"cms_page\"").contains("\"publishPage\"");
|
||||
|
||||
save("kof-website-analysts", PAGE_READ_EDIT);
|
||||
PolicyEditorWidget.Data saved = PolicyEditorWidget.load(t.config, groupPk);
|
||||
assertThat(saved.getDocument()).contains("\"cms_page\"");
|
||||
assertThat(saved.getChangedInAuthentik()).isFalse();
|
||||
|
||||
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT.replace("\"edit\"", "\"insert\""));
|
||||
t.admin().getUtil().getRuntime().invalidateCache();
|
||||
assertThat(PolicyEditorWidget.load(t.config, groupPk).getChangedInAuthentik()).isTrue();
|
||||
|
||||
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", "garbage");
|
||||
t.admin().getUtil().getRuntime().invalidateCache();
|
||||
PolicyEditorWidget.Data broken = PolicyEditorWidget.load(t.config, groupPk);
|
||||
assertThat(broken.getDocumentProblem()).contains("not a JSON object");
|
||||
assertThat(broken.getDocument()).isNull();
|
||||
|
||||
assertThat(PolicyEditorWidget.load(t.config, null).getGroupLabel()).isNull();
|
||||
assertThatThrownBy(() -> PolicyEditorWidget.load(t.config, "../x")).hasMessageContaining("Not a group id");
|
||||
|
||||
RenderWidgetInput input = new RenderWidgetInput();
|
||||
input.setWidgetMetaData(t.qInstance.getWidget("authentikPolicyEditor"));
|
||||
input.setQueryParams(new java.util.HashMap<>(Map.of("groupPk", groupPk)));
|
||||
RenderWidgetOutput output = new RenderWidgetAction().execute(input);
|
||||
assertThat(output.getWidgetData().getType()).isEqualTo("AUTHENTIK_POLICY_EDITOR");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyStore;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full user and group CRUD (DESIGN.md section 12.5): username and attribute
|
||||
** edits, delete user and delete group, and their gating.
|
||||
*******************************************************************************/
|
||||
class UserGroupCrudTest
|
||||
{
|
||||
private AdminTestSupport t;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
|
||||
AuthentikFeature.POLICIES, AuthentikFeature.DELETE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AuthentikAdmin.AdminResult run(AdminRequest request) throws QException
|
||||
{
|
||||
return (t.admin().execute(request));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Username changes are validated and unique.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void editUsername() throws QException
|
||||
{
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "rename").with("username", "second")).message()).contains("already has the username");
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "rename").with("username", "has space")).message()).contains("A username is");
|
||||
|
||||
AuthentikAdmin.AdminResult renamed = run(AdminRequest.of(AdminAction.EDIT_USER, "3", "rename").with("username", "samuel"));
|
||||
assertThat(renamed.ok()).as(renamed.message()).isTrue();
|
||||
assertThat(t.authentik.users.get(3).getString("username")).isEqualTo("samuel");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Other attributes replace the non-reserved part; reserved keys survive and
|
||||
** cannot be set; blank leaves them as they are; editing needs audit.read.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void editUserAttributes() throws QException
|
||||
{
|
||||
t.authentik.users.get(3).put("attributes", new JSONObject().put("qqq.io/protected", false).put("goauthentik.io/user/token-expires", true).put("team", "web").put("old", 1));
|
||||
|
||||
AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "{\"team\": \"ops\", \"desk\": 4}"));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
JSONObject attributes = t.authentik.users.get(3).getJSONObject("attributes");
|
||||
assertThat(attributes.getString("team")).isEqualTo("ops");
|
||||
assertThat(attributes.getInt("desk")).isEqualTo(4);
|
||||
assertThat(attributes.has("old")).isFalse();
|
||||
assertThat(attributes.getBoolean("qqq.io/protected")).isFalse();
|
||||
assertThat(attributes.getBoolean("goauthentik.io/user/token-expires")).isTrue();
|
||||
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "{\"qqq.io/protected\": true}")).message()).contains("reserved");
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "[1]")).message()).contains("JSON object");
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "").with("name", "Sam B")).ok()).isTrue();
|
||||
assertThat(t.authentik.users.get(3).getJSONObject("attributes").getString("team")).as("blank keeps them").isEqualTo("ops");
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
// an operator without authentik.audit.read: refused //
|
||||
///////////////////////////////////////////////////////
|
||||
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/permissions", new JSONArray(List.of(
|
||||
"authentik.users.read", "authentik.users.manage", "authentik.memberships.manage", "authentik.permissions.manage", "authentik.groups.manage")));
|
||||
t.admin().getUtil().getRuntime().invalidateCache();
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "{\"team\": \"x\"}")).message()).contains("authentik.audit.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group attributes the same way.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void editGroupAttributes() throws QException
|
||||
{
|
||||
String pk = t.authentik.pk("kof-website-analysts");
|
||||
AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_GROUP, pk, "update").with("otherAttributes", "{\"owner\": \"data team\"}"));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
JSONObject attributes = t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes");
|
||||
assertThat(attributes.getString("owner")).isEqualTo("data team");
|
||||
assertThat(attributes.getJSONArray("qqq.io/permissions").toList()).containsExactly("kof22.analytics.hasAccess");
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_GROUP, pk, "update").with("otherAttributes", "{\"owner\": \"data team\"}")).noChange()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete user: typed confirmation, never protected accounts or yourself.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deleteUser() throws QException
|
||||
{
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "3", "left")).message()).contains("Type the username (sam)");
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "4", "left").with("confirmText", "breakglass")).message()).contains("Protected");
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "1", "left").with("confirmText", "operator")).message()).contains("your own account");
|
||||
assertThat(t.changes()).isEmpty();
|
||||
|
||||
AuthentikAdmin.AdminResult deleted = run(AdminRequest.of(AdminAction.DELETE_USER, "3", "left the company").with("confirmText", "sam"));
|
||||
assertThat(deleted.ok()).as(deleted.message()).isTrue();
|
||||
assertThat(t.authentik.users).doesNotContainKey(3);
|
||||
assertThat(t.changes()).containsExactly("DELETE /core/users/3/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete group: typed, and only empty groups without children; the policy
|
||||
** record goes with it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deleteGroup() throws Exception
|
||||
{
|
||||
String publishers = t.authentik.pk("kof-website-publishers");
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, publishers, "cleanup").with("confirmText", "kof-website-publishers")).message()).contains("member(s)");
|
||||
|
||||
t.authentik.group("child-of-analysts", false, List.of(), List.of(), "kof-website-analysts");
|
||||
t.admin().getUtil().getRuntime().invalidateCache();
|
||||
String analysts = t.authentik.pk("kof-website-analysts");
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, analysts, "cleanup")).message()).contains("Type the group name");
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, analysts, "cleanup").with("confirmText", "kof-website-analysts")).message()).contains("child groups (child-of-analysts)");
|
||||
|
||||
String child = t.authentik.pk("child-of-analysts");
|
||||
PolicyStore.save(t.config, child, "child-of-analysts", new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0").put("Statement", new JSONArray()), 0, "x", null);
|
||||
AuthentikAdmin.AdminResult deleted = run(AdminRequest.of(AdminAction.DELETE_GROUP, child, "cleanup").with("confirmText", "child-of-analysts"));
|
||||
assertThat(deleted.ok()).as(deleted.message()).isTrue();
|
||||
assertThat(t.authentik.groups).doesNotContainKey(child);
|
||||
assertThat(PolicyStore.find(t.config, child)).isNull();
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, t.authentik.pk("authentik Admins"), "x").with("confirmText", "authentik Admins")).message()).contains("Superuser groups");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DELETE is off by default: no processes, and the action is refused; the
|
||||
** permission is still required with it on.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void gating() throws QException
|
||||
{
|
||||
assertThat(t.qInstance.getProcess("authentikDeleteUser")).isNotNull();
|
||||
assertThat(t.qInstance.getProcess("authentikDeleteGroup").getMaxInputRecords()).isEqualTo(1);
|
||||
|
||||
AdminTestSupport off = AdminTestSupport.create();
|
||||
assertThat(off.qInstance.getProcess("authentikDeleteUser")).isNull();
|
||||
assertThat(off.qInstance.getProcess("authentikDeleteGroup")).isNull();
|
||||
assertThat(off.admin().execute(AdminRequest.of(AdminAction.DELETE_USER, "3", "x").with("confirmText", "sam")).ok()).isFalse();
|
||||
|
||||
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ, IdentityPermission.GROUPS_MANAGE);
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "3", "x").with("confirmText", "sam")).ok()).isFalse();
|
||||
assertThat(t.authentik.users).containsKey(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Edit User prefills username and (with audit.read) other attributes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void editUserPrefill() throws QException
|
||||
{
|
||||
t.authentik.users.get(3).put("attributes", new JSONObject().put("team", "web").put("qqq.io/x", 1));
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("authentikEditUser");
|
||||
input.addValue("recordIds", "3");
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getValues().get("username")).isEqualTo("sam");
|
||||
assertThat(new JSONObject(String.valueOf(output.getValues().get("otherAttributes"))).toMap()).containsOnlyKeys("team");
|
||||
|
||||
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ, IdentityPermission.USERS_MANAGE);
|
||||
RunProcessInput limited = new RunProcessInput();
|
||||
limited.setProcessName("authentikEditUser");
|
||||
limited.addValue("recordIds", "3");
|
||||
assertThat(new RunProcessAction().execute(limited).getValues().get("otherAttributes")).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParser;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pins the scope-mapping example of docs/POLICY-FORMAT.md on Authentik
|
||||
** 2026.8.3: the blueprint applies, and the mapping (run through Authentik's
|
||||
** property-mapping test endpoint) emits a claim that the qbit's parser turns
|
||||
** into the union of the user's groups' policies, including inherited parents.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(46)
|
||||
class PolicyClaimMappingIT
|
||||
{
|
||||
private static TestApi admin;
|
||||
private static String mappingPk;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll() throws Exception
|
||||
{
|
||||
AuthentikTestStack stack = AuthentikTestStack.get();
|
||||
admin = stack.admin();
|
||||
try(InputStream in = PolicyClaimMappingIT.class.getResourceAsStream("/blueprints/qqq-policy-claim.yaml"))
|
||||
{
|
||||
stack.createContentBlueprint("qqq-policy-claim", new String(in.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
mappingPk = admin.first("/propertymappings/provider/scope/?name=qqq-policy-claim").getString("pk");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the mapping for a user; the claim as Authentik would put it in a token.
|
||||
*******************************************************************************/
|
||||
private static JSONObject claimsFor(int userPk)
|
||||
{
|
||||
TestApi.Response response = admin.post("/propertymappings/all/" + mappingPk + "/test/", new JSONObject().put("user", userPk));
|
||||
assertThat(response.status()).as(response.body()).isEqualTo(200);
|
||||
JSONObject result = response.json();
|
||||
assertThat(result.getBoolean("successful")).as(result.toString()).isTrue();
|
||||
return (new JSONObject(result.getString("result")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group with a canonical policy for these permissions.
|
||||
*******************************************************************************/
|
||||
private static String group(String name, String parentPk, Object policy) throws Exception
|
||||
{
|
||||
JSONObject group = new JSONObject().put("name", name).put("attributes", new JSONObject().put("qqq.io/policy", policy));
|
||||
if(parentPk != null)
|
||||
{
|
||||
group.put("parents", new JSONArray().put(parentPk));
|
||||
}
|
||||
admin.post("/core/groups/", group);
|
||||
return (admin.groupPk(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static int user(String username, String groupPk)
|
||||
{
|
||||
admin.post("/core/users/", new JSONObject().put("username", username).put("name", username).put("type", "internal").put("path", "users"));
|
||||
int pk = admin.userPk(username);
|
||||
if(groupPk != null)
|
||||
{
|
||||
admin.post("/core/groups/" + groupPk + "/add_user/", new JSONObject().put("pk", pk));
|
||||
}
|
||||
return (pk);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void claimIsTheUnionOfInheritedGroupPolicies() throws Exception
|
||||
{
|
||||
PolicyFormat format = PolicyFormat.DEFAULT;
|
||||
String parent = group("policy-parent", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("contentApp.hasAccess"), format)));
|
||||
String child = group("policy-child", parent, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("cms_page.read", "cms_page.edit"), format)));
|
||||
group("policy-object", null, PolicyGenerator.generate(List.of("objectAttr.read"), format));
|
||||
group("policy-other-spec", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("ignored.read"), new PolicyFormat("other", "1.0.0", "x", "y"))));
|
||||
|
||||
int member = user("policy.member", child);
|
||||
admin.post("/core/groups/" + admin.groupPk("policy-object") + "/add_user/", new JSONObject().put("pk", member));
|
||||
admin.post("/core/groups/" + admin.groupPk("policy-other-spec") + "/add_user/", new JSONObject().put("pk", member));
|
||||
|
||||
JSONObject claims = claimsFor(member);
|
||||
assertThat(claims.has("qqq_policy")).isTrue();
|
||||
assertThat(PolicyPermissions.fromClaims(claims, format))
|
||||
.as("parent (inherited), child, an object-valued attribute; another spec is skipped")
|
||||
.containsExactlyInAnyOrder("contentApp.hasAccess", "cms_page.read", "cms_page.edit", "objectAttr.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No policies: the claim is still a valid, empty document.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void userWithoutPoliciesGetsAnEmptyDocument() throws Exception
|
||||
{
|
||||
JSONObject claims = claimsFor(user("policy.nobody", null));
|
||||
assertThat(PolicyParser.parseStrict(claims.get("qqq_policy"), PolicyFormat.DEFAULT).permissions()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,12 @@ class QbitWriteContractIT
|
||||
{
|
||||
private static final String OPERATOR_EMAIL = "wr.operator@example.test";
|
||||
|
||||
/** The operator's own policy: they may grant read and edit on wr_page. */
|
||||
private static final String WR_POLICY = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"wr_page\"]}]}";
|
||||
|
||||
/** The phase 2/3 permissions of DESIGN.md section 5.1 (plus view_flow to resolve the invitation flow by slug). */
|
||||
private static final List<String> ROLE_PERMISSIONS = List.of(
|
||||
"authentik_core.add_user", "authentik_core.change_user", "authentik_core.reset_user_password",
|
||||
"authentik_core.add_user", "authentik_core.change_user", "authentik_core.reset_user_password", "authentik_core.delete_user", "authentik_core.delete_group",
|
||||
"authentik_core.add_group", "authentik_core.change_group", "authentik_core.add_user_to_group", "authentik_core.remove_user_from_group",
|
||||
"authentik_core.view_token", "authentik_core.delete_token", "authentik_core.delete_authenticatedsession",
|
||||
"authentik_stages_invitation.view_invitation", "authentik_stages_invitation.add_invitation", "authentik_stages_invitation.delete_invitation",
|
||||
@@ -110,7 +113,8 @@ class QbitWriteContractIT
|
||||
admin.post("/rbac/permissions/assigned_by_roles/" + roleUuid + "/assign/", new JSONObject().put("permissions", new JSONArray(ROLE_PERMISSIONS)));
|
||||
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "wr-admins").put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.*"))).put("qqq.io/app-access", new JSONArray(List.of(AuthentikTestStack.APP_SLUG)))));
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.*"))).put("qqq.io/app-access", new JSONArray(List.of(AuthentikTestStack.APP_SLUG)))
|
||||
.put("qqq.io/policy", WR_POLICY)));
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "wr-readers").put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.users.read")))));
|
||||
admin.post("/core/users/", new JSONObject().put("username", "wr.operator").put("name", "WR Operator").put("email", OPERATOR_EMAIL).put("type", "internal").put("path", "users"));
|
||||
@@ -162,11 +166,15 @@ class QbitWriteContractIT
|
||||
.withRecoveryEmailStage("kof22-recovery-email")
|
||||
.withInvitationFlow("wr-enrollment")
|
||||
.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS));
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE));
|
||||
|
||||
qInstance = new QInstance();
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
qInstance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
qInstance.addTable(new com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData().withName("wr_page").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("id", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER))
|
||||
.withPermissionRules(new com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules()
|
||||
.withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)));
|
||||
new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
|
||||
Set<String> permissions = new HashSet<>();
|
||||
@@ -305,6 +313,67 @@ class QbitWriteContractIT
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Save a policy on a real Authentik: the canonical compact string lands in
|
||||
** the group's attributes next to the others, the qbit reads it back, and a
|
||||
** grant the operator does not hold is refused.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void savePolicy() throws Exception
|
||||
{
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "wr-policy").put("attributes", new JSONObject().put("owner-note", "set in the Authentik UI")));
|
||||
String pk = admin.groupPk("wr-policy");
|
||||
String document = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"delete\"],\"Resource\":[\"wr_page\"]}]}";
|
||||
|
||||
AuthentikAdmin.AdminResult escalation = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, document));
|
||||
assertThat(escalation.ok()).isFalse();
|
||||
assertThat(escalation.message()).contains("wr_page.delete");
|
||||
|
||||
String allowed = document.replace(",\"delete\"", "");
|
||||
AuthentikAdmin.AdminResult saved = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, allowed));
|
||||
assertThat(saved.ok()).as(saved.message()).isTrue();
|
||||
|
||||
JSONObject attributes = admin.get("/core/groups/" + pk + "/").json().getJSONObject("attributes");
|
||||
assertThat(attributes.getString("owner-note")).isEqualTo("set in the Authentik UI");
|
||||
assertThat(attributes.getString("qqq.io/policy")).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"wr_page\"]}]}");
|
||||
assertThat(AuthentikActionUtil.forBackend(config.getApiBackendName()).groupGraph().byPk(pk).policyPermissions()).containsExactly("wr_page.read");
|
||||
|
||||
AuthentikAdmin.AdminResult cleared = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, ""));
|
||||
assertThat(cleared.ok()).as(cleared.message()).isTrue();
|
||||
assertThat(admin.get("/core/groups/" + pk + "/").json().getJSONObject("attributes").has("qqq.io/policy")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete user and delete group on a real Authentik (delete_user and
|
||||
** delete_group granted only for the DELETE feature); a group with members is
|
||||
** refused by the qbit before any call.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deleteUserAndGroup() throws Exception
|
||||
{
|
||||
admin.post("/core/users/", new JSONObject().put("username", "wr.leaver").put("name", "WR Leaver").put("email", "wr.leaver@example.test").put("type", "internal").put("path", "users"));
|
||||
int leaver = admin.userPk("wr.leaver");
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "wr-doomed"));
|
||||
String doomed = admin.groupPk("wr-doomed");
|
||||
admin.post("/core/groups/" + doomed + "/add_user/", new JSONObject().put("pk", leaver));
|
||||
|
||||
AuthentikAdmin.AdminResult notEmpty = run(AdminRequest.of(AdminAction.DELETE_GROUP, doomed, "contract test").with("confirmText", "wr-doomed"));
|
||||
assertThat(notEmpty.ok()).isFalse();
|
||||
assertThat(notEmpty.message()).contains("member(s)");
|
||||
|
||||
AuthentikAdmin.AdminResult user = run(AdminRequest.of(AdminAction.DELETE_USER, String.valueOf(leaver), "contract test").with("confirmText", "wr.leaver"));
|
||||
assertThat(user.ok()).as(user.message()).isTrue();
|
||||
assertThat(admin.get("/core/users/" + leaver + "/").status()).isEqualTo(404);
|
||||
|
||||
AuthentikAdmin.AdminResult group = run(AdminRequest.of(AdminAction.DELETE_GROUP, doomed, "contract test").with("confirmText", "wr-doomed"));
|
||||
assertThat(group.ok()).as(group.message()).isTrue();
|
||||
assertThat(admin.get("/core/groups/" + doomed + "/").status()).isEqualTo(404);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group create and edit (parents), and the superuser parent refused by the
|
||||
** guard.
|
||||
|
||||
+21
@@ -182,6 +182,27 @@ class AuthentikGroupPermissionMapperTest
|
||||
** Inherited authentik.* through a child group; existing permissions kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void policyClaimIsAdded()
|
||||
{
|
||||
String policy = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}]}";
|
||||
Map<String, Object> ctx = context("someone@example.test", new JSONArray());
|
||||
ctx.put("idToken", new JSONObject().put("qqq_policy", policy));
|
||||
QSession session = new QSession();
|
||||
new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, ctx);
|
||||
assertThat(session.getPermissions()).contains("cms_page.read");
|
||||
|
||||
ctx.put("idToken", new JSONObject().put("qqq_policy", policy.replace("Allow", "Deny")));
|
||||
QSession denied = new QSession();
|
||||
new AuthentikGroupPermissionMapper().customizeSession(qInstance, denied, ctx);
|
||||
assertThat(denied.getPermissions()).doesNotContain("cms_page.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void groupsBecomePermissions()
|
||||
{
|
||||
QSession session = new QSession();
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.document;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.statement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for AuthentikPolicyClaimMapper and PolicyPermissions.
|
||||
*******************************************************************************/
|
||||
class AuthentikPolicyClaimMapperTest
|
||||
{
|
||||
private static final String READ_PAGE = document(statement("Allow", List.of("read"), List.of("cms_page"))).toString();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QSession run(AuthentikPolicyClaimMapper mapper, Map<String, Object> context)
|
||||
{
|
||||
QSession session = new QSession();
|
||||
session.setPermissions(Set.of("existing.hasAccess"));
|
||||
mapper.customizeSession(null, session, context);
|
||||
return (session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The claim as a string (the scope mapping's json.dumps) in the ID token.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stringClaimInIdToken()
|
||||
{
|
||||
QSession session = run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", READ_PAGE)));
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.hasAccess", "cms_page.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An object claim in the access token (session resume: no ID token).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void objectClaimInAccessToken()
|
||||
{
|
||||
QSession session = run(new AuthentikPolicyClaimMapper(), Map.of("jwtPayloadJsonObject", new JSONObject().put("qqq_policy", new JSONObject(READ_PAGE))));
|
||||
assertThat(session.getPermissions()).contains("cms_page.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The ID token wins when both carry the claim; the access token is used when
|
||||
** only it has the claim.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void idTokenPreferred()
|
||||
{
|
||||
String other = document(statement("Allow", List.of("edit"), List.of("other"))).toString();
|
||||
Map<String, Object> context = new HashMap<>();
|
||||
context.put("idToken", new JSONObject().put("qqq_policy", READ_PAGE));
|
||||
context.put("jwtPayloadJsonObject", new JSONObject().put("qqq_policy", other));
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), context).getPermissions()).contains("cms_page.read").doesNotContain("other.edit");
|
||||
|
||||
context.put("idToken", new JSONObject().put("email", "a@example.test"));
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), context).getPermissions()).contains("other.edit");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing or invalid claims add nothing and keep what the session had.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failsClosed()
|
||||
{
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of()).getPermissions()).containsExactly("existing.hasAccess");
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), null).getPermissions()).containsExactly("existing.hasAccess");
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", "{}"))).getPermissions()).containsExactly("existing.hasAccess");
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", "garbage"))).getPermissions()).containsExactly("existing.hasAccess");
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", "not claims")).getPermissions()).containsExactly("existing.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host's own names (spec, claim).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void customFormat()
|
||||
{
|
||||
PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies");
|
||||
String doc = new JSONObject(READ_PAGE).put("PolicySpec", "acme-iam").toString();
|
||||
|
||||
QSession session = run(new AuthentikPolicyClaimMapper().withFormat(custom), Map.of("idToken", Map.of("policies", doc)));
|
||||
assertThat(session.getPermissions()).contains("cms_page.read");
|
||||
assertThat(new AuthentikPolicyClaimMapper().withFormat(null).getFormat()).isEqualTo(PolicyFormat.DEFAULT);
|
||||
|
||||
assertThat(PolicyPermissions.fromClaims(new JSONObject().put("policies", doc), custom)).containsExactly("cms_page.read");
|
||||
assertThat(PolicyPermissions.fromClaims(null, custom)).isEmpty();
|
||||
assertThat(PolicyPermissions.claimsWithPolicy(null, null)).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The security-key hook runs before permissions, with the claims, even when
|
||||
** the token has no policy claim.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void securityKeyHook()
|
||||
{
|
||||
List<String> calls = new ArrayList<>();
|
||||
AuthentikPolicyClaimMapper mapper = new AuthentikPolicyClaimMapper()
|
||||
{
|
||||
@Override
|
||||
protected void applySecurityKeys(QSession qSession, JSONObject claims)
|
||||
{
|
||||
calls.add(claims.optString("tenant") + ":" + qSession.getPermissions().contains("cms_page.read"));
|
||||
}
|
||||
};
|
||||
run(mapper, Map.of("idToken", new JSONObject().put("tenant", "t1").put("qqq_policy", READ_PAGE)));
|
||||
run(mapper, Map.of("jwtPayloadJsonObject", new JSONObject().put("tenant", "t2")));
|
||||
assertThat(calls).containsExactly("t1:false", "t2:false");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for PolicyCatalog: the editor's enumeration of what a policy can
|
||||
** grant in a QInstance, grouped by app.
|
||||
*******************************************************************************/
|
||||
class PolicyCatalogTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QPermissionRules rules(PermissionLevel level)
|
||||
{
|
||||
return (new QPermissionRules().withLevel(level));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData table(String name, String label, PermissionLevel level)
|
||||
{
|
||||
return (new QTableMetaData().withName(name).withLabel(label).withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withPermissionRules(rules(level)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An instance with every kind of object and permission shape.
|
||||
*******************************************************************************/
|
||||
static QInstance instance()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
|
||||
QTableMetaData page = table("cms_page", "Pages", PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS);
|
||||
QTableMetaData audit = table("cms_audit", "Audit", PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
QTableMetaData legacy = table("legacy", "Legacy", PermissionLevel.READ_WRITE_PERMISSIONS);
|
||||
QTableMetaData gate = table("gate", "Gate", PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
QTableMetaData shared1 = table("shared_one", "Shared One", PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
shared1.getPermissionRules().setPermissionBaseName("shared_base");
|
||||
QTableMetaData shared2 = table("shared_two", "Shared Two", PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
shared2.getPermissionRules().setPermissionBaseName("shared_base");
|
||||
QTableMetaData open = table("open_table", "Open", PermissionLevel.NOT_PROTECTED);
|
||||
QTableMetaData dotted = table("dotted", "Dotted", PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
dotted.getPermissionRules().setPermissionBaseName("cms.publish");
|
||||
QTableMetaData orphan = table("orphan", "Orphan", PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS);
|
||||
for(QTableMetaData t : List.of(page, audit, legacy, gate, shared1, shared2, open, dotted, orphan))
|
||||
{
|
||||
instance.addTable(t);
|
||||
}
|
||||
|
||||
instance.addProcess(new QProcessMetaData().withName("publishPage").withLabel("Publish").withTableName("cms_page").withIsHidden(true)
|
||||
.withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
instance.addProcess(new QProcessMetaData().withName("loose").withLabel("Loose").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
instance.addReport(new QReportMetaData().withName("pageReport").withLabel("Page Report").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
instance.addWidget(new QWidgetMetaData().withName("pageStats").withLabel("Page Stats").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
instance.addWidget(new QWidgetMetaData().withName("openWidget").withLabel("Open Widget").withPermissionRules(rules(PermissionLevel.NOT_PROTECTED)));
|
||||
|
||||
instance.addApp(new QAppMetaData().withName("content").withLabel("Content").withSortOrder(1)
|
||||
.withChild(page).withChild(audit).withChild(legacy).withChild(gate).withChild(shared1).withChild(shared2).withChild(open).withChild(dotted)
|
||||
.withChild(instance.getReport("pageReport")).withWidgets(List.of("pageStats"))
|
||||
.withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
instance.addApp(new QAppMetaData().withName("emptyApp").withLabel("Empty").withSortOrder(2).withPermissionRules(rules(PermissionLevel.NOT_PROTECTED)));
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void groupsByAppWithTheActionsQqqChecks()
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance());
|
||||
|
||||
assertThat(catalog.getGroups()).extracting(PolicyCatalog.Group::label).containsExactly("Content", "Other");
|
||||
PolicyCatalog.Group content = catalog.getGroups().get(0);
|
||||
assertThat(content.resources()).extracting(PolicyCatalog.Resource::name)
|
||||
.containsExactly("content", "cms_audit", "gate", "legacy", "cms_page", "shared_base", "publishPage", "pageReport", "pageStats");
|
||||
|
||||
assertThat(catalog.get("content").actions()).containsExactly(PolicyAction.HAS_ACCESS);
|
||||
assertThat(catalog.get("cms_page").actions()).containsExactly(PolicyAction.READ, PolicyAction.INSERT, PolicyAction.EDIT, PolicyAction.DELETE);
|
||||
assertThat(catalog.get("cms_audit").actions()).as("capabilities limit the actions").containsExactly(PolicyAction.READ);
|
||||
assertThat(catalog.get("legacy").actions()).containsExactly(PolicyAction.READ);
|
||||
assertThat(catalog.get("legacy").note()).contains("write permission");
|
||||
assertThat(catalog.get("gate").actions()).containsExactly(PolicyAction.HAS_ACCESS);
|
||||
assertThat(catalog.get("publishPage").kind()).isEqualTo(PolicyCatalog.Kind.PROCESS);
|
||||
assertThat(catalog.get("publishPage").label()).as("a hidden record action, with its table").isEqualTo("Publish (Pages action)");
|
||||
assertThat(catalog.get("shared_base").label()).as("one entry per base name").isEqualTo("Shared One / Shared Two");
|
||||
|
||||
assertThat(catalog.getGroups().get(1).resources()).extracting(PolicyCatalog.Resource::name).containsExactly("orphan", "loose");
|
||||
assertThat(catalog.get("open_table")).as("not protected: nothing to grant").isNull();
|
||||
assertThat(catalog.get("openWidget")).isNull();
|
||||
assertThat(catalog.get("emptyApp")).isNull();
|
||||
assertThat(catalog.get("cms.publish")).as("a dotted base name cannot be a resource").isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissionNamesAndUnknowns()
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance());
|
||||
assertThat(catalog.permissionNames()).contains("cms_page.read", "cms_page.delete", "content.hasAccess", "publishPage.hasAccess", "pageReport.hasAccess")
|
||||
.doesNotContain("cms_page.hasAccess", "cms_audit.edit", "open_table.read");
|
||||
assertThat(catalog.unknownPermissions(Set.of("cms_page.read", "cms_page.hasAccess", "gone.read"))).containsExactly("cms_page.hasAccess", "gone.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void json()
|
||||
{
|
||||
JSONArray json = PolicyCatalog.of(instance()).toJson();
|
||||
JSONObject content = json.getJSONObject(0);
|
||||
assertThat(content.getString("app")).isEqualTo("content");
|
||||
assertThat(content.getString("label")).isEqualTo("Content");
|
||||
JSONObject legacy = content.getJSONArray("resources").getJSONObject(3);
|
||||
assertThat(legacy.getString("name")).isEqualTo("legacy");
|
||||
assertThat(legacy.getString("kind")).isEqualTo("TABLE");
|
||||
assertThat(legacy.getJSONArray("actions").toList()).containsExactly("read");
|
||||
assertThat(legacy.has("note")).isTrue();
|
||||
assertThat(json.getJSONObject(1).isNull("app")).isTrue();
|
||||
assertThat(PolicyCatalog.of(new QInstance()).getGroups()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for PolicyGenerator: canonical form and the round trip.
|
||||
*******************************************************************************/
|
||||
class PolicyGeneratorTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canonicalForm() throws Exception
|
||||
{
|
||||
JSONObject doc = PolicyGenerator.generate(List.of("cms_page.edit", "contentApp.hasAccess", "cms_page.read", "cms_media.read"), PolicyFormat.DEFAULT);
|
||||
|
||||
assertThat(PolicyGenerator.toCompact(doc)).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":["
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"hasAccess\"],\"Resource\":[\"contentApp\"]},"
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_media\",\"cms_page\"]},"
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"edit\"],\"Resource\":[\"cms_page\"]}]}");
|
||||
|
||||
assertThat(PolicyGenerator.toPretty(doc)).isEqualTo("""
|
||||
{
|
||||
"PolicySpec": "qqq-iam",
|
||||
"PolicyVersion": "1.0.0",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["hasAccess"],
|
||||
"Resource": [
|
||||
"contentApp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["read"],
|
||||
"Resource": [
|
||||
"cms_media",
|
||||
"cms_page"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["edit"],
|
||||
"Resource": [
|
||||
"cms_page"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emptyDocument() throws Exception
|
||||
{
|
||||
JSONObject doc = PolicyGenerator.generate(List.of(), PolicyFormat.DEFAULT);
|
||||
assertThat(PolicyGenerator.toCompact(doc)).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}\n".trim());
|
||||
assertThat(PolicyGenerator.toPretty(doc)).isEqualTo("{\n \"PolicySpec\": \"qqq-iam\",\n \"PolicyVersion\": \"1.0.0\",\n \"Statement\": []\n}\n");
|
||||
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT).permissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** parse(generate(x)) == x for many random permission sets, in both forms,
|
||||
** and generate(parse(doc)) is stable (canonical).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void roundTripRandomSets() throws Exception
|
||||
{
|
||||
Random random = new Random(20261013);
|
||||
List<String> resources = List.of("a", "B_2", "cms_page", "contentApp", "x9", "authentik_user", "publishPage");
|
||||
for(int run = 0; run < 500; run++)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
for(String resource : resources)
|
||||
{
|
||||
for(PolicyAction action : PolicyAction.values())
|
||||
{
|
||||
if(random.nextInt(3) == 0)
|
||||
{
|
||||
permissions.add(action.permission(resource));
|
||||
}
|
||||
}
|
||||
}
|
||||
JSONObject doc = PolicyGenerator.generate(permissions, PolicyFormat.DEFAULT);
|
||||
assertThat(PolicyParser.parseStrict(PolicyGenerator.toCompact(doc), PolicyFormat.DEFAULT).permissions()).isEqualTo(permissions);
|
||||
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT).permissions()).isEqualTo(permissions);
|
||||
assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT))).isEqualTo(PolicyGenerator.toCompact(doc));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A hand-written document with "*" and repeated statements becomes the
|
||||
** canonical one with the same grants.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canonicalizesHandWrittenDocuments() throws Exception
|
||||
{
|
||||
String hand = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":["
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"*\"],\"Resource\":[\"b\"]},"
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"read\",\"read\"],\"Resource\":[\"a\",\"b\"]}]}";
|
||||
JSONObject canonical = PolicyGenerator.canonical(hand, PolicyFormat.DEFAULT);
|
||||
assertThat(PolicyParser.parse(canonical, PolicyFormat.DEFAULT)).isEqualTo(PolicyParser.parse(hand, PolicyFormat.DEFAULT));
|
||||
assertThat(PolicyGenerator.toCompact(canonical)).doesNotContain("\"*\"");
|
||||
assertThat(canonical.getJSONArray("Statement").length()).isEqualTo(5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rejectsWhatCannotRoundTrip()
|
||||
{
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("noaction"), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class).hasMessageContaining("{resource}.{action}");
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("a."), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class);
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of(".read"), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class);
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("a.write"), PolicyFormat.DEFAULT)).hasMessageContaining("unknown action \"write\"");
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("authentik.users.read"), PolicyFormat.DEFAULT)).hasMessageContaining("[A-Za-z0-9_]+");
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(java.util.Arrays.asList((String) null), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The generator writes the host's spec and version.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void customFormat() throws Exception
|
||||
{
|
||||
PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies");
|
||||
JSONObject doc = PolicyGenerator.generate(List.of("order.read"), custom);
|
||||
assertThat(doc.getString("PolicySpec")).isEqualTo("acme-iam");
|
||||
assertThat(PolicyParser.parse(PolicyGenerator.toCompact(doc), custom)).containsExactly("order.read");
|
||||
assertThat(PolicyGenerator.generate(List.of("order.read"), null).getString("PolicySpec")).isEqualTo("qqq-iam");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.document;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.statement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group policies in the group graph and the effective-permission resolver:
|
||||
** inherited from parents, fail closed per group, checked against the catalog.
|
||||
*******************************************************************************/
|
||||
class PolicyGraphTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static JSONObject group(String pk, List<String> parents, Object policy, String attribute)
|
||||
{
|
||||
JSONObject attributes = new JSONObject();
|
||||
if(policy != null)
|
||||
{
|
||||
attributes.put(attribute, policy);
|
||||
}
|
||||
return (new JSONObject().put("pk", pk).put("name", pk).put("is_superuser", false).put("parents", new JSONArray(parents)).put("attributes", attributes).put("users", new JSONArray()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void policiesAreInheritedAndFailClosedPerGroup()
|
||||
{
|
||||
String parentPolicy = document(statement("Allow", List.of("hasAccess"), List.of("contentApp"))).toString();
|
||||
JSONObject childPolicy = document(statement("Allow", List.of("read", "edit"), List.of("cms_page")));
|
||||
String broken = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"x\"]}]}";
|
||||
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(
|
||||
group("parent", List.of(), parentPolicy, "qqq.io/policy"),
|
||||
group("child", List.of("parent"), childPolicy, "qqq.io/policy"),
|
||||
group("broken", List.of("parent"), broken, "qqq.io/policy"),
|
||||
group("empty", List.of(), "", "qqq.io/policy")));
|
||||
|
||||
assertThat(graph.byPk("child").policyPermissions()).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
|
||||
assertThat(graph.byPk("broken").policyPermissions()).isEmpty();
|
||||
assertThat(graph.byPk("broken").malformed()).anyMatch(m -> m.startsWith("qqq.io/policy: ") && m.contains("Deny"));
|
||||
assertThat(graph.byPk("empty").malformed()).isEmpty();
|
||||
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
|
||||
EffectivePermissionResolver.Resolution child = resolver.resolveGroups(List.of("child"));
|
||||
assertThat(child.sessionPermissions()).containsExactlyInAnyOrder("contentApp.hasAccess", "cms_page.read", "cms_page.edit");
|
||||
assertThat(child.grantedBy().get("contentApp.hasAccess")).containsExactly("parent");
|
||||
|
||||
assertThat(resolver.resolveGroups(List.of("broken")).sessionPermissions()).as("the broken group grants nothing; its parent still does").containsExactly("contentApp.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Grants outside the app's permission catalog are reported, not granted.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void catalogFiltersPolicyGrants()
|
||||
{
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(group("g", List.of(), document(statement("Allow", List.of("read"), List.of("cms_page", "gone_table"))).toString(), "qqq.io/policy")));
|
||||
EffectivePermissionResolver.Resolution resolution = new EffectivePermissionResolver(graph, List.of("cms_page.read")).resolveGroups(List.of("g"));
|
||||
assertThat(resolution.sessionPermissions()).containsExactly("cms_page.read");
|
||||
assertThat(resolution.unknown()).containsExactly("gone_table.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host's own attribute and spec.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void customFormat()
|
||||
{
|
||||
PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies");
|
||||
String doc = document(statement("Allow", List.of("read"), List.of("order"))).put("PolicySpec", "acme-iam").toString();
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(group("g", List.of(), doc, "policies")), custom);
|
||||
assertThat(graph.byPk("g").policyPermissions()).containsExactly("order.read");
|
||||
assertThat(GroupGraph.fromGroups(List.of(group("g", List.of(), doc, "policies"))).byPk("g").policyPermissions()).as("default attribute is qqq.io/policy").isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for PolicyParser: the format and every fail-closed case.
|
||||
*******************************************************************************/
|
||||
class PolicyParserTest
|
||||
{
|
||||
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A statement.
|
||||
*******************************************************************************/
|
||||
static JSONObject statement(String effect, List<String> actions, List<String> resources)
|
||||
{
|
||||
return (new JSONObject().put("Effect", effect).put("Action", new JSONArray(actions)).put("Resource", new JSONArray(resources)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A document with the default spec and version.
|
||||
*******************************************************************************/
|
||||
static JSONObject document(JSONObject... statements)
|
||||
{
|
||||
return (new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0").put("Statement", new JSONArray(List.of(statements))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void allowStatementsGrantResourceDotAction()
|
||||
{
|
||||
JSONObject doc = document(
|
||||
statement("Allow", List.of("hasAccess"), List.of("contentApp", "publishPage")),
|
||||
statement("Allow", List.of("read", "edit"), List.of("cms_page")));
|
||||
|
||||
assertThat(PolicyParser.parse(doc, FORMAT)).containsExactlyInAnyOrder(
|
||||
"contentApp.hasAccess", "publishPage.hasAccess", "cms_page.read", "cms_page.edit");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void wildcardActionIsAllFive()
|
||||
{
|
||||
assertThat(PolicyParser.parse(document(statement("Allow", List.of("*"), List.of("cms_page"))), FORMAT)).containsExactlyInAnyOrder(
|
||||
"cms_page.hasAccess", "cms_page.read", "cms_page.insert", "cms_page.edit", "cms_page.delete");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** String (as stored in an attribute or claim), JSONObject and Map all parse.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void acceptsStringObjectAndMap()
|
||||
{
|
||||
JSONObject doc = document(statement("Allow", List.of("read"), List.of("a")));
|
||||
assertThat(PolicyParser.parse(doc.toString(), FORMAT)).containsExactly("a.read");
|
||||
assertThat(PolicyParser.parse(doc, FORMAT)).containsExactly("a.read");
|
||||
assertThat(PolicyParser.parse(doc.toMap(), FORMAT)).containsExactly("a.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emptyOrMissingStatementGrantsNothingWithoutError() throws Exception
|
||||
{
|
||||
assertThat(PolicyParser.parseStrict(document(), FORMAT).permissions()).isEmpty();
|
||||
assertThat(PolicyParser.parseStrict(new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0"), FORMAT).permissions()).isEmpty();
|
||||
assertThat(PolicyParser.parseStrict(document(statement("Allow", List.of(), List.of("a"))), FORMAT).permissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every malformed document grants nothing (fail closed), and parseStrict
|
||||
** names the problem.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"",
|
||||
" ",
|
||||
"not json",
|
||||
"[]",
|
||||
"{\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}",
|
||||
"{\"PolicySpec\":\"other-spec\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"Statement\":[]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"2.0.0\",\"Statement\":[]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":{}}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[\"x\"]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"allow\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"read\",\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":\"a\"}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"write\"],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"Read\"],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[1],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"*\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"authentik.users\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_*\"]}]}",
|
||||
})
|
||||
void malformedDocumentsFailClosed(String text)
|
||||
{
|
||||
assertThat(PolicyParser.parse(text, FORMAT)).isEmpty();
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(text, FORMAT)).isInstanceOf(PolicyParseException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One bad statement voids the whole document (no partial credit).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void oneBadStatementVoidsAll()
|
||||
{
|
||||
JSONObject doc = document(
|
||||
statement("Allow", List.of("read"), List.of("good")),
|
||||
statement("Deny", List.of("read"), List.of("other")));
|
||||
assertThat(PolicyParser.parse(doc, FORMAT)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void nullAndOtherTypesGrantNothing()
|
||||
{
|
||||
assertThat(PolicyParser.parse(null, FORMAT)).isEmpty();
|
||||
assertThat(PolicyParser.parse(JSONObject.NULL, FORMAT)).isEmpty();
|
||||
assertThat(PolicyParser.parse(42, FORMAT)).isEmpty();
|
||||
assertThat(PolicyParser.parse(new JSONArray(), FORMAT)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host keeps its own spec name and version.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void customSpecName()
|
||||
{
|
||||
PolicyFormat custom = new PolicyFormat("acme-iam", "2.1.0", "policies", "policies");
|
||||
JSONObject doc = new JSONObject().put("PolicySpec", "acme-iam").put("PolicyVersion", "2.1.0")
|
||||
.put("Statement", new JSONArray().put(statement("Allow", List.of("read"), List.of("order"))));
|
||||
|
||||
assertThat(PolicyParser.parse(doc, custom)).containsExactly("order.read");
|
||||
assertThat(PolicyParser.parse(doc, FORMAT)).as("the default spec rejects another spec").isEmpty();
|
||||
assertThat(PolicyParser.parse(doc, null)).as("null format means the default").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void messagesNameTheProblem()
|
||||
{
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(Map.of("PolicySpec", "x"), FORMAT)).hasMessageContaining("PolicySpec must be \"qqq-iam\"");
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Allow", List.of("write"), List.of("a"))), FORMAT)).hasMessageContaining("unknown action \"write\"");
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Deny", List.of("read"), List.of("a"))), FORMAT)).hasMessageContaining("Deny is not supported");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void formatProblems()
|
||||
{
|
||||
assertThat(PolicyFormat.DEFAULT.problem()).isNull();
|
||||
assertThat(new PolicyFormat("bad spec", "1.0.0", "a", "c").problem()).contains("policySpec");
|
||||
assertThat(new PolicyFormat("s", "1.0", "a", "c").problem()).contains("policyVersion");
|
||||
assertThat(new PolicyFormat("s", "1.0.0", " ", "c").problem()).contains("policyAttribute");
|
||||
assertThat(new PolicyFormat("s", "1.0.0", "a", "bad claim").problem()).contains("policyClaim");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user