Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a4b29548 | ||
|
|
b22ed15719 | ||
|
|
366d55b9ec | ||
|
|
f1ed82a92f | ||
|
|
dd55a61105 | ||
|
|
17486b88d2 | ||
|
|
7d1227adf5 | ||
|
|
7df05f2324 | ||
|
|
28cb654d13 | ||
|
|
33cdaa1141 | ||
|
|
44a46a3cfc
|
||
|
|
dbae77da5b | ||
|
|
1d72ce3cec
|
||
|
|
b784bfd097
|
||
|
|
da92be126d | ||
|
|
86ff924ebd
|
||
|
|
1f12334f4f
|
||
|
|
995a5900aa | ||
|
|
4d6140f7be
|
||
|
|
265dbb0468 | ||
|
|
bc92be338f
|
||
|
|
69830a095e
|
||
|
|
33e53263c5 | ||
|
|
1d8a2a85c7
|
||
|
|
eba93e68ca | ||
|
|
0df7bb49e2
|
||
|
|
8c8ef9fd28 | ||
|
|
5cf668f8e3
|
||
|
|
590788adf8 | ||
|
|
a6daba12df
|
||
|
|
f0648d1ef2
|
||
|
|
a5db01a071 | ||
|
|
c9e5506cdf
|
||
|
|
b196109d84
|
||
|
|
4d2bd07e4b
|
||
|
|
24a97823a9
|
||
|
|
fee7e2ad20
|
||
|
|
3b117cfff4
|
||
|
|
35d9886bbf
|
||
|
|
1c1d9bc594 | ||
|
|
cb20aecf18 | ||
|
|
d0c5c2971e
|
||
|
|
612307bde8
|
||
|
|
3bbb073ec7 | ||
|
|
a977b7a0da | ||
|
|
1b058b617d | ||
|
|
99a302c7ce
|
||
|
|
65aa816660
|
||
|
|
3722380db0
|
||
|
|
89fcdec2ab | ||
|
|
f78aaa3626 | ||
|
|
02c18de8c1
|
||
|
|
eb74cd12db
|
||
|
|
9dfef79929
|
||
|
|
eb5d019fe7 | ||
|
|
6c56a24960
|
||
|
|
3e18458fb3
|
||
|
|
5a3802193f
|
||
|
|
8698d627a9
|
||
|
|
32741508e8
|
||
|
|
2061677e16
|
||
|
|
7f8e742120
|
||
|
|
38d9776f25
|
||
|
|
d138c5c911
|
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
@@ -1,25 +1,74 @@
|
||||
# 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:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
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
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
@@ -1,42 +1,16 @@
|
||||
# Weekly: does the newest Authentik release still have every operation and
|
||||
# query parameter qbit-authentik uses? Downloads the release's schema.yml from
|
||||
# goauthentik/authentik and runs SchemaDriftTest against it (no Docker, no
|
||||
# credentials). A failure means: review the release notes before upgrading.
|
||||
name: authentik-schema-drift
|
||||
|
||||
on:
|
||||
'on':
|
||||
schedule:
|
||||
- cron: "17 6 * * 1"
|
||||
- cron: 17 6 * * 1
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Authentik version (e.g. 2026.10.0); empty = latest release"
|
||||
description: Authentik version (e.g. 2026.10.0); empty = latest release
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
drift:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: maven
|
||||
- name: Resolve version and fetch its schema
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
version="${INPUT_VERSION}"
|
||||
if [ -z "$version" ]; then
|
||||
version=$(gh release view --repo goauthentik/authentik --json tagName -q .tagName | sed 's#^version/##')
|
||||
fi
|
||||
echo "Authentik $version"
|
||||
curl -fsSL "https://raw.githubusercontent.com/goauthentik/authentik/version/${version}/schema.yml" -o "$RUNNER_TEMP/schema.yml"
|
||||
echo "SCHEMA=$RUNNER_TEMP/schema.yml" >> "$GITHUB_ENV"
|
||||
- name: Drift test
|
||||
run: ./mvnw -B -q test -Dtest=SchemaDriftTest -Dsurefire.failIfNoSpecifiedTests=false -Dauthentik.schema.file="$SCHEMA"
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-schema-drift.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
with:
|
||||
version: ${{ inputs.version || '' }}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -45,7 +45,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
|
||||
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
|
||||
- Never filter client-side; reject what Authentik cannot evaluate.
|
||||
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
|
||||
@@ -61,7 +61,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Dependencies
|
||||
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
|
||||
## Release
|
||||
|
||||
|
||||
+32
-1
@@ -4,14 +4,45 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255).
|
||||
- Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: <permission>`, `malformed <attribute>` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was.
|
||||
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
|
||||
- Replace expired token-delivery and credential-scope reflection bridges with the RC.7 APIs; retain conservative permission narrowing and explicit unavailable-delivery tests.
|
||||
- Ship the expanded, host-neutral QBIT help bundle using QQQ 4.1.0-RC.7 registration and validation, with coverage for optional features and live references. Preserve inline app/table help for hosts without help storage; document HOST bundle overrides.
|
||||
|
||||
- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied inline by `AuthentikHelp` as well as registered with core, which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes.
|
||||
- `AuthentikQBitConfig.fullAccessHostPermissions` lets a host declare permission names core cannot enumerate (custom checkers, named permissions); full access expands to them at use time, for the instance holding that runtime's backend only.
|
||||
- `AuthentikMaintenance.applyPolicy` lets reviewed host run-once jobs apply a policy to an exact group name through the existing audited Save Policy path. Dedicated maintenance identities, full-access expansion, canonical no-ops, context restoration, stale checks and optional two-person approval are covered; system-session writes remain forbidden. No HTTP route or process is registered.
|
||||
|
||||
- Explicit full-access policy statement (`Action: ["*"], Resource: ["*"]`) resolves against all current core and qbit permissions, including features, READ_WRITE `write`, identity names and future resources. Added canonical round trips, complete-set save authorization, last-admin removal protection and the editor `fullAccess` payload contract; named-resource wildcards retain their five-action meaning.
|
||||
|
||||
- Edit Policy supplies per-member preview data for up to 200 active, in-scope direct and descendant members, with relevant direct group names, base permissions excluding only the edited policy, a truncation flag, and the configured mapper-enabled flag. The Next UI can compute effective permissions and additions/removals locally while preserving inherited, named, static and bootstrap grants.
|
||||
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
|
||||
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
|
||||
|
||||
### Added (central health)
|
||||
- `authentik.api`, `authentik.api_token`, `authentik.mapper`, and `authentik.token_broker` dependency checks registered by the producer, with safe details and bounded requests through the existing client.
|
||||
- Client-credentials broker probe using the existing token transport; its bearer is discarded.
|
||||
|
||||
### Removed (central health)
|
||||
- The Identity home Connection health card, its direct version/token-expiry probes, and its inline mapper status. A single central component status links to System Health. The configuration drift-report widget is retained.
|
||||
|
||||
### Added (access tokens 6c)
|
||||
- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153.
|
||||
- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked.
|
||||
- Daily expiry reminders with persistent per-window deduplication and optional Alerts delivery; high-severity guard-bypass alerts.
|
||||
- A standard Access tokens TABLE widget with owner counts, age buckets and cleanup links, plus the existing record/list bulk revoke action.
|
||||
|
||||
### Added (access tokens, DESIGN.md section 13)
|
||||
- Default-on `TOKENS` with personal create/rotate/revoke/extend, service-account creation and rotation, protected-owner guardrails and optional two-person approval.
|
||||
- Action-scoped OAuth2 token exchange, response-only one-time key delivery (fail closed on older QQQ), generated usage instructions and non-secret token scope records.
|
||||
- Token-manager read access, expanded token metadata, secret redaction and in-memory process/persistence tests.
|
||||
|
||||
### Changed (core baseline, #20 / F19)
|
||||
- Build against the released QQQ BOM `4.1.0-RC.6` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. Adopt `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
|
||||
- Core actions now enforce permissions for non-HTTP callers. Authentik-specific guards remain for direct util calls and domain rules. See [UPGRADE.md](UPGRADE.md) for host impact and feature registration requirements.
|
||||
- Replace the expired feature-catalog stand-in with real `TableFeaturePermissionsMetaData` registrations and remove the test-only `PolicyCatalog` permission-source overload. Feature discovery uses the core catalog without static mocking.
|
||||
|
||||
### Added (automated sessions)
|
||||
- `AuthentikGroupPermissionMapper` resolves tokenless automated sessions by a unique active Authentik email match, applying direct and inherited group permissions, static fallbacks and the sign-in cut-over guard while preserving existing session permissions.
|
||||
|
||||
@@ -62,7 +93,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
|
||||
|
||||
### Changed
|
||||
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
|
||||
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
|
||||
- **Table names are lowercase snake_case with the `authentik_` prefix** (owner naming rule, 2026-10-12): `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event` (were `authentikUser`, ...). New `AuthentikQBitConfig.applyTablePrefix` and `toSnakeCase`; apps, widgets, the backend and processes keep camelCase names (`authentikAdmin`, `authentikApi`). The old names are kept as `@Deprecated` `AuthentikTables.LEGACY_*_TABLE_NAME` constants. See [UPGRADE.md](UPGRADE.md).
|
||||
|
||||
### Documentation
|
||||
|
||||
@@ -45,7 +45,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
|
||||
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
|
||||
- Never filter client-side; reject what Authentik cannot evaluate.
|
||||
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
|
||||
@@ -61,7 +61,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Dependencies
|
||||
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
|
||||
## Release
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065)
|
||||
| **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. |
|
||||
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, central health), 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. |
|
||||
|
||||
Everything is a standard QQQ table, action and widget, so it works in any QQQ UI and over the QQQ API.
|
||||
|
||||
@@ -45,6 +45,15 @@ Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill s
|
||||
| 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; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group |
|
||||
|
||||
### Tables, starter data and empty screens
|
||||
|
||||
Nothing is seeded: there are no starter defaults to include in a changelog. Setup comes from `AuthentikQBitConfig` (URL, API token, features, read-only mode), and the Identity home's "Needs attention" card lists what is left. The tables are of two kinds:
|
||||
|
||||
- **API-backed (live):** `authentik_user`, `_group`, `_group_member` (virtual), `_session`, `_event`, `_application`, `_policy_binding`, `_blueprint`, `_role`, `_token`, `_invitation`. Every row is read from Authentik on each view and nothing is stored locally. An empty list means Authentik returned nothing to you: your role lacks the permission, the qbit's feature or invitation flow is not enabled, or Authentik has no such records. Read-only mode only blocks changes.
|
||||
- **Local (the qbit's own records):** `authentik_admin_action` (audit trail), `authentik_approval_request`, `authentik_policy` and `authentik_token_scope`. They fill as people run actions, and are written only by the qbit.
|
||||
|
||||
Every table, and the app, ships help (`help/qbit-authentik.yaml`, the QQQ help-bundle format) that says what the table is for, how its rows get created and, for the live tables, what an empty list means. The Next UI shows it as the empty state. A host's own help on a table is never replaced, and a host's `tableNamePrefix` is applied to the bundle's table names. `AuthentikTablesGuardrailTest` fails when a table is added without runtime use, a documented reason it starts empty, and this help.
|
||||
|
||||
### Permissions
|
||||
|
||||
QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute `qqq.io/permissions` and read by `AuthentikGroupPermissionMapper`:
|
||||
@@ -68,7 +77,7 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
|
||||
|
||||
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.
|
||||
The editor discovers registered features and labels through core RC.6's `PermissionsHelper.getAllAvailablePermissions`. The build uses `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
|
||||
|
||||
### Authentik side
|
||||
|
||||
@@ -83,7 +92,7 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
|
||||
| Item | What to do |
|
||||
|---|---|
|
||||
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
|
||||
| QQQ version | QQQ 4.1 (see `pom.xml` for the exact RC). Java 21. |
|
||||
| QQQ version | QQQ 4.1.0-RC.8 (released BOM, including the core health SPI). Java 21. |
|
||||
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. |
|
||||
| Tables | Create `authentik_admin_action`, `authentik_approval_request` and `authentik_token_scope` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
|
||||
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. |
|
||||
@@ -110,6 +119,61 @@ Apache 2.0
|
||||
|
||||
Access-token metadata is readable with `authentik.audit.read` or `authentik.tokens.manage`. Personal actions have no additional process permission; ownership is checked live. Managing service-account tokens needs `authentik.tokens.manage`, and creating a service account also needs `authentik.users.manage`. Configure `tokenBrokerClientId`, `tokenBrokerClientSecret` (environment references supported), `appClientId`, `appPublicUrl` and `authentikPublicUrl` for exchange and usage instructions. Scope fields remain unavailable until `tokenScopesEnabled=true`.
|
||||
|
||||
| Access-token setting | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `tokenScopesEnabled` | `false` | Offer read-only, apps and tables fields after the signed claim mapping is installed. Names must exist and the operator must currently have read/hasAccess permission. |
|
||||
| `tokenScopeClaim` | `qqq_token` | Signed JWT claim containing the Authentik token identifier. Claims are enforced even when offering new scopes is disabled. |
|
||||
| `tokenUnusedDays` | `60` | Inactivity threshold for cleanup suggestions. With no login found, a known creation date and a threshold at most 90 days are required. |
|
||||
| `tokenReminderSchedulerName` | First registered scheduler | Run `authentikTokenReminders` daily. Without a scheduler the hidden process is available for a host to invoke. Register the scheduler before producing the qbit. |
|
||||
|
||||
The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list.
|
||||
|
||||
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The qbit conservatively narrows session permissions for direct consumers and attaches core's `QCredentialScope` for framework enforcement. It does not add permissions.
|
||||
|
||||
The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`.
|
||||
|
||||
|
||||
Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added.
|
||||
|
||||
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
|
||||
|
||||
## Health checks
|
||||
|
||||
The producer registers these central checks when Authentik is configured. All use
|
||||
component `authentik`, category `IDENTITY`, and a 10-second timeout. They reuse the
|
||||
API client and drift diagnostics, honor the runner deadline, and record only safe
|
||||
version, expiry-day, and finding-code details.
|
||||
|
||||
| Name | Kind | Interval |
|
||||
| --- | --- | --- |
|
||||
| `authentik.api` | DEPENDENCY | 300 seconds |
|
||||
| `authentik.api_token` | DEPENDENCY | 21600 seconds |
|
||||
| `authentik.mapper` | DEPENDENCY | 21600 seconds |
|
||||
| `authentik.token_broker` | DEPENDENCY | 300 seconds |
|
||||
|
||||
The broker check uses a client-credentials grant and discards the returned bearer;
|
||||
it reports UNKNOWN when broker credentials are not configured. Token metadata must
|
||||
identify one API token; ambiguous tokens or an unavailable expiry report UNKNOWN.
|
||||
The Identity home links its central status to System Health. The configuration
|
||||
`driftReport` widget remains available. Disable checks centrally with
|
||||
`health_check.enabled`; no Authentik table fields are added.
|
||||
|
||||
These registrations use the core health SPI released in QQQ 4.1.0-RC.8; no
|
||||
SNAPSHOT override or enforcer skip is needed.
|
||||
|
||||
## In-app help
|
||||
|
||||
The qbit ships `src/main/resources/help/qbit-authentik.yaml` and registers it with
|
||||
QQQ's help bundle registry (QQQ 4.1.0-RC.7). Registration is metadata-only; core
|
||||
skips startup synchronization when the host has no `helpContent` table. Inline
|
||||
app and table help remains available without help storage.
|
||||
|
||||
To override wording, register a separate bundle with `layer: HOST`, its own bundle
|
||||
ID and version, and entries with the same keys and roles. HOST entries take
|
||||
precedence over QBIT entries; do not edit the dependency's resource. Keep deployment
|
||||
names, navigation and authentication timing in the host bundle.
|
||||
|
||||
`AuthentikHelpBundleTest` enables every supported feature and uses
|
||||
`HelpBundleAssertions` to require coverage and validate keys, summaries and live
|
||||
references. Run it with `./mvnw test -Dtest=AuthentikHelpBundleTest`.
|
||||
The bundle version is the literal project version; update it when releasing.
|
||||
+26
@@ -1,5 +1,31 @@
|
||||
# Upgrading qbit-authentik
|
||||
|
||||
## Table help bundle (unreleased)
|
||||
|
||||
Nothing to do. The qbit now applies `help/qbit-authentik.yaml` to the Identity app and its tables at the end of `produce()`, filling only help slots the host has not set. There is no schema change and nothing to seed or include in a Liquibase changelog: the API-backed tables are live Authentik data, and the four local tables fill as people use the actions.
|
||||
|
||||
## QQQ 4.1.0-RC.6 baseline (#20 / F19)
|
||||
|
||||
Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.6`
|
||||
and use Java 21. The historical `qqq-snapshot` profile and `qqq.snapshot.version`
|
||||
property names remain, but the imported core version is a release, not a SNAPSHOT.
|
||||
Core actions now enforce permissions and field security locks for non-HTTP callers
|
||||
(QRun-IO/qqq#1061, #1062); callers must supply an appropriately authorized session.
|
||||
The qbit keeps its guards, scope checks and sensitive-field stripping because they
|
||||
also protect direct backend calls and implement Authentik-specific rules.
|
||||
No qbit configuration, stored policy format or schema changes are introduced.
|
||||
|
||||
The build adopts `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
|
||||
The expired #1136 test stand-in is removed: tests now register features through
|
||||
`TableFeaturePermissionsMetaData` and discover them through the real
|
||||
`PermissionsHelper.getAllAvailablePermissions`, as production hosts do. The
|
||||
unused explicit permission-source overload in `PolicyCatalog` is removed.
|
||||
Duplicate shared-base entries, replacement registrations and malformed suffixes
|
||||
remain covered. Hosts should register feature suffixes, labels and table
|
||||
applicability through the core metadata API; no synthetic catalog is needed.
|
||||
Client-channel attribution and identity/credential channel restrictions remain
|
||||
separate follow-up work.
|
||||
|
||||
## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release)
|
||||
|
||||
The qbit's permissions follow the same owner naming rule (2026-10-12) and now
|
||||
|
||||
+13
-7
@@ -1,10 +1,10 @@
|
||||
# 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).
|
||||
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**.
|
||||
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;
|
||||
@@ -36,7 +36,7 @@ curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData
|
||||
|
||||
## Manage your tokens
|
||||
|
||||
From **Preferences → Access tokens**:
|
||||
From **Preferences → Access tokens**:
|
||||
|
||||
| Action | What happens |
|
||||
|---|---|
|
||||
@@ -44,14 +44,20 @@ From **Preferences → Access tokens**:
|
||||
| **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.
|
||||
When the host has configured a scheduler and the optional Alerts delivery adapter, the daily job sends owner reminders in the 7-day and 1-day expiry windows. Delivery channels (bell/email) follow the host’s Alerts configuration. Each window sends once; if first seen within one day, only the urgent window sends. Extending a token through the qbit resets its reminder windows. Without delivery support, the job logs and leaves the reminder pending.
|
||||
|
||||
## 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;
|
||||
People with `authentik.tokens.manage` see the **Access tokens** TABLE widget on the Identity home page, with a link to **all access tokens**. From there they can:
|
||||
- filter by person, intent, expiring status or token identifier; view computed creation time, scope and last use (these computed columns cannot be filtered or sorted by the Authentik API);
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
The widget shows the top ten owners plus a total, age buckets (`<30d`, `30–90d`, `90–365d`, `>365d`, and unknown), unused tokens and tokens with deactivated owners. Cleanup rows include owner and token links; age buckets link to the explicit token identifiers. Creation dates are known only for tokens created by the qbit. External tokens can receive reminders without acquiring a scope or an invented creation date.
|
||||
|
||||
Last use comes from app-password login events over the last 90 days, cached for five minutes. The pinned Authentik schema has no date-range parameter for this endpoint, so the qbit pages newest-first to the window boundary. API-intent tokens show **not tracked** and never appear as unused. A token with no recorded login is suggested as unused only if its known creation date is older than `tokenUnusedDays` and that threshold is at most 90 days.
|
||||
|
||||
Create Token offers read-only, apps and tables scopes only when `tokenScopesEnabled` is enabled after installing the signed claim mapping. Enter one metadata name per line. Review checks existence and the operator’s current read/hasAccess permission and shows the scope. The claims mappers intersect permissions after resolving them; scopes never grant access the owner lacks. A deleted token receives no permissions even if its local scope row remains, subject to the 60-second verification cache.
|
||||
|
||||
Administrators can never create, rotate or extend a token for **another person**. Each person manages their own.
|
||||
|
||||
|
||||
+28
-14
@@ -24,7 +24,7 @@ Seven design rules shape everything below:
|
||||
- A group's QQQ permissions live in the group attribute `qqq.io/permissions`.
|
||||
- The reusable `AuthentikGroupPermissionMapper` turns a user's groups, including inherited parents, into QQQ session permissions.
|
||||
- Who can operate the qbit is decided by those same permissions (`authentik.*`). No operator group is hard-coded.
|
||||
4. **The qbit checks permissions itself.** QQQ core actions do not enforce permissions (QRun-IO/qqq#1061), and `FieldSecurityLock` is not enforced (#1062). An `AuthentikGuard` in the backend action layer checks every read and write.
|
||||
4. **The qbit checks permissions itself.** The original core gaps (QRun-IO/qqq#1061, #1062) are fixed in the RC.5 baseline. Direct backend calls and Authentik-specific rules still require local checks. An `AuthentikGuard` in the backend action layer checks every read and write.
|
||||
5. **No escalation.**
|
||||
- Operators can only grant permissions they hold themselves, whether through memberships or permission attributes.
|
||||
- Operators cannot edit themselves.
|
||||
@@ -74,6 +74,12 @@ Seven design rules shape everything below:
|
||||
| 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`, `set_token_key` and `add_token`, keeps `delete_token` and `add_user`, and **never** holds `view_token_key`. `add_token` was found necessary by the contract tests (`service_account` needs `add_user` + `add_token`; `set_key` needs the POST object permission `add_token` + `set_token_key`) and is safe because a non-superuser's create is always the caller's (§13.9). Limitation: one app password per service account. |
|
||||
|
||||
**Owner decision (2026-10-14): `add_token` (option A).** The contract tests showed that Authentik 2026.8.3 requires `add_token` for `POST /core/users/service_account/` (with `add_user`) and for `POST /core/tokens/{id}/set_key/` (the POST object permission, with `set_token_key`).
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| 31 | **Grant `add_token` to the qbit role, so service-account creation and rotation stay in the app.** It is safe: for a non-superuser, `POST /core/tokens/` always creates the token for the caller, so it can never mint a token for anyone else. `view_token_key` stays **not** granted. `TokenContractIT` pins 403 without `add_token` and 200/204 with it. The detective alarm (§13.4 e) also notifies `identity-admins` whenever the qbit's account creates a service account. The blueprint explains this next to the permission (cluster-gitops#103). |
|
||||
|
||||
### 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.
|
||||
@@ -668,7 +674,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost
|
||||
- `AuthentikActionUtil.doQuery/doGet/doInsert/doUpdate/doDelete` call `AuthentikGuard.check(table, op, session)` first. The guard checks the QQQ permission, the feature flag, `readOnly` and scope.
|
||||
- A session with no user is refused, except `MirrorSync`'s internal read-only session.
|
||||
- Every qbit process's first backend step re-checks `checkProcessPermissionThrowing` and the per-record rules (§5.4). It does not trust that the middleware ran.
|
||||
- Field-level protection does not use `FieldSecurityLock` (#1062):
|
||||
- Field-level protection retains explicit stripping for direct backend calls; core also enforces `FieldSecurityLock` in the RC.5 baseline (#1062):
|
||||
- Sensitive columns are removed in `jsonObjectToRecord` without `authentik.audit.read`. These are `attributes` (except the `qqq.io/*` keys, shown under `users.read`), `last_ip`, `user_agent` and `geo_ip`.
|
||||
- Filters on those fields are rejected for that session.
|
||||
- Tables also declare `QPermissionRules`, so the UI hides what the user can't use.
|
||||
@@ -955,7 +961,7 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh
|
||||
| A blueprint re-apply reverts QQQ edits | No `present` identity entries, no member lists (CI lint); bootstrap as `created` (verified, §4.2); reversion detector |
|
||||
| Lost update between QQQ and the Authentik UI | Stale check at execute; attribute merge-patch after a fresh read |
|
||||
| Account takeover through a recovery link | Links are never shown; email only |
|
||||
| A non-HTTP caller bypasses permissions (#1061) | Guard in the backend util, plus ArchUnit |
|
||||
| A direct backend caller bypasses core actions | Guard in the backend util, plus ArchUnit |
|
||||
| A field leaks because `FieldSecurityLock` isn't enforced (#1062) | Sensitive fields stripped in the util |
|
||||
| A forged webhook | Shared secret, constant-time compare; it only triggers re-reads |
|
||||
| Stale session permissions after a demotion | Re-evaluation on token refresh plus webhook-triggered marking (§5.6); live groups used for guard checks |
|
||||
@@ -1033,7 +1039,7 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a
|
||||
| The service account can lock administrators out (remove members from `authentik Admins`, deactivate break-glass, strip `qqq.io/protected`, §0.2 ii-b). Re-parenting under a superuser group **is** refused by Authentik (§0.2 ii) | Guard refusal of every write that targets a protected account or a superuser group; drift report; recovery through cluster access |
|
||||
| Load on the shared Postgres | Throttle, caps, mirror |
|
||||
| Webhook loss | Inbox, 15-minute reconcile, nightly sweep |
|
||||
| QQQ core gaps (#1061, #1062) | Guard in the backend util, ArchUnit, field stripping |
|
||||
| Direct backend calls outside core enforcement | Guard in the backend util, ArchUnit, field stripping |
|
||||
| Token leakage | Redaction tests; no body logging on sensitive paths |
|
||||
| An Authentik upgrade changes fields | Pinned schema, fixtures per version, version check |
|
||||
|
||||
@@ -1079,16 +1085,18 @@ 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 (`*` = 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.
|
||||
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` on a named resource = 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); individual policy resources cannot contain a dot. Full access includes these named permissions in session form.
|
||||
|
||||
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.
|
||||
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. `Action: ["*"]` on a named resource never includes features, so those statements do not acquire new grants when a feature is registered.
|
||||
|
||||
**Full access.** `{"Effect":"Allow","Action":["*"],"Resource":["*"]}` grants every name from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, the qbit permission catalog (including hidden record actions), and all `IdentityPermission` session names. This includes table features, READ_WRITE `write`, custom permission names enumerated by core, and future resources. Expansion happens at permission computation, never by replacing the stored statement with today's list. `Resource "*"` requires exactly `Action ["*"]`; other combinations reject the whole document. Other statements are still validated. Canonical full access is exactly one wildcard statement; enumerating all current grants never invents it. See [Full access](POLICY-FORMAT.md#full-access) for exact inclusion rules.
|
||||
|
||||
**Fail closed.** Any of these grants **zero** permissions from that document and logs a warning:
|
||||
- an unknown `PolicySpec` or an unsupported `PolicyVersion`
|
||||
- a document that is not a JSON object, or a `Statement` that is not an array
|
||||
- a statement whose `Effect` is not `Allow`
|
||||
- an unknown action
|
||||
- a resource that does not match `[A-Za-z0-9_]+`
|
||||
- a resource that does not match `[A-Za-z0-9_]+`, except the full-access `*`
|
||||
- any other parse error
|
||||
|
||||
This is stricter than the reference implementation in two places. The reference ignores unknown actions, while this qbit rejects the whole document. `Deny` is not supported yet, so a `Deny` statement also rejects the document; ignoring it would grant more than its author intended.
|
||||
@@ -1132,12 +1140,12 @@ The feature `POLICIES` turns on the editor, the `authentik_policy` table and the
|
||||
|
||||
*Edit Policy* is a record action on `authentik_group` and on `authentik_policy`, never in the nav. It is a standard streamed-ETL process with three steps.
|
||||
|
||||
**1. Edit** (frontend step). The step shows the widget `authentikPolicyEditor`, type `AUTHENTIK_POLICY_EDITOR`, a lazy-loaded Next UI widget. The server sends it two things.
|
||||
**1. Edit** (frontend step). The step shows the widget `authentikPolicyEditor`, type `AUTHENTIK_POLICY_EDITOR`, a lazy-loaded Next UI widget. The server sends its catalog, current document and member preview data.
|
||||
|
||||
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.
|
||||
- Table features from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, matched by the table's effective permission base name plus `.`. Their labels come from `AvailablePermission.permissionType`; registered `TableFeaturePermissionsMetaData` features appear through this core API. 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.
|
||||
|
||||
@@ -1147,8 +1155,12 @@ The catalog also follows these rules:
|
||||
- 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.
|
||||
|
||||
**Full-access UI contract (Next implementation in a separate PR).** The widget payload adds `fullAccess: boolean`, true only when the current valid document contains the full-access statement, false for an absent or invalid document. Next must show a prominent **Full access (all current and future resources)** checkbox initialized from this field. While checked, disable the permissions matrix and emit a document with the payload's `spec` and `version` and exactly `Statement: [{"Effect":"Allow","Action":["*"],"Resource":["*"]}]` through the existing `outputFieldName`. Do not enumerate the matrix into explicit permissions. Unchecking removes that statement and re-enables the matrix; the emitted document then contains only explicit selected grants. `fullAccess` is display state, not a separately trusted save input: the server derives authority from the submitted document. Member `basePermissions` already expands full-access policies from other groups; the UI must label full access as including future resources, not infer completeness from the matrix alone.
|
||||
|
||||
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.
|
||||
|
||||
**Preview for a member.** On editor load only, under `authentik.policies.manage`, the payload adds `members` (up to 200 active, in-scope members of this group or its descendants, sorted by username), `membersTruncated` (true when more eligible people exist), and `mapperEnabled` from config so the UI can say "not enforced at sign-in". Each member has `username`, `label` (`Full Name (username)`), `viaGroups` (sorted direct group names that are this group or its descendants), and `basePermissions` (sorted session permissions with only this group's policy document excluded). Named `qqq.io/permissions`, other groups' and ancestors' policies, wildcard expansion, static direct-group fallbacks and bootstrap-subject grants still count. The Next UI combines that base with the current and edited documents to preview effective permissions and additions/removals locally.
|
||||
|
||||
The screen works like this:
|
||||
- Resources are grouped by app, with a search box.
|
||||
- Each row has a checkbox per action, plus *all* for the row. Each action column and each app has *select all*.
|
||||
@@ -1161,7 +1173,7 @@ The screen works like this:
|
||||
- regenerates it in canonical form and checks that parse(generate(x)) = x (the round-trip invariant)
|
||||
- flags unknown resources
|
||||
- shows the permissions added and removed, and how many members are affected
|
||||
- applies the subset rule (§5.4 rule 4): the operator must hold every **added** permission
|
||||
- applies the subset rule (§5.4 rule 4): the operator must hold every **added** permission; for full access they must hold its **entire current expansion** (their own effective full-access policy satisfies this). Review and audit summaries say **Full access (all current and future resources)**. Removing full access also preserves the last qualifying identity administrator.
|
||||
|
||||
Refusals name the reason.
|
||||
|
||||
@@ -1171,11 +1183,13 @@ Refusals name the reason.
|
||||
- writes the `authentik_admin_action` row and the Authentik event
|
||||
- clears the caches
|
||||
|
||||
Saving an empty matrix removes the attribute. The canonical form is the reference generator's:
|
||||
Saving an empty matrix removes the attribute. Full access canonicalizes to one wildcard statement. Other documents retain the reference generator's canonical form:
|
||||
- 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
|
||||
|
||||
**Maintenance API.** Reviewed Java code in the host repository, typically a run-once job such as `KOF22_RUN_ONCE`, may call `AuthentikMaintenance.applyPolicy(apiBackendName, groupName, policyJson, operatorName, reason)`. This is a plain Java API with no registered process or route; hosts must not expose it over HTTP. It resolves exactly one group by name and uses the same audited Save Policy plan and execution, including stale, scope, superuser ancestry, read-only and feature checks. A temporary non-system session grants `authentik.policies.manage` plus the document's permissions (the complete current expansion for full access); its `maintenance:` or `run-once:` operator name and the required reason are recorded verbatim. The caller's context is restored. Equivalent policies return `NO_CHANGE` without a write or audit row; `policyTwoPersonApproval` queues approval instead of applying. `AuthentikMaintenance.fullAccessDocument(config)` generates the canonical compact full-access document for the host's spec/version. System sessions remain forbidden from writing directly.
|
||||
|
||||
### 12.5 Full user and group CRUD (revision 5 additions)
|
||||
|
||||
| Action | Table / slot | Permission | Feature | Confirmation | Authentik |
|
||||
@@ -1289,7 +1303,7 @@ A QQQ access token is an Authentik **app password** (`intent: app_password`) own
|
||||
- `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.
|
||||
No client secret: Authentik 2026.8.3 authenticates this grant by the username and app password, even for a confidential client (client authentication is required only for the code, refresh, device and token-exchange grants; pinned by `TokenContractIT`). 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).
|
||||
@@ -1362,7 +1376,7 @@ Without a broker, the self-service actions are hidden, and Review says why.
|
||||
| (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. |
|
||||
| (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. It also notifies `identity-admins` whenever the qbit's account creates a service account (decision 31). | 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
|
||||
@@ -1408,7 +1422,7 @@ STAND-IN(core=QRun-IO/qqq#1153, removeBy=the RC that ships #1153, RC.7 at the ea
|
||||
**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".
|
||||
- If the step outlives the middleware's step timeout (3 s) and finishes as a job, core parks the secrets in memory (`OneTimeSecretHandoff`, 5 minutes, never in state) and returns them once, to the first status poll that sees the job complete. A poll that reaches another server instance or comes too late finds nothing: the key is lost by design, and the result says "Rotate the token to get a new key". (Found in the demo: a create through the broker can take longer than 3 s.)
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
+49
-7
@@ -32,11 +32,11 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is
|
||||
| `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`, `*`, 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`) |
|
||||
| `Statement[].Resource` | array of strings | Either `*` with exactly `Action: ["*"]`, or 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 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.
|
||||
Each statement grants `{resource}.{action}` for every resource and action it lists. `Action: ["*"]` on a named resource stands for the five standard actions only; it never includes table features. The full-access statement below is the explicit exception. The result is the union over all statements, and over all of the user's groups.
|
||||
|
||||
| Action | QQQ permission | Applies to |
|
||||
|---|---|---|
|
||||
@@ -52,11 +52,51 @@ Examples:
|
||||
- `{"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 `*`.
|
||||
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`. For named resources every feature grant is explicit, so registering a new feature never broadens an existing `Action: ["*"]` statement on a named resource. 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.
|
||||
|
||||
**Not in 1.0.0:** `Deny`, wildcard or prefix resources, conditions, and field-level rules. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots. They stay in the group attribute `qqq.io/permissions`.
|
||||
**Not in 1.0.0:** `Deny`, prefix resources, wildcard resources other than the full-access form below, conditions, and field-level rules. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots. Explicit named grants stay in the group attribute `qqq.io/permissions`; full access includes them in session form.
|
||||
|
||||
## Full access
|
||||
|
||||
The exact full-access statement is:
|
||||
|
||||
```json
|
||||
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
|
||||
```
|
||||
|
||||
It grants **Full access (all current and future resources)**. A complete document is:
|
||||
|
||||
```json
|
||||
{
|
||||
"PolicySpec": "qqq-iam",
|
||||
"PolicyVersion": "1.0.0",
|
||||
"Statement": [
|
||||
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Full access expands when permissions are computed (sign-in, effective-permission resolution, access comparison and member preview), using the current QQQ instance. The document and cached group graph retain a symbolic marker; no expanded permission list is stored in the document or cached for full access. Adding a table, process, app, report, widget or table feature therefore requires no policy edit. Existing signed-in sessions receive the new set when their permissions are computed again.
|
||||
|
||||
The expansion is the union of:
|
||||
|
||||
- **Every `AvailablePermission.name` returned by `PermissionsHelper.getAllAvailablePermissions(qInstance)`**, without limiting names to the editor's resource syntax. Core uses effective permission base names and permission levels: table `hasAccess`, `read`, `insert`, `edit`, `delete`, READ_WRITE `write`, registered table feature names, and process/app/report/protected-widget `hasAccess`. Names supplied by custom permission checkers are included when those checkers expose them through this API; arbitrary permissions that core does not enumerate are not inferred.
|
||||
- **The qbit permission catalog**, including policy-catalog permissions for hidden tables and processes (core skips hidden tables/processes), plus any additional catalog names supplied to the resolver.
|
||||
- **Host-declared permission names** from `AuthentikQBitConfig.withFullAccessHostPermissions(qInstance -> names)`, for each runtime whose API backend is in the instance. Use it for names that core cannot enumerate: custom permission checkers and named permissions such as `cdn.purge` or `email.send`. The function runs at every expansion, so names the host adds later are covered without a policy edit. Without it, full access does not grant those names.
|
||||
- **Every `IdentityPermission` session name**, such as `authentik.users.manage.hasAccess`, `authentik.permissions.manage.hasAccess` and `authentik.policies.manage.hasAccess`. This covers the entire qbit identity catalog, even when a particular feature is not enabled.
|
||||
|
||||
This grants QQQ permissions, not Authentik superuser status. Existing scope, protected-account, audit, approval, last-admin and cut-over checks still apply.
|
||||
|
||||
Examples and boundaries:
|
||||
|
||||
- `{"Effect":"Allow","Action":["*"],"Resource":["*"]}` includes table features, READ_WRITE `write`, named identity permissions and future resources.
|
||||
- `{"Effect":"Allow","Action":["*"],"Resource":["cms_page"]}` still grants only the five standard actions for `cms_page`; it does not grant features or `write`.
|
||||
- `{"Effect":"Allow","Action":["read"],"Resource":["*"]}` is invalid. So is `Action: ["*", "read"]` with `Resource: ["*"]`. The error says that `Resource "*" requires Action ["*"]`.
|
||||
- A full-access statement may coexist with other valid statements (or named resources in the same statement). Canonicalization drops the redundant grants. Every statement and resource is validated first: an invalid statement anywhere still rejects the entire document, even beside full access.
|
||||
|
||||
Saving a full-access document requires the operator to hold **every permission in its current expansion**, even when the target already has full access. An operator whose own effective policy is full access meets that check. Review and audit summaries use **Full access (all current and future resources)**. Removing full access cannot remove the last qualifying identity administrator.
|
||||
|
||||
## Parsing fails closed
|
||||
|
||||
@@ -68,13 +108,15 @@ A document grants **nothing**, and the parser logs a warning, when any of these
|
||||
- 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 neither a standard action, `*`, nor a feature registered for every listed resource
|
||||
- a resource does not match `[A-Za-z0-9_]+`
|
||||
- a resource does not match `[A-Za-z0-9_]+`, except `*` with exactly `Action: ["*"]`
|
||||
|
||||
There is no partial credit: one bad statement voids the whole document. When the qbit reads policies from the group graph, each group's document is parsed on its own, so one bad group voids only that group. In the merged token claim, one bad statement voids the claim.
|
||||
|
||||
## Canonical form
|
||||
|
||||
The qbit always writes the canonical form:
|
||||
A document containing full access canonicalizes to exactly one full-access statement. A generated permission set produces full access only when it explicitly contains the internal full-access marker; enumerating all current permissions never creates future grants.
|
||||
|
||||
For other documents the qbit writes the existing canonical form:
|
||||
- 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 `*`
|
||||
@@ -105,7 +147,7 @@ Authentik puts the merged document into the token through a scope mapping. Steps
|
||||
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
|
||||
- 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 catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog accept standard actions and full access; `PolicyPermissions.fromClaims` uses the current `QContext` instance for full-access expansion when no catalog is supplied. Raw `PolicyParser` results are symbolic and must be resolved before entering a session. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the available-permission API from the pinned RC.6 core; registered `TableFeaturePermissionsMetaData` features appear in that API.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -36,7 +38,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -139,6 +141,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-authentik.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
@@ -146,6 +149,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-authentik.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
@@ -237,7 +241,7 @@
|
||||
<profiles>
|
||||
<!-- Needs qqq 4.1 (QBit producer API). Until qbit-build-parent imports the -->
|
||||
<!-- released 4.1 BOM, this profile is active by default and imports -->
|
||||
<!-- qqq-bom-pom 4.1.0-RC.2 ahead of the parent 4.0.0 BOM, as in -->
|
||||
<!-- qqq-bom-pom 4.1.0-RC.7 ahead of the parent 4.0.0 BOM, as in -->
|
||||
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
@@ -245,7 +249,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -61,9 +61,12 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
private List<String> tokenProtectedGroups = new ArrayList<>(List.of("authentik Admins", "identity-admins"));
|
||||
private Boolean tokenTwoPersonApproval = false;
|
||||
private Boolean tokenScopesEnabled = false;
|
||||
private String tokenScopeClaim = "qqq_token";
|
||||
private String tokenReminderSchedulerName;
|
||||
private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}";
|
||||
private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}";
|
||||
private String tokenEndpointUrl = null;
|
||||
private String maintenancePrincipal = "maintenance";
|
||||
private String appPublicUrl = null;
|
||||
private String authentikPublicUrl = null;
|
||||
private String appClientId = null;
|
||||
@@ -112,6 +115,7 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
private java.util.Map<String, List<String>> staticGroupPermissions = new java.util.LinkedHashMap<>();
|
||||
@JsonIgnore
|
||||
private com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource hostPermissionSource;
|
||||
private java.util.function.Function<QInstance, java.util.Collection<String>> fullAccessHostPermissions;
|
||||
|
||||
|
||||
|
||||
@@ -130,6 +134,10 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
{
|
||||
errors.add("tokenMaxDaysServiceAccount must be at least 1");
|
||||
}
|
||||
if(tokenScopeClaim == null || tokenScopeClaim.isBlank())
|
||||
{
|
||||
errors.add("tokenScopeClaim must not be blank");
|
||||
}
|
||||
if(tokenUnusedDays == null || tokenUnusedDays < 1)
|
||||
{
|
||||
errors.add("tokenUnusedDays must be at least 1");
|
||||
@@ -446,6 +454,26 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The features that are actually on: the configured set, plus TOKENS when it
|
||||
** is on by default (writes enabled, not opted out), minus TOKENS when it is
|
||||
** listed but off (read only or opted out). This is what hosts see in logs.
|
||||
*******************************************************************************/
|
||||
public java.util.Set<AuthentikFeature> getEffectiveFeatures()
|
||||
{
|
||||
java.util.EnumSet<AuthentikFeature> effective = java.util.EnumSet.noneOf(AuthentikFeature.class);
|
||||
for(AuthentikFeature feature : AuthentikFeature.values())
|
||||
{
|
||||
if(hasFeature(feature))
|
||||
{
|
||||
effective.add(feature);
|
||||
}
|
||||
}
|
||||
return (effective);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a feature is enabled.
|
||||
*******************************************************************************/
|
||||
@@ -1405,6 +1433,30 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fullAccessHostPermissions: the host's own permission names
|
||||
** (custom checkers, named permissions such as "cdn.purge") that QQQ
|
||||
** metadata cannot list. Full access expands to these as well, computed at
|
||||
** use time from the current instance. Null means none.
|
||||
*******************************************************************************/
|
||||
public java.util.function.Function<QInstance, java.util.Collection<String>> getFullAccessHostPermissions()
|
||||
{
|
||||
return (this.fullAccessHostPermissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fullAccessHostPermissions.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withFullAccessHostPermissions(java.util.function.Function<QInstance, java.util.Collection<String>> fullAccessHostPermissions)
|
||||
{
|
||||
this.fullAccessHostPermissions = fullAccessHostPermissions;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokensEnabled.
|
||||
@@ -1686,6 +1738,29 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maintenancePrincipal: the service principal (declared by the
|
||||
** host, docs/SERVICE-IDENTITY.md in qqq) that AuthentikMaintenance runs as.
|
||||
** Its Authentik policy must hold policies.manage and every grant it applies.
|
||||
*******************************************************************************/
|
||||
public String getMaintenancePrincipal()
|
||||
{
|
||||
return (this.maintenancePrincipal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maintenancePrincipal (default maintenance).
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withMaintenancePrincipal(String maintenancePrincipal)
|
||||
{
|
||||
this.maintenancePrincipal = maintenancePrincipal;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appPublicUrl.
|
||||
*******************************************************************************/
|
||||
@@ -1871,4 +1946,54 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Signed JWT claim identifying the Authentik token.
|
||||
*******************************************************************************/
|
||||
public String getTokenScopeClaim()
|
||||
{
|
||||
return (tokenScopeClaim);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the host's signed token claim name.
|
||||
*******************************************************************************/
|
||||
public void setTokenScopeClaim(String value)
|
||||
{
|
||||
tokenScopeClaim = value;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent signed token claim name.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenScopeClaim(String value)
|
||||
{
|
||||
tokenScopeClaim = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional scheduler; otherwise use the instance's first scheduler.
|
||||
*******************************************************************************/
|
||||
public String getTokenReminderSchedulerName()
|
||||
{
|
||||
return (tokenReminderSchedulerName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Select a scheduler when the host defines several.
|
||||
*******************************************************************************/
|
||||
public void setTokenReminderSchedulerName(String value)
|
||||
{
|
||||
tokenReminderSchedulerName = value;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent scheduler selection.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withTokenReminderSchedulerName(String value)
|
||||
{
|
||||
tokenReminderSchedulerName = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,12 +24,15 @@ import java.util.Properties;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qbits.authentik.app.AuthentikRelations;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.health.AuthentikHealthChecks;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
|
||||
import com.kingsrook.qbits.authentik.processes.AuthentikProcesses;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
@@ -184,15 +187,19 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
|
||||
output.addSelfToInstance(qInstance);
|
||||
|
||||
AuthentikHealthChecks.register(qInstance, qBitConfig);
|
||||
addPossibleValueSources(qInstance);
|
||||
AuthentikRelations.apply(qInstance, qBitConfig);
|
||||
for(QProcessMetaData process : AuthentikProcesses.processes(qBitConfig))
|
||||
{
|
||||
qInstance.addProcess(process);
|
||||
}
|
||||
// Registration is metadata-only; core skips startup sync without a helpContent table.
|
||||
HelpBundles.register(qInstance, "help/qbit-authentik.yaml");
|
||||
AuthentikHelp.apply(qInstance, qBitConfig);
|
||||
|
||||
LOG.info("Authentik QBit produced", logPair("version", VERSION), logPair("appKey", qBitConfig.getAppKey()),
|
||||
logPair("features", qBitConfig.getFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
|
||||
logPair("features", qBitConfig.getEffectiveFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
@@ -46,10 +46,13 @@ public class AuthentikRuntime
|
||||
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
|
||||
private final Memoization<String, String> responseCache;
|
||||
private final Memoization<String, GroupGraph> groupGraphCache;
|
||||
private final Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> tokenScopeCache = new Memoization<>(Duration.ofSeconds(60));
|
||||
private final Memoization<String, Map<String, java.time.Instant>> tokenActivityCache = new Memoization<>(Duration.ofMinutes(5));
|
||||
private volatile java.util.Set<String> permissionCatalog;
|
||||
private volatile PolicyCatalog policyCatalog;
|
||||
private volatile AccessComparison.State mapperState;
|
||||
private volatile boolean mapperInvoked;
|
||||
private com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink tokenAlerts = new com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts()::send;
|
||||
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
@@ -79,6 +82,16 @@ public class AuthentikRuntime
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every registered runtime (a snapshot).
|
||||
*******************************************************************************/
|
||||
public static java.util.List<AuthentikRuntime> all()
|
||||
{
|
||||
return (java.util.List.copyOf(BY_BACKEND.values()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find the runtime for an API backend name.
|
||||
*******************************************************************************/
|
||||
@@ -130,6 +143,8 @@ public class AuthentikRuntime
|
||||
public void invalidateCache()
|
||||
{
|
||||
responseCache.clear();
|
||||
tokenScopeCache.clear();
|
||||
tokenActivityCache.clear();
|
||||
groupGraphCache.clear();
|
||||
}
|
||||
|
||||
@@ -147,10 +162,11 @@ public class AuthentikRuntime
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ permission names that qqq.io/permissions may grant: every permission
|
||||
** the instance's metadata defines (PermissionsHelper), computed once.
|
||||
** the instance's metadata defines (PermissionsHelper), computed per instance.
|
||||
*******************************************************************************/
|
||||
public java.util.Set<String> getPermissionCatalog()
|
||||
{
|
||||
PolicyCatalog currentPolicyCatalog = getPolicyCatalog();
|
||||
if(permissionCatalog == null)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
|
||||
@@ -159,7 +175,7 @@ public class AuthentikRuntime
|
||||
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());
|
||||
catalog.addAll(currentPolicyCatalog.permissionNames());
|
||||
permissionCatalog = catalog;
|
||||
}
|
||||
return (permissionCatalog);
|
||||
@@ -168,19 +184,20 @@ public class AuthentikRuntime
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a policy can grant in the current instance (built once, lazily: the
|
||||
** What a policy can grant in the current instance (built lazily per instance: 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(policyCatalog == null || policyCatalog.getQInstance() != qInstance)
|
||||
{
|
||||
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);
|
||||
permissionCatalog = null;
|
||||
}
|
||||
return (policyCatalog);
|
||||
}
|
||||
@@ -280,4 +297,37 @@ public class AuthentikRuntime
|
||||
mapperState = null;
|
||||
}
|
||||
}
|
||||
/*******************************************************************************
|
||||
** Token claims are cached independently of the general list TTL.
|
||||
*******************************************************************************/
|
||||
public Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> getTokenScopeCache()
|
||||
{
|
||||
return (tokenScopeCache);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Login-event aggregation is shared by every token record in this runtime.
|
||||
*******************************************************************************/
|
||||
public Memoization<String, Map<String, java.time.Instant>> getTokenActivityCache()
|
||||
{
|
||||
return (tokenActivityCache);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional Alerts delivery, replaceable by a host or a capturing test sink.
|
||||
*******************************************************************************/
|
||||
public com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink getTokenAlerts()
|
||||
{
|
||||
return (tokenAlerts);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Install a sink for this runtime only.
|
||||
*******************************************************************************/
|
||||
public AuthentikRuntime withTokenAlerts(com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink sink)
|
||||
{
|
||||
tokenAlerts = java.util.Objects.requireNonNull(sink);
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -43,6 +43,7 @@ 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.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyStore;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
@@ -62,6 +63,7 @@ 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.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -328,7 +330,8 @@ public class AuthentikAdmin
|
||||
/*******************************************************************************
|
||||
** The operator, resolved once per instance from live Authentik data. When
|
||||
** the operator is not an Authentik user (static or development sessions),
|
||||
** the session's own permissions stand in. bootstrapOperatorSubjects additionally
|
||||
** the session's own permissions stand in. Service principal sessions
|
||||
** (maintenance) are never resolved as Authentik users by email. bootstrapOperatorSubjects additionally
|
||||
** hold every authentik.* permission.
|
||||
*******************************************************************************/
|
||||
public Operator operator() throws QException
|
||||
@@ -339,12 +342,13 @@ public class AuthentikAdmin
|
||||
}
|
||||
|
||||
String email = guard.operatorEmail();
|
||||
boolean bootstrap = email != null && com.kingsrook.qbits.authentik.security.BootstrapOperators.matchesUniqueUser(config.getResolvedBootstrapSubjects(), util, email);
|
||||
boolean maintenance = ServiceIdentities.isMintedServiceSession(QContext.getQSession());
|
||||
boolean bootstrap = !maintenance && email != null && com.kingsrook.qbits.authentik.security.BootstrapOperators.matchesUniqueUser(config.getResolvedBootstrapSubjects(), util, email);
|
||||
Integer pk = null;
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
Set<String> appAccess = new TreeSet<>();
|
||||
|
||||
JSONObject user = email == null ? null : findUserByEmail(email);
|
||||
JSONObject user = maintenance || email == null ? null : findUserByEmail(email);
|
||||
if(user != null)
|
||||
{
|
||||
pk = user.optInt("pk");
|
||||
@@ -1307,12 +1311,21 @@ public class AuthentikAdmin
|
||||
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())
|
||||
boolean fullAccess = newPermissions.contains(PolicyPermissions.FULL_ACCESS);
|
||||
boolean noChange = added.isEmpty() && removed.isEmpty() && storedProblem == null && extrasNew.isEmpty();
|
||||
if(noChange && !fullAccess)
|
||||
{
|
||||
return (plan.noChange("Nothing to change."));
|
||||
}
|
||||
|
||||
Set<String> needed = new TreeSet<>(added);
|
||||
if(fullAccess)
|
||||
{
|
||||
needed = resolver().expand(PolicyPermissions.FULL_ACCESS);
|
||||
}
|
||||
else if(oldPermissions.contains(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
needed.removeAll(resolver().expand(PolicyPermissions.FULL_ACCESS));
|
||||
}
|
||||
for(String extra : extrasNew)
|
||||
{
|
||||
needed.addAll(resolver().expand(extra));
|
||||
@@ -1323,6 +1336,24 @@ public class AuthentikAdmin
|
||||
return (plan.refuse("You cannot grant permissions you do not hold yourself: " + String.join(", ", missing)));
|
||||
}
|
||||
|
||||
if(noChange)
|
||||
{
|
||||
return (plan.noChange("Nothing to change."));
|
||||
}
|
||||
|
||||
if(removed.contains(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
GroupGraph.Node current = membersGraph().byPk(request.targetKey());
|
||||
GroupGraph simulated = AuthentikSafety.withNodes(membersGraph(), new GroupGraph.Node(current.pk(), current.name(),
|
||||
current.ownSuperuser(), current.parentPks(), current.permissions(), current.appAccess(), current.memberPks(),
|
||||
current.malformed(), List.copyOf(newPermissions)));
|
||||
if(AuthentikSafety.removesLastAdmin(adminsNow(), AuthentikSafety.qualifyingAdmins(simulated,
|
||||
new EffectivePermissionResolver(simulated, runtime.getPermissionCatalog()), userFacts())))
|
||||
{
|
||||
return (plan.refuse("Removing this policy would leave nobody able to manage memberships and permissions."));
|
||||
}
|
||||
}
|
||||
|
||||
plan.withChange(policySummary(oldPermissions), policySummary(newPermissions));
|
||||
if(request.bool("generated"))
|
||||
{
|
||||
@@ -1338,11 +1369,11 @@ public class AuthentikAdmin
|
||||
}
|
||||
if(!added.isEmpty())
|
||||
{
|
||||
plan.getNotes().add("Adds: " + abbreviate(added));
|
||||
plan.getNotes().add("Adds: " + (added.contains(PolicyPermissions.FULL_ACCESS) ? PolicyPermissions.FULL_ACCESS_LABEL : abbreviate(added)));
|
||||
}
|
||||
if(!removed.isEmpty())
|
||||
{
|
||||
plan.getNotes().add("Removes: " + abbreviate(removed));
|
||||
plan.getNotes().add("Removes: " + (removed.contains(PolicyPermissions.FULL_ACCESS) ? PolicyPermissions.FULL_ACCESS_LABEL : abbreviate(removed)));
|
||||
}
|
||||
if(storedProblem != null)
|
||||
{
|
||||
@@ -1355,7 +1386,8 @@ public class AuthentikAdmin
|
||||
}
|
||||
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.");
|
||||
int permissionCount = fullAccess ? resolver().expand(PolicyPermissions.FULL_ACCESS).size() : newPermissions.size();
|
||||
plan.getNotes().add(canonical.getJSONArray("Statement").length() + " statement(s), " + permissionCount + " current permission(s); the document round-trips.");
|
||||
|
||||
if(Boolean.TRUE.equals(config.getPolicyTwoPersonApproval()) && !request.bool("approved"))
|
||||
{
|
||||
@@ -1655,6 +1687,10 @@ public class AuthentikAdmin
|
||||
*******************************************************************************/
|
||||
private static String policySummary(Set<String> permissions)
|
||||
{
|
||||
if(permissions.contains(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
return (PolicyPermissions.FULL_ACCESS_LABEL);
|
||||
}
|
||||
return (permissions.isEmpty() ? "no policy" : permissions.size() + " permission(s): " + abbreviate(permissions));
|
||||
}
|
||||
|
||||
@@ -2221,7 +2257,8 @@ public class AuthentikAdmin
|
||||
|
||||
try
|
||||
{
|
||||
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissions.size(), guard.operatorEmail(),
|
||||
int permissionCount = permissions.contains(PolicyPermissions.FULL_ACCESS) ? resolver().expand(PolicyPermissions.FULL_ACCESS).size() : permissions.size();
|
||||
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissionCount, guard.operatorEmail(),
|
||||
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format, runtime.getPolicyCatalog()));
|
||||
}
|
||||
catch(Exception e)
|
||||
|
||||
@@ -293,10 +293,16 @@ public final class AuthentikLocalTables
|
||||
{
|
||||
table.withField(ro(name, QFieldType.TEXT));
|
||||
}
|
||||
for(String name : List.of("createdAt", "rotatedAt", "lastReminder7d", "lastReminder1d"))
|
||||
for(String name : List.of("createdAt", "rotatedAt"))
|
||||
{
|
||||
table.withField(ro(name, QFieldType.DATE_TIME));
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// explicit column names: a camel-to-snake mapping would give //
|
||||
// last_reminder7d, while docs/schema.sql (and hosts) use _7d / _1d //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
table.withField(ro("lastReminder7d", QFieldType.DATE_TIME).withBackendName("last_reminder_7d"));
|
||||
table.withField(ro("lastReminder1d", QFieldType.DATE_TIME).withBackendName("last_reminder_1d"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.admin;
|
||||
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
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.PolicyPermissions;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audited policy maintenance for reviewed host Java code, typically a run-once
|
||||
** job. This API deliberately creates its own authority; never expose it through
|
||||
** an HTTP route or a user-invokable process. No route or process is registered.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikMaintenance
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AuthentikMaintenance()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply a policy to exactly one group by name through the normal Save Policy
|
||||
** plan and execute path. Requires an initialized QContext with the host's
|
||||
** QInstance. The caller's context is restored on success, refusal or failure.
|
||||
**
|
||||
** Runs as the host's maintenance service principal (config
|
||||
** maintenancePrincipal, default "maintenance"; docs/SERVICE-IDENTITY.md in
|
||||
** qqq): under OAuth2 a real token for its Authentik service account, so its
|
||||
** authority is exactly what the owner granted that account (policies.manage
|
||||
** and every grant the document applies - the subset rule still holds). No
|
||||
** authority is created here. The audit operator is service:{principal};
|
||||
** the job name (maintenance: or run-once:, nonblank suffix) prefixes the
|
||||
** required reason. Existing scope, protection, readOnly, feature and stale
|
||||
** checks still apply. Equivalent policies return NO_CHANGE without a write
|
||||
** or an audit row. With policyTwoPersonApproval enabled, a change queues an
|
||||
** approval instead of writing to Authentik; this API never bypasses
|
||||
** second-person approval.
|
||||
*******************************************************************************/
|
||||
public static AuthentikAdmin.AdminResult applyPolicy(String apiBackendName, String groupName, String policyJson, String operatorName, String reason) throws QException
|
||||
{
|
||||
if(operatorName == null || !(operatorName.startsWith("maintenance:") || operatorName.startsWith("run-once:"))
|
||||
|| operatorName.substring(operatorName.indexOf(':') + 1).isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("The operator name must start with maintenance: or run-once: and name the job."));
|
||||
}
|
||||
if(!StringUtils.hasContent(reason))
|
||||
{
|
||||
throw (new QUserFacingException("A reason is required for every change."));
|
||||
}
|
||||
if(!StringUtils.hasContent(groupName))
|
||||
{
|
||||
throw (new QUserFacingException("A group name is required."));
|
||||
}
|
||||
|
||||
CapturedContext caller = QContext.capture();
|
||||
try
|
||||
{
|
||||
String principal = AuthentikRuntime.forBackend(apiBackendName).getConfig().getMaintenancePrincipal();
|
||||
QContext.init(caller.qInstance(), ServiceIdentities.session(caller.qInstance(), principal));
|
||||
|
||||
AuthentikAdmin admin = AuthentikAdmin.forBackend(apiBackendName);
|
||||
AuthentikRuntime runtime = admin.getUtil().getRuntime();
|
||||
runtime.getGuard().authorize(AdminAction.SAVE_POLICY);
|
||||
if(StringUtils.hasContent(policyJson))
|
||||
{
|
||||
PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
|
||||
}
|
||||
|
||||
runtime.invalidateCache();
|
||||
List<JSONObject> groups = admin.getUtil().getAllPages("/core/groups/?name=" + URLEncoder.encode(groupName, StandardCharsets.UTF_8)
|
||||
+ "&include_users=true&include_parents=true&include_children=true", 2);
|
||||
if(groups.size() != 1 || !groupName.equals(groups.get(0).optString("name")))
|
||||
{
|
||||
throw (new QUserFacingException("Expected exactly one Authentik group named " + groupName + "."));
|
||||
}
|
||||
JSONObject group = groups.get(0);
|
||||
if(isServicePrincipalGroup(group))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// a principal never changes a service principal's grants (its own //
|
||||
// or another's): only the owner does, in Identity //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
throw (new QUserFacingException("Service principal groups are changed only by the owner in Identity."));
|
||||
}
|
||||
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), operatorName + ": " + reason)
|
||||
.with(AuthentikAdmin.POLICY_DOCUMENT, policyJson)
|
||||
.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
|
||||
return (admin.execute(request));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
throw (new QUserFacingException("The policy is not valid: " + e.getMessage()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(caller);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A service principal role group: named service:..., or a child of
|
||||
** qqq-service-accounts.
|
||||
*******************************************************************************/
|
||||
static boolean isServicePrincipalGroup(JSONObject group)
|
||||
{
|
||||
if(group.optString("name", "").startsWith("service:"))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
org.json.JSONArray parents = group.optJSONArray("parents_obj");
|
||||
for(int i = 0; parents != null && i < parents.length(); i++)
|
||||
{
|
||||
if("qqq-service-accounts".equals(parents.optJSONObject(i) == null ? null : parents.getJSONObject(i).optString("name")))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical compact full-access document using the host's spec and version.
|
||||
*******************************************************************************/
|
||||
public static String fullAccessDocument(AuthentikQBitConfig config) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (PolicyGenerator.toCompact(PolicyGenerator.generate(Set.of(PolicyPermissions.FULL_ACCESS), config.getPolicyFormat())));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
throw (new QException("Could not generate the full-access policy.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional Alerts bridge. The qbit compiles on RC.2 without either Alerts API.
|
||||
** No credentials enter this interface. Hosts and tests can supply a sink.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokenAlerts
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenAlerts.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** Safe notification envelope. A null recipient denotes the admin audience.
|
||||
*******************************************************************************/
|
||||
public record Notice(String key, String severity, String recipient, String message)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return true only when delivery was accepted, so absence never dedupes.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Sink
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Accept a safe notification, returning false when delivery is unavailable.
|
||||
*******************************************************************************/
|
||||
boolean send(Notice notice);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor for the reflection adapter.
|
||||
*******************************************************************************/
|
||||
public AuthentikTokenAlerts()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard incidents are raised through core QAlerts; owner reminders through
|
||||
** core QAlerts.notify (qqq#1125, QQQ 4.1.0-RC.6+). Never another qbit's API.
|
||||
*******************************************************************************/
|
||||
public boolean send(Notice notice)
|
||||
{
|
||||
try
|
||||
{
|
||||
if("high".equals(notice.severity()))
|
||||
{
|
||||
QAlerts.raise(new AlertSignal("authentik.token.guard-bypass")
|
||||
.withDedupKey(notice.key())
|
||||
.withTitle("Access token guard bypass")
|
||||
.withSummary(notice.message())
|
||||
.withSeverity(AlertSeverity.CRITICAL));
|
||||
return (true);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// owner reminders: core's notification SPI (bell and email via //
|
||||
// whichever alerts producer the host installed; qqq#1125) //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
QAlerts.notify(new NotificationSignal("Access token expiry reminder")
|
||||
.withBody(notice.message())
|
||||
.withSourceName("authentik")
|
||||
.withSourceKey(notice.key())
|
||||
.withAudience(NotificationAudience.user(notice.recipient())));
|
||||
return (true);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// never fail the caller; never log the notice payload //
|
||||
///////////////////////////////////////////////////////////
|
||||
LOG.info("Access-token notification delivery failed; notification skipped.");
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,13 @@ public class AuthentikTokens
|
||||
return (plan.refuse("Scopes are not enabled on this app yet."));
|
||||
}
|
||||
}
|
||||
if(action == AdminAction.CREATE_TOKEN && Boolean.TRUE.equals(config.getTokenScopesEnabled()))
|
||||
{
|
||||
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.validate(request);
|
||||
QRecord scope = new QRecord();
|
||||
request.params().forEach(scope::setValue);
|
||||
plan.withChange(null, TokenScopeStore.summary(scope));
|
||||
}
|
||||
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
|
||||
{
|
||||
if(!TokenKeyDelivery.get().available())
|
||||
@@ -412,7 +419,7 @@ public class AuthentikTokens
|
||||
}
|
||||
JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
|
||||
JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before));
|
||||
checkProtected(owner);
|
||||
checkProtectedAfterRead(owner);
|
||||
boolean service = "service_account".equals(owner.optString("type"));
|
||||
boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk"));
|
||||
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
|
||||
@@ -433,6 +440,7 @@ public class AuthentikTokens
|
||||
util.writeAs(bearer, auth, "PATCH", path(request.targetKey()), new JSONObject().put("intent", before.getString("intent"))
|
||||
.put("user", owner.getInt("pk")).put("expires", expiry(request, service).toString()).put("expiring", true));
|
||||
raceGuard(before, token(request.targetKey()));
|
||||
scopes.resetReminders(request.targetKey());
|
||||
return (success("Token expiry updated."));
|
||||
case ROTATE_TOKEN:
|
||||
if(!service)
|
||||
@@ -462,13 +470,15 @@ public class AuthentikTokens
|
||||
/*******************************************************************************
|
||||
** Guard owner and intent after every PATCH or set_key, with a safe warning.
|
||||
*******************************************************************************/
|
||||
private static void raceGuard(JSONObject before, JSONObject after) throws QUserFacingException
|
||||
private void raceGuard(JSONObject before, JSONObject after) throws QException
|
||||
{
|
||||
if(ownerPk(before) != ownerPk(after) || !before.getString("intent").equals(after.getString("intent")))
|
||||
{
|
||||
alertGuardBypass();
|
||||
LOG.warn(TOKEN_MOVED);
|
||||
throw (new QUserFacingException(TOKEN_MOVED));
|
||||
}
|
||||
checkProtectedAfterRead(owner(ownerPk(after)));
|
||||
}
|
||||
|
||||
|
||||
@@ -487,6 +497,7 @@ public class AuthentikTokens
|
||||
try
|
||||
{
|
||||
JSONObject created = util.getAs(bearer, auth, path(identifier));
|
||||
checkProtectedAfterRead(owner(ownerPk(created)));
|
||||
if(ownerPk(created) != owner.getInt("pk") || !"app_password".equals(created.optString("intent")))
|
||||
{
|
||||
throw (new QUserFacingException(TOKEN_MOVED));
|
||||
@@ -569,8 +580,9 @@ public class AuthentikTokens
|
||||
util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk));
|
||||
}
|
||||
JSONObject owner = owner(pk);
|
||||
checkProtected(owner);
|
||||
checkProtectedAfterRead(owner);
|
||||
JSONObject after = token(identifier);
|
||||
checkProtectedAfterRead(owner(ownerPk(after)));
|
||||
scopes.create(identifier, owner, request, admin.operator().email());
|
||||
return (success("Service account created. " + String.join(" ", plan.getNotes()))
|
||||
.withSecret(oneTime(identifier, created.getString("token"), owner, checkExpiry(after, true), "Owner permissions"),
|
||||
@@ -656,4 +668,36 @@ public class AuthentikTokens
|
||||
String slug = username.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-]+", "-");
|
||||
return ("qqq-pat-" + slug + "-" + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC).format(Instant.now()) + "-" + suffix);
|
||||
}
|
||||
/*******************************************************************************
|
||||
** A protected owner observed after Review or a write is a guard incident.
|
||||
*******************************************************************************/
|
||||
private void checkProtectedAfterRead(JSONObject owner) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
checkProtected(owner);
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
alertGuardBypass();
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Safe incident payload: no response bodies, credentials or owner details.
|
||||
*******************************************************************************/
|
||||
private void alertGuardBypass()
|
||||
{
|
||||
try
|
||||
{
|
||||
util.getRuntime().getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null,
|
||||
"An access token changed owner or intent, or a protected owner was observed after Review. Investigate in Authentik."));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Access-token guard alert delivery failed.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -65,7 +65,7 @@ public class TokenScopeStore
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fetch the unique scope row.
|
||||
** Fetch the unique scope row as a trusted SYSTEM read (QueryInput default).
|
||||
*******************************************************************************/
|
||||
public QRecord find(String identifier) throws QException
|
||||
{
|
||||
@@ -139,4 +139,54 @@ public class TokenScopeStore
|
||||
throw (new QException("Token scope metadata could not be saved."));
|
||||
}
|
||||
}
|
||||
/*******************************************************************************
|
||||
** Human-readable, non-secret scope for lists and Review.
|
||||
*******************************************************************************/
|
||||
public static String summary(QRecord row)
|
||||
{
|
||||
java.util.ArrayList<String> parts = new java.util.ArrayList<>();
|
||||
for(String field : SCOPE_FIELDS)
|
||||
{
|
||||
if(row != null && row.getValue(field) != null && !row.getValueString(field).isBlank())
|
||||
{
|
||||
parts.add(field + ": " + row.getValueString(field));
|
||||
}
|
||||
}
|
||||
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist dedupe metadata. External tokens get no invented creation date or
|
||||
** restrictions; their token row remains unscoped.
|
||||
*******************************************************************************/
|
||||
public void reminded(String identifier, JSONObject owner, String field, Instant at) throws QException
|
||||
{
|
||||
QRecord row = find(identifier);
|
||||
if(row == null)
|
||||
{
|
||||
row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
|
||||
.withValue("ownerUsername", owner.getString("username")).withValue(field, at);
|
||||
check(new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords());
|
||||
}
|
||||
else
|
||||
{
|
||||
row.setValue(field, at);
|
||||
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A new expiry starts a new set of reminder windows.
|
||||
*******************************************************************************/
|
||||
public void resetReminders(String identifier) throws QException
|
||||
{
|
||||
QRecord row = find(identifier);
|
||||
if(row != null)
|
||||
{
|
||||
row.setValue("lastReminder7d", null);
|
||||
row.setValue("lastReminder1d", null);
|
||||
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,20 +17,18 @@
|
||||
package com.kingsrook.qbits.authentik.app;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -56,8 +54,6 @@ public class AuthentikHomeData
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikHomeData.class);
|
||||
|
||||
public static final int TOKEN_WARNING_DAYS = 14;
|
||||
|
||||
private boolean peopleAvailable;
|
||||
private int activePeople;
|
||||
private int inactivePeople;
|
||||
@@ -84,8 +80,10 @@ public class AuthentikHomeData
|
||||
private List<QRecord> recentActions = new ArrayList<>();
|
||||
private List<String> attention = new ArrayList<>();
|
||||
|
||||
private String healthStatus = "UNKNOWN";
|
||||
private Integer healthComponentId;
|
||||
|
||||
private AuthentikQBitConfig config;
|
||||
private String version;
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +102,7 @@ public class AuthentikHomeData
|
||||
data.loadApprovals();
|
||||
data.loadActions();
|
||||
data.loadAttention(util);
|
||||
data.version = util.fetchServerVersion().orElse(null);
|
||||
data.loadHealth();
|
||||
return (data);
|
||||
}
|
||||
|
||||
@@ -240,8 +238,7 @@ public class AuthentikHomeData
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Things that need a person: drift findings, blueprint state, tokens about
|
||||
** to expire, bootstrap operators still set.
|
||||
** Configuration review and bootstrap operators still set. Health is central.
|
||||
*******************************************************************************/
|
||||
private void loadAttention(AuthentikActionUtil util)
|
||||
{
|
||||
@@ -275,26 +272,6 @@ public class AuthentikHomeData
|
||||
LOG.info("Identity home: drift report unavailable", logPair("error", e.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
if(mayAudit)
|
||||
{
|
||||
try
|
||||
{
|
||||
Instant soon = Instant.now().plus(TOKEN_WARNING_DAYS, ChronoUnit.DAYS);
|
||||
for(JSONObject token : util.getAllPages("/core/tokens/?user__username=" + java.net.URLEncoder.encode(config.getEffectiveServiceAccountUsername(), java.nio.charset.StandardCharsets.UTF_8), 50))
|
||||
{
|
||||
Instant expires = AuthentikJson.parseInstant(token.optString("expires", null));
|
||||
if(token.optBoolean("expiring", true) && expires != null && expires.isBefore(soon))
|
||||
{
|
||||
attention.add("The qbit's API token (" + token.optString("identifier") + ") expires on "
|
||||
+ java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(java.time.ZoneOffset.UTC).format(expires) + ": rotate it (owner checklist).");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.debug("Identity home: token expiry not checked", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -320,11 +297,51 @@ public class AuthentikHomeData
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for version
|
||||
** Read the central rollup using the current session's table permissions.
|
||||
*******************************************************************************/
|
||||
public String getVersion()
|
||||
private void loadHealth()
|
||||
{
|
||||
return (version);
|
||||
if(QContext.getQInstance().getTable("health_component") == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
QueryInput query = new QueryInput();
|
||||
query.setTableName("health_component");
|
||||
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
query.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "authentik")).withLimit(1));
|
||||
List<QRecord> records = new QueryAction().execute(query).getRecords();
|
||||
if(!records.isEmpty())
|
||||
{
|
||||
healthStatus = com.kingsrook.qqq.backend.core.health.HealthStatus.valueOf(records.getFirst().getValueString("status")).name();
|
||||
healthComponentId = records.getFirst().getValueInteger("id");
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.debug("Identity home: central health unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the central component status.
|
||||
*******************************************************************************/
|
||||
public String getHealthStatus()
|
||||
{
|
||||
return (healthStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the central component record link.
|
||||
*******************************************************************************/
|
||||
public Integer getHealthComponentId()
|
||||
{
|
||||
return (healthComponentId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ public final class AuthentikHomeView
|
||||
people(html, data, config);
|
||||
groups(html, data, config);
|
||||
activity(html, data, config);
|
||||
connection(html, data);
|
||||
health(html, data);
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
@@ -204,31 +204,12 @@ public final class AuthentikHomeView
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void connection(StringBuilder html, AuthentikHomeData data)
|
||||
private static void health(StringBuilder html, AuthentikHomeData data)
|
||||
{
|
||||
open(html, "Connection");
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Authentik", data.getConfig().getBaseUrl());
|
||||
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>");
|
||||
open(html, "Health");
|
||||
String target = prefix(data.getConfig()) + "/health_component" + (data.getHealthComponentId() == null ? "" : "/" + data.getHealthComponentId());
|
||||
html.append("<p>Authentik: ").append(esc(data.getHealthStatus())).append(" - <a href=\"")
|
||||
.append(esc(target)).append("\">System Health</a></p>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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.app;
|
||||
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
|
||||
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.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard TABLE widget: inventory, age and actionable cleanup suggestions.
|
||||
** Computed fields never become API filters; links use owner or token keys.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokenWidget extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check the permission here as well as in metadata (non-HTTP callers).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String backend = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get("backendName"));
|
||||
return (new RenderWidgetOutput(data(AuthentikActionUtil.forBackend(backend), Instant.now())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Report complete inventory and explicit unknown ages. An API token's
|
||||
** activity is untracked and never qualifies as unused.
|
||||
*******************************************************************************/
|
||||
public static TableData data(AuthentikActionUtil util, Instant now) throws QException
|
||||
{
|
||||
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Access token widgets require authentik.tokens.manage."));
|
||||
}
|
||||
AuthentikQBitConfig config = util.getRuntime().getConfig();
|
||||
List<JSONObject> tokens = util.getAllPages("/core/tokens/", Integer.MAX_VALUE);
|
||||
Map<String, Instant> activity = AuthentikTokenActivity.lastUsed(util);
|
||||
Map<String, List<String>> owners = new LinkedHashMap<>();
|
||||
Map<String, List<String>> ages = new LinkedHashMap<>();
|
||||
for(String bucket : List.of("<30d", "30-90d", "90-365d", ">365d", "unknown"))
|
||||
{
|
||||
ages.put(bucket, new ArrayList<>());
|
||||
}
|
||||
Map<Integer, JSONObject> users = new LinkedHashMap<>();
|
||||
List<Map<String, Object>> cleanup = new ArrayList<>();
|
||||
TokenScopeStore scopes = new TokenScopeStore(config);
|
||||
for(JSONObject token : tokens)
|
||||
{
|
||||
String id = token.getString("identifier");
|
||||
JSONObject owner = token.getJSONObject("user_obj");
|
||||
String username = owner.getString("username");
|
||||
owners.computeIfAbsent(username, k -> new ArrayList<>()).add(id);
|
||||
QRecord scope = scopes.find(id);
|
||||
Instant created = scope == null ? null : scope.getValueInstant("createdAt");
|
||||
long days = created == null ? -1 : ChronoUnit.DAYS.between(created, now);
|
||||
ages.get(days < 0 ? "unknown" : days < 30 ? "<30d" : days < 90 ? "30-90d" : days <= 365 ? "90-365d" : ">365d").add(id);
|
||||
int pk = owner.getInt("pk");
|
||||
if(!users.containsKey(pk))
|
||||
{
|
||||
users.put(pk, util.getApiJsonOrNull("/core/users/" + pk + "/"));
|
||||
}
|
||||
JSONObject live = users.get(pk);
|
||||
String links = link(list(config, "username", List.of(username)), username) + " / " + link(list(config, "identifier", List.of(id)), id);
|
||||
if(live != null && !live.optBoolean("is_active", true))
|
||||
{
|
||||
cleanup.add(row("Owner deactivated", 1, links));
|
||||
}
|
||||
Instant last = activity.get(id);
|
||||
Instant cutoff = now.minus(config.getTokenUnusedDays(), ChronoUnit.DAYS);
|
||||
// Without a creation time or an event we cannot prove age. A 90-day
|
||||
// event window cannot prove inactivity beyond 90 days either.
|
||||
boolean unused = last != null ? last.isBefore(cutoff)
|
||||
: config.getTokenUnusedDays() <= 90 && created != null && created.isBefore(cutoff);
|
||||
if("app_password".equals(token.optString("intent")) && unused)
|
||||
{
|
||||
cleanup.add(row("Unused for " + config.getTokenUnusedDays() + " days", 1, links));
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(row("Total", tokens.size(), link(list(config, null, List.of()), "All access tokens")));
|
||||
owners.entrySet().stream().sorted(Comparator.<Map.Entry<String, List<String>>>comparingInt(e -> e.getValue().size()).reversed().thenComparing(Map.Entry::getKey))
|
||||
.limit(10).forEach(e -> rows.add(row("Owner: " + e.getKey(), e.getValue().size(), link(list(config, "username", List.of(e.getKey())), "Tokens"))));
|
||||
ages.forEach((bucket, ids) -> rows.add(row("Age: " + bucket, ids.size(), ids.isEmpty() ? "" : link(list(config, "identifier", ids), "Tokens"))));
|
||||
rows.addAll(cleanup);
|
||||
return (new TableData("Access tokens", List.of(
|
||||
new TableData.Column("default", "Category", "category", "35%", "left"),
|
||||
new TableData.Column("default", "Count", "count", "10%", "right"),
|
||||
new TableData.Column("html", "Access tokens", "tokens", "55%", "left")), rows)
|
||||
.withRowsPerPage(25).withLinkText("All access tokens / bulk revoke").withLinkURL(list(config, null, List.of())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A standard table row; only the link cell contains escaped anchor markup.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> row(String category, int count, String tokens)
|
||||
{
|
||||
return (Map.of("category", category, "count", count, "tokens", tokens));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Native list filter format shared by Material and Next. Primary-key IN is
|
||||
** supported by the API adapter as individual GETs; no computed filtering.
|
||||
*******************************************************************************/
|
||||
private static String list(AuthentikQBitConfig config, String field, List<String> values)
|
||||
{
|
||||
String url = (config.getLinkPrefix() == null ? "" : config.getLinkPrefix()) + "/" + config.applyTablePrefix("token");
|
||||
if(field != null)
|
||||
{
|
||||
JSONObject criteria = new JSONObject().put("fieldName", field).put("operator", values.size() == 1 ? "EQUALS" : "IN").put("values", values);
|
||||
String filter = new JSONObject().put("booleanOperator", "AND").put("criteria", new JSONArray().put(criteria)).toString();
|
||||
url += "?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8);
|
||||
}
|
||||
return (url);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escape both the link target and text; descriptions are never HTML.
|
||||
*******************************************************************************/
|
||||
private static String link(String url, String text)
|
||||
{
|
||||
return ("<a href=\"" + escape(url) + "\">" + escape(text) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal HTML escaping for a standard table link cell.
|
||||
*******************************************************************************/
|
||||
private static String escape(String value)
|
||||
{
|
||||
return (value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -30,6 +31,7 @@ import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
@@ -62,6 +64,7 @@ import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
import org.apache.http.Header;
|
||||
import org.apache.http.HttpEntityEnclosingRequest;
|
||||
import org.apache.http.NameValuePair;
|
||||
import org.apache.http.client.config.RequestConfig;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpDelete;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
@@ -73,7 +76,6 @@ import org.apache.http.entity.ContentType;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClientBuilder;
|
||||
import org.apache.http.client.config.RequestConfig;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
@@ -114,6 +116,8 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
/** Display value for derived fields when the group graph cannot be read. */
|
||||
public static final String UNAVAILABLE = "Unavailable (Authentik groups could not be read)";
|
||||
|
||||
private java.time.Instant deadline;
|
||||
|
||||
private EffectivePermissionResolver resolverForCall;
|
||||
|
||||
|
||||
@@ -136,6 +140,94 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound this dedicated health client; ordinary action clients are unchanged.
|
||||
*******************************************************************************/
|
||||
public AuthentikActionUtil withHealthDeadline(java.time.Instant deadline) throws QException
|
||||
{
|
||||
runtime().getGuard().checkHealth();
|
||||
this.deadline = java.util.Objects.requireNonNull(deadline);
|
||||
remainingMillis();
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remaining health budget; zero must never become an unlimited HTTP timeout.
|
||||
*******************************************************************************/
|
||||
private int remainingMillis() throws QException
|
||||
{
|
||||
long remaining = deadline == null ? Integer.MAX_VALUE : java.time.Duration.between(java.time.Instant.now(), deadline).toMillis();
|
||||
if(remaining <= 0 || (deadline != null && Thread.currentThread().isInterrupted()))
|
||||
{
|
||||
throw (new QException("Authentik health deadline exceeded"));
|
||||
}
|
||||
return ((int) Math.min(Integer.MAX_VALUE, remaining));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configured zero means unlimited normally, but health always has a deadline.
|
||||
*******************************************************************************/
|
||||
private static int healthTimeout(int remaining, int configured)
|
||||
{
|
||||
return (configured <= 0 ? remaining : Math.min(remaining, configured));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep retry delays inside the health budget and preserve cancellation.
|
||||
*******************************************************************************/
|
||||
private void retrySleep(long millis) throws QException
|
||||
{
|
||||
if(deadline == null)
|
||||
{
|
||||
SleepUtils.sleep(millis, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
return;
|
||||
}
|
||||
if(millis >= remainingMillis())
|
||||
{
|
||||
throw (new QException("Authentik health deadline exceeded"));
|
||||
}
|
||||
try
|
||||
{
|
||||
Thread.sleep(millis);
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new QException("Authentik health check interrupted"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the service account API token metadata through the existing paged path.
|
||||
*******************************************************************************/
|
||||
public List<JSONObject> getServiceAccountApiTokens() throws QException
|
||||
{
|
||||
runtime().getGuard().checkHealth();
|
||||
return (getAllPages("/core/tokens/?intent=api&user__username=" + java.net.URLEncoder.encode(
|
||||
runtime().getConfig().getEffectiveServiceAccountUsername(), StandardCharsets.UTF_8), 50));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Broker credentials use the same secret-safe request loop as token exchange.
|
||||
*******************************************************************************/
|
||||
JSONObject requestBrokerCredentials(HttpPost request) throws QException
|
||||
{
|
||||
runtime().getGuard().checkHealth();
|
||||
return (brokerResponse(request));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime for the backend this util serves.
|
||||
*******************************************************************************/
|
||||
@@ -461,6 +553,16 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
public JSONObject exchangeToken(WriteAuthorization auth, HttpPost request) throws QException
|
||||
{
|
||||
checkAuthorization(auth);
|
||||
return (brokerResponse(request));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a broker response without exposing its body.
|
||||
*******************************************************************************/
|
||||
private JSONObject brokerResponse(HttpPost request) throws QException
|
||||
{
|
||||
if(!request.getURI().toString().equals(runtime().getConfig().getTokenEndpointUrl()))
|
||||
{
|
||||
throw (new QException("Bad token endpoint"));
|
||||
@@ -803,6 +905,10 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
record.setDisplayValue("isSuperuserInherited", UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
else if(AuthentikTables.TOKEN.equals(spec.getName()))
|
||||
{
|
||||
AuthentikTokenActivity.enrich(this, record);
|
||||
}
|
||||
return (record);
|
||||
}
|
||||
catch(JSONException | IllegalArgumentException e)
|
||||
@@ -820,7 +926,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
public GroupGraph groupGraph() throws QException
|
||||
{
|
||||
AuthentikRuntime runtime = runtime();
|
||||
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
|
||||
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
|
||||
if(cache)
|
||||
{
|
||||
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("all").map(m -> m.getResult());
|
||||
@@ -874,7 +980,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
public GroupGraph groupGraphWithMembers() throws QException
|
||||
{
|
||||
AuthentikRuntime runtime = runtime();
|
||||
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
|
||||
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
|
||||
if(cache)
|
||||
{
|
||||
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("members").map(m -> m.getResult());
|
||||
@@ -925,6 +1031,51 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded, username-ordered policy-preview lookup. Membership, active
|
||||
** status and user scope are evaluated by Authentik before the limit.
|
||||
** This read needs only the editor permission, not the users table permission.
|
||||
*******************************************************************************/
|
||||
public List<JSONObject> policyPreviewUsers(Collection<String> groupPks, int maxObjects) throws QException
|
||||
{
|
||||
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.POLICIES_MANAGE))
|
||||
{
|
||||
throw (new QPermissionDeniedException("You do not have permission to preview policies (authentik.policies.manage)."));
|
||||
}
|
||||
if(groupPks.isEmpty())
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
try
|
||||
{
|
||||
URIBuilder builder = new URIBuilder("/core/users/");
|
||||
builder.addParameter("include_groups", "true");
|
||||
builder.addParameter("include_roles", "false");
|
||||
builder.addParameter("is_active", "true");
|
||||
builder.addParameter("ordering", "username");
|
||||
for(String pk : groupPks)
|
||||
{
|
||||
builder.addParameter("groups_by_pk", pk);
|
||||
}
|
||||
addUserScope(builder, List.of());
|
||||
List<JSONObject> users = new ArrayList<>();
|
||||
for(JSONObject user : getAllPages(builder.build().toString(), maxObjects))
|
||||
{
|
||||
if(runtime().getGuard().isUserInScope(user) && user.optBoolean("is_active", false))
|
||||
{
|
||||
users.add(user);
|
||||
}
|
||||
}
|
||||
return (users);
|
||||
}
|
||||
catch(java.net.URISyntaxException e)
|
||||
{
|
||||
throw (new QException("Bad Authentik URL", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A resolver over the current group graph and the instance's permissions.
|
||||
*******************************************************************************/
|
||||
@@ -1251,7 +1402,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
private String getBody(String url, boolean nullOn404) throws QException
|
||||
{
|
||||
AuthentikRuntime runtime = runtime();
|
||||
if(runtime.isCacheEnabled())
|
||||
if(deadline == null && runtime.isCacheEnabled())
|
||||
{
|
||||
Optional<String> cached = runtime.getResponseCache().getMemoizedResult(url).map(m -> m.getResult());
|
||||
if(cached.isPresent())
|
||||
@@ -1271,7 +1422,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
}
|
||||
|
||||
String body = response.getContent();
|
||||
if(runtime.isCacheEnabled() && body != null)
|
||||
if(deadline == null && runtime.isCacheEnabled() && body != null)
|
||||
{
|
||||
runtime.getResponseCache().storeResult(url, body);
|
||||
}
|
||||
@@ -1325,7 +1476,20 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
while(true)
|
||||
{
|
||||
attempts++;
|
||||
runtime().getThrottle().acquire();
|
||||
remainingMillis();
|
||||
if(deadline == null)
|
||||
{
|
||||
runtime().getThrottle().acquire();
|
||||
}
|
||||
else
|
||||
{
|
||||
runtime().getThrottle().acquire(deadline);
|
||||
int remaining = remainingMillis();
|
||||
request.setConfig(RequestConfig.custom()
|
||||
.setConnectTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
|
||||
.setConnectionRequestTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
|
||||
.setSocketTimeout(healthTimeout(remaining, runtime().getConfig().getSocketTimeoutMillis())).build());
|
||||
}
|
||||
if(!exchange)
|
||||
{
|
||||
request.setHeader("Authorization", "Bearer " + token);
|
||||
@@ -1341,6 +1505,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
try
|
||||
{
|
||||
response = send(request);
|
||||
remainingMillis();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -1348,7 +1513,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
LOG.warn("Authentik API call failed", logPair("method", request.getMethod()), logPair("path", path), logPair("error", message));
|
||||
if(attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
|
||||
{
|
||||
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
retrySleep(backoffMs);
|
||||
backoffMs *= 2;
|
||||
continue;
|
||||
}
|
||||
@@ -1361,17 +1526,20 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
logOutboundCall(request, path, response, token);
|
||||
}
|
||||
checkVersionOnce(status);
|
||||
if(deadline == null)
|
||||
{
|
||||
checkVersionOnce(status);
|
||||
}
|
||||
|
||||
if(status == 429 && attempts <= MAX_RETRIES)
|
||||
{
|
||||
SleepUtils.sleep(retryAfterMillis(response, backoffMs), java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
retrySleep(retryAfterMillis(response, backoffMs));
|
||||
backoffMs *= 2;
|
||||
continue;
|
||||
}
|
||||
if(status >= 500 && attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
|
||||
{
|
||||
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
retrySleep(backoffMs);
|
||||
backoffMs *= 2;
|
||||
continue;
|
||||
}
|
||||
@@ -1395,7 +1563,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
// Never echo an untrusted broker error body.
|
||||
}
|
||||
throw (new QUserFacingException("Authentik refused to act as you (" + error + ")"));
|
||||
throw (new BrokerRefusedException(error));
|
||||
}
|
||||
throw (mapError(request, path, status, response.getContent(), token));
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@ public final class AuthentikApiSurface
|
||||
///////////////////////////////////////////////////////////
|
||||
rs.add(new Operation("get", "/core/groups/", Set.of("include_users", "include_parents", "include_children", "name", "ordering", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/core/groups/{key}/", Set.of("include_users")));
|
||||
rs.add(new Operation("get", "/core/users/", Set.of("username", "include_groups", "attributes", "type", "path_startswith", "groups_by_name", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/core/users/", Set.of("username", "include_groups", "include_roles", "attributes", "type", "path_startswith", "groups_by_name", "groups_by_pk", "is_active", "ordering", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/core/users/{key}/", Set.of()));
|
||||
rs.add(new Operation("get", "/managed/blueprints/", Set.of("page", "page_size")));
|
||||
rs.add(new Operation("get", "/core/tokens/", Set.of("user__username", "intent", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/events/events/", Set.of("username", "ordering", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/events/events/", Set.of("username", "action", "ordering", "page", "page_size")));
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
@@ -419,6 +419,10 @@ public final class AuthentikTables
|
||||
spec.withField(new AuthentikFieldSpec("ownerType", "Owner Type", QFieldType.STRING, at("user_obj", "type")));
|
||||
spec.withField(new AuthentikFieldSpec("ownerName", "Owner", QFieldType.STRING, at("user_obj", "name")));
|
||||
spec.withField(new AuthentikFieldSpec("managed", "Managed", QFieldType.STRING, string("managed")));
|
||||
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("lastUsedNote", "Last Used Note", QFieldType.STRING, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("createdAt", "Created", QFieldType.DATE_TIME, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("scopeSummary", "Scope", QFieldType.STRING, o -> null));
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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.client;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Non-secret local metadata and the most recent app-password login per token.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikTokenActivity
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AuthentikTokenActivity()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The pinned schema has no created__gt. Walk descending pages until the
|
||||
** 90-day boundary instead of sending a silently ignored query parameter.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Instant> lastUsed(AuthentikActionUtil util) throws QException
|
||||
{
|
||||
return (util.getRuntime().getTokenActivityCache().getResultThrowing("login", key ->
|
||||
{
|
||||
Map<String, Instant> latest = new LinkedHashMap<>();
|
||||
Instant cutoff = Instant.now().minus(90, ChronoUnit.DAYS);
|
||||
for(int page = 1; ; page++)
|
||||
{
|
||||
String path = "/events/events/?action=login&ordering=-created&page=" + page + "&page_size=100";
|
||||
util.getRuntime().getResponseCache().clearKey(util.getRuntime().getConfig().getApiRoot() + path);
|
||||
JSONObject response = util.getApiJson(path);
|
||||
JSONArray events = response.getJSONArray("results");
|
||||
for(int i = 0; i < events.length(); i++)
|
||||
{
|
||||
JSONObject event = events.getJSONObject(i);
|
||||
Instant at = Instant.parse(event.getString("created"));
|
||||
if(at.isBefore(cutoff))
|
||||
{
|
||||
return (Map.copyOf(latest));
|
||||
}
|
||||
JSONObject context = event.optJSONObject("context");
|
||||
if("login".equals(event.optString("action")) && context != null && "token".equals(context.optString("method")))
|
||||
{
|
||||
JSONObject args = context.optJSONObject("method_args");
|
||||
String identifier = args == null ? "" : args.optString("identifier");
|
||||
if(!identifier.isBlank())
|
||||
{
|
||||
latest.merge(identifier, at, (a, b) -> a.isAfter(b) ? a : b);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(events.isEmpty() || page >= response.getJSONObject("pagination").getInt("total_pages"))
|
||||
{
|
||||
return (Map.copyOf(latest));
|
||||
}
|
||||
}
|
||||
}).orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enrich only token records, never leaking token keys into the table.
|
||||
*******************************************************************************/
|
||||
public static void enrich(AuthentikActionUtil util, QRecord record) throws QException
|
||||
{
|
||||
var config = util.getRuntime().getConfig();
|
||||
QRecord scope = QContext.getQInstance().getTable(config.applyTablePrefix("token_scope")) == null ? null
|
||||
: new TokenScopeStore(config).find(record.getValueString("identifier"));
|
||||
record.setValue("createdAt", scope == null ? null : scope.getValue("createdAt"));
|
||||
record.setValue("scopeSummary", TokenScopeStore.summary(scope));
|
||||
if("api".equals(record.getValueString("intent")))
|
||||
{
|
||||
record.setValue("lastUsedNote", "not tracked");
|
||||
record.setDisplayValue("lastUsed", "not tracked");
|
||||
}
|
||||
else
|
||||
{
|
||||
record.setValue("lastUsed", lastUsed(util).get(record.getValueString("identifier")));
|
||||
record.setValue("lastUsedNote", record.getValue("lastUsed") == null ? "No login in the last 90 days" : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,4 +58,44 @@ public class AuthentikTokenBroker
|
||||
}
|
||||
return (bearer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prove the broker's client credentials with the grant it actually uses
|
||||
** (token exchange), without a subject token, so nothing is issued and no
|
||||
** user data is sent. Authentik checks the grant type, then the client
|
||||
** secret, then the subject: invalid_request (missing subject) means both
|
||||
** passed; invalid_client means a bad secret; invalid_grant means the
|
||||
** provider does not allow token exchange.
|
||||
*******************************************************************************/
|
||||
public void probeCredentials(AuthentikActionUtil util) throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = util.getRuntime().getConfig();
|
||||
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
|
||||
request.setEntity(new UrlEncodedFormEntity(List.of(
|
||||
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
|
||||
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
|
||||
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret())), StandardCharsets.UTF_8));
|
||||
String code;
|
||||
try
|
||||
{
|
||||
util.requestBrokerCredentials(request);
|
||||
code = "issued";
|
||||
}
|
||||
catch(BrokerRefusedException e)
|
||||
{
|
||||
code = e.getErrorCode();
|
||||
}
|
||||
switch(code)
|
||||
{
|
||||
case "invalid_request" ->
|
||||
{
|
||||
return;
|
||||
}
|
||||
case "invalid_client" -> throw (new QUserFacingException("Authentik rejected the token broker's client credentials"));
|
||||
case "invalid_grant", "unsupported_grant_type" -> throw (new QUserFacingException("The token broker's Authentik provider does not allow token exchange"));
|
||||
default -> throw (new QUserFacingException("Authentik answered the token broker probe unexpectedly (" + code + ")"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.client;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentik's token endpoint refused a broker request. Carries only the
|
||||
** standard OAuth2 error code (never the untrusted response body).
|
||||
*******************************************************************************/
|
||||
public class BrokerRefusedException extends QUserFacingException
|
||||
{
|
||||
private final String errorCode;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public BrokerRefusedException(String errorCode)
|
||||
{
|
||||
super("Authentik refused to act as you (" + errorCode + ")");
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The OAuth2 error code, or exchange_failed when it was not a standard one.
|
||||
*******************************************************************************/
|
||||
public String getErrorCode()
|
||||
{
|
||||
return (errorCode);
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,30 @@ public class TokenBucketThrottle
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reserve a token only when the health deadline permits the throttle delay.
|
||||
*******************************************************************************/
|
||||
public void acquire(java.time.Instant deadline) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
long waitNanos;
|
||||
synchronized(this)
|
||||
{
|
||||
refill();
|
||||
waitNanos = (long) Math.ceil(Math.max(0, 1 - tokens) / tokensPerNano);
|
||||
if(Thread.currentThread().isInterrupted() || waitNanos >= java.time.Duration.between(java.time.Instant.now(), deadline).toNanos())
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QException("Authentik health deadline exceeded"));
|
||||
}
|
||||
tokens -= 1;
|
||||
}
|
||||
if(waitNanos > 0)
|
||||
{
|
||||
nanoSleeper.accept(waitNanos);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tokens currently available (may be negative while callers are waiting).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -22,6 +22,7 @@ import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
@@ -32,9 +33,9 @@ import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -61,6 +62,9 @@ public class AuthentikDriftReport
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikDriftReport.class);
|
||||
|
||||
private static final Pattern SAFE_PERMISSION = Pattern.compile("[A-Za-z0-9_.*-]{1,120}");
|
||||
private static final Pattern SAFE_ATTRIBUTE = Pattern.compile("[A-Za-z0-9_./-]{1,60}");
|
||||
|
||||
private final AuthentikActionUtil client;
|
||||
private final AuthentikQBitConfig config;
|
||||
private final AuthentikGuard guard;
|
||||
@@ -98,6 +102,19 @@ public class AuthentikDriftReport
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse only mapper attributes and service-account lockout diagnostics.
|
||||
*******************************************************************************/
|
||||
public List<DriftFinding> runMapperChecks()
|
||||
{
|
||||
List<DriftFinding> findings = new ArrayList<>();
|
||||
runCheck(findings, "group attributes", this::checkGroupAttributes);
|
||||
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
|
||||
return (findings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A check.
|
||||
*******************************************************************************/
|
||||
@@ -360,6 +377,19 @@ public class AuthentikDriftReport
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The attribute name of a GroupGraph malformed entry ("key: detail"), never
|
||||
** the detail, which can echo whatever a person typed.
|
||||
*******************************************************************************/
|
||||
static String attributeOf(String malformedEntry)
|
||||
{
|
||||
int colon = malformedEntry == null ? -1 : malformedEntry.indexOf(": ");
|
||||
String key = colon < 0 ? "" : malformedEntry.substring(0, colon);
|
||||
return (SAFE_ATTRIBUTE.matcher(key).matches() ? key : "attribute");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 6
|
||||
*******************************************************************************/
|
||||
@@ -375,14 +405,16 @@ public class AuthentikDriftReport
|
||||
for(String bad : node.malformed())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings."));
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings.")
|
||||
.withReason("malformed " + attributeOf(bad)));
|
||||
}
|
||||
for(String permission : node.permissions())
|
||||
{
|
||||
if(resolver.expand(permission).isEmpty())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard)."));
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard; a name the app checks only in code, not in its metadata, is not in the catalog).")
|
||||
.withReason("grants nothing: " + (SAFE_PERMISSION.matcher(permission).matches() ? permission : "(unprintable name)")));
|
||||
}
|
||||
}
|
||||
for(String app : node.appAccess())
|
||||
@@ -396,7 +428,8 @@ public class AuthentikDriftReport
|
||||
if(graph.isSuperuserInherited(node.pk()) && !node.permissions().isEmpty())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups."));
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups.")
|
||||
.withReason("superuser group carries qqq.io/permissions"));
|
||||
}
|
||||
}
|
||||
if(issues == 0)
|
||||
|
||||
@@ -25,10 +25,33 @@ package com.kingsrook.qbits.authentik.drift;
|
||||
** @param check the check's short name, e.g. "blueprints"
|
||||
** @param subject what the finding is about, e.g. a group name
|
||||
** @param message plain-language explanation and what to do
|
||||
** @param reason a short, safe summary for health outcomes (no attribute
|
||||
** values a person typed beyond validated permission names);
|
||||
** null when the finding has none
|
||||
*******************************************************************************/
|
||||
public record DriftFinding(Severity severity, String check, String subject, String message)
|
||||
public record DriftFinding(Severity severity, String check, String subject, String message, String reason)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** A finding without a health reason.
|
||||
*******************************************************************************/
|
||||
public DriftFinding(Severity severity, String check, String subject, String message)
|
||||
{
|
||||
this(severity, check, subject, message, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** This finding with a short, safe reason for health outcomes.
|
||||
*******************************************************************************/
|
||||
public DriftFinding withReason(String reason)
|
||||
{
|
||||
return (new DriftFinding(severity, check, subject, message, reason));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Severity, most severe first.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* 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.health;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker;
|
||||
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthDetails;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central identity checks reuse the qbit client and configuration diagnostics.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikHealthChecks
|
||||
{
|
||||
private static final String GROUP_ATTRIBUTES = "group attributes";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AuthentikHealthChecks()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Called only after the producer has validated and registered the runtime.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance, AuthentikQBitConfig config)
|
||||
{
|
||||
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
for(String name : List.of("api", "api_token", "mapper", "token_broker"))
|
||||
{
|
||||
registry.withCheck(new HealthCheckMetaData().withName("authentik." + name).withLabel("Authentik " + name)
|
||||
.withComponent("authentik").withCategory(HealthCategory.IDENTITY).withKind(HealthCheckKind.DEPENDENCY)
|
||||
.withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : 21600)
|
||||
.withTimeoutMs(10000).withCheckInstance(input ->
|
||||
{
|
||||
try
|
||||
{
|
||||
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName()).withHealthDeadline(input.getDeadline());
|
||||
HealthCheckOutcome outcome;
|
||||
switch(name)
|
||||
{
|
||||
case "api" -> outcome = api(util, config);
|
||||
case "api_token" -> outcome = apiToken(util, Instant.now());
|
||||
case "mapper" -> outcome = mapper(util, config);
|
||||
default -> outcome = tokenBroker(util, config);
|
||||
}
|
||||
return (Instant.now().isBefore(input.getDeadline()) && !Thread.currentThread().isInterrupted()
|
||||
? outcome : HealthCheckOutcome.down("Authentik health deadline exceeded"));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// Never copy exception messages, URLs or credential values into outcomes.
|
||||
return (HealthCheckOutcome.down("Authentik health check failed"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Version compatibility follows the existing drift report's prefix rule.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome api(AuthentikActionUtil util, AuthentikQBitConfig config)
|
||||
{
|
||||
String version = util.fetchServerVersion().orElse("");
|
||||
if(!version.matches("[0-9]{1,4}(\\.[0-9]{1,4}){1,3}"))
|
||||
{
|
||||
return (HealthCheckOutcome.down("Authentik server version unavailable"));
|
||||
}
|
||||
String expected = config.getExpectedServerVersion();
|
||||
boolean matches = !StringUtils.hasContent(expected) || version.equals(expected) || version.startsWith(expected + ".");
|
||||
return ((matches ? HealthCheckOutcome.up("Authentik API reachable") : HealthCheckOutcome.degraded("Authentik server version differs"))
|
||||
.withDetails(new HealthDetails().withValue("serverVersion", version)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The service account should have one API token; never read its key.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome apiToken(AuthentikActionUtil util, Instant now) throws QException
|
||||
{
|
||||
List<JSONObject> tokens = util.getServiceAccountApiTokens();
|
||||
if(tokens.isEmpty())
|
||||
{
|
||||
return (HealthCheckOutcome.down("API token missing"));
|
||||
}
|
||||
if(tokens.size() != 1)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("API token cannot be identified uniquely"));
|
||||
}
|
||||
JSONObject token = tokens.getFirst();
|
||||
if(!token.optBoolean("expiring", true))
|
||||
{
|
||||
return (HealthCheckOutcome.up("API token present without expiry"));
|
||||
}
|
||||
Instant expires;
|
||||
try
|
||||
{
|
||||
expires = AuthentikJson.parseInstant(token.optString("expires", null));
|
||||
}
|
||||
catch(java.time.format.DateTimeParseException e)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
|
||||
}
|
||||
if(expires == null)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
|
||||
}
|
||||
HealthCheckOutcome outcome = !expires.isAfter(now) ? HealthCheckOutcome.down("API token expired")
|
||||
: expires.isBefore(now.plus(14, ChronoUnit.DAYS)) ? HealthCheckOutcome.degraded("API token expires within 14 days")
|
||||
: HealthCheckOutcome.up("API token present");
|
||||
return (outcome.withDetails(new HealthDetails().withValue("daysToExpiry", ChronoUnit.DAYS.between(now, expires))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Findings carry fixed codes, plus the flagged group names and each group
|
||||
** finding's safe reason (DriftFinding.reason), so a degraded mapper says
|
||||
** what to fix. Service-account activity subjects (people) stay private.
|
||||
*******************************************************************************/
|
||||
static HealthCheckOutcome mapper(AuthentikActionUtil util, AuthentikQBitConfig config)
|
||||
{
|
||||
return (mapperOutcome(new AuthentikDriftReport(util, config).runMapperChecks()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The mapper outcome for a set of drift findings.
|
||||
*******************************************************************************/
|
||||
static HealthCheckOutcome mapperOutcome(List<DriftFinding> findings)
|
||||
{
|
||||
HealthStatus status = HealthStatus.UP;
|
||||
TreeSet<String> codes = new TreeSet<>();
|
||||
TreeMap<String, TreeMap<String, TreeSet<String>>> byGroup = new TreeMap<>();
|
||||
TreeSet<String> other = new TreeSet<>();
|
||||
for(DriftFinding finding : findings)
|
||||
{
|
||||
if(finding.severity() != DriftFinding.Severity.ERROR && finding.severity() != DriftFinding.Severity.WARNING)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
boolean groupCheck = GROUP_ATTRIBUTES.equals(finding.check());
|
||||
codes.add((groupCheck ? "group_attributes" : "service_account_activity") + "." + finding.severity().name().toLowerCase(Locale.ROOT));
|
||||
status = finding.severity() == DriftFinding.Severity.ERROR || status == HealthStatus.DOWN ? HealthStatus.DOWN : HealthStatus.DEGRADED;
|
||||
if(groupCheck && finding.reason() != null)
|
||||
{
|
||||
String reason = finding.reason();
|
||||
int colon = reason.indexOf(": ");
|
||||
TreeSet<String> values = byGroup.computeIfAbsent(safeName(finding.subject()), k -> new TreeMap<>())
|
||||
.computeIfAbsent(colon < 0 ? reason : reason.substring(0, colon), k -> new TreeSet<>());
|
||||
if(colon >= 0)
|
||||
{
|
||||
values.add(reason.substring(colon + 2));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
other.add(groupCheck ? "group attributes check could not run" : "service-account activity (see the identity drift report)");
|
||||
}
|
||||
}
|
||||
if(status == HealthStatus.UP)
|
||||
{
|
||||
return (HealthCheckOutcome.up("Mapper checks passed").withDetails(new HealthDetails().withValue("findingCodes", "")));
|
||||
}
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
byGroup.forEach((group, reasons) ->
|
||||
{
|
||||
List<String> texts = new ArrayList<>();
|
||||
reasons.forEach((label, values) -> texts.add(values.isEmpty() ? label : label + ": " + String.join(", ", values)));
|
||||
parts.add(group + " (" + String.join("; ", texts) + ")");
|
||||
});
|
||||
parts.addAll(other);
|
||||
String summary = String.join("; ", parts);
|
||||
return (new HealthCheckOutcome().withStatus(status).withMessage(truncate("Mapper drift: " + summary, 500))
|
||||
.withDetails(new HealthDetails()
|
||||
.withValue("findingCodes", String.join(",", codes))
|
||||
.withValue("flaggedGroups", truncate(String.join(", ", byGroup.keySet()), 500))
|
||||
.withValue("summary", truncate(summary, 500))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group name fit for a health message: printable and bounded.
|
||||
*******************************************************************************/
|
||||
private static String safeName(String name)
|
||||
{
|
||||
String printable = name == null ? "" : name.replaceAll("\\p{Cntrl}", "").trim();
|
||||
return (printable.isEmpty() ? "(unnamed group)" : truncate(printable, 100));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cut a text to a length, marking the cut.
|
||||
*******************************************************************************/
|
||||
private static String truncate(String text, int max)
|
||||
{
|
||||
return (text.length() <= max ? text : text.substring(0, max - 3) + "...");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticate through the existing broker transport, retaining no bearer.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome tokenBroker(AuthentikActionUtil util, AuthentikQBitConfig config) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(config.getTokenBrokerClientId()) || !StringUtils.hasContent(config.getTokenBrokerClientSecret()))
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("not configured"));
|
||||
}
|
||||
new AuthentikTokenBroker().probeCredentials(util);
|
||||
return (HealthCheckOutcome.up("Token broker authenticated"));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -74,7 +74,7 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
|
||||
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(AuthentikTokenWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME),
|
||||
config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.COMPARE)))
|
||||
{
|
||||
if(produced.get(QWidgetMetaData.class, widgetName) != null)
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the drift report as a TableData. Re-checks authentik.admin itself
|
||||
** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
** for direct renderer calls as well as calls through core RenderWidgetAction.
|
||||
*******************************************************************************/
|
||||
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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 java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
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.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-authentik.yaml (the QQQ help-bundle format) to the Identity
|
||||
** app and its authentik_* tables: what each table is for and how its rows get
|
||||
** created (for the API-backed tables: that they are read live, and what an
|
||||
** empty list means), shown on the table screen and when it is empty.
|
||||
**
|
||||
** The bundle's keys use the default table prefix; a host's own tableNamePrefix
|
||||
** is applied to them, so renamed tables get the same text.
|
||||
** Inline fallback for hosts without help storage; core owns bundle validation
|
||||
** and the producer also registers the bundle for startup synchronization.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-authentik.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
private static final String DEFAULT_TABLE_PREFIX = "authentik_";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AuthentikHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
var bundle = HelpBundles.load(RESOURCE.substring(1), AuthentikHelp.class.getClassLoader());
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
for(var entry : bundle.getEntries())
|
||||
{
|
||||
entries.put(entry.getKey(), mapper.valueToTree(entry));
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every table the qbit produced. A host's own
|
||||
** help on a table or the app is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance, AuthentikQBitConfig config) throws QException
|
||||
{
|
||||
for(var bundle : HelpBundles.of(qInstance).getBundles())
|
||||
{
|
||||
if("qbit-authentik".equals(bundle.getId()))
|
||||
{
|
||||
for(var entry : bundle.getEntries())
|
||||
{
|
||||
entry.setKey(configuredNames(entry.getKey(), config));
|
||||
entry.setSummary(configuredNames(entry.getSummary(), config));
|
||||
entry.setContent(configuredNames(entry.getContent(), config));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
|
||||
{
|
||||
String key = entry.getKey();
|
||||
if(key.startsWith("table:") && key.endsWith(";slot:help"))
|
||||
{
|
||||
String tableKey = key.substring("table:".length(), key.indexOf(';'));
|
||||
String unprefixed = tableKey.substring(DEFAULT_TABLE_PREFIX.length());
|
||||
QTableMetaData table = qInstance.getTable(config.applyTablePrefix(unprefixed));
|
||||
if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry.getValue(), config));
|
||||
}
|
||||
}
|
||||
else if(key.startsWith("app:") && key.endsWith(";slot:help"))
|
||||
{
|
||||
QAppMetaData app = qInstance.getApp(config.applyPrefix(AuthentikAppMetaDataProducer.APP_NAME));
|
||||
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(entry.getValue(), config));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry, AuthentikQBitConfig config)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(configuredNames(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip(), config)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Follow the host prefix in keys and live references; status enums are fixed.
|
||||
*******************************************************************************/
|
||||
private static String configuredNames(String text, AuthentikQBitConfig config)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (Pattern.compile("authentik_[a-zA-Z0-9_]+|authentik[A-Z][a-zA-Z0-9]*").matcher(text).replaceAll(match ->
|
||||
{
|
||||
String name = match.group();
|
||||
if(name.equals("authentik_action_status") || name.equals("authentik_approval_status"))
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
if(name.startsWith(DEFAULT_TABLE_PREFIX))
|
||||
{
|
||||
return (config.applyTablePrefix(name.substring(DEFAULT_TABLE_PREFIX.length())));
|
||||
}
|
||||
return (config.applyPrefix(name.substring("authentik".length())));
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.processes.AuthentikTokenReminders;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
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.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Daily background task, hidden from navigation; no scheduler dependency.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokenReminderProcessProducer extends QBitComponentMetaDataProducer<QProcessMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Reminders require local dedupe storage and the TOKENS feature.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the configured scheduler or the instance's first registered scheduler.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance instance)
|
||||
{
|
||||
AuthentikQBitConfig config = getQBitConfig();
|
||||
QProcessMetaData process = new QProcessMetaData().withName(config.applyPrefix("tokenReminders"))
|
||||
.withLabel("Access token reminders").withIsHidden(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules())
|
||||
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(AuthentikTokenReminders.class))
|
||||
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
|
||||
.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("backendName", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING)
|
||||
.withDefaultValue(config.getApiBackendName()))));
|
||||
String scheduler = config.getTokenReminderSchedulerName();
|
||||
if(scheduler == null && !instance.getSchedulers().isEmpty())
|
||||
{
|
||||
scheduler = instance.getSchedulers().keySet().iterator().next();
|
||||
}
|
||||
if(scheduler != null)
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(scheduler).withRepeatSeconds(86400).withInitialDelaySeconds(3600));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.app.AuthentikTokenWidget;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access token administration uses a standard TABLE widget (guardrail G5).
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokenWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
public static final String NAME = "accessTokens";
|
||||
|
||||
/*******************************************************************************
|
||||
** Token inventory needs the local metadata table.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register before the app, alongside the existing home widgets.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (500);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission is checked again in the renderer.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance instance)
|
||||
{
|
||||
AuthentikQBitConfig config = getQBitConfig();
|
||||
return (new QWidgetMetaData().withName(config.applyPrefix(NAME)).withLabel("Access tokens").withType(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType())
|
||||
.withGridColumns(12).withIsCard(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules())
|
||||
.withCodeReference(new QCodeReference(AuthentikTokenWidget.class)).withDefaultValue("backendName", config.getApiBackendName()));
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,19 @@ public final class AccessComparison
|
||||
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());
|
||||
return (mapperPermissions(config, resolver, groupPks, directGroupNames, userPk, userUuid, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview the mapper with one group's policy removed, preserving the
|
||||
** static floor and immutable bootstrap-subject grants.
|
||||
*******************************************************************************/
|
||||
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
|
||||
String userPk, String userUuid, String excludePolicyOfGroupPk)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>(resolver.resolveGroups(groupPks, excludePolicyOfGroupPk).sessionPermissions());
|
||||
rs.addAll(staticPermissions(config, directGroupNames));
|
||||
Set<String> subjects = config.getResolvedBootstrapSubjects();
|
||||
if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid)))
|
||||
|
||||
+3
-2
@@ -121,7 +121,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
AuthentikRuntime runtime = runtime();
|
||||
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
|
||||
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format,
|
||||
runtime == null ? (qInstance == null ? null : PolicyCatalog.of(qInstance)) : runtime.getPolicyCatalog()));
|
||||
qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance)));
|
||||
|
||||
if(runtime != null)
|
||||
{
|
||||
@@ -170,6 +170,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
}
|
||||
qSession.setPermissions(permissions);
|
||||
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims);
|
||||
}
|
||||
|
||||
|
||||
@@ -326,7 +327,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
{
|
||||
AuthentikActionUtil client = client(qInstance);
|
||||
GroupGraph graph = client.groupGraph();
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog());
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog(), qInstance);
|
||||
List<String> pks = new ArrayList<>();
|
||||
for(String name : groupNames)
|
||||
{
|
||||
|
||||
+36
-2
@@ -25,14 +25,18 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Turns groups (and their ancestors) into QQQ permissions (DESIGN.md
|
||||
** section 5.6). Pure: give it a GroupGraph and a permission catalog.
|
||||
** section 5.6). Full access uses the explicit instance or current QContext.
|
||||
**
|
||||
** Rules:
|
||||
** - A full-access policy marker expands to all current instance/catalog names.
|
||||
** - A group confers the union of qqq.io/permissions on itself and every
|
||||
** ancestor ("inherited parents").
|
||||
** - "authentik.*" expands to every identity permission; an identity short
|
||||
@@ -47,6 +51,7 @@ public class EffectivePermissionResolver
|
||||
{
|
||||
private final GroupGraph graph;
|
||||
private final Set<String> catalog;
|
||||
private final QInstance qInstance;
|
||||
|
||||
|
||||
|
||||
@@ -68,8 +73,19 @@ public class EffectivePermissionResolver
|
||||
** null to pass non-identity names through unchecked
|
||||
*******************************************************************************/
|
||||
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog)
|
||||
{
|
||||
this(graph, catalog, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve against an explicit sign-in instance, or the current QContext.
|
||||
*******************************************************************************/
|
||||
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog, QInstance qInstance)
|
||||
{
|
||||
this.graph = graph;
|
||||
this.qInstance = qInstance;
|
||||
this.catalog = catalog == null ? null : new TreeSet<>(catalog);
|
||||
if(this.catalog != null)
|
||||
{
|
||||
@@ -86,6 +102,17 @@ public class EffectivePermissionResolver
|
||||
** Effective permissions of a set of groups (e.g. a user's direct groups).
|
||||
*******************************************************************************/
|
||||
public Resolution resolveGroups(Collection<String> groupPks)
|
||||
{
|
||||
return (resolveGroups(groupPks, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve normally, except for one group's policy document. Its named
|
||||
** permissions and every other group's policy still contribute.
|
||||
*******************************************************************************/
|
||||
public Resolution resolveGroups(Collection<String> groupPks, String excludePolicyOfGroupPk)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
Map<String, Set<String>> grantedBy = new LinkedHashMap<>();
|
||||
@@ -103,7 +130,10 @@ public class EffectivePermissionResolver
|
||||
}
|
||||
appAccess.addAll(node.appAccess());
|
||||
List<String> raws = new ArrayList<>(node.permissions());
|
||||
raws.addAll(node.policyPermissions());
|
||||
if(!node.pk().equals(excludePolicyOfGroupPk))
|
||||
{
|
||||
raws.addAll(node.policyPermissions());
|
||||
}
|
||||
for(String raw : raws)
|
||||
{
|
||||
Set<String> expanded = expand(raw);
|
||||
@@ -146,6 +176,10 @@ public class EffectivePermissionResolver
|
||||
return (rs);
|
||||
}
|
||||
String name = raw.trim();
|
||||
if(name.equals(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
return (PolicyPermissions.allPermissions(qInstance == null ? QContext.getQInstance() : qInstance, catalog));
|
||||
}
|
||||
|
||||
if(name.equals(IdentityPermission.WILDCARD))
|
||||
{
|
||||
|
||||
@@ -92,6 +92,9 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
|
||||
if(claims == null)
|
||||
{
|
||||
LOG.info("No policy claim in the token; no policy permissions added", logPair("claim", format.claim()));
|
||||
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any);
|
||||
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession,
|
||||
context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,6 +104,9 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
}
|
||||
qSession.setPermissions(permissions);
|
||||
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any);
|
||||
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession,
|
||||
context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -113,14 +113,16 @@ public final class PolicyCatalog
|
||||
|
||||
private final List<Group> groups;
|
||||
private final Map<String, Resource> byName;
|
||||
private final QInstance qInstance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
private PolicyCatalog(List<Group> groups)
|
||||
private PolicyCatalog(List<Group> groups, QInstance qInstance)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.groups = groups;
|
||||
this.byName = new LinkedHashMap<>();
|
||||
for(Group group : groups)
|
||||
@@ -139,17 +141,7 @@ public final class PolicyCatalog
|
||||
*******************************************************************************/
|
||||
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)
|
||||
{
|
||||
Collection<AvailablePermission> availablePermissions = PermissionsHelper.getAllAvailablePermissions(qInstance);
|
||||
Map<String, String> appOfObject = appsOfObjects(qInstance);
|
||||
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
|
||||
|
||||
@@ -266,7 +258,17 @@ public final class PolicyCatalog
|
||||
}
|
||||
groups.add(new Group(entry.getKey(), groupLabel, resources));
|
||||
}
|
||||
return (new PolicyCatalog(groups));
|
||||
return (new PolicyCatalog(groups, qInstance));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The source instance, used for live full-access expansion, not a snapshot.
|
||||
*******************************************************************************/
|
||||
public QInstance getQInstance()
|
||||
{
|
||||
return (qInstance);
|
||||
}
|
||||
|
||||
|
||||
@@ -474,7 +476,7 @@ public final class PolicyCatalog
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(String permission : permissions)
|
||||
{
|
||||
if(!known.contains(permission))
|
||||
if(!known.contains(permission) && !PolicyPermissions.FULL_ACCESS.equals(permission))
|
||||
{
|
||||
rs.add(permission);
|
||||
}
|
||||
|
||||
@@ -17,15 +17,26 @@
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
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.qbits.authentik.permissions.AccessComparison;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
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.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
@@ -33,6 +44,7 @@ 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.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
@@ -41,8 +53,8 @@ import org.json.JSONObject;
|
||||
** 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.
|
||||
** policy can grant, the group's current document read live from Authentik,
|
||||
** and the base permissions of members who inherit it.
|
||||
*******************************************************************************/
|
||||
public final class PolicyEditorWidget
|
||||
{
|
||||
@@ -58,6 +70,8 @@ public final class PolicyEditorWidget
|
||||
/** Widget parameter (process value) with the group's pk. */
|
||||
public static final String GROUP_PK_PARAM = "groupPk";
|
||||
|
||||
private static final int MAX_MEMBERS = 200;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -74,12 +88,17 @@ public final class PolicyEditorWidget
|
||||
*******************************************************************************/
|
||||
public static Data load(AuthentikQBitConfig config, String groupPk) throws QException
|
||||
{
|
||||
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.POLICIES_MANAGE))
|
||||
{
|
||||
throw (new QPermissionDeniedException("You do not have permission to edit policies (authentik.policies.manage)."));
|
||||
}
|
||||
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())
|
||||
.withMapperEnabled(config.getMapperEnabled())
|
||||
.withOutputFieldName(AuthentikAdmin.POLICY_DOCUMENT);
|
||||
|
||||
if(!StringUtils.hasContent(groupPk))
|
||||
@@ -107,6 +126,7 @@ public final class PolicyEditorWidget
|
||||
// canonical form of what is stored (unknown resources survive) //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format, runtime.getPolicyCatalog())));
|
||||
data.withFullAccess(PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).permissions().contains(PolicyPermissions.FULL_ACCESS));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
@@ -119,11 +139,61 @@ public final class PolicyEditorWidget
|
||||
{
|
||||
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format, runtime.getPolicyCatalog())));
|
||||
}
|
||||
loadMembers(config, groupPk, data);
|
||||
return (data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Members who inherit this policy, with only the edited document removed
|
||||
** from their sign-in grants. Fetch one extra eligible member to detect truncation.
|
||||
*******************************************************************************/
|
||||
private static void loadMembers(AuthentikQBitConfig config, String groupPk, Data data) throws QException
|
||||
{
|
||||
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName());
|
||||
GroupGraph graph = util.groupGraph();
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, util.getRuntime().getPermissionCatalog());
|
||||
Set<String> affectedGroups = graph.nodes().stream()
|
||||
.filter(node -> node.pk().equals(groupPk) || graph.ancestorPks(node.pk()).contains(groupPk))
|
||||
.map(GroupGraph.Node::pk).collect(Collectors.toSet());
|
||||
List<JSONObject> users = new ArrayList<>(util.policyPreviewUsers(affectedGroups, MAX_MEMBERS + 1));
|
||||
users.sort(Comparator.comparing(user -> user.optString("username")));
|
||||
List<Map<String, Object>> members = new ArrayList<>();
|
||||
for(JSONObject user : users)
|
||||
{
|
||||
List<String> groupPks = new ArrayList<>();
|
||||
JSONArray groups = user.optJSONArray("groups_obj");
|
||||
for(int i = 0; groups != null && i < groups.length(); i++)
|
||||
{
|
||||
String pk = groups.getJSONObject(i).getString("pk");
|
||||
if(graph.byPk(pk) != null)
|
||||
{
|
||||
groupPks.add(pk);
|
||||
}
|
||||
}
|
||||
List<String> viaGroups = groupPks.stream().filter(affectedGroups::contains).map(pk -> graph.byPk(pk).name()).sorted().toList();
|
||||
if(viaGroups.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(members.size() == MAX_MEMBERS)
|
||||
{
|
||||
data.withMembersTruncated(true);
|
||||
break;
|
||||
}
|
||||
List<String> names = groupPks.stream().map(pk -> graph.byPk(pk).name()).toList();
|
||||
Set<String> basePermissions = AccessComparison.mapperPermissions(config, resolver, groupPks, names,
|
||||
String.valueOf(user.getInt("pk")), user.optString("uuid"), groupPk);
|
||||
String username = user.optString("username");
|
||||
members.add(Map.of("username", username, "label", user.optString("name") + " (" + username + ")",
|
||||
"viaGroups", viaGroups, "basePermissions", basePermissions.stream().sorted().toList()));
|
||||
}
|
||||
data.withMembers(members);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer. QQQ checks the widget's permission rules
|
||||
** (authentik.policies.manage) before calling it.
|
||||
@@ -158,6 +228,31 @@ public final class PolicyEditorWidget
|
||||
private String documentProblem;
|
||||
private Boolean changedInAuthentik = false;
|
||||
private String outputFieldName;
|
||||
private List<Map<String, Object>> members = List.of();
|
||||
private Boolean membersTruncated = false;
|
||||
private Boolean mapperEnabled;
|
||||
private Boolean fullAccess = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the current valid document grants all current and future resources.
|
||||
*******************************************************************************/
|
||||
public Boolean getFullAccess()
|
||||
{
|
||||
return (fullAccess);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fullAccess.
|
||||
*******************************************************************************/
|
||||
public Data withFullAccess(Boolean fullAccess)
|
||||
{
|
||||
this.fullAccess = fullAccess;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -337,5 +432,68 @@ public final class PolicyEditorWidget
|
||||
this.outputFieldName = outputFieldName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for members.
|
||||
*******************************************************************************/
|
||||
public List<Map<String, Object>> getMembers()
|
||||
{
|
||||
return (members);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for members.
|
||||
*******************************************************************************/
|
||||
public Data withMembers(List<Map<String, Object>> members)
|
||||
{
|
||||
this.members = members;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for membersTruncated.
|
||||
*******************************************************************************/
|
||||
public Boolean getMembersTruncated()
|
||||
{
|
||||
return (membersTruncated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for membersTruncated.
|
||||
*******************************************************************************/
|
||||
public Data withMembersTruncated(Boolean membersTruncated)
|
||||
{
|
||||
this.membersTruncated = membersTruncated;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for mapperEnabled.
|
||||
*******************************************************************************/
|
||||
public Boolean getMapperEnabled()
|
||||
{
|
||||
return (mapperEnabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for mapperEnabled.
|
||||
*******************************************************************************/
|
||||
public Data withMapperEnabled(Boolean mapperEnabled)
|
||||
{
|
||||
this.mapperEnabled = mapperEnabled;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import org.json.JSONObject;
|
||||
** (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
|
||||
** full access is a single Action/Resource "*" statement. Every document is checked to round-trip through
|
||||
** PolicyParser to exactly the permissions it came from; one that does not is
|
||||
** never returned.
|
||||
*******************************************************************************/
|
||||
@@ -68,6 +68,17 @@ public final class PolicyGenerator
|
||||
public static JSONObject generate(Collection<String> permissions, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
|
||||
{
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
if(permissions.contains(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
JSONObject document = new JSONObject().put("PolicySpec", f.spec()).put("PolicyVersion", f.version())
|
||||
.put("Statement", new JSONArray().put(new JSONObject().put("Effect", PolicyParser.ALLOW)
|
||||
.put("Action", new JSONArray().put("*")).put("Resource", new JSONArray().put("*"))));
|
||||
if(!PolicyParser.parseStrict(toCompact(document), f, catalog).permissions().equals(Set.of(PolicyPermissions.FULL_ACCESS)))
|
||||
{
|
||||
throw (new PolicyParseException("The full-access document does not round-trip"));
|
||||
}
|
||||
return (document);
|
||||
}
|
||||
|
||||
Map<String, Set<String>> byAction = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> byFeature = new TreeMap<>();
|
||||
|
||||
@@ -53,7 +53,7 @@ public final class PolicyParser
|
||||
/*******************************************************************************
|
||||
** The result of a successful parse.
|
||||
**
|
||||
** @param permissions QQQ permission names ({resource}.{action}), sorted
|
||||
** @param permissions QQQ permission names, or the symbolic FULL_ACCESS marker
|
||||
** @param resources every resource named, in document order
|
||||
*******************************************************************************/
|
||||
public record Parsed(Set<String> permissions, Set<String> resources)
|
||||
@@ -120,7 +120,7 @@ public final class PolicyParser
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse standard actions and features registered for every listed resource.
|
||||
** Without a catalog only the five standard actions are accepted.
|
||||
** Without a catalog only standard actions and full access are accepted.
|
||||
*******************************************************************************/
|
||||
public static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
|
||||
{
|
||||
@@ -163,8 +163,13 @@ public final class PolicyParser
|
||||
}
|
||||
|
||||
Set<String> statementResources = strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource");
|
||||
Set<String> statementActions = strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action");
|
||||
if(statementResources.contains("*") && !statementActions.equals(Set.of("*")))
|
||||
{
|
||||
throw (new PolicyParseException("Statement " + (i + 1) + ": Resource \"*\" requires Action [\"*\"] (full access)"));
|
||||
}
|
||||
Set<String> actions = new LinkedHashSet<>();
|
||||
for(String name : strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action"))
|
||||
for(String name : statementActions)
|
||||
{
|
||||
if(PolicyAction.WILDCARD.equals(name))
|
||||
{
|
||||
@@ -186,6 +191,12 @@ public final class PolicyParser
|
||||
|
||||
for(String resource : statementResources)
|
||||
{
|
||||
if(resource.equals("*"))
|
||||
{
|
||||
permissions.add(PolicyPermissions.FULL_ACCESS);
|
||||
resources.add(resource);
|
||||
continue;
|
||||
}
|
||||
if(!RESOURCE_PATTERN.matcher(resource).matches())
|
||||
{
|
||||
throw (new PolicyParseException("Statement " + (i + 1) + ": resource \"" + resource + "\" must match [A-Za-z0-9_]+"));
|
||||
@@ -197,7 +208,7 @@ public final class PolicyParser
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new Parsed(permissions, resources));
|
||||
return (new Parsed(permissions.contains(PolicyPermissions.FULL_ACCESS) ? Set.of(PolicyPermissions.FULL_ACCESS) : permissions, resources));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
@@ -29,6 +35,12 @@ import org.json.JSONObject;
|
||||
*******************************************************************************/
|
||||
public final class PolicyPermissions
|
||||
{
|
||||
/** Internal parse marker, never a session permission or persisted permission list. */
|
||||
public static final String FULL_ACCESS = "<full-access>";
|
||||
|
||||
/** Operator-facing description of the wildcard statement. */
|
||||
public static final String FULL_ACCESS_LABEL = "Full access (all current and future resources)";
|
||||
|
||||
/** Customizer context key of the ID token claims (QQQ OAuth2 module). */
|
||||
public static final String ID_TOKEN_CONTEXT_KEY = "idToken";
|
||||
|
||||
@@ -67,7 +79,61 @@ public final class PolicyPermissions
|
||||
{
|
||||
return (Collections.emptySet());
|
||||
}
|
||||
return (PolicyParser.parse(claims.opt(f.claim()), f, catalog));
|
||||
Set<String> parsed = PolicyParser.parse(claims.opt(f.claim()), f, catalog);
|
||||
return (parsed.contains(FULL_ACCESS)
|
||||
? allPermissions(catalog == null ? QContext.getQInstance() : catalog.getQInstance(), Set.of()) : parsed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expand full access at use time, including core names, the qbit catalog
|
||||
** (hidden record actions included), and every identity session permission.
|
||||
** No expanded set is cached, so instance additions and reloads are visible.
|
||||
*******************************************************************************/
|
||||
public static Set<String> allPermissions(QInstance qInstance, Collection<String> additional)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
if(additional != null)
|
||||
{
|
||||
permissions.addAll(additional);
|
||||
}
|
||||
if(qInstance != null)
|
||||
{
|
||||
PermissionsHelper.getAllAvailablePermissions(qInstance).forEach(permission -> permissions.add(permission.getName()));
|
||||
permissions.addAll(PolicyCatalog.of(qInstance).permissionNames());
|
||||
permissions.addAll(hostPermissions(qInstance));
|
||||
}
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
permissions.add(permission.getSessionPermission());
|
||||
}
|
||||
permissions.remove(FULL_ACCESS);
|
||||
return (permissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The host-declared permission names (config fullAccessHostPermissions) of
|
||||
** every Authentik runtime whose API backend is in this instance.
|
||||
*******************************************************************************/
|
||||
static Set<String> hostPermissions(QInstance qInstance)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(com.kingsrook.qbits.authentik.AuthentikRuntime runtime : com.kingsrook.qbits.authentik.AuthentikRuntime.all())
|
||||
{
|
||||
com.kingsrook.qbits.authentik.AuthentikQBitConfig config = runtime.getConfig();
|
||||
if(config.getFullAccessHostPermissions() != null && config.getApiBackendName() != null && qInstance.getBackend(config.getApiBackendName()) != null)
|
||||
{
|
||||
java.util.Collection<String> names = config.getFullAccessHostPermissions().apply(qInstance);
|
||||
if(names != null)
|
||||
{
|
||||
names.stream().filter(n -> n != null && !n.isBlank()).forEach(rs::add);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -147,8 +147,8 @@ public final class AuthentikProcesses
|
||||
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
|
||||
{
|
||||
createTokenFields.add(new QFieldMetaData("readOnly", QFieldType.BOOLEAN));
|
||||
createTokenFields.add(new QFieldMetaData("apps", QFieldType.TEXT));
|
||||
createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT));
|
||||
createTokenFields.add(new QFieldMetaData("apps", QFieldType.TEXT).withLabel("Apps (names, one per line)"));
|
||||
createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT).withLabel("Tables (names, one per line)"));
|
||||
}
|
||||
createTokenFields.add(reason);
|
||||
defs.add(new Def("createToken", "Create Token", AdminAction.CREATE_TOKEN, Kind.CREATE, AuthentikTables.TOKEN, "key", false,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts;
|
||||
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
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.exceptions.QPermissionDeniedException;
|
||||
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.data.QRecord;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Daily app-password expiry reminders, deduplicated in local token metadata.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTokenReminders implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The scheduler invokes the same guarded path as manual process execution.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
run(AuthentikActionUtil.forBackend(input.getValueString("backendName")), Instant.now());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Two non-overlapping windows avoid two reminders when first seen on day 1.
|
||||
** Synchronization prevents overlapping local scheduled runs from duplicating.
|
||||
*******************************************************************************/
|
||||
public static void run(AuthentikActionUtil util, Instant now) throws QException
|
||||
{
|
||||
if(!(QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession)
|
||||
&& !AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Token reminders require authentik.tokens.manage."));
|
||||
}
|
||||
AuthentikRuntime runtime = util.getRuntime();
|
||||
synchronized(runtime)
|
||||
{
|
||||
TokenScopeStore store = new TokenScopeStore(runtime.getConfig());
|
||||
for(JSONObject token : util.getAllPages("/core/tokens/?intent=app_password", Integer.MAX_VALUE))
|
||||
{
|
||||
if(!"app_password".equals(token.optString("intent")) || !token.optBoolean("expiring") || token.isNull("expires"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Instant expires = Instant.parse(token.getString("expires"));
|
||||
if(!expires.isAfter(now) || expires.isAfter(now.plus(7, ChronoUnit.DAYS)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String field = expires.isAfter(now.plus(1, ChronoUnit.DAYS)) ? "lastReminder7d" : "lastReminder1d";
|
||||
String id = token.getString("identifier");
|
||||
QRecord scope = store.find(id);
|
||||
Instant remindedAt = scope == null ? null : scope.getValueInstant(field);
|
||||
Instant windowStart = expires.minus(field.equals("lastReminder7d") ? 7 : 1, ChronoUnit.DAYS);
|
||||
if(remindedAt != null && !remindedAt.isBefore(windowStart))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int pk = token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk");
|
||||
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
|
||||
if(owner == null || owner.optString("email").isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String message = "Your access token " + token.optString("description") + " (" + id + ") expires on " + expires
|
||||
+ ". Extend or rotate it in Preferences > Access tokens.";
|
||||
if(runtime.getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_expiry:" + id + ":" + field + ":" + expires,
|
||||
"info", owner.getString("email"), message)))
|
||||
{
|
||||
store.reminded(id, owner, field, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package com.kingsrook.qbits.authentik.processes;
|
||||
|
||||
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -43,11 +44,11 @@ public interface TokenKeyDelivery
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current adapter; null restores the safe reflection adapter.
|
||||
** Current adapter; null restores the core response-only adapter.
|
||||
*******************************************************************************/
|
||||
static void set(TokenKeyDelivery delivery)
|
||||
{
|
||||
Holder.delivery = delivery == null ? new ReflectiveDelivery() : delivery;
|
||||
Holder.delivery = delivery == null ? new CoreDelivery() : delivery;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +91,7 @@ public interface TokenKeyDelivery
|
||||
*******************************************************************************/
|
||||
final class Holder
|
||||
{
|
||||
private static volatile TokenKeyDelivery delivery = new ReflectiveDelivery();
|
||||
private static volatile TokenKeyDelivery delivery = new CoreDelivery();
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility holder.
|
||||
@@ -103,56 +104,18 @@ public interface TokenKeyDelivery
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7)
|
||||
** Reflection keeps RC.2 binary-compatible and never falls back to values.
|
||||
** Core response-only delivery; credentials never enter process values.
|
||||
*******************************************************************************/
|
||||
final class ReflectiveDelivery implements TokenKeyDelivery
|
||||
final class CoreDelivery implements TokenKeyDelivery
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check the complete core contract before any irreversible key mutation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean available()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
|
||||
type.getConstructor();
|
||||
for(String field : new String[] { "Label", "Value", "Masked", "UsageText" })
|
||||
{
|
||||
type.getMethod("with" + field, String.class);
|
||||
}
|
||||
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type);
|
||||
return (true);
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add to core's response-only channel; never include failures' contents.
|
||||
** Add to core's response-only channel.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void deliver(RunBackendStepOutput output, OneTimeKey key)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
|
||||
Object secret = type.getConstructor().newInstance();
|
||||
type.getMethod("withLabel", String.class).invoke(secret, key.label());
|
||||
type.getMethod("withValue", String.class).invoke(secret, key.value());
|
||||
type.getMethod("withMasked", String.class).invoke(secret, key.masked());
|
||||
type.getMethod("withUsageText", String.class).invoke(secret, key.usageText());
|
||||
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type).invoke(output, secret);
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("One-time output unavailable"));
|
||||
}
|
||||
output.addOneTimeSecret(new OneTimeSecret().withLabel(key.label()).withValue(key.value())
|
||||
.withMasked(key.masked()).withUsageText(key.usageText()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ package com.kingsrook.qbits.authentik.security;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminAction;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminAction;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
@@ -36,9 +36,9 @@ import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's own enforcement point (DESIGN.md section 5.3). QQQ core actions
|
||||
** do not enforce permissions for non-HTTP callers (QRun-IO/qqq#1061), so every
|
||||
** call into AuthentikActionUtil is checked here first:
|
||||
** The qbit's own enforcement point (DESIGN.md section 5.3). Core actions now
|
||||
** enforce permissions (QRun-IO/qqq#1061); direct AuthentikActionUtil calls and
|
||||
** Authentik-specific rules still need this guard:
|
||||
** - reads need the table's authentik.* permission (system sessions may read,
|
||||
** for the mirror sync);
|
||||
** - sensitive fields need authentik.audit.read;
|
||||
@@ -65,6 +65,21 @@ public class AuthentikGuard
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Health probes expose only safe outcomes, including to the central runner.
|
||||
*******************************************************************************/
|
||||
public void checkHealth() throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
requireUser(session);
|
||||
if(!has(session, IdentityPermission.ADMIN) && (session.getPermissions() == null || !session.getPermissions().contains("health.read")))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Health read permission is required."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check that the current session may read a table.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.security;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminRequest;
|
||||
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Signed token claims can only narrow the permissions computed by a host.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikTokenScopes
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenScopes.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** Distinguish unrestricted credentials from revoked credentials.
|
||||
*******************************************************************************/
|
||||
public enum Mode
|
||||
{
|
||||
SCOPED,
|
||||
UNSCOPED,
|
||||
DENY_ALL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable, non-secret credential restrictions, including advanced scopes.
|
||||
*******************************************************************************/
|
||||
public record TokenScope(String identifier, Mode mode, boolean readOnly, Set<String> apps, Set<String> tables,
|
||||
Set<String> processes, Set<String> reports, Set<String> widgets, Set<String> operationIds)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A missing row is unrestricted; a missing token is denied.
|
||||
*******************************************************************************/
|
||||
public static TokenScope marker(String identifier, Mode mode)
|
||||
{
|
||||
return (new TokenScope(identifier, mode, false, Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), Set.of()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Decode a local metadata row without retaining the mutable record.
|
||||
*******************************************************************************/
|
||||
public static TokenScope from(String identifier, QRecord row)
|
||||
{
|
||||
return (new TokenScope(identifier, Mode.SCOPED, Boolean.TRUE.equals(row.getValueBoolean("readOnly")),
|
||||
names(row.getValueString("apps")), names(row.getValueString("tables")), names(row.getValueString("processes")),
|
||||
names(row.getValueString("reports")), names(row.getValueString("widgets")), names(row.getValueString("operationIds"))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AuthentikTokenScopes()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One name per line; immutable sets keep cached scopes safe to share.
|
||||
*******************************************************************************/
|
||||
public static Set<String> names(String value)
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return (Set.of());
|
||||
}
|
||||
return (Set.copyOf(Arrays.stream(value.split("\\R")).map(String::trim).filter(s -> !s.isEmpty()).toList()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve only qbit backends present in this instance. Cache by identifier
|
||||
** within each runtime, so tenants never share a scope or token existence.
|
||||
*******************************************************************************/
|
||||
public static Optional<TokenScope> forClaims(QInstance instance, JSONObject claims) throws QException
|
||||
{
|
||||
if(claims == null)
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
if(instance != null)
|
||||
{
|
||||
for(var backend : instance.getBackends().values())
|
||||
{
|
||||
AuthentikRuntime runtime;
|
||||
try
|
||||
{
|
||||
runtime = AuthentikRuntime.forBackend(backend.getName());
|
||||
}
|
||||
catch(IllegalStateException ignored)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String claim = runtime.getConfig().getTokenScopeClaim();
|
||||
if(!claims.has(claim))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Object value = claims.opt(claim);
|
||||
if(!(value instanceof String identifier) || !identifier.matches("[A-Za-z0-9._-]+"))
|
||||
{
|
||||
return (Optional.of(TokenScope.marker("invalid", Mode.DENY_ALL)));
|
||||
}
|
||||
return (runtime.getTokenScopeCache().getResultThrowing(identifier, id -> lookupAsSystem(instance, () ->
|
||||
{
|
||||
APIBackendMetaData api = (APIBackendMetaData) backend;
|
||||
AuthentikActionUtil util = (AuthentikActionUtil) QCodeLoader.getAdHoc(BaseAPIActionUtil.class, api.getActionUtil());
|
||||
util.setBackendMetaData(api);
|
||||
// Check existence even when a local row remains after an external revoke.
|
||||
// Do not let a longer general response TTL extend the 60-second
|
||||
// revocation window of this dedicated cache.
|
||||
runtime.getResponseCache().clearKey(runtime.getConfig().getApiRoot() + "/core/tokens/" + id + "/");
|
||||
if(util.getApiJsonOrNull("/core/tokens/" + id + "/") == null)
|
||||
{
|
||||
return (TokenScope.marker(id, Mode.DENY_ALL));
|
||||
}
|
||||
if(instance.getTable(runtime.getConfig().applyTablePrefix("token_scope")) == null)
|
||||
{
|
||||
throw (new QException("Token scope metadata is unavailable."));
|
||||
}
|
||||
QRecord row = new TokenScopeStore(runtime.getConfig()).find(id);
|
||||
return (row == null ? TokenScope.marker(id, Mode.UNSCOPED) : TokenScope.from(id, row));
|
||||
})));
|
||||
}
|
||||
}
|
||||
// A default token claim must never silently lose enforcement if the qbit is missing.
|
||||
return (claims.has("qqq_token") ? Optional.of(TokenScope.marker(claims.optString("qqq_token"), Mode.DENY_ALL)) : Optional.empty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A lookup step that may throw.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Lookup
|
||||
{
|
||||
/***************************************************************************
|
||||
** Run the lookup.
|
||||
***************************************************************************/
|
||||
TokenScope get() throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scope lookup runs while a session is still being built (no user yet),
|
||||
** so it reads Authentik and the scope row as the internal system session,
|
||||
** then restores the caller's context.
|
||||
*******************************************************************************/
|
||||
private static TokenScope lookupAsSystem(QInstance instance, Lookup lookup) throws QException
|
||||
{
|
||||
QInstance previousInstance = QContext.getQInstance();
|
||||
com.kingsrook.qqq.backend.core.model.session.QSession previousSession = QContext.getQSession();
|
||||
try
|
||||
{
|
||||
if(previousInstance == null)
|
||||
{
|
||||
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession());
|
||||
}
|
||||
else
|
||||
{
|
||||
QContext.setQSession(new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession());
|
||||
}
|
||||
return (lookup.get());
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Token scope lookup failed", e));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(previousInstance == null)
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
QContext.setQSession(previousSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hosts with their own customizer call this AFTER computing permissions.
|
||||
*******************************************************************************/
|
||||
public static void apply(QSession session, JSONObject claims)
|
||||
{
|
||||
apply(QContext.getQInstance(), session, claims);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit instance variant for authentication, before QContext is installed.
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance instance, QSession session, JSONObject claims)
|
||||
{
|
||||
try
|
||||
{
|
||||
forClaims(instance, claims).ifPresent(scope -> narrow(instance, session, scope));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
session.setPermissions(new TreeSet<>());
|
||||
LOG.warn("Token scope could not be verified; denying credential permissions.", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Intersect permissions for direct session consumers and attach core scope.
|
||||
*******************************************************************************/
|
||||
public static void narrow(QSession session, TokenScope scope)
|
||||
{
|
||||
narrow(QContext.getQInstance(), session, scope);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep conservative permission narrowing for direct session consumers, then
|
||||
** attach core scope for framework enforcement. Management is not read-only.
|
||||
*******************************************************************************/
|
||||
private static void narrow(QInstance instance, QSession session, TokenScope scope)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>(session.getPermissions() == null ? Set.of() : session.getPermissions());
|
||||
if(scope.mode() == Mode.DENY_ALL)
|
||||
{
|
||||
permissions.clear();
|
||||
}
|
||||
else if(scope.mode() == Mode.SCOPED)
|
||||
{
|
||||
if(scope.readOnly())
|
||||
{
|
||||
permissions.removeIf(p -> p.startsWith("authentik.") || !(p.endsWith(".read") || p.endsWith(".hasAccess")));
|
||||
}
|
||||
if(!scope.apps().isEmpty() || !scope.tables().isEmpty() || !scope.processes().isEmpty() || !scope.reports().isEmpty() || !scope.widgets().isEmpty())
|
||||
{
|
||||
Set<String> bases = new LinkedHashSet<>();
|
||||
if(instance != null)
|
||||
{
|
||||
for(String name : scope.tables())
|
||||
{
|
||||
if(instance.getTable(name) != null)
|
||||
{
|
||||
bases.add(base(instance, instance.getTable(name), name));
|
||||
}
|
||||
}
|
||||
// Policy-format names exclude dots; host permission aliases need
|
||||
// direct metadata traversal as well as the policy catalog.
|
||||
for(String appName : scope.apps())
|
||||
{
|
||||
var app = instance.getApp(appName);
|
||||
if(app == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
bases.add(base(instance, app, appName));
|
||||
Set<String> appTables = new LinkedHashSet<>();
|
||||
if(app.getChildren() != null)
|
||||
{
|
||||
for(var child : app.getChildren())
|
||||
{
|
||||
if(child instanceof MetaDataWithPermissionRules secured)
|
||||
{
|
||||
bases.add(base(instance, secured, child.getName()));
|
||||
}
|
||||
if(instance.getTable(child.getName()) != null)
|
||||
{
|
||||
appTables.add(child.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var process : instance.getProcesses().values())
|
||||
{
|
||||
if(process.getTableName() != null && appTables.contains(process.getTableName()))
|
||||
{
|
||||
bases.add(base(instance, process, process.getName()));
|
||||
}
|
||||
}
|
||||
if(app.getWidgets() != null)
|
||||
{
|
||||
for(String name : app.getWidgets())
|
||||
{
|
||||
if(instance.getWidget(name) != null)
|
||||
{
|
||||
bases.add(base(instance, instance.getWidget(name), name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for(PolicyCatalog.Group group : PolicyCatalog.of(instance).getGroups())
|
||||
{
|
||||
for(PolicyCatalog.Resource resource : group.resources())
|
||||
{
|
||||
if((group.app() != null && scope.apps().contains(group.app())) || scope.processes().contains(resource.name())
|
||||
|| scope.reports().contains(resource.name()) || scope.widgets().contains(resource.name()))
|
||||
{
|
||||
bases.add(resource.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
permissions.removeIf(p -> p.lastIndexOf('.') < 0 || !bases.contains(p.substring(0, p.lastIndexOf('.'))));
|
||||
}
|
||||
}
|
||||
session.setPermissions(permissions);
|
||||
session.setValue("qqq.credentialScope", scope.identifier());
|
||||
attachCoreScope(session, scope);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the permission base, which may differ from a table's metadata name.
|
||||
*******************************************************************************/
|
||||
private static String base(QInstance instance, MetaDataWithPermissionRules object, String name)
|
||||
{
|
||||
String base = PermissionsHelper.getEffectivePermissionRules(object, instance).getPermissionBaseName();
|
||||
return (base == null || base.isBlank() ? name : base);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate against current operator permissions at Review and execution.
|
||||
*******************************************************************************/
|
||||
public static void validate(AdminRequest request) throws QUserFacingException
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
for(String field : List.of("apps", "tables"))
|
||||
{
|
||||
for(String name : names(request.str(field)))
|
||||
{
|
||||
MetaDataWithPermissionRules object = field.equals("apps") ? instance.getApp(name) : instance.getTable(name);
|
||||
if(object == null)
|
||||
{
|
||||
throw (new QUserFacingException("Unknown " + field + " scope: " + name));
|
||||
}
|
||||
String base = base(instance, object, name);
|
||||
QSession session = QContext.getQSession();
|
||||
if(!session.hasPermission(base + ".hasAccess") && !session.hasPermission(base + ".read"))
|
||||
{
|
||||
throw (new QUserFacingException("You do not currently have read access to scope: " + name));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(String advanced : List.of("processes", "reports", "widgets", "operationIds"))
|
||||
{
|
||||
if(!request.str(advanced).isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("Advanced token scopes are not offered by Create Token."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An empty scope dimension means "not restricted" (null for core).
|
||||
*******************************************************************************/
|
||||
private static Set<String> nullIfEmpty(Set<String> set)
|
||||
{
|
||||
return (set == null || set.isEmpty() ? null : set);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Attach the released core scope contract; a revoked token allows nothing.
|
||||
*******************************************************************************/
|
||||
private static void attachCoreScope(QSession session, TokenScope scope)
|
||||
{
|
||||
boolean denied = scope.mode() == Mode.DENY_ALL;
|
||||
session.withCredentialScope(new QCredentialScope().withCredentialId(scope.identifier())
|
||||
.withReadOnly(denied || scope.readOnly())
|
||||
.withApps(denied ? Set.of() : nullIfEmpty(scope.apps()))
|
||||
.withTables(denied ? Set.of() : nullIfEmpty(scope.tables()))
|
||||
.withProcesses(denied ? Set.of() : nullIfEmpty(scope.processes()))
|
||||
.withReports(denied ? Set.of() : nullIfEmpty(scope.reports()))
|
||||
.withWidgets(denied ? Set.of() : nullIfEmpty(scope.widgets()))
|
||||
.withOperationIds(denied ? Set.of() : nullIfEmpty(scope.operationIds())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
bundle: qbit-authentik
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:authentikAdmin;slot:help
|
||||
summary: Review Authentik accounts, groups, application access and identity administration.
|
||||
content: |
|
||||
Identity shows the accounts, groups and applications held in Authentik, the service that signs people in to this application. The directory data is read from Authentik with the host's configured caching, and Authentik stays the source of truth. Changes made in Authentik itself are equally valid, and nothing here overwrites them blindly.
|
||||
|
||||
**Read only by default.** Out of the box you can look but not change anything. The change actions (creating and editing users and groups, resetting passwords, revoking sessions, editing policies) appear only when writes are switched on for this site. When they are off, those buttons are absent, not just disabled. Deleting a user or group needs a second switch and is off unless it was turned on deliberately.
|
||||
|
||||
Some details, such as sessions, tokens, events and raw attributes, need the audit permission. You need the permission to read users to open this app.
|
||||
- key: table:authentik_application;slot:help
|
||||
summary: The apps that people sign in to through Authentik, and who is allowed to open them.
|
||||
content: |
|
||||
Each row is an application set up in Authentik. Open one to see its provider, how it is launched and, under **Who can open it (policy bindings)**, the rules that decide which groups or users may use it.
|
||||
|
||||
This list is read only. Applications are managed in Authentik, not here.
|
||||
|
||||
**How rows get created:** Define applications in Authentik; you can inspect their live state here. An empty list means no applications match your query and visible scope.
|
||||
- key: table:authentik_application;section:details
|
||||
summary: The application's name, address, provider and library settings.
|
||||
- key: table:authentik_application;section:bindings
|
||||
summary: The rules that allow or deny groups and users from opening this application.
|
||||
- key: table:authentik_application;field:slug
|
||||
summary: The application's short address name inside Authentik.
|
||||
- key: table:authentik_application;field:name
|
||||
summary: The application's display name in Authentik.
|
||||
- key: table:authentik_application;field:group
|
||||
summary: The heading Authentik lists this application under in its library; often empty.
|
||||
- key: table:authentik_application;field:providerName
|
||||
summary: The sign-in provider connected to this application, such as an OAuth2 or proxy provider.
|
||||
- key: table:authentik_application;field:policyEngineMode
|
||||
summary: How the application's access rules combine.
|
||||
content: |
|
||||
Authentik uses **any** (one passing rule is enough) or **all** (every rule must pass). See the rules under **Who can open it (policy bindings)**.
|
||||
- key: table:authentik_application;field:launchUrl
|
||||
summary: The address people are sent to when they open the application from Authentik.
|
||||
- key: table:authentik_application;field:publisher
|
||||
summary: The publisher name shown for the application in Authentik; often empty.
|
||||
- key: table:authentik_application;field:hiddenFromLibrary
|
||||
summary: Yes when the application is hidden from users' Authentik app library.
|
||||
content: |
|
||||
Hidden applications can still be opened directly.
|
||||
- key: table:authentik_application;field:bindingTarget
|
||||
summary: Internal link used to list this application's access rules.
|
||||
- key: table:authentik_blueprint;slot:help
|
||||
summary: The configuration files Authentik applies to set itself up, and whether each applied cleanly.
|
||||
content: |
|
||||
Blueprints are files that describe parts of Authentik, such as its groups and service accounts. Authentik applies them itself. This list shows each one's **{{label:authentik_blueprint.status}}**, whether it is **{{label:authentik_blueprint.enabled}}**, and when it was **{{label:authentik_blueprint.lastApplied}}**.
|
||||
|
||||
It is read only and for checking. A status that is not successful means something in Authentik's own setup needs attention.
|
||||
|
||||
**How rows get created:** Authentik discovers and applies blueprints; you can inspect their live status here. An empty list means no blueprint instances match your query.
|
||||
- key: table:authentik_blueprint;field:pk
|
||||
summary: Authentik's internal id for the blueprint.
|
||||
- key: table:authentik_blueprint;field:name
|
||||
summary: The blueprint's name.
|
||||
- key: table:authentik_blueprint;field:path
|
||||
summary: Where Authentik finds the blueprint file.
|
||||
- key: table:authentik_blueprint;field:status
|
||||
summary: Whether Authentik applied the blueprint successfully, as reported by Authentik.
|
||||
- key: table:authentik_blueprint;field:enabled
|
||||
summary: Whether Authentik applies this blueprint automatically.
|
||||
- key: table:authentik_blueprint;field:lastApplied
|
||||
summary: When Authentik last applied this blueprint.
|
||||
- key: table:authentik_event;slot:help
|
||||
summary: 'Authentik''s activity log: sign-ins, failed logins, changes and other security events, newest first.'
|
||||
content: |
|
||||
Use this to answer who did what and when: a login, a password reset, a group change, or something an admin did from this app. Each row names the **{{label:authentik_event.action}}**, who did it, from which **{{label:authentik_event.clientIp}}**, and what it touched. **{{label:authentik_event.context}}** holds the raw detail.
|
||||
|
||||
The list is read only and needs the audit permission. On a user, the **History** section shows just that person's events. Authentik deletes old events on its own schedule, shown in **{{label:authentik_event.expires}}**.
|
||||
|
||||
**How rows get created:** Authentik records security events; you can inspect the live event log here. An empty list means no events match your query.
|
||||
- key: table:authentik_event;field:pk
|
||||
summary: Authentik's internal id for the event.
|
||||
- key: table:authentik_event;field:created
|
||||
summary: When the event happened.
|
||||
- key: table:authentik_event;field:action
|
||||
summary: What happened, in Authentik's own terms, such as login, login failed, model created or model updated.
|
||||
- key: table:authentik_event;field:username
|
||||
summary: The account that did it, where known.
|
||||
- key: table:authentik_event;field:userPk
|
||||
summary: The Authentik number of the account that did it.
|
||||
- key: table:authentik_event;field:app
|
||||
summary: The part of Authentik that recorded the event.
|
||||
- key: table:authentik_event;field:clientIp
|
||||
summary: The network address the request came from.
|
||||
- key: table:authentik_event;field:modelApp
|
||||
summary: For changes, which part of Authentik owns the thing that changed.
|
||||
- key: table:authentik_event;field:modelName
|
||||
summary: For changes, the kind of thing that changed, such as user or group.
|
||||
- key: table:authentik_event;field:modelPk
|
||||
summary: For changes, the id of the thing that changed.
|
||||
- key: table:authentik_event;field:modelLabel
|
||||
summary: For changes, the name of the thing that changed.
|
||||
- key: table:authentik_event;field:authorizedApp
|
||||
summary: For sign-ins, the application the person was signing in to.
|
||||
- key: table:authentik_event;field:context
|
||||
summary: The raw details Authentik recorded with the event, as JSON text.
|
||||
- key: table:authentik_event;field:expires
|
||||
summary: When Authentik will delete this event from its log.
|
||||
- key: table:authentik_group_member;slot:help
|
||||
summary: Which people belong to which Authentik groups, one row per person and group.
|
||||
content: |
|
||||
This is the list behind the **Members** section on a group and the **Memberships** section on a user. It is read only. To change who is in a group you use the **{{label:authentikAddMembers}}**, **{{label:authentikRemoveMembers}}**, **{{label:authentikAddToGroup}}** or **{{label:authentikRemoveFromGroup}}** actions, which are present only when writes are enabled.
|
||||
|
||||
**How rows get created:** Membership rows are assembled live from Authentik. Use the membership actions to add or remove people. An empty list means no memberships match your query.
|
||||
- key: table:authentik_group_member;field:groupName
|
||||
summary: The group the person belongs to.
|
||||
- key: table:authentik_group_member;field:username
|
||||
summary: The member's sign-in username.
|
||||
- key: table:authentik_group_member;field:name
|
||||
summary: The member's full name.
|
||||
- key: table:authentik_group_member;field:email
|
||||
summary: The member's email address.
|
||||
- key: table:authentik_group_member;field:isActive
|
||||
summary: Whether the member's account can currently sign in.
|
||||
- key: table:authentik_group_member;field:groupPk
|
||||
summary: Authentik's internal id of the group.
|
||||
- key: table:authentik_group_member;field:userPk
|
||||
summary: Authentik's number for the member's account.
|
||||
- key: table:authentik_group;slot:help
|
||||
summary: The groups that decide what each person can do in this application and which applications they can open.
|
||||
content: |
|
||||
Access in this application comes from group membership. A group can grant specific application permissions and app sign-in, can sit under parent groups whose access it inherits, and can carry a written policy.
|
||||
|
||||
Open a group to see its members, the permissions it grants directly and the full set including inherited ones, and its policy.
|
||||
|
||||
You can inspect the details your permissions allow. These actions appear when their features and writes are enabled: **{{label:authentikCreateGroup}}**, **{{label:authentikEditGroup}}**, **{{label:authentikEditPermissions}}**, **Edit Policy**, **{{label:authentikGeneratePolicy}}**, **{{label:authentikAddMembers}}** and **{{label:authentikRemoveMembers}}**. Safeguards apply: you can only grant permissions you hold yourself, you cannot edit your own access, superuser groups cannot be managed, and granting identity administration rights needs a second person when two-person approval is enabled.
|
||||
|
||||
**How rows get created:** Create groups in Authentik or use Create Group when enabled. You read their live state from Authentik. An empty list means no groups match your query.
|
||||
- key: table:authentik_group;section:enforcement
|
||||
summary: Whether saved policies currently take effect when people sign in.
|
||||
- key: table:authentik_group;section:details
|
||||
summary: The group's name, parents, children, member count and superuser status.
|
||||
- key: table:authentik_group;section:permissions
|
||||
summary: The application permissions and app sign-in this group grants, directly and by inheritance.
|
||||
- key: table:authentik_group;section:members
|
||||
summary: The people in this group.
|
||||
- key: table:authentik_group;section:policy
|
||||
summary: The written access policy saved on this group, if any.
|
||||
- key: table:authentik_group;section:attributes
|
||||
summary: The raw extra settings stored on the group in Authentik; needs the audit permission.
|
||||
- key: table:authentik_group;field:pk
|
||||
summary: Authentik's internal id for the group.
|
||||
- key: table:authentik_group;field:name
|
||||
summary: The group's name.
|
||||
- key: table:authentik_group;field:isSuperuser
|
||||
summary: Yes when this group itself gives its members full superuser rights in Authentik.
|
||||
content: |
|
||||
Superuser groups cannot be managed from here.
|
||||
- key: table:authentik_group;field:isSuperuserInherited
|
||||
summary: Yes when this group, or any parent it inherits from, gives members superuser rights.
|
||||
- key: table:authentik_group;field:parentNames
|
||||
summary: The groups this group inherits access from.
|
||||
- key: table:authentik_group;field:childNames
|
||||
summary: The groups that inherit access from this one.
|
||||
- key: table:authentik_group;field:memberCount
|
||||
summary: How many people are directly in this group.
|
||||
- key: table:authentik_group;field:memberUsername
|
||||
summary: Filter for groups containing a given username; not shown.
|
||||
- key: table:authentik_group;field:permissions
|
||||
summary: The application permissions this group grants directly, one per line.
|
||||
content: |
|
||||
These come from the group's permissions setting in Authentik. A permission looks like a screen or action name followed by what is allowed, for example read or has access.
|
||||
- key: table:authentik_group;field:effectivePermissions
|
||||
summary: Everything this group grants once parent groups and the group's policy are included.
|
||||
content: |
|
||||
Use it to answer why a person has a permission. The permissions here are the full set for the group's members.
|
||||
- key: table:authentik_group;field:appAccess
|
||||
summary: The applications the group's members may sign in to, one per line.
|
||||
- key: table:authentik_group;field:roleNames
|
||||
summary: The Authentik roles attached to this group.
|
||||
- key: table:authentik_group;field:numPk
|
||||
summary: Authentik's numeric id for the group; used internally.
|
||||
- key: table:authentik_group;field:parentPks
|
||||
summary: The internal ids of the parent groups; used internally.
|
||||
- key: table:authentik_group;field:attributes
|
||||
summary: The raw attributes stored on the group in Authentik, as JSON text.
|
||||
content: |
|
||||
Needs the audit permission. Permissions, app access and policy are kept here, so edit them with the actions rather than by hand.
|
||||
- key: table:authentik_policy_binding;slot:help
|
||||
summary: The rules attached to an application that allow or deny specific groups or users from opening it.
|
||||
content: |
|
||||
Each binding points at a group or user (or a named policy) and says whether it grants or blocks access. They are evaluated in **{{label:authentik_policy_binding.order}}**, and the application's **Policy Engine Mode** decides how the results combine.
|
||||
|
||||
This list is read only and appears on an application under **Who can open it (policy bindings)**. Bindings are changed in Authentik.
|
||||
|
||||
**How rows get created:** Define application bindings in Authentik; you can inspect the live bindings here. An empty list means no bindings match your query.
|
||||
- key: table:authentik_policy_binding;field:pk
|
||||
summary: Authentik's internal id for the binding.
|
||||
- key: table:authentik_policy_binding;field:target
|
||||
summary: Internal id of the application the binding is attached to.
|
||||
- key: table:authentik_policy_binding;field:policyName
|
||||
summary: The named policy the binding uses, when it is a policy rather than a group or user.
|
||||
- key: table:authentik_policy_binding;field:groupName
|
||||
summary: The group this binding applies to, if it targets a group.
|
||||
- key: table:authentik_policy_binding;field:username
|
||||
summary: The user this binding applies to, if it targets a person.
|
||||
- key: table:authentik_policy_binding;field:negate
|
||||
summary: Yes when the rule is reversed, so it applies to everyone except the group or user named.
|
||||
- key: table:authentik_policy_binding;field:enabled
|
||||
summary: Whether the rule is currently in force.
|
||||
- key: table:authentik_policy_binding;field:order
|
||||
summary: The position of the rule; lower numbers are checked first.
|
||||
- key: table:authentik_policy_binding;field:expires
|
||||
summary: When the rule stops applying, if it has an end date.
|
||||
- key: table:authentik_role;slot:help
|
||||
summary: Authentik roles, which bundle Authentik's own admin rights and can be attached to groups.
|
||||
content: |
|
||||
A role controls what someone may do inside Authentik itself, not inside this application. Only roles created by people are listed; ones Authentik manages itself are left out.
|
||||
|
||||
This list is read only. Managing roles from here is not available.
|
||||
|
||||
**How rows get created:** Define roles in Authentik; you can inspect its live, unmanaged roles here. An empty list means no unmanaged roles match your query.
|
||||
- key: table:authentik_role;field:pk
|
||||
summary: Authentik's internal id for the role.
|
||||
- key: table:authentik_role;field:name
|
||||
summary: The role's name.
|
||||
- key: table:authentik_session;slot:help
|
||||
summary: The sign-in sessions currently open in Authentik, with where and when each was last used.
|
||||
content: |
|
||||
A session is a signed-in browser or device. Use this to see who is signed in, from which address, and whether a session looks unfamiliar. A person's sessions also appear under **{{label:authentik_session}}** on their user record.
|
||||
|
||||
The list is read only and needs the audit permission. The **{{label:authentikRevokeSessions}}** action, which signs a person out everywhere, appears only when writes are switched on.
|
||||
|
||||
**How rows get created:** Authentik creates sessions when people sign in; you can inspect them live here. An empty list means no sessions match your query.
|
||||
- key: table:authentik_session;field:uuid
|
||||
summary: Authentik's id for the session.
|
||||
- key: table:authentik_session;field:userPk
|
||||
summary: The Authentik id of the person this sign-in session belongs to.
|
||||
- key: table:authentik_session;field:username
|
||||
summary: The username of the person who owns this session.
|
||||
- key: table:authentik_session;field:lastIp
|
||||
summary: The network address this session was last seen from.
|
||||
content: |
|
||||
Only people with the audit permission can open the Sessions list.
|
||||
- key: table:authentik_session;field:lastUserAgent
|
||||
summary: The browser or device description sent with the session's most recent request.
|
||||
- key: table:authentik_session;field:lastUsed
|
||||
summary: When the session was last used.
|
||||
- key: table:authentik_session;field:expires
|
||||
summary: When the session ends by itself if it is not revoked first.
|
||||
- key: table:authentik_session;field:current
|
||||
summary: Yes when this is the session you are using right now.
|
||||
- key: table:authentik_session;field:geoIp
|
||||
summary: The approximate location Authentik worked out from the last IP address, when it has one.
|
||||
- key: table:authentik_session;field:asn
|
||||
summary: The network provider Authentik matched to the last IP address, when it has one.
|
||||
- key: table:authentik_token;slot:help
|
||||
summary: Details of Authentik tokens and app passwords. The secret key is never shown.
|
||||
content: |
|
||||
This list holds the details of access tokens and app passwords: who owns each one, what it is for and when it expires. The secret key is never stored or shown here.
|
||||
|
||||
It is read from Authentik, subject to the host's configured cache lifetime.
|
||||
|
||||
You need the audit permission (or the permission to manage tokens) to see it. You normally reach it from the **Tokens and app passwords** section on a person's record.
|
||||
|
||||
Token actions are available when the host enables token management and writes, and you have the required permissions.
|
||||
|
||||
**How rows get created:** Create tokens in Authentik or use Create Token when enabled. This list reads live token metadata, never keys. An empty list means no tokens match your query and visible scope.
|
||||
- key: table:authentik_token;field:identifier
|
||||
summary: The token's unique name in Authentik, not its secret key.
|
||||
- key: table:authentik_token;field:intent
|
||||
summary: What the token is for in Authentik, for example an API token or an app password.
|
||||
- key: table:authentik_token;field:username
|
||||
summary: The username of the person who owns the token.
|
||||
- key: table:authentik_token;field:description
|
||||
summary: The note whoever created the token gave it, to remember what it is used for.
|
||||
- key: table:authentik_token;field:expires
|
||||
summary: When the token stops working.
|
||||
- key: table:authentik_token;field:expiring
|
||||
summary: Yes when the token has an expiry date; No when it never expires.
|
||||
- key: table:authentik_user;slot:help
|
||||
summary: Review people and service accounts within your configured Authentik user scope.
|
||||
content: |
|
||||
**{{label:authentik_user}}** lists the accounts in Authentik. It is read live (with caching configured by your host), so changes made in the Authentik admin screens show up here too. Authentik stays the source of truth.
|
||||
|
||||
Open a person to see their identity, groups and roles, sessions, tokens and recent history.
|
||||
|
||||
**Changing people** (create, edit, deactivate and reactivate, add to group, send a password reset, reset MFA, revoke app passwords, revoke sessions) only appears when changes are switched on for this site. Otherwise the Identity screens are read-only and every change must be made in Authentik itself.
|
||||
|
||||
Some accounts are protected and cannot be changed from here: Authentik superusers, the service account that connects this site to Authentik, internal service accounts, and any account marked protected. You also cannot change your own account.
|
||||
|
||||
Every change needs a reason and is recorded under **{{label:authentik_admin_action}}**.
|
||||
|
||||
**How rows get created:** Create accounts in Authentik or use the enabled user actions. You read their live state from Authentik. An empty list means no accounts match your query and configured user scope.
|
||||
- key: table:authentik_user;section:identity
|
||||
summary: 'Who the person is: username, name, email, status, type and sign-in dates.'
|
||||
- key: table:authentik_user;section:access
|
||||
summary: The groups and roles the person belongs to and the permissions they add up to in this app.
|
||||
- key: table:authentik_user;section:memberships
|
||||
summary: The groups this person is a member of.
|
||||
- key: table:authentik_user;section:sessions
|
||||
summary: The person's current sign-in sessions. Needs the audit permission.
|
||||
- key: table:authentik_user;section:tokens
|
||||
summary: Details of the person's access tokens and app passwords, never the secret keys. Needs the audit permission.
|
||||
- key: table:authentik_user;section:events
|
||||
summary: Recent things Authentik recorded for this person, such as sign-ins and changes. Needs the audit permission.
|
||||
- key: table:authentik_user;section:attributes
|
||||
summary: Extra data stored on the person in Authentik, plus when their password last changed. Needs the audit permission.
|
||||
- key: table:authentik_user;field:pk
|
||||
summary: The number Authentik uses to identify this person.
|
||||
- key: table:authentik_user;field:username
|
||||
summary: "The name the person signs in with."
|
||||
content: |
|
||||
The name the person signs in with. Letters, digits and . _ @ + - only.
|
||||
- key: table:authentik_user;field:name
|
||||
summary: The person's full name as shown in Authentik.
|
||||
- key: table:authentik_user;field:email
|
||||
summary: The person's email address. Password reset emails are sent here.
|
||||
- key: table:authentik_user;field:isActive
|
||||
summary: Yes if the person can sign in; deactivated accounts keep their history but cannot sign in.
|
||||
- key: table:authentik_user;field:type
|
||||
summary: The kind of account in Authentik.
|
||||
content: |
|
||||
Values you can see here:
|
||||
- **internal**: a normal member account.
|
||||
- **external**: an account for someone outside the organisation.
|
||||
- **service_account**: an account for a script or service rather than a person.
|
||||
- key: table:authentik_user;field:isSuperuser
|
||||
summary: Yes if the account is an Authentik superuser, directly or through a parent group. Not editable here.
|
||||
- key: table:authentik_user;field:isProtected
|
||||
summary: Yes if this account cannot be changed from this screen.
|
||||
content: |
|
||||
An account is protected when it is an Authentik superuser, the service account that connects this site to Authentik, an internal Authentik service account, listed as protected in the site's setup, or marked as protected in its attributes. Use the Authentik admin screens for these.
|
||||
- key: table:authentik_user;field:groupNames
|
||||
summary: The groups the person is directly a member of.
|
||||
- key: table:authentik_user;field:roleNames
|
||||
summary: The Authentik roles the person has through their groups.
|
||||
- key: table:authentik_user;field:lastLogin
|
||||
summary: When the person last signed in. Blank if they never have.
|
||||
- key: table:authentik_user;field:dateJoined
|
||||
summary: When the account was created in Authentik.
|
||||
- key: table:authentik_user;field:lastUpdated
|
||||
summary: When the account was last changed in Authentik.
|
||||
- key: table:authentik_user;field:path
|
||||
summary: The folder-like location of the account in Authentik, used to organise users.
|
||||
- key: table:authentik_user;field:uuid
|
||||
summary: The unique identifier Authentik uses for this account.
|
||||
- key: table:authentik_user;field:qqqAttributes
|
||||
summary: The Authentik attributes that start with qqq.io/, which this application reads for permissions and protection.
|
||||
- key: table:authentik_user;field:effectivePermissions
|
||||
summary: The permissions this person ends up with in this app, added up from all their groups and parent groups.
|
||||
content: |
|
||||
This list is worked out when you open the record. Policies on groups only count here when policy enforcement is on (see **Policy Enforcement** on a group).
|
||||
- key: table:authentik_user;field:passwordChangeDate
|
||||
summary: When the person last changed their password. Needs the audit permission.
|
||||
- key: table:authentik_user;field:attributes
|
||||
summary: All extra data stored on the person in Authentik, as JSON. Needs the audit permission.
|
||||
content: |
|
||||
**{{label:authentikEditUser}}** can change attributes that do not start with qqq.io/ or goauthentik.io/. Those two prefixes are managed elsewhere.
|
||||
- key: table:authentik_admin_action;slot:help
|
||||
summary: The audit trail of every change attempted from the Identity screens, successful or not.
|
||||
content: |
|
||||
**{{label:authentik_admin_action}}** records each attempt to change something in Authentik: who did it, what, to whom, why, and how it ended. A row is written before the call to Authentik is made, so failures and refusals are recorded too. Authentik's own event log also gets a matching entry naming the person who did it.
|
||||
|
||||
Use it to answer who changed someone's access and when, and to see the state before and after.
|
||||
|
||||
You need the audit permission to read it. Rows are written by the system only; you cannot add or edit them by hand.
|
||||
|
||||
**How rows get created:** Your administrative actions write audit records before making changes in Authentik. An empty list means no audit records match your query.
|
||||
- key: table:authentik_admin_action;field:correlationId
|
||||
summary: An id shared by every record of the same attempt, to match it with Authentik's own log.
|
||||
- key: table:authentik_admin_action;field:actionName
|
||||
summary: Which change was attempted, for example Edit User or Add To Group.
|
||||
- key: table:authentik_admin_action;field:operator
|
||||
summary: The person who did it, shown by their sign-in email.
|
||||
- key: table:authentik_admin_action;field:reason
|
||||
summary: The reason the person typed when they made the change.
|
||||
- key: table:authentik_admin_action;field:targetType
|
||||
summary: 'What kind of thing was changed: a user, group, membership or similar.'
|
||||
- key: table:authentik_admin_action;field:targetKey
|
||||
summary: The Authentik id of the thing that was changed.
|
||||
- key: table:authentik_admin_action;field:targetLabel
|
||||
summary: The name of the thing that was changed, such as a username or group name.
|
||||
- key: table:authentik_admin_action;field:beforeText
|
||||
summary: How the thing looked before the change.
|
||||
- key: table:authentik_admin_action;field:afterText
|
||||
summary: How the thing looked after the change.
|
||||
- key: table:authentik_admin_action;field:status
|
||||
summary: 'How the attempt ended: {{values:authentik_action_status}}.'
|
||||
content: |
|
||||
Values:
|
||||
- **Waiting for approval**: needs a second person to approve it; nothing has been sent to Authentik.
|
||||
- **Running**: in progress.
|
||||
- **Succeeded**: done in Authentik.
|
||||
- **Failed**: sent to Authentik but it did not work; see the error message.
|
||||
- **Refused**: a safety rule or missing permission stopped it before anything was sent.
|
||||
- key: table:authentik_admin_action;field:errorMessage
|
||||
summary: Why the attempt failed or was refused. Blank when it succeeded.
|
||||
- key: table:authentik_admin_action;field:authentikEventPk
|
||||
summary: The id of the matching entry in Authentik's own event log, when one was written.
|
||||
- key: table:authentik_admin_action;field:approver
|
||||
summary: The second person who approved the change, when approval was needed.
|
||||
- key: table:authentik_approval_request;slot:help
|
||||
summary: Changes waiting for a second person to approve before they are sent to Authentik.
|
||||
content: |
|
||||
Some changes need two people, so one person cannot grant themselves power. When two-person approval is enabled, adding identity administration permissions requires approval. Your host can also require approval for policy changes. The first person's request waits here. Nothing has changed in Authentik yet.
|
||||
|
||||
A different person with the approve permission opens the request and runs **{{label:authentikApprove}}**, or **{{label:authentikReject}}**. You cannot approve your own request.
|
||||
|
||||
Requests expire after 24 hours; ask again if one has expired. When approved, the change is checked again against live data, so it can still fail if something changed in the meantime.
|
||||
|
||||
Waiting requests also show on the Identity home under **Needs attention**.
|
||||
|
||||
**How rows get created:** Changes requiring a second person create approval requests. An empty list means no requests match your query; completed requests remain recorded.
|
||||
- key: table:authentik_approval_request;field:actionName
|
||||
summary: Which change is waiting for approval.
|
||||
- key: table:authentik_approval_request;field:targetKey
|
||||
summary: The Authentik id of the thing the change applies to.
|
||||
- key: table:authentik_approval_request;field:targetLabel
|
||||
summary: The name of the person or group the change applies to.
|
||||
- key: table:authentik_approval_request;field:summary
|
||||
summary: A plain description of what will change if the request is approved.
|
||||
- key: table:authentik_approval_request;field:reason
|
||||
summary: The reason the requester gave for the change.
|
||||
- key: table:authentik_approval_request;field:payloadJson
|
||||
summary: The exact change that will be applied, saved so the approver applies what was requested.
|
||||
- key: table:authentik_approval_request;field:requester
|
||||
summary: The person who asked for the change, shown by their sign-in email.
|
||||
- key: table:authentik_approval_request;field:approver
|
||||
summary: The person who approved or rejected the request. Blank while it is still waiting.
|
||||
- key: table:authentik_approval_request;field:status
|
||||
summary: 'Where the request stands: waiting, approved, rejected, expired or failed.'
|
||||
content: |
|
||||
Values:
|
||||
- **Waiting for approval**: nobody has decided yet.
|
||||
- **Approved and applied**: a second person approved it and the change was made.
|
||||
- **Rejected**: a second person said no; nothing changed.
|
||||
- **Expired**: nobody decided within 24 hours.
|
||||
- **Approved but failed**: it was approved but the change could not be made; see the error message.
|
||||
- key: table:authentik_approval_request;field:expiresDate
|
||||
summary: When the request lapses if nobody decides it. It is 24 hours after the request was made.
|
||||
- key: table:authentik_approval_request;field:decidedDate
|
||||
summary: When the request was approved, rejected or marked expired.
|
||||
- key: table:authentik_approval_request;field:errorMessage
|
||||
summary: Why an approved change failed, or the word expired. Blank otherwise.
|
||||
- key: table:authentik_policy;slot:help
|
||||
summary: 'The saved policy of each group: a list of what its members may do in this app.'
|
||||
content: |
|
||||
A **policy** is a document saved on a group in Authentik. It lists the apps, tables, actions and reports the group's members may use, and what they may do (open, read, add, edit, delete). Members of the group, and of groups below it, receive those permissions when they sign in.
|
||||
|
||||
This list shows the last policy saved from this application for each group, with who saved it and when. Authentik holds the live document.
|
||||
|
||||
Saved policies only take effect at sign-in when policy enforcement is on. The banner on this screen says whether it is. When signed-in users receive changed permissions depends on the host's authentication and session configuration.
|
||||
|
||||
Use **{{label:authentikEditPolicyRecord}}** to change one. It needs the policies permission, and you can only grant permissions you hold yourself.
|
||||
|
||||
**How rows get created:** Saving a group policy records its document and author here. Authentik holds the live policy. An empty list means no saved policy records match your query.
|
||||
- key: table:authentik_policy;section:enforcement
|
||||
summary: Whether saved policies are actually applied when people sign in.
|
||||
- key: table:authentik_policy;section:policy
|
||||
summary: The group, how big its policy is, who saved it and the policy document itself.
|
||||
- key: table:authentik_policy;section:system
|
||||
summary: Technical details kept by the system, including the check used to spot changes made in Authentik.
|
||||
- key: table:authentik_policy;field:groupPk
|
||||
summary: The Authentik id of the group this policy belongs to.
|
||||
- key: table:authentik_policy;field:groupName
|
||||
summary: The name of the group the policy is saved on.
|
||||
- key: table:authentik_policy;field:policyJson
|
||||
summary: The policy document as it was last saved, in JSON.
|
||||
content: |
|
||||
The document lists statements. Each statement allows some actions (open, read, insert, edit, delete) on named apps, tables, actions, reports or widgets. Use **{{label:authentikEditPolicyRecord}}** to change it with tick boxes and a live preview instead of editing the JSON by hand. A document that is not valid grants nothing.
|
||||
- key: table:authentik_policy;field:statementCount
|
||||
summary: How many statements the saved policy has.
|
||||
- key: table:authentik_policy;field:permissionCount
|
||||
summary: How many separate permissions the saved policy grants in total.
|
||||
- key: table:authentik_policy;field:savedBy
|
||||
summary: Who saved this policy last, shown by their sign-in email.
|
||||
- key: table:authentik_policy;field:savedDate
|
||||
summary: When this policy was last saved from this application.
|
||||
- key: table:authentik_policy;field:authentikFingerprint
|
||||
summary: A short code of the policy as saved, used to detect that someone changed it directly in Authentik.
|
||||
content: |
|
||||
When you open **{{label:authentikEditPolicyRecord}}**, the editor compares this code to what Authentik holds now. If they differ, someone changed the policy in Authentik, and you are asked to review it again instead of overwriting their change.
|
||||
- key: process:authentikCreateUser;slot:help
|
||||
summary: Creates a new person in Authentik and, by default, emails them a link to set their own password.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage users.
|
||||
|
||||
You never set or see a password. If **Email them a link to set their own password** is ticked (the default), Authentik sends the person a link to choose one.
|
||||
|
||||
- The username may use letters, digits and . _ @ + - (up to 150 characters), and must not already exist.
|
||||
- The email must be valid and not already used by another person.
|
||||
- **{{label:authentik_group}}** adds them to existing groups straight away. You can only pick groups whose permissions you hold yourself.
|
||||
|
||||
Review shows what will be created before anything is sent to Authentik. Every run needs a reason and is recorded under **{{label:authentik_admin_action}}**.
|
||||
- key: process:authentikCreateUser;field:username
|
||||
summary: "The unique sign-in name for the new person."
|
||||
content: |
|
||||
The sign-in name: letters, digits and . _ @ + - only, up to 150 characters, and not already taken.
|
||||
- key: process:authentikCreateUser;field:name
|
||||
summary: The person's full name.
|
||||
- key: process:authentikCreateUser;field:email
|
||||
summary: Their email address. The link to set a password is sent here, and it must not belong to another person.
|
||||
- key: process:authentikCreateUser;field:groups
|
||||
summary: Groups to add them to, one group name per line. Optional.
|
||||
- key: process:authentikCreateUser;field:sendRecovery
|
||||
summary: Tick to have Authentik email the person a link so they can set their own password. On by default.
|
||||
- key: process:authentikCreateUser;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikEditUser;slot:help
|
||||
summary: Changes a person's username, name, email, path or other attributes in Authentik.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage users.
|
||||
|
||||
You can edit one person at a time. The form opens filled in with their current details; change what you need. Review shows exactly what will change.
|
||||
|
||||
- Attributes starting with qqq.io/ or goauthentik.io/ are managed elsewhere and cannot be changed here. Editing the other attributes also needs the audit permission.
|
||||
- If someone changed this person in Authentik after you opened the form, the change is stopped and you are asked to review it again, rather than overwriting their work.
|
||||
- Protected accounts, and your own account, cannot be edited here.
|
||||
- key: process:authentikEditUser;field:username
|
||||
summary: The sign-in name. It must not already belong to another person.
|
||||
- key: process:authentikEditUser;field:name
|
||||
summary: The person's full name. It cannot be empty.
|
||||
- key: process:authentikEditUser;field:email
|
||||
summary: The person's email address. It must be valid and not used by another person.
|
||||
- key: process:authentikEditUser;field:path
|
||||
summary: The folder-like location of the account in Authentik. It cannot be empty.
|
||||
- key: process:authentikEditUser;field:otherAttributes
|
||||
summary: 'Extra attributes as a JSON object, for example {"department": "sales"}. Leave blank to keep them as they are.'
|
||||
- key: process:authentikEditUser;field:expectedFingerprint
|
||||
summary: Filled in automatically. Used to detect that the person was changed in Authentik while you had this form open.
|
||||
- key: process:authentikEditUser;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikDeactivateUser;slot:help
|
||||
summary: Stops selected people from signing in and ends their sessions. Nothing is deleted.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage users.
|
||||
|
||||
You can select up to 100 people. Deactivating signs them out immediately and keeps the account and its history. Use **{{label:authentikReactivateUser}}** to undo it.
|
||||
|
||||
To confirm, type the username. When you select several people, type the username of the first one. Tick **Also revoke their app passwords** to remove those too.
|
||||
|
||||
Protected accounts, service accounts that connect this site, superusers and your own account cannot be deactivated here. The last person able to manage memberships and permissions cannot be deactivated either: add another administrator first.
|
||||
- key: process:authentikDeactivateUser;field:revokeAppPasswords
|
||||
summary: Also deletes the person's app passwords. Off by default.
|
||||
- key: process:authentikDeactivateUser;field:confirmText
|
||||
summary: Type the person's username to confirm. With several people selected, type the first one's username.
|
||||
- key: process:authentikDeactivateUser;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikReactivateUser;slot:help
|
||||
summary: Lets selected deactivated people sign in again.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage users.
|
||||
|
||||
You can select up to 100 people. Their groups and history are as they were. Protected accounts and your own account cannot be changed from here.
|
||||
- key: process:authentikReactivateUser;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikAddToGroup;slot:help
|
||||
summary: Adds the selected people to one group.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage memberships.
|
||||
|
||||
Pick the group and give a reason. You can select up to 100 people. Above 25 people you must also type how many you selected.
|
||||
|
||||
You can only add people to a group whose permissions and app sign-in access you hold yourself. Superuser groups and the groups below them cannot be managed here, and protected accounts and your own account cannot be changed.
|
||||
|
||||
Review shows who will be added before anything is sent to Authentik. To add several people to a group from the group's own screen, use **{{label:authentikAddMembers}}**.
|
||||
- key: process:authentikAddToGroup;field:group
|
||||
summary: The group to add the selected people to.
|
||||
- key: process:authentikAddToGroup;field:confirmText
|
||||
summary: Type the number of people you selected. Only needed when you selected more than 25.
|
||||
- key: process:authentikAddToGroup;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikRemoveFromGroup;slot:help
|
||||
summary: Removes the selected people from one group.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage memberships.
|
||||
|
||||
Pick the group and give a reason. You can select up to 100 people. Above 25 people you must also type how many you selected.
|
||||
|
||||
The last person able to manage memberships and permissions cannot be removed: add another administrator first. Superuser groups cannot be managed here, and protected accounts and your own account cannot be changed.
|
||||
|
||||
Use **{{label:authentikRevokeSessions}}** to end Authentik sessions. Permission refresh timing depends on your host.
|
||||
- key: process:authentikRemoveFromGroup;field:group
|
||||
summary: The group to remove the selected people from.
|
||||
- key: process:authentikRemoveFromGroup;field:confirmText
|
||||
summary: Type the number of people you selected. Only needed when you selected more than 25.
|
||||
- key: process:authentikRemoveFromGroup;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikSendPasswordReset;slot:help
|
||||
summary: Has Authentik email each selected person a link to choose a new password.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to reset credentials.
|
||||
|
||||
You never see the link. The person must be active and have an email address, and the password email must be set up for this site, otherwise the run is refused for that person. You can select up to 100 people.
|
||||
- key: process:authentikSendPasswordReset;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikResetMfa;slot:help
|
||||
summary: Removes every multi-factor sign-in device for the selected people.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to reset credentials.
|
||||
|
||||
Use it when someone has lost their phone or authenticator. Whether they must enroll another device depends on your Authentik sign-in flow. Type the username to confirm. It is recorded under **{{label:authentik_admin_action}}**.
|
||||
- key: process:authentikResetMfa;field:confirmText
|
||||
summary: Type the person's username to confirm.
|
||||
- key: process:authentikResetMfa;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikRevokeAppPasswords;slot:help
|
||||
summary: Deletes the selected people's app passwords. API tokens are not touched.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to reset credentials.
|
||||
|
||||
App passwords are long-lived passwords people create for scripts and other apps. Use this when one may have leaked. Type the username to confirm. It does not sign anyone out; use **{{label:authentikRevokeSessions}}** for that.
|
||||
- key: process:authentikRevokeAppPasswords;field:confirmText
|
||||
summary: Type the person's username to confirm.
|
||||
- key: process:authentikRevokeAppPasswords;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikRevokeSessions;slot:help
|
||||
summary: Signs the selected people out everywhere.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to revoke sessions.
|
||||
|
||||
Use it if an account may be compromised or its Authentik sessions should end. The person can sign in again unless deactivated. Existing host sessions and permission refresh timing depend on your host.
|
||||
- key: process:authentikRevokeSessions;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikCreateGroup;slot:help
|
||||
summary: Creates a group in Authentik, with its parents, permissions and app sign-in access.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage groups.
|
||||
|
||||
A group's permissions apply to everyone in it and to the groups below it.
|
||||
|
||||
- **Parent groups** and group names must exist; a group name must not already exist.
|
||||
- **QQQ permissions** and **App access** are optional and can be set later with **{{label:authentikEditPermissions}}**.
|
||||
- You can only grant permissions you hold yourself. Granting identity administration permissions needs a second person when two-person approval is enabled.
|
||||
- A group cannot be placed under a superuser group.
|
||||
- key: process:authentikCreateGroup;field:name
|
||||
summary: The new group's name. It must not already exist.
|
||||
- key: process:authentikCreateGroup;field:parents
|
||||
summary: Parent groups, one group name per line. Optional. Members of this group also get the parents' access.
|
||||
- key: process:authentikCreateGroup;field:permissions
|
||||
summary: Named permissions the group grants, one per line. You can only list permissions you hold yourself.
|
||||
- key: process:authentikCreateGroup;field:appAccess
|
||||
summary: Apps the group may sign in to, one app key per line. You can only list apps you can sign in to yourself.
|
||||
- key: process:authentikCreateGroup;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikEditGroup;slot:help
|
||||
summary: Renames a group, changes its parent groups or its other attributes.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage groups.
|
||||
|
||||
You can edit one group at a time. The form opens filled in with the current values.
|
||||
|
||||
- Parent groups: one name per line. Write none to clear them. A group cannot be put below itself or its own descendants, and not under a superuser group.
|
||||
- Changes that would leave nobody able to manage memberships and permissions are refused.
|
||||
- If someone changed the group in Authentik after you opened the form, you are asked to review it again instead of overwriting their change.
|
||||
|
||||
To change what the group grants, use **{{label:authentikEditPermissions}}** or **Edit Policy**.
|
||||
- key: process:authentikEditGroup;field:name
|
||||
summary: The group's name. It must not already belong to another group.
|
||||
- key: process:authentikEditGroup;field:parents
|
||||
summary: Parent groups, one group name per line. Write none to clear them.
|
||||
- key: process:authentikEditGroup;field:otherAttributes
|
||||
summary: Extra attributes as a JSON object. Leave blank to keep them as they are.
|
||||
- key: process:authentikEditGroup;field:expectedFingerprint
|
||||
summary: Filled in automatically. Used to detect that the group was changed in Authentik while you had this form open.
|
||||
- key: process:authentikEditGroup;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikEditPermissions;slot:help
|
||||
summary: Changes the named permissions and app sign-in access a group grants.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage permissions.
|
||||
|
||||
Both lists are one entry per line. They replace what the group has now, so Review shows what is added and removed.
|
||||
|
||||
- You can only add permissions you hold yourself, and give sign-in access only to apps you can sign in to yourself.
|
||||
- When two-person approval is enabled, adding identity administration permissions requires approval, so the change waits under **{{label:authentik_approval_request}}**.
|
||||
- Removing permissions is refused if nobody would be left able to manage memberships and permissions.
|
||||
- Superuser groups cannot be managed here.
|
||||
|
||||
Permission refresh timing depends on your host. Use **{{label:authentikRevokeSessions}}** to end Authentik sessions.
|
||||
- key: process:authentikEditPermissions;field:permissions
|
||||
summary: Named permissions the group grants, one per line. Replaces the current list.
|
||||
- key: process:authentikEditPermissions;field:appAccess
|
||||
summary: Apps the group may sign in to, one app key per line. Replaces the current list.
|
||||
- key: process:authentikEditPermissions;field:expectedFingerprint
|
||||
summary: Filled in automatically. Used to detect that the group was changed in Authentik while you had this form open.
|
||||
- key: process:authentikEditPermissions;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikEditPolicy;slot:help
|
||||
summary: Opens the policy editor to choose what a group's members may do, then saves it to Authentik.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage policies.
|
||||
|
||||
Tick the apps, tables, actions and reports the group's members may use, and what they may do with each (open, read, insert, edit, delete). A live preview shows the policy document that will be saved. Members of the group, and of groups below it, receive these permissions when they sign in.
|
||||
|
||||
- You can only grant permissions you hold yourself.
|
||||
- A document that is not valid grants nothing, and one that cannot be read back exactly is never saved.
|
||||
- If someone changed the policy in Authentik after you opened it, you are asked to review it again instead of overwriting their change.
|
||||
- The banner at the top says whether saved policies are enforced at sign-in. If not, saving still works but changes nothing yet.
|
||||
|
||||
Permission refresh timing depends on your host. Use **{{label:authentikRevokeSessions}}** to end Authentik sessions.
|
||||
- key: process:authentikEditPolicy;field:groupPk
|
||||
summary: Filled in automatically with the group you opened.
|
||||
- key: process:authentikEditPolicy;field:expectedFingerprint
|
||||
summary: Filled in automatically. Used to detect that the policy was changed in Authentik while you had this form open.
|
||||
- key: process:authentikEditPolicy;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikGeneratePolicy;slot:help
|
||||
summary: Builds a group's policy from the access its members get today, so nothing is lost when policies are switched on.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage policies.
|
||||
|
||||
The generated policy is joined with the group's current policy, so nothing is removed. Named permissions this app has that a policy cannot express stay on the group's permissions list, and anything that cannot be written is listed as skipped.
|
||||
|
||||
Review shows the generated document and what changes before anything is saved to Authentik. Use it once per group when moving from fixed access to policy-based access. See **Compare Access** to check the result.
|
||||
- key: process:authentikGeneratePolicy;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikEditPolicyRecord;slot:help
|
||||
summary: Opens the policy editor for this group's saved policy, then saves it to Authentik.
|
||||
content: |
|
||||
Works the same as **{{label:authentikEditPolicy}}** on a group. Only available when changes are switched on for this site. Needs the permission to manage policies.
|
||||
|
||||
Tick what members of the group, and of the groups below it, may do. A live preview shows the document that will be saved. You can only grant permissions you hold yourself, and you are asked to review again if someone changed the policy in Authentik meanwhile.
|
||||
|
||||
Saved policies only take effect at sign-in when enforcement is on; the banner says whether it is.
|
||||
- key: process:authentikEditPolicyRecord;field:groupPk
|
||||
summary: Filled in automatically with the group this policy belongs to.
|
||||
- key: process:authentikEditPolicyRecord;field:expectedFingerprint
|
||||
summary: Filled in automatically. Used to detect that the policy was changed in Authentik while you had this form open.
|
||||
- key: process:authentikEditPolicyRecord;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikAddMembers;slot:help
|
||||
summary: Adds people to the selected group by typing their usernames.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage memberships.
|
||||
|
||||
Enter one username per line. You can add up to 100 people at a time; above 25 you must also type how many. The group must grant only permissions and app access you hold yourself, and superuser groups cannot be managed here. Protected accounts and your own account cannot be changed.
|
||||
|
||||
Review shows who will be added before anything is sent to Authentik. See also **{{label:authentikAddToGroup}}**, which starts from selected people.
|
||||
- key: process:authentikAddMembers;field:usernames
|
||||
summary: The usernames to add, one per line.
|
||||
- key: process:authentikAddMembers;field:confirmText
|
||||
summary: Type the number of people listed. Only needed when there are more than 25.
|
||||
- key: process:authentikAddMembers;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikRemoveMembers;slot:help
|
||||
summary: Removes people from the selected group by typing their usernames.
|
||||
content: |
|
||||
Only available when changes are switched on for this site. Needs the permission to manage memberships.
|
||||
|
||||
Enter one username per line. You can remove up to 100 people at a time; above 25 you must also type how many. The last person able to manage memberships and permissions cannot be removed: add another administrator first. Superuser groups cannot be managed here.
|
||||
|
||||
Review shows who will be removed before anything is sent to Authentik. See also **{{label:authentikRemoveFromGroup}}**, which starts from selected people.
|
||||
- key: process:authentikRemoveMembers;field:usernames
|
||||
summary: The usernames to remove, one per line.
|
||||
- key: process:authentikRemoveMembers;field:confirmText
|
||||
summary: Type the number of people listed. Only needed when there are more than 25.
|
||||
- key: process:authentikRemoveMembers;field:reason
|
||||
summary: Why you are making this change. It is saved in the audit trail.
|
||||
- key: process:authentikApprove;slot:help
|
||||
summary: Applies a change a colleague asked for, after you check it.
|
||||
content: |
|
||||
Needs the approve permission. It only exists while two-person approval is on.
|
||||
|
||||
You must be a different person from the one who asked, and the request must still be waiting and under 24 hours old. The change is checked again against live data, under your own permissions, then sent to Authentik. If it can no longer be applied the request is marked as failed and nothing is changed.
|
||||
|
||||
The result is recorded under **{{label:authentik_admin_action}}** with both names.
|
||||
- key: process:authentikReject;slot:help
|
||||
summary: Turns down a colleague's request. Nothing is changed in Authentik.
|
||||
content: |
|
||||
Needs the approve permission, and you must be a different person from the one who asked. The request is marked as rejected and stays in the list as a record.
|
||||
- key: widget:authentikHome;slot:label
|
||||
summary: 'The Identity home: what needs attention, people and group counts, recent changes and the connection to Authentik.'
|
||||
content: |
|
||||
Needs attention shows waiting approval requests, drift report problems, a read-only notice, and an expiring Authentik connection token. Recent changes lists the latest 8 entries from Admin Actions and needs the audit permission.
|
||||
- key: widget:authentikPolicyEditor;slot:label
|
||||
summary: 'The editor for a group''s policy: tick boxes for apps, tables, actions and reports, with a live document preview.'
|
||||
- key: widget:authentikPolicyEnforcement;slot:label
|
||||
summary: A banner saying whether saved policies are enforced when people sign in.
|
||||
content: |
|
||||
Green and "Enforced" means saved policies decide people's permissions at sign-in. "Not enforced" means they are saved but ignored for now. The text says why, for example that enforcement is switched off, or that switching it on would take permissions away from current administrators.
|
||||
- key: widget:authentikCompareAccess;slot:label
|
||||
summary: Shows, for every active person, the permissions they have today next to what saved policies would give them.
|
||||
content: |
|
||||
Needs the admin permission. Added shows what policies would give them that they lack today, and Removed shows what they would lose. Use it before switching policy enforcement on. Read only.
|
||||
- key: widget:authentikDriftReport;slot:label
|
||||
summary: Health checks on the Authentik setup, with errors listed first.
|
||||
content: |
|
||||
Needs the admin permission. It checks the Authentik version, blueprints, the service account, a protected break-glass superuser, that at least one person can administer identities, group attributes, and the service account's activity. Each finding is OK, info, warning or error. A check that cannot run is reported as an error with an unknown result.
|
||||
- key: widget:authentik_user_memberships;slot:label
|
||||
summary: The groups this person belongs to.
|
||||
- key: widget:authentik_user_sessions;slot:label
|
||||
summary: The person's current sign-in sessions. Needs the audit permission.
|
||||
- key: widget:authentik_user_tokens;slot:label
|
||||
summary: Details of the person's access tokens and app passwords, never the keys. Needs the audit permission.
|
||||
- key: widget:authentik_user_events;slot:label
|
||||
summary: The latest events Authentik recorded for this person. Needs the audit permission.
|
||||
- key: widget:authentik_group_members;slot:label
|
||||
summary: The people who are members of this group.
|
||||
- key: widget:authentik_group_policy;slot:label
|
||||
summary: The saved policy for this group, with who saved it and when.
|
||||
- key: widget:authentik_application_bindings;slot:label
|
||||
summary: The groups and people Authentik allows to open this application.
|
||||
- key: possibleValueSource:authentik_action_status;slot:help
|
||||
summary: The outcomes an attempted change can have in the audit trail.
|
||||
content: |
|
||||
Values:
|
||||
- **Waiting for approval**: a second person must approve it; nothing has been sent to Authentik.
|
||||
- **Running**: in progress.
|
||||
- **Succeeded**: the change was made.
|
||||
- **Failed**: it was sent to Authentik but did not work.
|
||||
- **Refused**: a safety rule or missing permission stopped it before anything was sent.
|
||||
- key: possibleValueSource:authentik_approval_status;slot:help
|
||||
summary: The states an approval request can be in.
|
||||
content: |
|
||||
Values:
|
||||
- **Waiting for approval**: nobody has decided yet.
|
||||
- **Approved and applied**: a second person approved it and the change was made.
|
||||
- **Rejected**: a second person said no; nothing changed.
|
||||
- **Expired**: nobody decided within 24 hours.
|
||||
- **Approved but failed**: it was approved but the change could not be made.
|
||||
- key: table:authentik_token;field:ownerPk
|
||||
summary: 'The Authentik identifier of the token’s owner.'
|
||||
- key: table:authentik_token;field:ownerType
|
||||
summary: 'Whether the token belongs to a person or a service account.'
|
||||
- key: table:authentik_token;field:ownerName
|
||||
summary: 'The displayed name of the token’s owner.'
|
||||
- key: table:authentik_token;field:managed
|
||||
summary: 'The management marker reported for this token.'
|
||||
- key: table:authentik_token;field:lastUsed
|
||||
summary: 'The last usage time available for this token.'
|
||||
- key: table:authentik_token;field:lastUsedNote
|
||||
summary: 'An explanation of the available token usage information.'
|
||||
- key: table:authentik_token;field:createdAt
|
||||
summary: 'The creation time available for this token.'
|
||||
- key: table:authentik_token;field:scopeSummary
|
||||
summary: 'A readable summary of the restrictions associated with this token.'
|
||||
- key: table:authentik_token_scope;slot:help
|
||||
summary: 'Locally stored access restrictions and reminder history for an Authentik token.'
|
||||
content: |
|
||||
Use **{{label:authentik_token_scope}}** to inspect the restrictions associated with an access token. These records contain identifiers and scope metadata, not the credential itself. Read-only mode and selected resources can narrow the access already held by the token’s owner; they cannot grant additional permissions.
|
||||
|
||||
Scope records also track creation, rotation and expiry reminders. Review them alongside **Tokens** when investigating access. Token creation and rotation actions maintain this metadata, and an empty scope dimension means that dimension adds no restriction.
|
||||
|
||||
**How rows get created:** Token actions maintain local scope and reminder metadata here. An empty list means no token scope records match your query.
|
||||
- key: table:authentik_token_scope;field:tokenIdentifier
|
||||
summary: 'The identifier of the Authentik token these restrictions belong to.'
|
||||
- key: table:authentik_token_scope;field:ownerUsername
|
||||
summary: 'The username of the token’s owner.'
|
||||
- key: table:authentik_token_scope;field:ownerPk
|
||||
summary: 'The Authentik identifier of the token’s owner.'
|
||||
- key: table:authentik_token_scope;field:readOnly
|
||||
summary: 'Whether the token is restricted to read operations.'
|
||||
content: |
|
||||
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
|
||||
- key: table:authentik_token_scope;field:createdBy
|
||||
summary: 'Who created this token scope record.'
|
||||
- key: table:authentik_token_scope;field:apps
|
||||
summary: 'The applications included in this token’s recorded scope.'
|
||||
content: |
|
||||
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
|
||||
- key: table:authentik_token_scope;field:tables
|
||||
summary: 'The tables included in this token’s recorded scope.'
|
||||
content: |
|
||||
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
|
||||
- key: table:authentik_token_scope;field:processes
|
||||
summary: 'The processes included in this token’s recorded scope.'
|
||||
content: |
|
||||
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
|
||||
- key: table:authentik_token_scope;field:reports
|
||||
summary: 'The reports included in this token’s recorded scope.'
|
||||
content: |
|
||||
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
|
||||
- key: table:authentik_token_scope;field:widgets
|
||||
summary: 'The widgets included in this token’s recorded scope.'
|
||||
content: |
|
||||
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
|
||||
- key: table:authentik_token_scope;field:operationIds
|
||||
summary: 'The individual operations included in this token’s recorded scope.'
|
||||
content: |
|
||||
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
|
||||
- key: table:authentik_token_scope;field:createdAt
|
||||
summary: 'When this token scope record was created.'
|
||||
- key: table:authentik_token_scope;field:rotatedAt
|
||||
summary: 'When the token associated with this scope was last rotated.'
|
||||
- key: table:authentik_token_scope;field:lastReminder7d
|
||||
summary: 'When the seven-day expiry reminder was last recorded.'
|
||||
- key: table:authentik_token_scope;field:lastReminder1d
|
||||
summary: 'When the one-day expiry reminder was last recorded.'
|
||||
- key: process:authentikTokenReminders;slot:help
|
||||
summary: 'Check approaching token expiries and issue the configured reminders.'
|
||||
content: |
|
||||
This background action checks tokens approaching their expiry and records reminder timing in **{{label:authentik_token_scope}}**. Use it to understand when owners were warned to renew or replace credentials. Reminders do not extend expiry or rotate a key automatically. Review **Tokens** for the current expiry, then use **{{label:authentikExtendToken}}**, **Rotate Token** or **Revoke Token** as appropriate for the credential.
|
||||
- key: process:authentikCreateToken;slot:help
|
||||
summary: 'Create a personal access token with an expiry and optional access restrictions.'
|
||||
content: |
|
||||
Use **Create Token** to create an app-password token for your own account. Review the expiry and any available scope restrictions before submitting. Scopes can narrow your permissions but cannot grant new access. The credential is shown once after creation; keep it in your intended credential store. The **Tokens** list and **{{label:authentik_token_scope}}** retain metadata rather than a recoverable copy of that credential.
|
||||
- key: process:authentikRotateToken;slot:help
|
||||
summary: 'Replace a token’s credential and invalidate the previous credential.'
|
||||
content: |
|
||||
Use **Rotate Token** when a credential needs replacement. Review the selected token and its ownership before continuing. Supported app-password tokens can be rotated; protected accounts require management in Authentik. Rotation invalidates the old credential, so update the consumers that use it. The new credential is shown once. Review **Tokens** and the recorded scope after completion.
|
||||
- key: process:authentikRevokeToken;slot:help
|
||||
summary: 'Invalidate a selected token so it can no longer authenticate.'
|
||||
content: |
|
||||
Use **Revoke Token** when access is no longer needed or a token should stop working. Review the identifier and owner before submitting because any consumer relying on that token will lose access. Revocation also removes its local scope metadata. It does not delete the owner’s account. Review the resulting **Tokens** list to confirm the selected token is no longer available.
|
||||
- key: process:authentikExtendToken;slot:help
|
||||
summary: 'Update the expiry of an eligible app-password token.'
|
||||
content: |
|
||||
Use **{{label:authentikExtendToken}}** when an existing eligible credential should remain valid for a longer allowed period. Review the owner and requested expiry before submitting. The action is limited to supported app-password tokens and permitted ownership; protected accounts are managed in Authentik. Extending resets expiry reminders but does not rotate the credential. Use **Rotate Token** when you need a replacement key.
|
||||
- key: process:authentikCreateServiceAccount;slot:help
|
||||
summary: 'Create a service account with checked group access and an expiring app password.'
|
||||
content: |
|
||||
Use **Create Service Account** for an integration that needs its own identity. Review the account name, requested groups and expiry before proceeding. Group checks and protection rules still apply. The account receives an app-password credential that is shown once, so store it for the intended integration. Inspect the resulting account and **Tokens** after creation, and use **Rotate Token** for later credential replacement.
|
||||
- key: widget:authentikAccessTokens;slot:label
|
||||
summary: 'Access tokens and their ownership, expiry and available management actions.'
|
||||
|
||||
- key: table:authentik_invitation;slot:help
|
||||
summary: Review enrollment invitations and their expiry dates in Authentik.
|
||||
content: |
|
||||
Use **{{label:authentik_invitation}}** to review invitations when your host has configured an enrollment flow. Check the email address and expiry before following up with someone. Use **{{label:authentikCancelInvitation}}** to withdraw an invitation.
|
||||
|
||||
**How rows get created:** **{{label:authentikInviteUser}}** creates a single-use invitation in Authentik and requests its delivery by email. You read invitations live from Authentik. An empty list means no invitations match your query.
|
||||
- key: table:authentik_invitation;field:name
|
||||
summary: The invitation name recorded in Authentik.
|
||||
- key: table:authentik_invitation;field:email
|
||||
summary: The email address supplied for the invited person.
|
||||
- key: table:authentik_invitation;field:singleUse
|
||||
summary: Whether the invitation can be redeemed only once.
|
||||
- key: table:authentik_invitation;field:expires
|
||||
summary: When the invitation stops being valid.
|
||||
- key: table:authentik_invitation;field:flowSlug
|
||||
summary: The enrollment flow used by this invitation.
|
||||
- key: table:authentik_invitation;field:createdBy
|
||||
summary: The username Authentik records as the invitation creator.
|
||||
- key: process:authentikInviteUser;slot:help
|
||||
summary: Invite a new person to enroll through Authentik by email.
|
||||
content: |
|
||||
Enter the person's email, an optional full name and a reason. You need permission to manage users, writes must be enabled, and your host must configure an enrollment flow. The email must not already belong to an existing user.
|
||||
|
||||
Review the invitation before continuing. Authentik creates a single-use invitation that expires after seven days and sends the email. You can inspect it in **{{label:authentik_invitation}}** or withdraw it with **{{label:authentikCancelInvitation}}**.
|
||||
- key: process:authentikCancelInvitation;slot:help
|
||||
summary: Withdraw an invitation so it can no longer be used to enroll.
|
||||
content: |
|
||||
Select the invitation and give a reason. Review your selection before continuing. You need permission to manage users and writes must be enabled.
|
||||
|
||||
This action deletes the invitation in Authentik. An invitation that is already absent needs no change. Cancelling an invitation does not delete an account that has already enrolled.
|
||||
- key: process:authentikDeleteUser;slot:help
|
||||
summary: Permanently delete an eligible account from Authentik.
|
||||
content: |
|
||||
Use **{{label:authentikDeactivateUser}}** if you want to keep the account and its history. Deletion is permanent and requires the host to enable deletion as well as user management and writes.
|
||||
|
||||
Give a reason and type the username to confirm, then check **Review** before continuing. You cannot delete protected accounts, your own account or the last identity administrator. You need permission to manage users.
|
||||
- key: process:authentikDeleteGroup;slot:help
|
||||
summary: Permanently delete an empty group and the access it grants.
|
||||
content: |
|
||||
Remove the group's members and child groups first. Deletion removes the group with its permissions, application access and policy. You need permission to manage groups, and your host must enable deletion, group management and writes.
|
||||
|
||||
Give a reason and type the group name to confirm. Check **Review** before continuing. Superuser groups and changes that would remove the last identity administrator are refused.
|
||||
- key: possibleValueSource:authentik_user;slot:help
|
||||
summary: Choose an Authentik user from the accounts visible to you.
|
||||
- key: possibleValueSource:authentik_group;slot:help
|
||||
summary: Choose an Authentik group for the membership action.
|
||||
@@ -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;
|
||||
|
||||
|
||||
import java.util.EnumSet;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Coverage of the shipped help with all supported optional metadata present.
|
||||
*******************************************************************************/
|
||||
class AuthentikHelpBundleTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** New objects and non-trivial fields must ship with valid help.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bundleCoversOwnedMetadata() throws Exception
|
||||
{
|
||||
var support = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS,
|
||||
AuthentikFeature.POLICIES, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.DELETE,
|
||||
AuthentikFeature.MEMBERSHIP, AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS));
|
||||
HelpBundleAssertions.forInstance(support.qInstance)
|
||||
.bundles("help/qbit-authentik.yaml")
|
||||
.owning(target -> target.name().startsWith("authentik"))
|
||||
.assertComplete();
|
||||
var bundle = HelpBundles.of(support.qInstance).getBundles().get(0);
|
||||
assertThat(bundle.getVersion()).isEqualTo(AuthentikQBitProducer.getVersion());
|
||||
var keys = bundle.getEntries().stream().map(entry -> entry.getKey()).toList();
|
||||
assertThat(keys).doesNotHaveDuplicates();
|
||||
// Core's gate currently requires apps, tables, actions and table fields.
|
||||
// Also pin coverage for this qbit's widgets and possible-value sources.
|
||||
support.qInstance.getWidgets().keySet().stream().filter(name -> name.startsWith("authentik"))
|
||||
.forEach(name -> assertThat(keys).contains("widget:" + name + ";slot:label"));
|
||||
support.qInstance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("authentik"))
|
||||
.forEach(name -> assertThat(keys).contains("possibleValueSource:" + name + ";slot:help"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata-only registration is safe without help storage and follows prefixes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registersWithoutHelpStorage() throws Exception
|
||||
{
|
||||
var instance = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig().withTableNamePrefix("idp"));
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
var bundles = HelpBundles.of(instance).getBundles();
|
||||
assertThat(bundles).hasSize(1);
|
||||
assertThat(bundles.get(0).getEntries()).anySatisfy(entry ->
|
||||
{
|
||||
assertThat(entry.getKey()).isEqualTo("table:idp_user;slot:help");
|
||||
assertThat(entry.getContent()).contains("{{label:idp_user}}").doesNotContain("{{label:authentik");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -236,4 +236,17 @@ class AuthentikQBitConfigTest
|
||||
assertThat(config.getTokenBrokerClientSecret()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The startup log lists TOKENS when it is on by default (writes enabled).
|
||||
*******************************************************************************/
|
||||
@org.junit.jupiter.api.Test
|
||||
void effectiveFeaturesIncludeDefaultTokens()
|
||||
{
|
||||
AuthentikQBitConfig writes = new AuthentikQBitConfig().withFeatures(java.util.EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS)).withTokensEnabled(true);
|
||||
org.assertj.core.api.Assertions.assertThat(writes.getEffectiveFeatures()).contains(AuthentikFeature.TOKENS, AuthentikFeature.READ, AuthentikFeature.USERS);
|
||||
AuthentikQBitConfig readOnly = new AuthentikQBitConfig().withFeatures(java.util.EnumSet.of(AuthentikFeature.READ));
|
||||
org.assertj.core.api.Assertions.assertThat(readOnly.getEffectiveFeatures()).containsExactly(AuthentikFeature.READ);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every authentik_* table:
|
||||
** - it is used at runtime: an API-backed table through its backend (every row
|
||||
** is read live from Authentik by AuthentikActionUtil, so it has no rows of
|
||||
** its own); a local table by runtime code that reads or writes it, not only
|
||||
** its metadata or the home screen;
|
||||
** - it has nothing to seed (setup comes from config; every table is live data
|
||||
** or a log), and says why in STARTS_EMPTY;
|
||||
** - it has bundle help that says what it is for and how its rows get created
|
||||
** (shown on the table screen and when it is empty).
|
||||
** A new table fails this test until it is wired, explained and documented.
|
||||
*******************************************************************************/
|
||||
class AuthentikTablesGuardrailTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "authentik");
|
||||
|
||||
/*******************************************************************************
|
||||
** The API-backed tables: rows are read live from Authentik, never stored.
|
||||
*******************************************************************************/
|
||||
static final List<String> API_TABLES = List.of("authentik_user", "authentik_group", "authentik_group_member", "authentik_session", "authentik_event",
|
||||
"authentik_application", "authentik_policy_binding", "authentik_blueprint", "authentik_role", "authentik_token", "authentik_invitation");
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's own tables, with the constant runtime code uses to reach them.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> LOCAL_TABLES = Map.of(
|
||||
"authentik_admin_action", "AuthentikLocalTables.ADMIN_ACTION",
|
||||
"authentik_approval_request", "AuthentikLocalTables.APPROVAL_REQUEST",
|
||||
"authentik_policy", "AuthentikLocalTables.POLICY",
|
||||
"authentik_token_scope", "AuthentikLocalTables.TOKEN_SCOPE");
|
||||
|
||||
/*******************************************************************************
|
||||
** Why no table is seeded.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("authentik_user", "live Authentik data, no rows of its own; setup is the qbit config"),
|
||||
Map.entry("authentik_group", "live Authentik data, no rows of its own"),
|
||||
Map.entry("authentik_group_member", "virtual: computed live from group member lists"),
|
||||
Map.entry("authentik_session", "live Authentik data: signed-in sessions"),
|
||||
Map.entry("authentik_event", "live Authentik data: its event log"),
|
||||
Map.entry("authentik_application", "live Authentik data, defined by blueprints in Authentik"),
|
||||
Map.entry("authentik_policy_binding", "live Authentik data, defined on applications in Authentik"),
|
||||
Map.entry("authentik_blueprint", "live Authentik data: blueprint status"),
|
||||
Map.entry("authentik_role", "live Authentik data: RBAC roles"),
|
||||
Map.entry("authentik_token", "live Authentik data: token metadata, never the key"),
|
||||
Map.entry("authentik_invitation", "live Authentik data: pending invitations"),
|
||||
Map.entry("authentik_admin_action", "log: one row per action a person runs"),
|
||||
Map.entry("authentik_approval_request", "log: a second-person approval is requested"),
|
||||
Map.entry("authentik_policy", "history: written when a person saves a group policy"),
|
||||
Map.entry("authentik_token_scope", "history: written when a scoped token is created"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every produced table is accounted for here, and the other way round.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsKnown() throws Exception
|
||||
{
|
||||
QInstance instance = support().qInstance;
|
||||
List<String> produced = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("authentik_")).sorted().toList();
|
||||
assertThat(produced).containsExactlyInAnyOrderElementsOf(STARTS_EMPTY.keySet());
|
||||
assertThat(STARTS_EMPTY.keySet()).containsAll(API_TABLES).containsAll(LOCAL_TABLES.keySet());
|
||||
assertThat(STARTS_EMPTY).hasSize(API_TABLES.size() + LOCAL_TABLES.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used at runtime: an API table through its backend, a local
|
||||
** table by non-metadata code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
AdminTestSupport support = support();
|
||||
for(String table : API_TABLES)
|
||||
{
|
||||
QTableMetaData metaData = support.qInstance.getTable(table);
|
||||
assertThat(metaData).as(table).isNotNull();
|
||||
assertThat(metaData.getBackendName()).as(table + " is served by the Authentik API backend").isEqualTo(support.config.getApiBackendName());
|
||||
assertThat(AuthentikTables.get(specName(table))).as(table + " has an API spec").isNotNull();
|
||||
}
|
||||
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")))
|
||||
.filter(p -> !p.endsWith("AuthentikLocalTables.java"))
|
||||
.map(AuthentikTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(Map.Entry<String, String> local : LOCAL_TABLES.entrySet())
|
||||
{
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> source.contains(local.getValue())))
|
||||
.as(local.getKey() + " (" + local.getValue() + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
assertThat(support.qInstance.getTable(local.getKey()).getBackendName()).isEqualTo(AdminTestSupport.LOCAL);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created,
|
||||
** and what an empty list means; the API-backed ones say rows are live.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
QInstance instance = support().qInstance;
|
||||
assertThat(AuthentikHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
for(String table : STARTS_EMPTY.keySet())
|
||||
{
|
||||
List<QHelpContent> help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(AuthentikHelp.SLOT);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
String text = help.get(0).getContent();
|
||||
assertThat(text).as(table + " help says how rows get created").contains("How rows get created");
|
||||
assertThat(text).as(table + " help says what empty means").containsAnyOf("An empty list means", "An empty table means");
|
||||
if(API_TABLES.contains(table))
|
||||
{
|
||||
assertThat(text).as(table + " help says rows are live").containsAnyOf("live", "Live");
|
||||
}
|
||||
}
|
||||
assertThat(instance.getApp("authentikAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host with its own table prefix gets the same help on the renamed tables,
|
||||
** and a host's own help is never replaced.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void helpFollowsTheHostPrefixAndKeepsHostHelp() throws Exception
|
||||
{
|
||||
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withTableNamePrefix("idp");
|
||||
QInstance idp = AuthentikTestSupport.produce(config);
|
||||
assertThat(idp.getTable("idp_user").getHelpContent().get(AuthentikHelp.SLOT).get(0).getContent()).contains("How rows get created");
|
||||
assertThat(idp.getApp("idpAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT);
|
||||
|
||||
QInstance hosted = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig());
|
||||
hosted.getTable("authentik_user").getHelpContent().put(AuthentikHelp.SLOT, List.of(new QHelpContent().withContentAsMarkdown("host text")));
|
||||
AuthentikHelp.apply(hosted, AuthentikTestSupport.unitConfig());
|
||||
assertThat(hosted.getTable("authentik_user").getHelpContent().get(AuthentikHelp.SLOT)).hasSize(1);
|
||||
assertThat(hosted.getTable("authentik_user").getHelpContent().get(AuthentikHelp.SLOT).get(0).getContent()).isEqualTo("host text");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every feature on, local tables on a memory backend, an invitation flow set.
|
||||
*******************************************************************************/
|
||||
private static AdminTestSupport support() throws Exception
|
||||
{
|
||||
return (AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS, AuthentikFeature.POLICIES, AuthentikFeature.USERS,
|
||||
AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "authentik_group_member" -> "groupMember" (the spec's own name).
|
||||
*******************************************************************************/
|
||||
private static String specName(String table)
|
||||
{
|
||||
String name = table.substring("authentik_".length());
|
||||
for(String spec : AuthentikTables.all().keySet())
|
||||
{
|
||||
if(AuthentikQBitConfig.toSnakeCase(spec).equals(name))
|
||||
{
|
||||
return (spec);
|
||||
}
|
||||
}
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -174,4 +174,18 @@ public class SchemaDriftTest
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Login aggregation uses only parameters documented by the pinned schema.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tokenActivitySurfacePinsLoginOrderingWithoutUnsupportedDateFilter()
|
||||
{
|
||||
assertThat(AuthentikApiSurface.operations()).anySatisfy(operation ->
|
||||
{
|
||||
assertThat(operation.path()).isEqualTo("/events/events/");
|
||||
assertThat(operation.method()).isEqualTo("get");
|
||||
assertThat(operation.params()).contains("action", "ordering", "page", "page_size").doesNotContain("created__gt");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* 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 java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
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.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.QServicePrincipalMetaData;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentitiesMetaData;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host maintenance uses the audited Save Policy path as the maintenance service
|
||||
** principal (a local principal here: the test instance uses MOCK auth).
|
||||
*******************************************************************************/
|
||||
class AuthentikMaintenanceTest
|
||||
{
|
||||
private static final String GROUP = "kof-website-analysts";
|
||||
private static final String OPERATOR = "run-once:KOF22_RUN_ONCE";
|
||||
private static final String REASON = " owner request 2026-10-14: full-access policy for kof22-admins ";
|
||||
private static final String PRINCIPAL_ID = "service:maintenance";
|
||||
private static final String AUDIT_REASON = OPERATOR + ": " + REASON;
|
||||
|
||||
private AdminTestSupport t;
|
||||
private String fullAccess;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A real qbit instance backed by the in-memory Authentik API and audit store.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.GROUPS, AuthentikFeature.POLICIES));
|
||||
fullAccess = AuthentikMaintenance.fullAccessDocument(t.config);
|
||||
Set<String> all = new java.util.TreeSet<>(PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog()));
|
||||
all.add(IdentityPermission.POLICIES_MANAGE.getSessionPermission());
|
||||
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("maintenance").withLocalPermissions(all.toArray(new String[0])));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A system job runs as the maintenance principal (its own grants, nothing
|
||||
** minted here). The audit precedes PATCH, the operator is the principal,
|
||||
** the reason names the job, and an equivalent document is a no-op.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appliesFullAccessAuditedAndIdempotently() throws Exception
|
||||
{
|
||||
QContext.setQSession(new QSystemUserSession());
|
||||
QContext.getActionStack().push(new QueryInput());
|
||||
QContext.enterAuthorizedActionScope();
|
||||
CapturedContext caller = QContext.capture();
|
||||
Set<String> all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog());
|
||||
t.authentik.user(10, "collision", OPERATOR, "internal", true);
|
||||
t.config.withBootstrapOperatorSubjects("10");
|
||||
FixtureActionUtil.handler = request ->
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
assertThat(session).isNotInstanceOf(QSystemUserSession.class);
|
||||
assertThat(ServiceIdentities.isMintedServiceSession(session)).isTrue();
|
||||
assertThat(session.getUser().getIdReference()).isEqualTo(PRINCIPAL_ID);
|
||||
assertThat(session.getPermissions()).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
assertThat(QContext.getActionStack()).isNotSameAs(caller.actionStack());
|
||||
assertThat(QContext.getAuthorizedActionScopeDepth()).isZero();
|
||||
if(request.getMethod().equals("PATCH"))
|
||||
{
|
||||
try
|
||||
{
|
||||
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
|
||||
assertThat(row.getValueString("status")).isEqualTo("RUNNING"));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new AssertionError(e));
|
||||
}
|
||||
}
|
||||
return (t.authentik.handle(request));
|
||||
};
|
||||
|
||||
AuthentikAdmin.AdminResult result = apply(GROUP, fullAccess);
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
assertThat(result.noChange()).isFalse();
|
||||
assertThat(result.pending()).isFalse();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
assertThat(t.authentik.groupByName(GROUP).getJSONObject("attributes").getString(t.config.getPolicyAttribute())).isEqualTo(fullAccess);
|
||||
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("operator")).isEqualTo(PRINCIPAL_ID);
|
||||
assertThat(row.getValueString("reason")).isEqualTo(AUDIT_REASON);
|
||||
assertThat(row.getValueString("status")).isEqualTo("SUCCEEDED");
|
||||
});
|
||||
assertThat(t.authentik.events).singleElement().satisfies(event ->
|
||||
{
|
||||
assertThat(event.getString("action")).isEqualTo("custom_qqq_admin_action");
|
||||
assertThat(event.getJSONObject("context").getString("operator")).isEqualTo(PRINCIPAL_ID);
|
||||
assertThat(event.getJSONObject("context").getString("reason")).isEqualTo(AUDIT_REASON);
|
||||
});
|
||||
assertThat(result.eventPk()).isNotBlank();
|
||||
int writes = t.writes().size();
|
||||
assertThat(apply(GROUP, new JSONObject(fullAccess).toString(2)).noChange()).isTrue();
|
||||
assertThat(t.writes()).hasSize(writes);
|
||||
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).hasSize(1);
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
assertThat(FixtureActionUtil.REQUESTS).noneMatch(request -> request.getURI().getPath().contains("/core/users/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary policies need only their grants and policies.manage (held by the
|
||||
** principal, not added here), and honor
|
||||
** the configured spec/version. The maintenance: prefix is accepted too.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appliesNamedPolicyWithLimitedSession() throws Exception
|
||||
{
|
||||
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("policy-maintenance")
|
||||
.withLocalPermissions("cms_page.read", IdentityPermission.POLICIES_MANAGE.getSessionPermission()));
|
||||
t.config.withMaintenancePrincipal("policy-maintenance");
|
||||
t.config.withPolicySpec("host-iam");
|
||||
String document = AuthentikMaintenance.fullAccessDocument(t.config).replace("\"Action\":[\"*\"]", "\"Action\":[\"read\"]")
|
||||
.replace("\"Resource\":[\"*\"]", "\"Resource\":[\"cms_page\"]");
|
||||
FixtureActionUtil.handler = request ->
|
||||
{
|
||||
assertThat(QContext.getQSession().getPermissions()).containsExactlyInAnyOrder("cms_page.read", IdentityPermission.POLICIES_MANAGE.getSessionPermission());
|
||||
return (t.authentik.handle(request));
|
||||
};
|
||||
AuthentikAdmin.AdminResult result = AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), GROUP, document, "maintenance:policy", REASON);
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
assertThat(t.authentik.groupByName(GROUP).getJSONObject("attributes").getString(t.config.getPolicyAttribute())).contains("host-iam", "cms_page");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Superuser ancestry remains protected, including when the policy matches.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesSuperuserDescendant() throws Exception
|
||||
{
|
||||
t.authentik.group("protected-child", false, List.of(), List.of(), "authentik Admins");
|
||||
t.authentik.groupByName("protected-child").getJSONObject("attributes").put(t.config.getPolicyAttribute(), fullAccess);
|
||||
CapturedContext caller = QContext.capture();
|
||||
AuthentikAdmin.AdminResult result = apply("protected-child", fullAccess);
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).contains("Superuser groups");
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact names must resolve uniquely; neither missing nor ambiguous is safe.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesMissingAndAmbiguousGroups() throws Exception
|
||||
{
|
||||
CapturedContext caller = QContext.capture();
|
||||
assertThatThrownBy(() -> apply("kof-website", fullAccess)).hasMessageContaining("exactly one");
|
||||
t.authentik.group("duplicate", false, List.of(), List.of());
|
||||
t.authentik.groupByName("duplicate").put("name", GROUP);
|
||||
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("exactly one");
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid inputs fail without contacting Authentik or altering the caller.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validatesInputs() throws Exception
|
||||
{
|
||||
CapturedContext caller = QContext.capture();
|
||||
for(String name : new String[] { null, "", "operator@example.test", "run-once:", "maintenance: " })
|
||||
{
|
||||
assertThatThrownBy(() -> AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), GROUP, fullAccess, name, REASON))
|
||||
.hasMessageContaining("operator name");
|
||||
}
|
||||
assertThatThrownBy(() -> AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), GROUP, fullAccess, OPERATOR, " "))
|
||||
.hasMessageContaining("reason");
|
||||
assertThatThrownBy(() -> apply(" ", fullAccess)).hasMessageContaining("group name");
|
||||
assertThatThrownBy(() -> apply(GROUP, "{bad json")).hasMessageContaining("policy is not valid");
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The kill switch and feature gate are applied even to maintenance jobs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesReadOnlyAndDisabledPolicies() throws Exception
|
||||
{
|
||||
CapturedContext caller = QContext.capture();
|
||||
t.config.withReadOnly(true);
|
||||
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("read-only");
|
||||
t.config.withReadOnly(false).withFeatures(EnumSet.of(AuthentikFeature.READ));
|
||||
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("POLICIES");
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Approval remains a second person's decision; maintenance only queues it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void queuesTwoPersonApproval() throws Exception
|
||||
{
|
||||
t.config.withPolicyTwoPersonApproval(true);
|
||||
CapturedContext caller = QContext.capture();
|
||||
AuthentikAdmin.AdminResult result = apply(GROUP, fullAccess);
|
||||
assertThat(result.ok()).isTrue();
|
||||
assertThat(result.pending()).isTrue();
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
|
||||
assertThat(row.getValueString("status")).isEqualTo("PENDING_APPROVAL"));
|
||||
assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("requester")).isEqualTo(PRINCIPAL_ID);
|
||||
assertThat(row.getValueString("reason")).isEqualTo(AUDIT_REASON);
|
||||
});
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A change after the name lookup is caught when execute plans against live data.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesStaleGroup() throws Exception
|
||||
{
|
||||
FixtureActionUtil.handler = request ->
|
||||
{
|
||||
if(request.getURI().getPath().equals("/api/v3/core/groups/"))
|
||||
{
|
||||
var response = t.authentik.handle(request);
|
||||
t.authentik.groupByName(GROUP).getJSONObject("attributes").put("qqq.io/changed", true);
|
||||
return (response);
|
||||
}
|
||||
return (t.authentik.handle(request));
|
||||
};
|
||||
AuthentikAdmin.AdminResult result = apply(GROUP, fullAccess);
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).contains("was changed");
|
||||
assertThat(t.writes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed audit persistence prevents the write and still restores the caller.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void auditFailurePreventsWrite() throws Exception
|
||||
{
|
||||
CapturedContext caller = QContext.capture();
|
||||
t.qInstance.getTables().remove(t.config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
|
||||
assertThatThrownBy(() -> apply(GROUP, fullAccess)).isInstanceOf(QException.class);
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct system writes are still refused, even with a previously minted permit.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void systemSessionStillCannotWrite() throws Exception
|
||||
{
|
||||
var util = t.admin().getUtil();
|
||||
var authorization = util.getRuntime().getGuard().authorize(AdminAction.SAVE_POLICY);
|
||||
QSystemUserSession system = new QSystemUserSession();
|
||||
system.setUser(QContext.getQSession().getUser());
|
||||
QContext.setQSession(system);
|
||||
assertThatThrownBy(() -> util.getRuntime().getGuard().authorize(AdminAction.SAVE_POLICY)).hasMessageContaining("System sessions");
|
||||
assertThatThrownBy(() -> util.getRuntime().getGuard().checkWrite(AuthentikTables.get(AuthentikTables.GROUP), "update")).hasMessageContaining("System sessions");
|
||||
assertThatThrownBy(() -> util.write(authorization, "PATCH", "/core/groups/" + t.authentik.pk(GROUP) + "/", new JSONObject()))
|
||||
.hasMessageContaining("System sessions");
|
||||
assertThat(t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(GROUP), REASON)
|
||||
.with(AuthentikAdmin.POLICY_DOCUMENT, fullAccess)).ok()).isFalse();
|
||||
assertThat(t.writes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a declared principal, or with one that lacks the grants, nothing
|
||||
** is written: maintenance never creates authority.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void needsAPrincipalHoldingTheGrants() throws Exception
|
||||
{
|
||||
CapturedContext caller = QContext.capture();
|
||||
t.config.withMaintenancePrincipal("undeclared");
|
||||
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("not declared");
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
|
||||
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("weak")
|
||||
.withLocalPermissions(IdentityPermission.POLICIES_MANAGE.getSessionPermission()));
|
||||
t.config.withMaintenancePrincipal("weak");
|
||||
AuthentikAdmin.AdminResult result;
|
||||
try
|
||||
{
|
||||
result = apply(GROUP, fullAccess);
|
||||
assertThat(result.ok()).isFalse();
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
assertThat(e).isNotNull();
|
||||
}
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Maintenance never changes a service principal's role group.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesServicePrincipalGroups() throws Exception
|
||||
{
|
||||
t.authentik.group("service:kof22-scheduler", false, List.of(), List.of());
|
||||
assertThatThrownBy(() -> apply("service:kof22-scheduler", fullAccess)).hasMessageContaining("Service principal groups");
|
||||
assertThat(AuthentikMaintenance.isServicePrincipalGroup(new JSONObject().put("name", "x")
|
||||
.put("parents_obj", new org.json.JSONArray().put(new JSONObject().put("name", "qqq-service-accounts"))))).isTrue();
|
||||
assertThat(t.writes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply using the production public API.
|
||||
*******************************************************************************/
|
||||
private AuthentikAdmin.AdminResult apply(String group, String document) throws QException
|
||||
{
|
||||
return (AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), group, document, OPERATOR, REASON));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect the real local audit and approval tables.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows(String table) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput();
|
||||
input.setTableName(t.config.applyTablePrefix(table));
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
}
|
||||
@@ -460,8 +460,38 @@ public class FakeAuthentik
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(query.containsKey("is_active") && user.optBoolean("is_active") != Boolean.parseBoolean(first(query, "is_active")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(query.containsKey("path_startswith") && !user.optString("path").startsWith(first(query, "path_startswith")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
List<String> names = groupNamesOf(user.getInt("pk"));
|
||||
if(query.containsKey("groups_by_name") && names.stream().noneMatch(query.get("groups_by_name")::contains))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(query.containsKey("groups_by_pk") && names.stream().map(this::pk).noneMatch(query.get("groups_by_pk")::contains))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
rs.add(rendered);
|
||||
}
|
||||
if("username".equals(first(query, "ordering")))
|
||||
{
|
||||
rs.sort(java.util.Comparator.comparing(user -> user.getString("username")));
|
||||
}
|
||||
if(query.containsKey("page_size"))
|
||||
{
|
||||
int size = Integer.parseInt(first(query, "page_size"));
|
||||
int number = Integer.parseInt(first(query, "page"));
|
||||
int from = Math.min(rs.size(), (number - 1) * size);
|
||||
int to = Math.min(rs.size(), from + size);
|
||||
return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", rs.size()).put("current", number)
|
||||
.put("total_pages", Math.max(1, (rs.size() + size - 1) / size))).put("results", new JSONArray(rs.subList(from, to)))));
|
||||
}
|
||||
return (page(rs));
|
||||
}
|
||||
if(path.matches("/core/users/\\d+/"))
|
||||
@@ -532,7 +562,22 @@ public class FakeAuthentik
|
||||
}
|
||||
if(path.equals("/events/events/"))
|
||||
{
|
||||
return (page(loginEvents));
|
||||
List<JSONObject> selected = new ArrayList<>(loginEvents);
|
||||
String action = first(query, "action");
|
||||
if(action != null)
|
||||
{
|
||||
selected.removeIf(event -> !action.equals(event.optString("action")));
|
||||
}
|
||||
if("-created".equals(first(query, "ordering")))
|
||||
{
|
||||
selected.sort(java.util.Comparator.comparing((JSONObject event) -> java.time.Instant.parse(event.getString("created"))).reversed());
|
||||
}
|
||||
int size = query.containsKey("page_size") ? Integer.parseInt(first(query, "page_size")) : 100;
|
||||
int number = query.containsKey("page") ? Integer.parseInt(first(query, "page")) : 1;
|
||||
int from = Math.min(selected.size(), (number - 1) * size);
|
||||
int to = Math.min(selected.size(), from + size);
|
||||
return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", selected.size()).put("current", number)
|
||||
.put("total_pages", Math.max(1, (selected.size() + size - 1) / size))).put("results", new JSONArray(selected.subList(from, to)))));
|
||||
}
|
||||
if(path.equals("/managed/blueprints/") || path.equals("/core/applications/"))
|
||||
{
|
||||
|
||||
@@ -175,7 +175,7 @@ class PolicyCutoverTest
|
||||
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");
|
||||
assertThat(home).contains("System Health").doesNotContain("Policies at sign-in");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,12 +22,17 @@ import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
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.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
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.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
|
||||
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
@@ -64,12 +69,97 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
*******************************************************************************/
|
||||
class PolicyEditorTest
|
||||
{
|
||||
private static final String FULL_ACCESS = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"*\"],\"Resource\":[\"*\"]}]}";
|
||||
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;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full access requires every current grant, even when some were already in
|
||||
** the target policy. An operator holding all grants may save it explicitly.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fullAccessSubsetRuleAndEditorFlag() throws Exception
|
||||
{
|
||||
String group = "kof-website-analysts";
|
||||
assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getFullAccess()).isFalse();
|
||||
assertThat(save(group, FULL_ACCESS).message()).contains("do not hold yourself", "cms_page.delete");
|
||||
assertThat(t.changes()).isEmpty();
|
||||
Set<String> all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog());
|
||||
Set<String> almostAll = new TreeSet<>(all);
|
||||
almostAll.remove("cms_page.delete");
|
||||
t.config.withHostPermissionSource((email, groups) -> almostAll);
|
||||
assertThat(save(group, FULL_ACCESS).message()).contains("do not hold yourself", "cms_page.delete");
|
||||
t.config.withHostPermissionSource((email, groups) -> all);
|
||||
AdminPlan plan = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(group), "because")
|
||||
.with(AuthentikAdmin.POLICY_DOCUMENT, FULL_ACCESS));
|
||||
assertThat(plan.isRunnable()).as(plan.getMessage()).isTrue();
|
||||
assertThat(plan.getAfter()).isEqualTo(PolicyPermissions.FULL_ACCESS_LABEL);
|
||||
assertThat(save(group, FULL_ACCESS).ok()).isTrue();
|
||||
assertThat(t.authentik.groupByName(group).getJSONObject("attributes").getString("qqq.io/policy")).isEqualTo(FULL_ACCESS);
|
||||
PolicyEditorWidget.Data widget = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
|
||||
assertThat(widget.getFullAccess()).isTrue();
|
||||
assertThat(new JSONObject(widget).getBoolean("fullAccess")).isTrue();
|
||||
assertThat(policyRows().get(0).getValueInteger("permissionCount")).isEqualTo(all.size());
|
||||
assertThat(widget.getDocumentProblem()).isNull();
|
||||
assertThat(widget.getDocument()).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
t.config.withHostPermissionSource((email, groups) -> Set.of());
|
||||
assertThat(save(group, FULL_ACCESS).message()).contains("do not hold yourself");
|
||||
assertThat(save(group, PAGE_READ_EDIT).ok()).isTrue();
|
||||
assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getFullAccess()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A full-access operator can grant full access. Both mapper paths, comparison
|
||||
** and preview bases expand it, including grants outside the editor matrix.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fullAccessGraphClaimsComparisonAndMemberPreview() throws Exception
|
||||
{
|
||||
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/policy", FULL_ACCESS);
|
||||
assertThat(save("kof-website-analysts", FULL_ACCESS).ok()).isTrue();
|
||||
t.authentik.user(10, "full-member", "full@example.test", "internal", true, "kof-website-analysts", "kof-website-publishers");
|
||||
Set<String> all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog());
|
||||
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper();
|
||||
assertThat(mapper.resolve(t.qInstance, List.of("kof-website-analysts"), "10", "full@example.test", true))
|
||||
.containsAll(all).containsAll(PermissionsHelper.getAllAvailablePermissionNames(t.qInstance)).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
t.config.withMapperEnabled(true).withMapperOverride(true);
|
||||
QSession session = new QSession();
|
||||
JSONObject claims = new JSONObject().put("groups", List.of()).put("sub", "10").put("qqq_policy", new JSONObject(FULL_ACCESS));
|
||||
mapper.customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
|
||||
assertThat(session.getPermissions()).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
assertThat(AccessComparison.run(t.config, t.admin().getUtil(), t.admin().getUtil().getRuntime().getPermissionCatalog()))
|
||||
.anyMatch(row -> row.username().equals("full-member") && row.after().containsAll(all));
|
||||
Map<String, Object> member = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-publishers")).getMembers().stream()
|
||||
.filter(row -> row.get("username").equals("full-member")).findFirst().orElseThrow();
|
||||
assertThat((List<String>) member.get("basePermissions")).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
Map<String, Object> excluded = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-analysts")).getMembers().stream()
|
||||
.filter(row -> row.get("username").equals("full-member")).findFirst().orElseThrow();
|
||||
assertThat((List<String>) excluded.get("basePermissions")).doesNotContain("cms_page.delete");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Removing a full-access policy must not remove the last qualifying admin.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fullAccessPolicyProtectsLastAdmin() throws Exception
|
||||
{
|
||||
JSONObject attributes = t.authentik.groupByName("identity-admins").getJSONObject("attributes");
|
||||
attributes.put("qqq.io/permissions", List.of()).remove("qqq.io/policy");
|
||||
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", FULL_ACCESS);
|
||||
t.authentik.user(10, "last-admin", "last@example.test", "internal", true, "kof-website-analysts");
|
||||
assertThat(save("kof-website-analysts", "").message()).contains("leave nobody");
|
||||
assertThat(t.changes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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).
|
||||
@@ -350,6 +440,7 @@ class PolicyEditorTest
|
||||
PolicyEditorWidget.Data broken = PolicyEditorWidget.load(t.config, groupPk);
|
||||
assertThat(broken.getDocumentProblem()).contains("not a JSON object");
|
||||
assertThat(broken.getDocument()).isNull();
|
||||
assertThat(broken.getFullAccess()).isFalse();
|
||||
|
||||
assertThat(PolicyEditorWidget.load(t.config, null).getGroupLabel()).isNull();
|
||||
assertThatThrownBy(() -> PolicyEditorWidget.load(t.config, "../x")).hasMessageContaining("Not a group id");
|
||||
@@ -360,4 +451,154 @@ class PolicyEditorTest
|
||||
RenderWidgetOutput output = new RenderWidgetAction().execute(input);
|
||||
assertThat(output.getWidgetData().getType()).isEqualTo("AUTHENTIK_POLICY_EDITOR");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct and descendant members get all other grants, including inherited
|
||||
** policies, wildcard attributes and static permissions of direct groups.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void memberPreviewPreservesOtherGrants() throws Exception
|
||||
{
|
||||
String group = "kof-website-analysts";
|
||||
t.authentik.groupByName(group).getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT);
|
||||
t.authentik.group("child", false, List.of(), List.of(), group)
|
||||
.group("grandchild", false, List.of(), List.of(), "child")
|
||||
.group("other-parent", false, List.of(), List.of())
|
||||
.group("other", false, List.of("cms_page.*"), List.of(), "other-parent")
|
||||
.user(10, "zoe", "zoe@example.test", "internal", true, group, "child", "other")
|
||||
.user(11, "amy", "amy@example.test", "external", true, "child", "other-parent")
|
||||
.user(12, "ben", "ben@example.test", "internal", true, "grandchild")
|
||||
.user(13, "inactive", "inactive@example.test", "internal", false, group);
|
||||
t.authentik.groupByName("other-parent").getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT.replace("\"read\",\"edit\"", "\"insert\""));
|
||||
t.config.withStaticGroupPermissions(Map.of("child", List.of("static.floor"), group, List.of("direct.floor")));
|
||||
|
||||
PolicyEditorWidget.Data data = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
|
||||
assertThat(data.getMembers()).extracting(m -> m.get("username")).containsExactly("amy", "ben", "zoe");
|
||||
assertThat(data.getMembersTruncated()).isFalse();
|
||||
assertThat(data.getMembers().get(0)).containsExactlyInAnyOrderEntriesOf(Map.of(
|
||||
"username", "amy", "label", "amy Name (amy)", "viaGroups", List.of("child"),
|
||||
"basePermissions", List.of("cms_page.insert", "kof22.analytics.hasAccess", "static.floor")));
|
||||
assertThat(data.getMembers().get(1)).containsEntry("viaGroups", List.of("grandchild"))
|
||||
.containsEntry("basePermissions", List.of("kof22.analytics.hasAccess"));
|
||||
assertThat(data.getMembers().get(2)).containsEntry("viaGroups", List.of("child", group))
|
||||
.containsEntry("basePermissions", List.of("cms_page.delete", "cms_page.edit", "cms_page.insert", "cms_page.read", "direct.floor", "kof22.analytics.hasAccess", "static.floor"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cap is applied after eligibility and username ordering, and duplicate
|
||||
** memberships do not consume multiple slots. Exactly 200 is not truncated.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void memberPreviewCapsEligiblePeople() throws Exception
|
||||
{
|
||||
String group = "kof-website-analysts";
|
||||
t.authentik.group("child", false, List.of(), List.of(), group);
|
||||
for(int i = 199; i >= 0; i--)
|
||||
{
|
||||
String username = "person%03d".formatted(i);
|
||||
t.authentik.user(100 + i, username, username + "@example.test", "internal", true, group, "child");
|
||||
}
|
||||
t.authentik.user(400, "aaa-inactive", "inactive@example.test", "internal", false, group);
|
||||
PolicyEditorWidget.Data exact = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
|
||||
assertThat(exact.getMembers()).hasSize(200);
|
||||
assertThat(exact.getMembersTruncated()).isFalse();
|
||||
|
||||
t.authentik.user(401, "aaa-active", "active@example.test", "internal", true, "child");
|
||||
t.admin().getUtil().getRuntime().invalidateCache();
|
||||
FixtureActionUtil.REQUESTS.clear();
|
||||
PolicyEditorWidget.Data capped = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
|
||||
assertThat(capped.getMembers()).hasSize(200);
|
||||
assertThat(capped.getMembersTruncated()).isTrue();
|
||||
assertThat(capped.getMembers()).extracting(m -> (String) m.get("username")).isSorted().doesNotContain("person199", "aaa-inactive");
|
||||
assertThat(capped.getMembers().get(0)).containsEntry("username", "aaa-active");
|
||||
List<String> queries = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().equals("/api/v3/core/users/"))
|
||||
.map(r -> r.getURI().getQuery()).toList();
|
||||
assertThat(queries).hasSize(3).allSatisfy(query -> assertThat(query)
|
||||
.contains("ordering=username", "is_active=true", "groups_by_pk=" + t.authentik.pk(group), "groups_by_pk=" + t.authentik.pk("child")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scope restrictions apply even to inherited members; no extra user-read
|
||||
** permission is needed beyond the editor permission.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void memberPreviewRespectsScopeAndEditorPermission() throws Exception
|
||||
{
|
||||
String group = "kof-website-analysts";
|
||||
t.authentik.group("child", false, List.of(), List.of(), group)
|
||||
.group("scope", false, List.of(), List.of())
|
||||
.user(10, "allowed", "allowed@example.test", "internal", true, "child", "scope")
|
||||
.user(11, "wrong-path", "path@example.test", "internal", true, group, "scope")
|
||||
.user(12, "wrong-group", "group@example.test", "internal", true, group);
|
||||
t.authentik.users.get(11).put("path", "outside");
|
||||
t.config.withUserScopePathStartsWith("users").withUserScopeGroupNames(List.of("scope"));
|
||||
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.POLICIES_MANAGE);
|
||||
assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getMembers())
|
||||
.extracting(m -> m.get("username")).containsExactly("allowed");
|
||||
|
||||
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ);
|
||||
FixtureActionUtil.REQUESTS.clear();
|
||||
assertThatThrownBy(() -> PolicyEditorWidget.load(t.config, t.authentik.pk(group))).hasMessageContaining("authentik.policies.manage");
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
assertThatThrownBy(() -> t.admin().getUtil().policyPreviewUsers(List.of(t.authentik.pk(group)), 201)).hasMessageContaining("authentik.policies.manage");
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bootstrap subjects retain identity grants by immutable pk or uuid; a
|
||||
** superuser flag alone never grants QQQ permissions or leaks private fields.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void memberPreviewIncludesBootstrapWithoutSuperuserEscalation() throws Exception
|
||||
{
|
||||
String group = "kof-website-analysts";
|
||||
t.authentik.user(10, "bootstrap-pk", "pk@example.test", "internal", true, group)
|
||||
.user(11, "bootstrap-uuid", "uuid@example.test", "internal", true, group)
|
||||
.user(12, "superuser", "superuser@example.test", "internal", true, group, "authentik Admins");
|
||||
t.authentik.users.get(11).put("uuid", "immutable-subject");
|
||||
t.config.withBootstrapOperatorSubjects("10,immutable-subject");
|
||||
List<Map<String, Object>> members = PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getMembers();
|
||||
for(Map<String, Object> member : members.subList(0, 2))
|
||||
{
|
||||
assertThat((List<?>) member.get("basePermissions")).hasSize(IdentityPermission.values().length + 1);
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
assertThat(((List<?>) member.get("basePermissions")).contains(permission.getSessionPermission())).isTrue();
|
||||
}
|
||||
}
|
||||
assertThat(members.get(2)).containsEntry("basePermissions", List.of("kof22.analytics.hasAccess"));
|
||||
assertThat(members.get(2)).containsOnlyKeys("username", "label", "viaGroups", "basePermissions");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The added fields serialize like the existing widget fields, including an
|
||||
** empty member list when no group is selected and the configured mapper flag.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void memberPreviewPayloadAndMapperFlag() throws Exception
|
||||
{
|
||||
t.config.withMapperEnabled(false);
|
||||
FixtureActionUtil.REQUESTS.clear();
|
||||
PolicyEditorWidget.Data empty = PolicyEditorWidget.load(t.config, null);
|
||||
assertThat(empty.getMembers()).isEmpty();
|
||||
assertThat(empty.getMembersTruncated()).isFalse();
|
||||
assertThat(empty.getMapperEnabled()).isFalse();
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
t.config.withMapperEnabled(true);
|
||||
PolicyEditorWidget.Data data = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-publishers"));
|
||||
JSONObject json = new JSONObject(data);
|
||||
assertThat(json.getBoolean("mapperEnabled")).isTrue();
|
||||
assertThat(json.getBoolean("membersTruncated")).isFalse();
|
||||
assertThat(json.getJSONArray("members").getJSONObject(0).keySet()).containsExactlyInAnyOrder("username", "label", "viaGroups", "basePermissions");
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
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.RunBackendStepOutput;
|
||||
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.tables.query.QueryInput;
|
||||
@@ -135,6 +136,7 @@ class TokenManagementTest
|
||||
void createOwnAndRotateScopeThenRevoke() throws QException
|
||||
{
|
||||
t.config.withTokenScopesEnabled(true);
|
||||
QContext.getQSession().withPermission("cms.publish.hasAccess");
|
||||
AuthentikAdmin.AdminResult result = t.admin().execute(create().with("readOnly", true).with("tables", "post"));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
OneTimeKey key = result.takeSecret();
|
||||
@@ -298,7 +300,26 @@ class TokenManagementTest
|
||||
t.config.withTokenBrokerClientSecret("test-secret").withSubjectTokenSource(session -> null);
|
||||
refused(create(), "signed-in Authentik session");
|
||||
t.config.withSubjectTokenSource(session -> "subject:3");
|
||||
TokenKeyDelivery.set(null);
|
||||
TokenKeyDelivery.set(new TokenKeyDelivery()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Simulate a host without a usable response channel.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean available()
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delivery must never be attempted while unavailable.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void deliver(RunBackendStepOutput output, OneTimeKey key)
|
||||
{
|
||||
throw (new AssertionError("Unavailable delivery was called"));
|
||||
}
|
||||
});
|
||||
refused(create(), "upgrade QQQ");
|
||||
seed("own", 3);
|
||||
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "own", "replace"), "upgrade QQQ");
|
||||
@@ -529,4 +550,21 @@ class TokenManagementTest
|
||||
assertThat(t.qInstance.getTable("authentik_token_scope").getFields()).doesNotContainKey("key");
|
||||
assertThat(t.config.toString()).doesNotContain("test-broker-secret");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.7 delivers credentials only through the response-only secret channel.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void coreOneTimeDelivery()
|
||||
{
|
||||
TokenKeyDelivery.set(null);
|
||||
RunBackendStepOutput output = new RunBackendStepOutput();
|
||||
OneTimeKey key = new OneTimeKey("Token", "test-key-not-a-credential", "Copy once");
|
||||
assertThat(TokenKeyDelivery.get().available()).isTrue();
|
||||
assertThat(TokenKeyDelivery.tryDeliver(output, key)).isTrue();
|
||||
assertThat(output.getOneTimeSecrets()).hasSize(1);
|
||||
assertThat(output.getOneTimeSecrets().get(0).getValue()).isEqualTo(key.value());
|
||||
assertThat(output.getValues()).doesNotContainValue(key.value());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikTokenReminderProcessProducer;
|
||||
import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
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.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
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.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Event aggregation, scheduling, delivery dedupe and standard admin widgets.
|
||||
*******************************************************************************/
|
||||
class TokenOperationsTest
|
||||
{
|
||||
private AdminTestSupport t;
|
||||
private AuthentikActionUtil util;
|
||||
private final List<AuthentikTokenAlerts.Notice> notices = new ArrayList<>();
|
||||
private final Instant now = Instant.now();
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture delivery without a dependency on Alerts or a real mail service.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
t = AdminTestSupport.create();
|
||||
util = AuthentikActionUtil.forBackend(t.config.getApiBackendName());
|
||||
util.getRuntime().withTokenAlerts(notice -> { notices.add(notice); return (true); });
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed an expiring token for the chosen owner.
|
||||
*******************************************************************************/
|
||||
private JSONObject token(String id, int owner, int days)
|
||||
{
|
||||
return (t.authentik.seedToken(id, owner, "app_password", now.plus(days, ChronoUnit.DAYS).toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A login event using the pinned context.method_args.identifier contract.
|
||||
*******************************************************************************/
|
||||
private JSONObject event(String id, Instant at)
|
||||
{
|
||||
return (new JSONObject().put("action", "login").put("created", at.toString())
|
||||
.put("context", new JSONObject().put("method", "token").put("method_args", new JSONObject().put("identifier", id))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create non-secret bookkeeping with a known age for widget assertions.
|
||||
*******************************************************************************/
|
||||
private void created(String id, int days) throws QException
|
||||
{
|
||||
TokenScopeStore store = new TokenScopeStore(t.config);
|
||||
store.create(id, t.authentik.users.get(3), AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test"), "sam@example.test");
|
||||
QRecord row = store.find(id).withValue("createdAt", now.minus(days, ChronoUnit.DAYS));
|
||||
new UpdateAction().execute(new UpdateInput().withTableName("authentik_token_scope").withRecords(List.of(row)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Page, stop at 90 days, exclude non-token events, and reuse the 5-minute map.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void eventPagingWindowAndCache() throws QException
|
||||
{
|
||||
for(int i = 0; i < 101; i++)
|
||||
{
|
||||
t.authentik.loginEvents.add(event("token-" + i, now.minus(i, ChronoUnit.HOURS)));
|
||||
}
|
||||
t.authentik.loginEvents.add(event("token-0", now.minus(20, ChronoUnit.DAYS)));
|
||||
t.authentik.loginEvents.add(event("password", now).put("context", new JSONObject().put("method", "password")));
|
||||
t.authentik.loginEvents.add(event("wrong-action", now).put("action", "logout"));
|
||||
t.authentik.loginEvents.add(event("old", now.minus(91, ChronoUnit.DAYS)));
|
||||
Map<String, Instant> used = AuthentikTokenActivity.lastUsed(util);
|
||||
assertThat(used).hasSize(101).containsEntry("token-0", now).doesNotContainKeys("password", "wrong-action", "old");
|
||||
long requests = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count();
|
||||
assertThat(requests).isEqualTo(2);
|
||||
t.authentik.loginEvents.add(event("later", now));
|
||||
assertThat(AuthentikTokenActivity.lastUsed(util)).isEqualTo(used);
|
||||
assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count()).isEqualTo(requests);
|
||||
assertThat(util.getRuntime().getTokenActivityCache().getTimeout()).isEqualTo(java.time.Duration.ofMinutes(5));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** API activity is explicitly not tracked, and local creation/scope is shown.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void computedFields() throws QException
|
||||
{
|
||||
token("personal", 3, 5);
|
||||
created("personal", 31);
|
||||
t.authentik.loginEvents.add(event("personal", now));
|
||||
QRecord record = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), util.getApiJson("/core/tokens/personal/"), true);
|
||||
assertThat(record.getValueInstant("lastUsed")).isEqualTo(now);
|
||||
assertThat(record.getValueInstant("createdAt")).isEqualTo(now.minus(31, ChronoUnit.DAYS));
|
||||
assertThat(record.getValueString("scopeSummary")).isEqualTo("Owner permissions");
|
||||
JSONObject api = token("api", 3, 5).put("intent", "api");
|
||||
QRecord untracked = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), api, true);
|
||||
assertThat(untracked.getValue("lastUsed")).isNull();
|
||||
assertThat(untracked.getValueString("lastUsedNote")).isEqualTo("not tracked");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each window sends once to the owner's email; a minimal external row does
|
||||
** not invent restrictions or a creation date. Extending resets the windows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void remindersDedupeAcrossBothWindows() throws QException
|
||||
{
|
||||
token("external", 3, 5);
|
||||
token("tomorrow", 3, 1);
|
||||
token("api", 3, 1).put("intent", "api");
|
||||
token("expired", 3, -1);
|
||||
token("later", 3, 20);
|
||||
token("no-email", 6, 2);
|
||||
AuthentikTokenReminders.run(util, now);
|
||||
AuthentikTokenReminders.run(util, now);
|
||||
assertThat(notices).hasSize(2).allMatch(n -> "sam@example.test".equals(n.recipient()));
|
||||
assertThat(notices.getFirst().message()).contains("Your access token Test access (external)", "Preferences > Access tokens.");
|
||||
TokenScopeStore store = new TokenScopeStore(t.config);
|
||||
QRecord row = store.find("external");
|
||||
assertThat(row.getValue("createdAt")).isNull();
|
||||
assertThat(row.getValue("readOnly")).isNull();
|
||||
assertThat(row.getValue("lastReminder7d")).isNotNull();
|
||||
assertThat(row.getValue("lastReminder1d")).isNull();
|
||||
AuthentikTokenReminders.run(util, now.plus(4, ChronoUnit.DAYS));
|
||||
assertThat(notices).hasSize(3);
|
||||
assertThat(store.find("external").getValue("lastReminder1d")).isNotNull();
|
||||
store.resetReminders("external");
|
||||
assertThat(store.find("external").getValue("lastReminder7d")).isNull();
|
||||
AuthentikTokenScopes.apply(QContext.getQSession(), new JSONObject().put("qqq_token", "external"));
|
||||
assertThat(QContext.getQSession().getPermissions()).isNotEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delivery absence leaves dedupe unset so installing Alerts permits retry.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableDeliveryAndUnauthorizedRun() throws QException
|
||||
{
|
||||
token("external", 3, 5);
|
||||
util.getRuntime().withTokenAlerts(new AuthentikTokenAlerts()::send);
|
||||
AuthentikTokenReminders.run(util, now);
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// RC.6 core has the notification SPI: the reminder goes out through //
|
||||
// QAlerts.notify and is recorded for dedupe //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
assertThat(new TokenScopeStore(t.config).find("external")).isNotNull();
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// core QAlerts exists from RC.5: guard incidents always have a path //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
assertThat(new AuthentikTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null, "safe"))).isTrue();
|
||||
t.loginAs("sam@example.test");
|
||||
assertThatThrownBy(() -> AuthentikTokenReminders.run(util, now)).hasMessageContaining("tokens.manage");
|
||||
assertThatThrownBy(() -> AuthentikTokenWidget.data(util, now)).hasMessageContaining("tokens.manage");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The hidden daily process runs with core's system scheduler session.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scheduledProcessAndBulkRevokeMetadata() throws QException
|
||||
{
|
||||
t.qInstance.addScheduler(new SimpleSchedulerMetaData().withName("test"));
|
||||
AuthentikTokenReminderProcessProducer producer = new AuthentikTokenReminderProcessProducer();
|
||||
producer.setQBitConfig(t.config);
|
||||
var process = producer.produce(t.qInstance);
|
||||
assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(86400);
|
||||
assertThat(process.getSchedule().getSchedulerName()).isEqualTo("test");
|
||||
assertThat(process.getIsHidden()).isTrue();
|
||||
token("external", 3, 5);
|
||||
QContext.init(t.qInstance, new QSystemUserSession());
|
||||
new RunProcessAction().execute(new RunProcessInput().withProcessName("authentikTokenReminders"));
|
||||
assertThat(notices).hasSize(1);
|
||||
var revoke = t.qInstance.getProcess("authentikRevokeToken");
|
||||
assertThat(revoke.getTableName()).isEqualTo("authentik_token");
|
||||
assertThat(revoke.getMinInputRecords()).isEqualTo(1);
|
||||
assertThat(revoke.getMaxInputRecords()).isEqualTo(AuthentikAdmin.MAX_BULK);
|
||||
assertThat(Boolean.TRUE.equals(revoke.getIsHidden())).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Widget counts, known/unknown ages, links, deactivated owners and inactivity.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void adminWidgetUsesStandardTableAndSafeFilters() throws QException
|
||||
{
|
||||
token("new", 3, 5); created("new", 10);
|
||||
token("middle", 3, 5); created("middle", 45);
|
||||
token("unused", 3, 5); created("unused", 100);
|
||||
token("ancient", 3, 5); created("ancient", 400);
|
||||
token("unknown", 7, 5);
|
||||
token("api", 3, 5).put("intent", "api"); created("api", 100);
|
||||
t.authentik.loginEvents.add(event("middle", now));
|
||||
TableData data = AuthentikTokenWidget.data(util, now);
|
||||
assertThat(data.getRows()).contains(Map.of("category", "Age: unknown", "count", 1, "tokens", data.getRows().stream()
|
||||
.filter(row -> "Age: unknown".equals(row.get("category"))).findFirst().orElseThrow().get("tokens")));
|
||||
assertThat(data.getRows()).filteredOn(row -> "Unused for 60 days".equals(row.get("category"))).hasSize(2);
|
||||
assertThat(data.getRows()).filteredOn(row -> "Owner deactivated".equals(row.get("category"))).hasSize(1);
|
||||
assertThat(data.getRows().toString()).contains("?filter=", "identifier", "username").doesNotContain("lastUsed%22");
|
||||
QWidgetMetaData widget = (QWidgetMetaData) t.qInstance.getWidget("authentikAccessTokens");
|
||||
assertThat(widget.getType()).isEqualTo(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType());
|
||||
assertThat(new AuthentikTokenWidget().render(new RenderWidgetInput().withWidgetMetaData(widget))).isNotNull();
|
||||
t.config.withTokenUnusedDays(120);
|
||||
assertThat(AuthentikTokenWidget.data(util, now).getRows()).noneMatch(row -> "Unused for 120 days".equals(row.get("category")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A post-write owner move is a high-severity incident without a token key.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void protectedOwnerRaceRaisesAlert() throws QException
|
||||
{
|
||||
t.authentik.users.get(6).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365");
|
||||
token("service", 6, 5);
|
||||
t.authentik.moveTokenAfterUpdate = true;
|
||||
var result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "test").with("expiresInDays", 20));
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(notices).hasSize(1);
|
||||
assertThat(notices.getFirst().key()).isEqualTo("authentik_token_guard_bypass");
|
||||
assertThat(notices.getFirst().severity()).isEqualTo("high");
|
||||
assertThat(notices.toString()).doesNotContain(t.authentik.tokenKeys.get("service"));
|
||||
}
|
||||
/*******************************************************************************
|
||||
** External extension opens a new reminder window without rewriting scope.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void externalExtensionStartsNewReminderWindow() throws QException
|
||||
{
|
||||
JSONObject token = token("external", 3, 5);
|
||||
AuthentikTokenReminders.run(util, now);
|
||||
token.put("expires", now.plus(20, ChronoUnit.DAYS).toString());
|
||||
util.getRuntime().invalidateCache();
|
||||
AuthentikTokenReminders.run(util, now.plus(15, ChronoUnit.DAYS));
|
||||
assertThat(notices).hasSize(2);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
|
||||
import com.kingsrook.qbits.authentik.policy.AuthentikPolicyClaimMapper;
|
||||
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.Mode;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scope enforcement intersects existing permissions without Docker or OAuth.
|
||||
*******************************************************************************/
|
||||
class TokenScopesTest
|
||||
{
|
||||
private AdminTestSupport t;
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance includes a table whose permission base differs from its name.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
t = AdminTestSupport.create();
|
||||
t.config.withTokenScopesEnabled(true).withTokenBrokerClientId("broker").withTokenBrokerClientSecret("secret")
|
||||
.withSubjectTokenSource(session -> "subject:3");
|
||||
TokenKeyDelivery.set((output, key) -> {});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reset the response-only test adapter.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
TokenKeyDelivery.set(null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed a credential and optional local metadata.
|
||||
*******************************************************************************/
|
||||
private void seed(String id, AdminRequest scope) throws QException
|
||||
{
|
||||
t.authentik.seedToken(id, 3, "app_password", Instant.now().plusSeconds(86400).toString());
|
||||
if(scope != null)
|
||||
{
|
||||
new TokenScopeStore(t.config).create(id, t.authentik.users.get(3), scope, "sam@example.test");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No claim means no scope lookup and no changes to the permission set.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void absentAndExternalClaims() throws QException
|
||||
{
|
||||
QSession session = QContext.getQSession().withPermissions("post.read", "post.edit");
|
||||
Set<String> before = Set.copyOf(session.getPermissions());
|
||||
AuthentikTokenScopes.apply(session, new JSONObject());
|
||||
assertThat(session.getPermissions()).isEqualTo(before);
|
||||
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, null)).isEmpty();
|
||||
seed("external", null);
|
||||
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, new JSONObject().put("qqq_token", "external")).orElseThrow().mode()).isEqualTo(Mode.UNSCOPED);
|
||||
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "external"));
|
||||
assertThat(session.getPermissions()).isEqualTo(before);
|
||||
assertThat(session.getValue("qqq.credentialScope")).isEqualTo("external");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read-only strips writes and management hasAccess permissions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readOnlyAndDeletedToken() throws QException
|
||||
{
|
||||
seed("limited", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("readOnly", true));
|
||||
QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "post.read", "post.edit", "authentik.tokens.manage.hasAccess");
|
||||
JSONObject claims = new JSONObject().put("qqq_token", "limited");
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("cms.publish.hasAccess", "post.read");
|
||||
assertThat(session.getCredentialScope().getReadOnly()).isTrue();
|
||||
assertThat(session.getCredentialScope().getTables()).isNull();
|
||||
t.authentik.tokens.get("sam").clear();
|
||||
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, claims).orElseThrow().mode()).isEqualTo(Mode.DENY_ALL);
|
||||
assertThat(session.getCredentialScope().getTables()).isEmpty();
|
||||
assertThat(session.getCredentialScope().getOperationIds()).isEmpty();
|
||||
assertThat(new TokenScopeStore(t.config).find("limited")).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A scope cannot grant missing read permissions, even with a valid table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableNamesResolvePermissionAliasesAndNeverWiden() throws QException
|
||||
{
|
||||
seed("table", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("tables", "post"));
|
||||
QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "kof22.analytics.hasAccess");
|
||||
JSONObject claims = new JSONObject().put("qqq_token", "table");
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).containsExactly("cms.publish.hasAccess");
|
||||
session.setPermissions(Set.of("kof22.analytics.hasAccess"));
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An app includes its table actions, reports and widgets, not other apps.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appChildrenAndExplicitTableUnion() throws QException
|
||||
{
|
||||
var rules = new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
QProcessMetaData process = new QProcessMetaData().withName("publish").withTableName("post").withPermissionRules(rules);
|
||||
QReportMetaData report = new QReportMetaData().withName("report").withPermissionRules(rules);
|
||||
QWidgetMetaData widget = new QWidgetMetaData().withName("widget").withType("TABLE").withPermissionRules(rules);
|
||||
t.qInstance.addProcess(process);
|
||||
t.qInstance.addReport(report);
|
||||
t.qInstance.addWidget(widget);
|
||||
t.qInstance.addApp(new QAppMetaData().withName("content").withPermissionRules(rules).withChild(t.qInstance.getTable("post"))
|
||||
.withChild(report).withWidgets(List.of("widget")));
|
||||
seed("app", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("apps", "content").with("tables", "analytics"));
|
||||
QSession session = t.loginAs("sam@example.test").withPermissions("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess",
|
||||
"widget.hasAccess", "kof22.analytics.hasAccess", "unrelated.read");
|
||||
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "app"));
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess",
|
||||
"widget.hasAccess", "kof22.analytics.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured claim is enforced even if offering new scopes is disabled.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configuredClaimCacheAndMalformedClaim() throws QException
|
||||
{
|
||||
t.config.withTokenScopesEnabled(false).withTokenScopeClaim("credential");
|
||||
seed("external", null);
|
||||
QSession session = t.loginAs("sam@example.test").withPermissions("post.read");
|
||||
JSONObject claims = new JSONObject().put("credential", "external");
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
t.authentik.tokens.get("sam").clear();
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).containsExactly("post.read");
|
||||
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
session.withPermission("post.read");
|
||||
AuthentikTokenScopes.apply(session, new JSONObject().put("credential", new JSONObject()));
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both claims customizers narrow after computing their permission unions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mappersCannotAddPermissionsAfterDenyAll()
|
||||
{
|
||||
JSONObject claims = new JSONObject().put("qqq_token", "deleted");
|
||||
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
|
||||
new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
session.withPermission("post.read");
|
||||
new AuthentikGroupPermissionMapper().withGroupPermissions("test", List.of("post.edit"))
|
||||
.customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims.put("groups", List.of("test"))));
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review validates current access and displays the scope; execution repeats.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void reviewRejectsUnknownOrUnheldAndExecutionRechecks() throws QException
|
||||
{
|
||||
t.loginAs("sam@example.test");
|
||||
AdminRequest request = AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("description", "CI").with("expiresInDays", 30).with("tables", "missing");
|
||||
assertThat(t.admin().plan(request).getMessage()).contains("Unknown");
|
||||
request.with("tables", "post");
|
||||
assertThat(t.admin().plan(request).getMessage()).contains("read access");
|
||||
QContext.getQSession().withPermission("cms.publish.hasAccess");
|
||||
assertThat(t.admin().plan(request).describe()).contains("tables: post");
|
||||
QContext.getQSession().setPermissions(Set.of());
|
||||
assertThat(t.admin().execute(request).ok()).isFalse();
|
||||
assertThat(t.changes()).isEmpty();
|
||||
}
|
||||
/*******************************************************************************
|
||||
** A long general response cache cannot extend the scope revocation window.
|
||||
** Lookup outages deny, rather than falling back to unrestricted access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void verificationCacheIsIndependentAndApiFailureDenies() throws QException
|
||||
{
|
||||
t.config.withListCacheTtlSeconds(3600);
|
||||
seed("cached", null);
|
||||
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
|
||||
JSONObject claims = new JSONObject().put("qqq_token", "cached");
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).containsExactly("post.read");
|
||||
t.authentik.tokens.get("sam").clear();
|
||||
AuthentikRuntime.forBackend(t.config.getApiBackendName()).getTokenScopeCache().clear();
|
||||
AuthentikTokenScopes.apply(session, claims);
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
session.withPermission("post.read");
|
||||
com.kingsrook.qbits.authentik.client.FixtureActionUtil.handler = request ->
|
||||
com.kingsrook.qbits.authentik.client.FixtureActionUtil.response(403, "{}");
|
||||
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "unavailable"));
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The ID token may supply policy context while the access token binds scope.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void idTokenContextCannotHideAccessTokenScope()
|
||||
{
|
||||
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
|
||||
new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of(
|
||||
"idToken", new JSONObject().put("email", "sam@example.test"),
|
||||
"jwtPayloadJsonObject", new JSONObject().put("qqq_token", "deleted")));
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -61,7 +61,7 @@ class AuthentikAppTest
|
||||
assertThat(app.getChildren()).extracting(c -> c.getName())
|
||||
.containsExactly("authentik_user", "authentik_invitation", "authentik_group", "authentik_application", "authentik_approval_request", "authentik_admin_action");
|
||||
assertThat(app.getSections()).extracting(s -> s.getLabel()).containsExactly("People", "Access", "Activity");
|
||||
assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikDriftReport");
|
||||
assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikAccessTokens", "authentikDriftReport");
|
||||
|
||||
for(String subTable : List.of("authentik_group_member", "authentik_session", "authentik_token", "authentik_event", "authentik_policy_binding", "authentik_role", "authentik_blueprint"))
|
||||
{
|
||||
@@ -137,7 +137,7 @@ class AuthentikAppTest
|
||||
RenderWidgetOutput output = ((AbstractWidgetRenderer) new AuthentikHomeWidget.Renderer()).render(input);
|
||||
String html = ((RawHTML) output.getWidgetData()).getHtml();
|
||||
|
||||
assertThat(html).contains("Needs attention", "People", "Groups and permissions", "Recent changes", "Connection");
|
||||
assertThat(html).contains("Needs attention", "People", "Groups and permissions", "Recent changes", "Health", "System Health");
|
||||
assertThat(html).contains("Active</dt><dd style=\"margin:0;\">4<");
|
||||
assertThat(html).contains("Inactive</dt><dd style=\"margin:0;\">1<");
|
||||
assertThat(html).contains("Service accounts</dt><dd style=\"margin:0;\">2<");
|
||||
@@ -154,6 +154,28 @@ class AuthentikAppTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Home reads the persisted central component and links directly to its record.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void centralHealthRollup() throws Exception
|
||||
{
|
||||
AdminTestSupport t = AdminTestSupport.create();
|
||||
new com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider().defineAll(t.qInstance, AdminTestSupport.LOCAL, table -> { });
|
||||
com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore.getInstance().insert(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput().withTableName("health_component")
|
||||
.withRecord(new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("id", 7).withValue("name", "authentik").withValue("status", "DEGRADED")), true);
|
||||
AuthentikTestSupport.loginWithPermissions(t.qInstance, java.util.Set.of("health.read"));
|
||||
AuthentikHomeData data = AuthentikHomeData.load(t.config);
|
||||
assertThat(data.getHealthStatus()).isEqualTo("DEGRADED");
|
||||
assertThat(AuthentikHomeView.render(data)).contains("Authentik: DEGRADED", "href=\"/app/health_component/7\"")
|
||||
.doesNotContain("Policies at sign-in", "Version</dt>");
|
||||
AuthentikTestSupport.loginWithPermissions(t.qInstance, java.util.Set.of());
|
||||
assertThat(AuthentikHomeData.load(t.config).getHealthStatus()).isEqualTo("UNKNOWN");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Nothing but AuthentikAdmin may send a change to Authentik: the util's
|
||||
** write method is only called from there (the guard's authorization is the
|
||||
|
||||
@@ -334,6 +334,79 @@ class TokenContractIT
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner decision A (2026-10-14): the role holds add_token because 2026.8.3
|
||||
** needs it for service_account and for set_key (POST object permission).
|
||||
** Pin both: 403 without add_token, 200/204 with it (restored in finally).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void serviceAccountActionsNeedAddToken() throws Exception
|
||||
{
|
||||
JSONObject seed = expect(admin.post("/core/users/service_account/", new JSONObject().put("name", unique("needs-add"))
|
||||
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
|
||||
users.add(seed.getInt("user_pk"));
|
||||
String seedToken = "service-account-" + seed.getString("username") + "-password";
|
||||
tokens.add(seedToken);
|
||||
String role = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
|
||||
JSONObject permissions = new JSONObject().put("permissions", new org.json.JSONArray().put("authentik_core.add_token"));
|
||||
String name = unique("no-add-token");
|
||||
assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", permissions).status()).isEqualTo(204);
|
||||
try
|
||||
{
|
||||
expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name)
|
||||
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 403);
|
||||
assertThat(admin.first("/core/users/?username=" + name)).isNull();
|
||||
expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 403);
|
||||
}
|
||||
finally
|
||||
{
|
||||
assertThat(admin.post("/rbac/permissions/assigned_by_roles/" + role + "/assign/", permissions).status()).isEqualTo(200);
|
||||
}
|
||||
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name)
|
||||
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
|
||||
users.add(created.getInt("user_pk"));
|
||||
tokens.add("service-account-" + name + "-password");
|
||||
expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 204);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alarm also notifies identity-admins whenever the qbit's account
|
||||
** creates a service account (owner decision 2026-10-14).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void detectiveRuleNotifiesOnServiceAccountCreation() throws Exception
|
||||
{
|
||||
int member = user("alarm-sa");
|
||||
expect(admin.post("/core/groups/" + admin.groupPk("identity-admins") + "/add_user/", new JSONObject().put("pk", member)), 204);
|
||||
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", unique("alarmed"))
|
||||
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
|
||||
int servicePk = created.getInt("user_pk");
|
||||
users.add(servicePk);
|
||||
tokens.add("service-account-" + created.getString("username") + "-password");
|
||||
String[] eventPk = { null };
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(30), "service-account model_created event", () ->
|
||||
{
|
||||
JSONArray events = admin.getOk("/events/events/?action=model_created&ordering=-created&page_size=50&username="
|
||||
+ TestApi.encode(AuthentikTestStack.SERVICE_ACCOUNT)).getJSONArray("results");
|
||||
for(int i = 0; i < events.length(); i++)
|
||||
{
|
||||
JSONObject model = events.getJSONObject(i).getJSONObject("context").optJSONObject("model");
|
||||
if(model != null && "user".equals(model.optString("model_name")) && String.valueOf(servicePk).equals(model.optString("pk")))
|
||||
{
|
||||
eventPk[0] = events.getJSONObject(i).getString("pk");
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
});
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "service-account creation notification", () ->
|
||||
notifications(member, eventPk[0]).length() > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real qbit calls, local audit/scope persistence and the response-only SPI.
|
||||
*******************************************************************************/
|
||||
@@ -542,8 +615,13 @@ class TokenContractIT
|
||||
*******************************************************************************/
|
||||
private TestApi.Response credentials(String username, String key, String scope) throws Exception
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// no client_secret: exactly what a PAT holder sends (the generated //
|
||||
// usage text). 2026.8.3 authenticates client_credentials by the //
|
||||
// username and app password even for a confidential client //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
return (form(Map.of("grant_type", "client_credentials", "client_id", AuthentikTestStack.APP_SLUG,
|
||||
"client_secret", "contract-test-oauth2-client-secret", "username", username, "password", key, "scope", scope)));
|
||||
"username", username, "password", key, "scope", scope)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -186,4 +186,20 @@ class AuthentikDriftReportTest
|
||||
assertThatThrownBy(() -> new AuthentikDriftReportWidgetRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A malformed entry's health reason keeps the attribute name, never the
|
||||
** value a person typed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void malformedReasonKeepsOnlyTheAttribute()
|
||||
{
|
||||
assertThat(AuthentikDriftReport.attributeOf("qqq.io/permissions: {\"x\":\"typed value\"}")).isEqualTo("qqq.io/permissions");
|
||||
assertThat(AuthentikDriftReport.attributeOf("qqq.io/policy: line 1: bad")).isEqualTo("qqq.io/policy");
|
||||
assertThat(AuthentikDriftReport.attributeOf("no separator")).isEqualTo("attribute");
|
||||
assertThat(AuthentikDriftReport.attributeOf("bad key with spaces: x")).isEqualTo("attribute");
|
||||
assertThat(AuthentikDriftReport.attributeOf(null)).isEqualTo("attribute");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
/*
|
||||
* 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.health;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitProducer;
|
||||
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.client.TokenBucketThrottle;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.client.methods.HttpRequestBase;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Offline probes run under the central runner's health-only permissions.
|
||||
*******************************************************************************/
|
||||
class AuthentikHealthChecksTest
|
||||
{
|
||||
private static final String SECRET = "S3NT1NEL-secret";
|
||||
private AuthentikQBitConfig config;
|
||||
private QInstance instance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install the existing send seam with poisoned credentials.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
config = AuthentikTestSupport.unitConfig().withApiToken(SECRET).withTokenBrokerClientId(SECRET)
|
||||
.withTokenBrokerClientSecret(SECRET).withGroupCacheTtlSeconds(0);
|
||||
instance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(instance, config, HealthActionUtil.class);
|
||||
AuthentikTestSupport.loginWithPermissions(instance, Set.of("health.read", "health.write"));
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(200, FixtureActionUtil.EMPTY_PAGE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not leak static fixture state or thread context between tests.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata is registered by production, with no probes during registration.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registration() throws QException
|
||||
{
|
||||
var checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
|
||||
assertThat(checks).extracting(c -> c.getName()).containsExactly("authentik.api", "authentik.api_token", "authentik.mapper", "authentik.token_broker");
|
||||
assertThat(checks).allSatisfy(c ->
|
||||
{
|
||||
assertThat(c.getComponent()).isEqualTo("authentik");
|
||||
assertThat(c.getCategory()).isEqualTo(HealthCategory.IDENTITY);
|
||||
assertThat(c.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
|
||||
assertThat(c.getIntervalSeconds()).isEqualTo(c.getName().equals("authentik.api") || c.getName().equals("authentik.token_broker") ? 300 : 21600);
|
||||
assertThat(c.getTimeoutMs()).isEqualTo(10000);
|
||||
});
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
assertThat(instance.getWidget("authentikDriftReport")).isNotNull();
|
||||
QInstance empty = new QInstance();
|
||||
assertThat(new AuthentikQBitProducer().isEnabled()).isFalse();
|
||||
assertThatThrownBy(() -> new AuthentikQBitProducer().produce(empty)).isInstanceOf(QException.class);
|
||||
assertThat(HealthChecksMetaData.ofOrWithNew(empty).getChecks()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Version responses are allowlisted before entering details.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void apiVersions() throws QException
|
||||
{
|
||||
respond(new JSONObject().put("version_current", "2026.8.3"));
|
||||
assertThat(run("api").getDetails().getValues()).containsEntry("serverVersion", "2026.8.3");
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
config.withExpectedServerVersion("2025.10");
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
config.withExpectedServerVersion(null);
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
respond(new JSONObject().put("version_current", SECRET));
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertClean(run("api"));
|
||||
respond(new JSONObject());
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Present, approaching, expired, missing and ambiguous token metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void apiTokenStates() throws QException
|
||||
{
|
||||
JSONObject token = new JSONObject().put("intent", "api").put("identifier", SECRET).put("expiring", true);
|
||||
token.put("expires", Instant.now().plusSeconds(30 * 86400).toString());
|
||||
page(token);
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
token.put("expires", Instant.now().plusSeconds(13 * 86400).toString());
|
||||
page(token);
|
||||
var outcome = run("api_token");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getDetails().getValues()).containsOnlyKeys("daysToExpiry");
|
||||
token.put("expires", Instant.now().minusSeconds(60).toString());
|
||||
page(token);
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
token.put("expiring", false);
|
||||
page(token);
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
token.put("expiring", true).put("expires", SECRET);
|
||||
page(token);
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
page(token, token);
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
page();
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(FixtureActionUtil.REQUESTS).allSatisfy(request ->
|
||||
{
|
||||
assertThat(request.getURI().getPath()).endsWith("/core/tokens/");
|
||||
assertThat(request.getURI().getQuery()).contains("intent=api", "user__username=");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the two relevant drift checks run. Group findings name the group and
|
||||
** a safe reason; typed attribute values and people (service-account
|
||||
** activity subjects) stay private.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mapperStates() throws QException
|
||||
{
|
||||
assertThat(run("mapper").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// a group with no qqq.io/* attributes (kof22-admins on its static floor) is //
|
||||
// not drift //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
JSONObject floorOnly = new JSONObject().put("pk", "g0").put("name", "kof22-admins").put("attributes", new JSONObject());
|
||||
groups(floorOnly);
|
||||
var outcome = run("mapper");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper checks passed");
|
||||
|
||||
JSONObject unknown = new JSONObject().put("pk", "g1").put("name", "file-gateway-admins")
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("file_gateway.view").put("file_gateway.admin")));
|
||||
JSONObject malformed = new JSONObject().put("pk", "g2").put("name", "editors")
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put(new JSONObject().put("x", SECRET))));
|
||||
JSONObject superuser = new JSONObject().put("pk", "g3").put("name", "authentik Admins").put("is_superuser", true)
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.users.read")));
|
||||
groups(floorOnly, unknown, malformed, superuser);
|
||||
outcome = run("mapper");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: authentik Admins (superuser group carries qqq.io/permissions); "
|
||||
+ "editors (malformed qqq.io/permissions); file-gateway-admins (grants nothing: file_gateway.admin, file_gateway.view)");
|
||||
assertThat(outcome.getDetails().getValues())
|
||||
.containsEntry("findingCodes", "group_attributes.warning")
|
||||
.containsEntry("flaggedGroups", "authentik Admins, editors, file-gateway-admins")
|
||||
.containsEntry("summary", outcome.getMessage().substring("Mapper drift: ".length()));
|
||||
assertClean(outcome);
|
||||
|
||||
groups(unknown);
|
||||
page(new JSONObject().put("action", "model_deleted").put("context", new JSONObject()
|
||||
.put("model", new JSONObject().put("model_name", "user").put("pk", "1").put("name", SECRET))));
|
||||
outcome = run("mapper");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: file-gateway-admins (grants nothing: file_gateway.admin, file_gateway.view); "
|
||||
+ "service-account activity (see the identity drift report)");
|
||||
assertClean(outcome);
|
||||
assertThat(FixtureActionUtil.REQUESTS).allSatisfy(request -> assertThat(request.getURI().getPath())
|
||||
.doesNotContain("/admin/version/", "/core/tokens/", "/managed/blueprints/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group check that cannot run, unprintable names and long summaries stay
|
||||
** readable and within the health limits.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mapperOutcomeEdges()
|
||||
{
|
||||
var outcome = AuthentikHealthChecks.mapperOutcome(List.of(
|
||||
DriftFinding.error("group attributes", "-", "Check could not run: " + SECRET),
|
||||
DriftFinding.info("group attributes", "x", "info only").withReason("ignored"),
|
||||
DriftFinding.warning("group attributes", "bad\u0007name", "m").withReason("grants nothing: (unprintable name)"),
|
||||
DriftFinding.warning("group attributes", "", "m").withReason("malformed attribute")));
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: (unnamed group) (malformed attribute); badname (grants nothing: (unprintable name)); "
|
||||
+ "group attributes check could not run");
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("findingCodes", "group_attributes.error,group_attributes.warning");
|
||||
assertClean(outcome);
|
||||
|
||||
List<DriftFinding> many = new java.util.ArrayList<>();
|
||||
for(int i = 0; i < 60; i++)
|
||||
{
|
||||
many.add(DriftFinding.warning("group attributes", "group-number-" + i, "m").withReason("grants nothing: some.long.permission.name." + i));
|
||||
}
|
||||
outcome = AuthentikHealthChecks.mapperOutcome(many);
|
||||
assertThat(outcome.getMessage()).hasSize(500).endsWith("...");
|
||||
assertThat(outcome.getDetails().toJson().getBytes(java.nio.charset.StandardCharsets.UTF_8).length).isLessThanOrEqualTo(4096);
|
||||
assertThat(outcome.getDetails().getValues()).containsKeys("findingCodes", "flaggedGroups", "summary");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve these groups as the group graph.
|
||||
*******************************************************************************/
|
||||
private static void groups(JSONObject... groups)
|
||||
{
|
||||
JSONArray results = new JSONArray();
|
||||
for(JSONObject group : groups)
|
||||
{
|
||||
results.put(group);
|
||||
}
|
||||
FixtureActionUtil.groupGraphBody = new JSONObject(FixtureActionUtil.EMPTY_PAGE).put("results", results).toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The broker check uses the grant the broker uses (token exchange) with no
|
||||
** subject: invalid_request proves the credentials; nothing is ever issued.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void brokerStates() throws Exception
|
||||
{
|
||||
refuse("invalid_request");
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
HttpPost request = (HttpPost) FixtureActionUtil.REQUESTS.getLast();
|
||||
String body = EntityUtils.toString(request.getEntity());
|
||||
assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange", "client_id=", "client_secret=");
|
||||
assertThat(body).doesNotContain("client_credentials", "subject_token");
|
||||
assertThat(request.getFirstHeader("Authorization")).isNull();
|
||||
assertThat(request.getURI().toString()).isEqualTo(config.getTokenEndpointUrl());
|
||||
|
||||
refuse("invalid_client");
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
refuse("invalid_grant");
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
respond(new JSONObject().put("access_token", SECRET));
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
|
||||
config.withTokenBrokerClientSecret(null);
|
||||
FixtureActionUtil.REQUESTS.clear();
|
||||
var outcome = run("token_broker");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
assertThat(outcome.getMessage()).isEqualTo("not configured");
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve a token-endpoint refusal with a standard OAuth2 error code.
|
||||
*******************************************************************************/
|
||||
private void refuse(String errorCode)
|
||||
{
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(400, new JSONObject().put("error", errorCode).toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both response bodies and thrown errors may echo every configured secret.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { "api", "api_token", "mapper", "token_broker" })
|
||||
void failuresNeverExposeSecrets(String name) throws QException
|
||||
{
|
||||
FixtureActionUtil.groupGraphHandler = request -> FixtureActionUtil.response(401, SECRET);
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(401, SECRET);
|
||||
assertThat(run(name).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
FixtureActionUtil.handler = request ->
|
||||
{
|
||||
throw new IllegalStateException(SECRET);
|
||||
};
|
||||
assertThat(run(name).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expired input performs no I/O, including unconfigured and mapper checks.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { "api", "api_token", "mapper", "token_broker" })
|
||||
void expiredDeadline(String name) throws QException
|
||||
{
|
||||
assertThat(run(name, Instant.now().minusSeconds(1)).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retries and HTTP timeouts use the input budget, never the 30-second default.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void boundsRequestsAndRetries() throws QException
|
||||
{
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(429, SECRET, FixtureActionUtil.retryAfter(30));
|
||||
assertThat(run("api_token", Instant.now().plusSeconds(2)).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(FixtureActionUtil.REQUESTS).hasSize(1);
|
||||
var settings = FixtureActionUtil.REQUESTS.getFirst().getConfig();
|
||||
assertThat(settings.getSocketTimeout()).isBetween(1, 2000);
|
||||
assertThat(settings.getConnectTimeout()).isBetween(1, 2000);
|
||||
assertThat(settings.getConnectionRequestTimeout()).isBetween(1, 2000);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unlimited normal client settings cannot override the central deadline.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void boundsUnlimitedTimeouts() throws QException
|
||||
{
|
||||
config.withConnectTimeoutMillis(0).withSocketTimeoutMillis(0);
|
||||
run("api_token", Instant.now().plusSeconds(2));
|
||||
var settings = FixtureActionUtil.REQUESTS.getFirst().getConfig();
|
||||
assertThat(settings.getSocketTimeout()).isBetween(1, 2000);
|
||||
assertThat(settings.getConnectTimeout()).isBetween(1, 2000);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A saturated throttle refuses a reservation beyond the health deadline.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void boundsThrottle() throws QException
|
||||
{
|
||||
TokenBucketThrottle throttle = new TokenBucketThrottle(1, 1);
|
||||
throttle.acquire(Instant.now().plusSeconds(1));
|
||||
assertThatThrownBy(() -> throttle.acquire(Instant.now().plusMillis(10))).isInstanceOf(QException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central permission does not grant Authentik mutations or arbitrary users.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void requiresHealthPermission() throws QException
|
||||
{
|
||||
AuthentikTestSupport.loginWithPermissions(instance, Set.of());
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
assertThatThrownBy(() -> AuthentikActionUtil.forBackend(config.getApiBackendName()).withHealthDeadline(Instant.now().plusSeconds(1)))
|
||||
.hasMessageContaining("permission");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the registered production check, with a short normal budget.
|
||||
*******************************************************************************/
|
||||
private HealthCheckOutcome run(String name) throws QException
|
||||
{
|
||||
return (run(name, Instant.now().plusSeconds(5)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HealthRunner.sanitize is package-private; assert the raw outcome is clean.
|
||||
*******************************************************************************/
|
||||
private HealthCheckOutcome run(String name, Instant deadline) throws QException
|
||||
{
|
||||
var definition = HealthChecksMetaData.ofOrWithNew(instance).getChecks().stream()
|
||||
.filter(c -> c.getName().equals("authentik." + name)).findFirst().orElseThrow();
|
||||
var outcome = definition.getCheckInstance().run(new HealthCheckInput().withQInstance(instance).withDeadline(deadline));
|
||||
assertClean(outcome);
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No credential can appear even before core sanitization.
|
||||
*******************************************************************************/
|
||||
private void assertClean(HealthCheckOutcome outcome)
|
||||
{
|
||||
assertThat(outcome.getMessage() + outcome.getDetails().toJson()).doesNotContain(SECRET, "authentik.example.test", "operator@example.test");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve a JSON response through the existing fixture handler.
|
||||
*******************************************************************************/
|
||||
private void respond(JSONObject body)
|
||||
{
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(200, body.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve paginated metadata without any network.
|
||||
*******************************************************************************/
|
||||
private void page(JSONObject... records)
|
||||
{
|
||||
respond(new JSONObject(FixtureActionUtil.EMPTY_PAGE).put("results", new JSONArray(List.of(records))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Let version probes use the same configurable fixture handler as other calls.
|
||||
*******************************************************************************/
|
||||
public static class HealthActionUtil extends FixtureActionUtil
|
||||
{
|
||||
/***************************************************************************
|
||||
** Preserve the existing group graph seam and capture version requests.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected QHttpResponse send(HttpRequestBase request)
|
||||
{
|
||||
if(request.getURI().getPath().endsWith("/admin/version/"))
|
||||
{
|
||||
REQUESTS.add(request);
|
||||
return (handler.apply(request));
|
||||
}
|
||||
return (super.send(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -173,4 +173,27 @@ class EffectivePermissionResolverTest
|
||||
assertThat(resolver.expand(null)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exclusion applies through ancestors and duplicate paths without removing
|
||||
** overlapping named grants or policies from other groups, or mutating the graph.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void excludesOnlyTheSelectedPolicy()
|
||||
{
|
||||
GroupGraph graph = new GroupGraph()
|
||||
.add(new GroupGraph.Node("parent", "Parent", false, List.of(), List.of("cms.publish"), List.of("app"), List.of(), List.of(), List.of("cms.*", "missing")))
|
||||
.add(new GroupGraph.Node("child", "Child", false, List.of("parent"), List.of(), List.of(), List.of(), List.of(), List.of("cms.edit")));
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, CATALOG);
|
||||
EffectivePermissionResolver.Resolution base = resolver.resolveGroups(List.of("child", "parent"), "parent");
|
||||
assertThat(base.sessionPermissions()).containsExactly("cms.edit", "cms.publish");
|
||||
assertThat(base.grantedBy().get("cms.edit")).containsExactly("Child");
|
||||
assertThat(base.grantedBy().get("cms.publish")).containsExactly("Parent");
|
||||
assertThat(base.appAccess()).containsExactly("app");
|
||||
assertThat(base.unknown()).isEmpty();
|
||||
assertThat(resolver.resolveGroups(List.of("child")).sessionPermissions()).contains("cms.delete");
|
||||
assertThat(resolver.resolveGroups(List.of("child"), "absent")).isEqualTo(resolver.resolveGroups(List.of("child")));
|
||||
}
|
||||
|
||||
}
|
||||
+32
-39
@@ -26,26 +26,25 @@ 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.permissions.TableFeaturePermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
|
||||
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.
|
||||
** Table features are discovered through core's registered metadata and catalog.
|
||||
*******************************************************************************/
|
||||
class PolicyFeaturePermissionsTest
|
||||
{
|
||||
@@ -61,31 +60,27 @@ class PolicyFeaturePermissionsTest
|
||||
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")));
|
||||
instance.addTable(new QTableMetaData().withName("person_extra").withLabel("Extra People").withBackendName("memory")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.ofOrWithNew(instance);
|
||||
features.withPermission(feature("comment", "Comment", "person_records", "legacy"))
|
||||
.withPermission(feature("analyze", "Old label", "person_records"))
|
||||
.withPermission(feature("analyze", "Analyze", "person_records", "gate", "open_table"))
|
||||
.withPermission(feature("tag", "Tag", "shared_one", "shared_two"))
|
||||
.withPermission(feature("share", "Share", "person_extra"))
|
||||
.withPermission(feature("", "Empty", "person_records"));
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shape returned by PermissionsHelper when core registers a feature.
|
||||
** Register a feature only for the named tables; core supplies permission names.
|
||||
*******************************************************************************/
|
||||
private static AvailablePermission feature(String name, String label)
|
||||
private static TableFeaturePermission feature(String suffix, String label, String... tables)
|
||||
{
|
||||
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")));
|
||||
return (new TableFeaturePermission().withSuffix(suffix).withLabel(label)
|
||||
.withAppliesTo((instance, table) -> List.of(tables).contains(table.getName())));
|
||||
}
|
||||
|
||||
|
||||
@@ -106,11 +101,13 @@ class PolicyFeaturePermissionsTest
|
||||
@Test
|
||||
void catalogListsFeaturesSeparately()
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
QInstance instance = instance();
|
||||
assertThat(PermissionsHelper.getAllAvailablePermissions(instance)).filteredOn(permission -> permission.getName().equals("shared_base.tag")).hasSize(2);
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance);
|
||||
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("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")
|
||||
@@ -136,7 +133,7 @@ class PolicyFeaturePermissionsTest
|
||||
{
|
||||
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");
|
||||
PolicyCatalog.Resource resource = PolicyCatalog.of(instance).get("person");
|
||||
assertThat(resource.actions()).isEmpty();
|
||||
assertThat(resource.features()).hasSize(2);
|
||||
}
|
||||
@@ -149,7 +146,7 @@ class PolicyFeaturePermissionsTest
|
||||
@Test
|
||||
void parserValidatesFeaturesPerResource() throws Exception
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance());
|
||||
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");
|
||||
@@ -171,7 +168,7 @@ class PolicyFeaturePermissionsTest
|
||||
@Test
|
||||
void wildcardDoesNotGrantFeatures()
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance());
|
||||
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");
|
||||
@@ -185,7 +182,7 @@ class PolicyFeaturePermissionsTest
|
||||
@Test
|
||||
void generatorRoundTripsFeatures() throws Exception
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance());
|
||||
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);
|
||||
@@ -206,22 +203,18 @@ class PolicyFeaturePermissionsTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The real mapper discovers simulated core entries and adds their exact names.
|
||||
** The real mapper discovers registered core features 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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +225,7 @@ class PolicyFeaturePermissionsTest
|
||||
@Test
|
||||
void comparisonIncludesInheritedFeatures() throws Exception
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance());
|
||||
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")))
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.QAuthenticationType;
|
||||
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.permissions.TableFeaturePermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full access stays symbolic in documents and expands against live metadata.
|
||||
*******************************************************************************/
|
||||
class PolicyFullAccessTest
|
||||
{
|
||||
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact full-access statement in a valid document envelope.
|
||||
*******************************************************************************/
|
||||
private static JSONObject document()
|
||||
{
|
||||
return (PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("*"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mixed documents still validate every statement before dropping redundancy.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void parserAcceptsOnlyExplicitFullAccess() throws Exception
|
||||
{
|
||||
JSONObject document = document();
|
||||
assertThat(PolicyParser.parseStrict(document, FORMAT).permissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
|
||||
document.getJSONArray("Statement").put(PolicyParserTest.statement("Allow", List.of("read"), List.of("cms_page")));
|
||||
assertThat(PolicyParser.parseStrict(document, FORMAT).permissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
|
||||
document.getJSONArray("Statement").put(PolicyParserTest.statement("Deny", List.of("read"), List.of("cms_page")));
|
||||
assertThat(PolicyParser.parse(document, FORMAT)).isEmpty();
|
||||
for(List<String> actions : List.of(List.of("read"), List.of("*", "read"), List.<String>of()))
|
||||
{
|
||||
JSONObject invalid = PolicyParserTest.document(PolicyParserTest.statement("Allow", actions, List.of("*")));
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(invalid, FORMAT)).hasMessageContaining("requires Action");
|
||||
assertThat(PolicyParser.parse(invalid, FORMAT)).isEmpty();
|
||||
}
|
||||
JSONObject badResource = PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("*", "bad.resource")));
|
||||
assertThat(PolicyParser.parse(badResource, FORMAT)).isEmpty();
|
||||
JSONObject mixedResources = PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("*", "cms_page")));
|
||||
assertThat(PolicyParser.parse(mixedResources, FORMAT)).containsExactly(PolicyPermissions.FULL_ACCESS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The marker alone creates the wildcard; enumerated grants never do.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canonicalFormAndRoundTrip() throws Exception
|
||||
{
|
||||
JSONObject mixed = document();
|
||||
mixed.getJSONArray("Statement").put(PolicyParserTest.statement("Allow", List.of("*"), List.of("cms_page")));
|
||||
String expected = PolicyGenerator.toCompact(document());
|
||||
assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(mixed, FORMAT))).isEqualTo(expected);
|
||||
JSONObject generated = PolicyGenerator.generate(Set.of(PolicyPermissions.FULL_ACCESS, "cms_page.read"), FORMAT);
|
||||
assertThat(PolicyGenerator.toCompact(generated)).isEqualTo(expected);
|
||||
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(generated), FORMAT).permissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
|
||||
Set<String> fiveActions = PolicyParser.parse(PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("cms_page"))), FORMAT);
|
||||
assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(fiveActions, FORMAT))).doesNotContain("[\"*\"]");
|
||||
assertThat(PolicyCatalog.of(PolicyCatalogTest.instance()).unknownPermissions(Set.of(PolicyPermissions.FULL_ACCESS))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An already parsed graph and catalog cover later features and processes.
|
||||
** The member's grants must be a superset of the actual core API's output.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void resolverAndClaimsExpandCurrentAndFutureInstancePermissions()
|
||||
{
|
||||
QInstance instance = PolicyCatalogTest.instance();
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance);
|
||||
JSONObject parent = new JSONObject().put("pk", "parent").put("name", "Owners")
|
||||
.put("attributes", new JSONObject().put(FORMAT.attribute(), document()));
|
||||
JSONObject child = new JSONObject().put("pk", "child").put("name", "Staff").put("parents", List.of("parent")).put("users", List.of(7));
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(parent, child), FORMAT, catalog);
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, catalog.permissionNames(), instance);
|
||||
assertThat(resolver.resolveUser(7).sessionPermissions()).doesNotContain("cms_page.analyze", "futureProcess.hasAccess");
|
||||
|
||||
TableFeaturePermissionsMetaData.ofOrWithNew(instance).withPermission(new TableFeaturePermission().withSuffix("analyze").withLabel("Analyze")
|
||||
.withAppliesTo((qi, table) -> table.getName().equals("cms_page")));
|
||||
instance.addProcess(new QProcessMetaData().withName("futureProcess")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
Set<String> permissions = resolver.resolveUser(7).sessionPermissions();
|
||||
assertThat(permissions).containsAll(PermissionsHelper.getAllAvailablePermissionNames(instance))
|
||||
.contains("cms_page.analyze", "futureProcess.hasAccess", "legacy.write", "cms.publish.hasAccess", "publishPage.hasAccess",
|
||||
"content.hasAccess", "pageReport.hasAccess", "pageStats.hasAccess")
|
||||
.doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
assertThat(permissions).contains(permission.getSessionPermission());
|
||||
}
|
||||
assertThat(resolver.resolveUser(7).grantedBy().get("futureProcess.hasAccess")).containsExactly("Owners");
|
||||
assertThat(resolver.resolveGroups(List.of("child"), "parent").sessionPermissions()).isEmpty();
|
||||
assertThat(graph.byPk("parent").policyPermissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
|
||||
|
||||
JSONObject claims = new JSONObject().put(FORMAT.claim(), document());
|
||||
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, catalog)).isEqualTo(permissions);
|
||||
QSession session = new QSession();
|
||||
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
|
||||
assertThat(session.getPermissions()).isEqualTo(permissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QContext-based consumers see a replacement instance without a new graph.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contextReloadAndCatalogOnlyResolution()
|
||||
{
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(new JSONObject().put("pk", "owners").put("name", "Owners")
|
||||
.put("attributes", new JSONObject().put(FORMAT.attribute(), document()))), FORMAT);
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, Set.of("host.extra"));
|
||||
QInstance original = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
QInstance sample = PolicyCatalogTest.instance();
|
||||
original.addBackend(sample.getBackend("memory"));
|
||||
original.addTable(sample.getTable("cms_page"));
|
||||
QContext.init(original, new QSession());
|
||||
try
|
||||
{
|
||||
AuthentikRuntime runtime = new AuthentikRuntime(new AuthentikQBitConfig());
|
||||
assertThat(runtime.getPermissionCatalog()).contains("cms_page.read");
|
||||
assertThat(resolver.resolveGroups(List.of("owners")).sessionPermissions()).contains("cms_page.read", "host.extra");
|
||||
QInstance replacement = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
replacement.addBackend(sample.getBackend("memory"));
|
||||
replacement.addTable(sample.getTable("orphan"));
|
||||
QContext.init(replacement, new QSession());
|
||||
assertThat(runtime.getPermissionCatalog()).contains("orphan.read").doesNotContain("cms_page.read");
|
||||
assertThat(resolver.resolveGroups(List.of("owners")).sessionPermissions()).contains("host.extra").doesNotContain("cms_page.read");
|
||||
assertThat(PolicyPermissions.fromClaims(new JSONObject().put(FORMAT.claim(), document()), FORMAT))
|
||||
.contains(IdentityPermission.USERS_READ.getSessionPermission()).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
assertThat(PolicyPermissions.allPermissions(null, null)).hasSize(IdentityPermission.values().length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-declared names (custom checkers QQQ metadata cannot list) join the
|
||||
** expansion for an instance holding that runtime's backend, are computed at
|
||||
** use time, and never leak into another instance.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostDeclaredPermissionsJoinFullAccessForTheirInstanceOnly()
|
||||
{
|
||||
AuthentikQBitConfig config = new AuthentikQBitConfig().withTableNamePrefix("hostdecl");
|
||||
List<String> names = new java.util.ArrayList<>(List.of("host.purge"));
|
||||
config.withFullAccessHostPermissions(qInstance -> names);
|
||||
AuthentikRuntime.register(new AuthentikRuntime(config));
|
||||
|
||||
QInstance mine = new QInstance();
|
||||
mine.addBackend(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData().withName(config.getApiBackendName()));
|
||||
assertThat(PolicyPermissions.allPermissions(mine, Set.of())).contains("host.purge");
|
||||
|
||||
names.add("host.later");
|
||||
assertThat(PolicyPermissions.allPermissions(mine, Set.of())).contains("host.purge", "host.later");
|
||||
|
||||
assertThat(PolicyPermissions.allPermissions(new QInstance(), Set.of())).doesNotContain("host.purge", "host.later");
|
||||
config.withFullAccessHostPermissions(qInstance -> null);
|
||||
assertThat(PolicyPermissions.allPermissions(mine, Set.of())).doesNotContain("host.purge");
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,14 @@ class AuthentikProcessesTest
|
||||
|
||||
for(QProcessMetaData process : t.qInstance.getProcesses().values())
|
||||
{
|
||||
assertThat(process.getTableName()).as(process.getName()).isNotBlank();
|
||||
if(!process.getName().equals("authentikTokenReminders"))
|
||||
{
|
||||
assertThat(process.getTableName()).as(process.getName()).isNotBlank();
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(process.getIsHidden()).isTrue();
|
||||
}
|
||||
assertThat(t.qInstance.getApp("authentikAdmin").getChildren()).extracting(c -> c.getName()).doesNotContain(process.getName());
|
||||
}
|
||||
assertThat(t.qInstance.getProcess("authentikCreateUser").getMaxInputRecords()).isEqualTo(0);
|
||||
|
||||
@@ -104,6 +104,9 @@ entries:
|
||||
# superuser, a member of authentik Admins or identity-admins, or
|
||||
# qqq.io/protected). If it ever happens, guard (a) was bypassed:
|
||||
# identity-admins get an Authentik notification and an email.
|
||||
# It also notifies whenever that account creates a service account (each
|
||||
# comes with an app password; owner decision 2026-10-14), so in-app
|
||||
# service-account creation is always visible to identity-admins.
|
||||
- model: authentik_policies_expression.expressionpolicy
|
||||
id: qqq-token-alarm-policy
|
||||
state: present
|
||||
@@ -121,6 +124,11 @@ entries:
|
||||
if (event.user or {}).get("username") != "qqq-kof22-admin-idadmin":
|
||||
return False
|
||||
model = event.context.get("model") or event.context.get("secret") or {}
|
||||
# Also notify (owner decision 2026-10-14) whenever the qbit's account
|
||||
# creates a service account: each one comes with an app password.
|
||||
if event.action == "model_created" and model.get("app") == "authentik_core" and model.get("model_name") == "user":
|
||||
created = User.objects.filter(pk=model.get("pk")).first()
|
||||
return created is not None and created.type == "service_account"
|
||||
# Two shapes: the usual model reference (app, model_name, pk), and set_key's
|
||||
# model_to_dict(token) (token_uuid, identifier, intent, user; key masked).
|
||||
is_reference = model.get("app") == "authentik_core" and model.get("model_name") == "token"
|
||||
|
||||
Reference in new issue
Block a user