Compare commits

..
Author SHA1 Message Date
james.maesandCodex d72ab492b3 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:24:37 -05:00
james.maesandCodex 53c54c659c ci: use the shared orb pipeline on main
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 09:00:59 -05:00
210 changed files with 292 additions and 106971 deletions

No files matched your search

+1 -1
View File
@@ -11,4 +11,4 @@ workflows:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
ignore: /.*/
-20
View File
@@ -1,20 +0,0 @@
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 3
tab_width = 3
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{cmd,bat}]
end_of_line = crlf
-3
View File
@@ -1,3 +0,0 @@
* text=auto eol=lf
*.cmd text eol=crlf
mvnw text eol=lf
-25
View File
@@ -1,25 +0,0 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once. Security updates are enabled in the repository settings and are
# not limited by this schedule.
version: 2
updates:
- package-ecosystem: maven
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
patterns: ['*']
-74
View File
@@ -1,74 +0,0 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
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
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
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@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
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
-16
View File
@@ -1,16 +0,0 @@
name: authentik-schema-drift
'on':
schedule:
- cron: 17 6 * * 1
workflow_dispatch:
inputs:
version:
description: Authentik version (e.g. 2026.10.0); empty = latest release
required: false
permissions:
contents: read
jobs:
drift:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-schema-drift.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
with:
version: ${{ inputs.version || '' }}
+15 -18
View File
@@ -1,23 +1,20 @@
# Maven
target/
.env
.idea
*.iml
*.class
*.log
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
# IDE
.idea/
*.iml
.vscode/
.project
.classpath
.settings/
# OS
.DS_Store
*.sw*
.flattened-pom.xml
dependency-reduced-pom.xml
/.env.local
/.cache/
.claude/settings.local.json
__pycache__/
*.py[cod]
Thumbs.db
# Logs
*.log
-3
View File
@@ -1,3 +0,0 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+5 -6
View File
@@ -6,11 +6,10 @@ browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
release_tag_policy: none
test_branch_policy: all
publish:
snapshot: true
release_candidate: true
release: true
snapshot: false
release_candidate: false
release: false
asciidoc: false
release_completion: true
-73
View File
@@ -1,73 +0,0 @@
# AGENTS.md / CLAUDE.md
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md in sync.
## Project Overview
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and writes through the Authentik API (never its DB). Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = the full user lifecycle and group/permission management (phases 0-3), read-only by default.
## Build Commands
```bash
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
./mvnw test -Dtest=ClassName
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
./mvnw test -Dtest=SchemaDriftTest -Dauthentik.schema.file=/path/to/schema.yml # drift vs another release
```
- **Contract ITs** (`*IT`, package `contract`) share one `AuthentikTestStack` per JVM; first boot takes ~1.5-3 minutes. `@ExtendWith(RequiresDockerCondition.class)`: skipped without Docker locally, failed with `CI=true`.
- The stack boots with `src/test/resources/blueprints/*.yaml` already in `/blueprints/custom`: that IS contract test (h) (bootstrap on an empty instance).
- `TestApi` uses HTTP/1.1 on purpose: Java's HttpClient tries an h2c upgrade on plain-http POSTs, which Authentik answers with "400 Invalid HTTP request received."
- Tests that grant extra permissions to the service-account role must remove them again (they are additive and shared).
## Architecture
- `AuthentikQBitProducer` validates the config, registers an `AuthentikRuntime` for the API backend, then runs the component producers in `metadata/`.
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `authentik.admin`.
## Invariants (do not break)
- 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[]=`.
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
## Code Style
3-space indentation, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters, wrapper types in config.
## Dependencies
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
CircleCI qqq-orb 0.6.8:
- `develop` publishes SNAPSHOTs
- `release/*` publishes RCs
- `v*` tags publish releases
Cut `release/0.1` only after phase 1 is green and reviewed.
-125
View File
@@ -1,125 +0,0 @@
# Changelog
All notable changes to this project are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [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.
### Added (table feature permissions)
- Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData
### Added (policy cut-over, DESIGN.md section 12.8)
- `AuthentikHostPermissionSource` SPI (today's access) and config `mapperEnabled`, `cutoverGuardGroups`, `mapperOverride`, `staticGroupPermissions`.
- **Enforcement status**: banner widget `authentikPolicyEnforcement` (first on group and policy records and on the Edit Policy step) and a "Policies at sign-in" row in the home's Connection card: OFF, ACTIVE, OVERRIDDEN or BLOCKED.
- **Compare Access** widget `authentikCompareAccess` on the app home (`authentik.admin`, read only): per active person, today vs the mapper, with added, removed and unchanged, and the guard result.
- **Cut-over guard**: while a member of a guarded group would lose a permission (and no override), the mapper grants today's access plus the static fallback and bootstrap subjects instead of the policies.
- **Generate Policy From Current Access** group action: the expressible part of today's access (joined with the current policy) becomes the policy, named permissions go to `qqq.io/permissions`, the rest is listed; Review shows the document and diff.
### Added (full user and group CRUD, phase 5c: DESIGN.md section 12.5)
- **Edit User** also changes the username (validated, unique) and the other attributes (a JSON object; reserved `qqq.io/` and `goauthentik.io/` keys are kept and cannot be set; blank leaves them as they are; editing attributes needs `authentik.audit.read`, which is also what shows them on the form). **Edit Group** edits other attributes the same way.
- **Delete User** and **Delete Group** record actions behind the new `DELETE` feature (off by default; needs Authentik's `delete_user` / `delete_group`). Typed confirmation; never yourself, a protected account, a superuser group or the last identity administrator; a group must have no members and no child groups first; the group's `authentik_policy` row goes with it. Deactivate stays the default for people.
### Added (policy editor, phase 5b: DESIGN.md section 12.4)
- **Edit Policy** record action on groups and on policies (feature `POLICIES`, permission `authentik.policies.manage`): a standard streamed-ETL action whose step shows the `AUTHENTIK_POLICY_EDITOR` widget (Next UI). Review parses the submitted document (fail closed), regenerates it in canonical form with the round-trip check, refuses new resources this app does not define, applies the subset rule to the permissions it adds, and lists what is added and removed. Result merge-patches `qqq.io/policy` into the group's current attributes (removed when empty). Optional second-person approval (`policyTwoPersonApproval`).
- **`PolicyCatalog`**: every app, table, process (hidden record actions included), report and protected widget a policy can grant, with the actions QQQ checks, grouped by app. Its permission names join the permission catalog.
- **`authentik_policy`** local table (one row per group; field-level audit is the change history; DDL in `docs/schema.sql`), in the nav under Access and as a child list on the group.
- Widget `authentikPolicyEditor` (type `AUTHENTIK_POLICY_EDITOR`): the catalog and the group's live document, with a flag when it was changed in Authentik since the last save in QQQ.
### Added (policies, phase 5a: DESIGN.md section 12, docs/POLICY-FORMAT.md)
- **Policy documents** (IAM-style statements) on Authentik groups: `PolicyParser` (fails closed: unknown spec or version, non-Allow effect, unknown action, bad resource or malformed JSON grant nothing), `PolicyGenerator` (canonical form with a round-trip check), `PolicyFormat` and the config `policySpec`, `policyVersion`, `policyAttribute`, `policyClaim`, `policyTwoPersonApproval`.
- **Runtime**: `AuthentikPolicyClaimMapper` (reads the `qqq_policy` claim from the ID token or access token; `applySecurityKeys` hook for record locks) and `PolicyPermissions.fromClaims` for a host's own customizer. `AuthentikGroupPermissionMapper` also adds the claim, and the group graph and `EffectivePermissionResolver` read each group's `qqq.io/policy`, so effective permissions, the subset rule and the last-admin check see policy grants. An invalid policy on a group is listed as malformed.
- **Blueprint example** for the scope mapping that puts the merged policies into the token, pinned by `PolicyClaimMappingIT` on Authentik 2026.8.3.
### Added (v1: full user lifecycle and group/permission management, phases 2-3)
- **Writes through the Authentik API**, behind the features `USERS`, `GROUPS`, `MEMBERSHIP`, `CREDENTIALS` and `SESSIONS` (read-only stays the default; `readOnly` is a hard kill switch). Every change goes through `AuthentikAdmin`: it plans against live Authentik data (the guardrails of DESIGN.md 5.4), audits first, calls Authentik, posts a `custom_qqq_admin_action` event naming the operator, and finishes the audit row. `AuthentikActionUtil.write` needs a `WriteAuthorization`, which only `AuthentikGuard.authorize` can mint.
- **Actions** (table and record actions with Review and Result steps; none in the nav): Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject.
- **Safety**: the subset (no escalation) rule evaluated from the operator's live groups; protected accounts and superuser groups; no self-edits; last-administrator protection; reasons; typed confirmations (and a typed count above 25 targets); a 100-target cap; stale checks by fingerprint (groups have no `last_updated`); attribute edits merge only the `qqq.io/*` keys into the current attributes; two-person approval for granting `authentik.*`.
- **Local tables** `authentik_admin_action` (append-only audit) and `authentik_approval_request` (DDL in `docs/schema.sql`), on the new `localBackendName`.
- **Read tables** `authentik_application`, `authentik_policy_binding`, `authentik_blueprint`, `authentik_role`, `authentik_token` (metadata only, never a key) and `authentik_invitation`. Sub-tables appear as child lists on their parents; the nav lists only the main tables.
- **Home**: the `authentikHome` widget (needs attention, people, groups and permissions, recent changes, connection).
- Config: `localBackendName` (replaces `mirrorBackendName`), `twoPersonApproval`, `schedulerName`, `linkPrefix`.
- Tests: an in-memory Authentik for the rules, process tests through `RunProcessAction`, and write contract tests on Authentik 2026.8.3 (`QbitWriteContractIT`, including the recovery email arriving in Mailpit).
### Security
- `bootstrapOperators` (emails) is replaced by `bootstrapOperatorSubjects` (env `AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS`): users can change their own email in Authentik, so an email must never confer identity permissions. A bootstrap operator is now matched on the token `sub` (email-looking subs ignored) or, with a verified email, on the single active Authentik user with that email whose pk or uuid is configured. Spoofed, duplicate or unverified emails are denied; entries containing `@` fail validation. The admin service uses the same rule.
### Fixed
- Count reported no distinct count, so a process Review showed no total; group lists now include member pks (member counts were 0).
- Spec: multi-value filters are repeated plain names (`type=a&type=b`), never `type[]=a` (which Authentik silently ignores); stale role-state, `check_access`, re-parenting-risk, private-repo and backend-name wording corrected (spec revision 4).
### Not in this release
- The Authentik webhook, the local mirror tables and reconcile, the access-matrix widget, snapshot export, RBAC role management (phase 4) and the Alerts producer integration. `MIRROR`, `WEBHOOK`, `ACCESS_MATRIX`, `SNAPSHOT_EXPORT` and `RBAC` are refused by config validation.
### 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).
- **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
- README: "Adopting in another app" section (coordinates, configuration, secrets, permissions, limitations) linking the QQQ adoption checklist; examples use neutral names instead of one host's (#3).
### Added (phase 1, read-only MVP)
- Tables: `authentik_group_member` (virtual membership table: by group, by user, by key, or a bounded sweep), `authentik_session` and `authentik_event` (both need `authentik.audit.read`).
- `GroupGraph` and `EffectivePermissionResolver`: groups, including inherited parents, become QQQ permissions. `authentik.*` and catalog wildcards are expanded; unknown names are reported, never granted. Groups show inherited superuser status and effective permissions; users show effective permissions.
- `AuthentikGroupPermissionMapper`: an authentication customizer that turns the token's `groups` claim into QQQ session permissions through the resolver. It supports static fallbacks and `bootstrapOperators`; if Authentik cannot be read it grants only the static fallbacks, never everything.
- Review hardening:
- `search` accepts plain text only; Authentik's query language could reach sensitive fields.
- The group graph is thread-safe.
- A failed group-graph read shows "Unavailable", never empty permissions.
- Malformed `qqq.io/*` attributes are reported.
- Operator filters can't widen the user scope, so count and query agree.
- Membership rows are always scope-checked.
- Missing pagination is an error.
- Sorting by the key falls back to the default order.
- Booleans are strict.
- `..` keys are refused.
- https is required (except loopback or `allowInsecureHttp`).
- `groupScopeNamePrefix` is refused until implemented.
- Drift checks that cannot run report ERROR ("result unknown"); service-account tokens are filtered server-side; deleted targets are flagged; only human users count as identity admins.
- Identity drift report widget (`authentik.admin`). It checks the server version, blueprint status, the service account (type, groups, superuser, protection, extra self-minted tokens), break-glass presence, the last identity administrator, `qqq.io/*` attribute validity, and service-account changes to protected or superuser objects (lockout detection).
### Added (phase 0)
- Phase 0 foundations: `AuthentikQBitConfig`, `AuthentikQBitProducer`, `AuthentikActionUtil` (bearer auth, page/page_size paging, filter mapping that rejects unsupported filters, 10 rps token-bucket throttle, log redaction), read-only Users and Groups tables, Identity app.
- Pinned Authentik 2026.8.3 OpenAPI schema and schema drift tests.
- Testcontainers contract tests against Authentik 2026.8.3 (bootstrap on an empty instance, superuser floor, blueprint re-apply safety, access-check behaviour) and a fixture recorder.
+10 -66
View File
@@ -1,73 +1,17 @@
# AGENTS.md / CLAUDE.md
# qbit-template-extension
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md in sync.
GitHub template repository for scaffolding QQQ "Extension QBits" (infrastructure-level plugins: table customizers, auth, audit, action wrappers). Not a shipping library — consumers click "Use this template" and rename the `com.kingsrook.qbits.example` package.
## Project Overview
## QQQ 4.0 baseline — 2026-09-24
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and writes through the Authentik API (never its DB). Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = the full user lifecycle and group/permission management (phases 0-3), read-only by default.
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
## Build Commands
## Knowledge base
```bash
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
./mvnw test -Dtest=ClassName
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
./mvnw test -Dtest=SchemaDriftTest -Dauthentik.schema.file=/path/to/schema.yml # drift vs another release
```
Reviewed dossiers for this repo and the wider QQQ platform live in the second-brain vault:
- **Contract ITs** (`*IT`, package `contract`) share one `AuthentikTestStack` per JVM; first boot takes ~1.5-3 minutes. `@ExtendWith(RequiresDockerCondition.class)`: skipped without Docker locally, failed with `CI=true`.
- The stack boots with `src/test/resources/blueprints/*.yaml` already in `/blueprints/custom`: that IS contract test (h) (bootstrap on an empty instance).
- `TestApi` uses HTTP/1.1 on purpose: Java's HttpClient tries an h2c upgrade on plain-http POSTs, which Authentik answers with "400 Invalid HTTP request received."
- Tests that grant extra permissions to the service-account role must remove them again (they are additive and shared).
- Hub / entry point: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-extension.md`
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
## Architecture
- `AuthentikQBitProducer` validates the config, registers an `AuthentikRuntime` for the API backend, then runs the component producers in `metadata/`.
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `authentik.admin`.
## Invariants (do not break)
- 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[]=`.
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
## Code Style
3-space indentation, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters, wrapper types in config.
## Dependencies
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
CircleCI qqq-orb 0.6.8:
- `develop` publishes SNAPSHOTs
- `release/*` publishes RCs
- `v*` tags publish releases
Cut `release/0.1` only after phase 1 is green and reviewed.
Reviewed at commit `e7d4b4d672cc` (branch `main`, 2026-05-21). Known drift as of that review: no `qbit-build-parent` parent pom (org convention), qqq pinned at 0.35.0 on main (0.40.0-SNAPSHOT on develop), README still claims AGPL-3.0 while LICENSE/NOTICE are Apache-2.0, and README references `scripts/customize_template.py` / `actions/ExampleActionCustomizer.java` which do not exist. See the dossier's "Maturity & risks" and "v4.0 impact" sections before modifying the scaffold.
-3
View File
@@ -1,3 +0,0 @@
# Code of Conduct
See Project level [Code of Conduct](https://github.com/Kingsrook/qqq/blob/develop/CODE_OF_CONDUCT.md)
-3
View File
@@ -1,3 +0,0 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/Kingsrook/qqq/blob/develop/CODE_STYLE.md)
-3
View File
@@ -1,3 +0,0 @@
# Contributing to QBit Authentik
* See Project [Contributing](https://github.com/Kingsrook/qqq/blob/develop/CONTRIBUTING.md)
+1 -1
View File
@@ -176,7 +176,7 @@
END OF TERMS AND CONDITIONS
Copyright 2024 Kingsrook, LLC
Copyright 2022-2025 QRunIO, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+3 -3
View File
@@ -1,6 +1,6 @@
QBit Authentik
Copyright 2026 Kingsrook, LLC
qBit Template - Extension
Copyright 2022-2025 QRunIO, LLC
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
This product includes software developed at QRunIO, LLC (https://qrun.io/).
Licensed under the Apache License, Version 2.0.
+27 -164
View File
@@ -1,179 +1,42 @@
# QBit Authentik
# QBit Template: Extension
A QQQ QBit to manage [Authentik](https://goauthentik.io) identities from a QQQ app: **the full user lifecycle, and groups with the permissions they grant**. Authentik stays the source of truth. The qbit reads Authentik live and writes through the Authentik API, never its database. Changes made in the Authentik admin UI are equally valid, and the qbit never overwrites them blindly.
Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers.
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065). Access tokens (PATs): [`docs/ACCESS-TOKENS.md`](docs/ACCESS-TOKENS.md). Status: [`docs/STATUS.md`](docs/STATUS.md). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Table DDL: [`docs/schema.sql`](docs/schema.sql).
## What is an Extension QBit?
## What you get
Extension QBits extend or enhance how QQQ operates at the infrastructure/framework level. They provide:
- New backend implementations
- Authentication mechanisms
- Audit capabilities
- Table customizers
- Action handlers
| | |
|---|---|
| **Access tokens** | Create and rotate your personal access tokens, revoke (including bulk), extend expiry, and create service accounts. Enabled with any write feature; opt out with `tokensEnabled=false` or `AUTHENTIK_QBIT_TOKENS=false`. Keys are response-only and shown once; key-producing actions require QQQ 4.1.0-RC.7+ one-time output support. See [Access tokens](docs/ACCESS-TOKENS.md). |
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. Edit covers username, name, email, path and other attributes. Delete is a separate, explicit action behind the `DELETE` feature (off by default); deactivate stays the default. |
| **Groups and permissions** | Create, edit (parents, other attributes) and delete groups, add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, 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. |
## Quick Start
Everything is a standard QQQ table, action and widget, so it works in any QQQ UI and over the QQQ API.
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create your repository, customize its Maven coordinates and Java package/classes, implement the table customizer, then run `mvn clean verify`. See [Getting Started](docs/00-getting-started.md).
## Usage
## Structure
```java
AuthentikQBitConfig config = new AuthentikQBitConfig()
.withBaseUrl("https://auth.example.com") // host root; /api/v3 is appended
.withApiToken("${env.AUTHENTIK_API_TOKEN}") // service-account token, resolved by the qbit
.withAppKey("my-app-admin") // service account: qqq-my-app-admin-idadmin
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS, POLICIES)) // + DELETE for delete actions
.withLocalBackendName("rdbms") // audit trail and approval requests (see docs/schema.sql)
.withRecoveryEmailStage("my-recovery-email") // Authentik email stage (name or uuid)
.withInvitationFlow("my-enrollment"); // optional: enables invitations
new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
```
src/main/java/com/kingsrook/qbits/example/
├── ExampleExtensionQBitConfig.java # Configuration
├── ExampleExtensionQBitProducer.java # Entry point
└── customizers/
└── ExampleTableCustomizer.java # Table customization
```
Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill switch that refuses every write whatever the features say.
## Key Characteristics
### What it produces
- **No QAppSection** - Pure infrastructure
- **No user-facing tables** (or minimal operational)
- **Wraps existing tables/processes** with new behavior
- **Validates dependencies** on other QQQ components
| | |
|---|---|
| API backend | `authentikApi` |
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_policy` (with `POLICIES`), `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
| Child lists (on their parents, not in the nav) | `authentik_group_member`, `authentik_session`, `authentik_token` (metadata only, never a key), `authentik_event`, `authentik_policy_binding`, `authentik_role`, `authentik_blueprint` |
| App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report |
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group |
## Documentation
### 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`:
| Permission | Grants |
|---|---|
| `authentik.users.read` | the Identity app, home, users, groups, applications, bindings, roles, blueprints |
| `authentik.users.manage` | create, invite, edit, deactivate, reactivate users; cancel invitations |
| `authentik.groups.manage` | create and edit groups |
| `authentik.memberships.manage` | add and remove members (subset rule) |
| `authentik.permissions.manage` | edit a group's permissions and app access (subset rule) |
| `authentik.policies.manage` | edit a group's policy (subset rule) |
| `authentik.credentials.reset` | password reset email, reset MFA, revoke app passwords |
| `authentik.sessions.revoke` | revoke sessions |
| `authentik.audit.read` | sessions, tokens, events, the audit trail, sensitive fields |
| `authentik.approve` | approve or reject a second person's request |
| `authentik.admin` | the drift report |
| `authentik.roles.manage` | reserved (role management is phase 4) |
`authentik.*` grants all of them.
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
The editor discovers 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
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, `view_token_key` or impersonate permissions, and holds user/group delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. Saving a policy uses `change_group`. Token management additionally needs `change_token`, `set_token_key` and `add_token` (Authentik requires `add_token` for `service_account` and `set_key`; for a non-superuser it can only create the caller's own token); never grant `view_token_key`. Personal token operations use an exchanged user bearer from the confidential token broker, never the qbit bearer. The `DELETE` feature also needs `delete_user` and `delete_group`. The contract fixtures include the token broker, claim mapping, detective rule and token write permissions.
## Adopting in another app
See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/docs/migration/adoption-checklist.adoc) (section 5 covers this QBit).
| 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.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)`. |
| Required config | `baseUrl`, `apiToken`, `appKey`. For writes: `features`, `localBackendName`, `recoveryEmailStage` (users and credentials). Optional: `invitationFlow`, `twoPersonApproval` (default on), `userScopePathStartsWith`, `protectedUsernames`, `applicationsInScope`, `linkPrefix` (default `/app`), `throttleRequestsPerSecond` (default 10), `bulkPacingMillis`. |
| Secrets / env | `apiToken` as a `${env.X}` reference; `bootstrapOperatorSubjects` (the lockout escape hatch) defaults to `${env.AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS}`: immutable ids only (the Authentik user pk or uuid, or the token `sub`), never emails, because users can change their own email. Empty it once real group grants exist. Keep secrets in the host's secret store. |
| Optional host wiring | ESB: publish the local tables with `EsbTableMetaData.ofOrWithNew(table).withPublication(...)`. Quick Search: register `authentik_user` and `authentik_group` with the Quick Search qbit. Alerts: see Known limitations. |
| Known limitations | Not in this release: the Authentik webhook, the local mirror tables and reconcile, the access-matrix widget, snapshot export, RBAC role management (phase 4), Alerts producer integration. Invitations add people but not their groups (add them to groups after they sign up). Group and user lists are read live (cached 30 s). |
| Upgrading | See `UPGRADE.md`. |
## Build
```bash
./mvnw test # unit tests (recorded 2026.8.3 fixtures and an in-memory Authentik, no network)
CI=true ./mvnw verify # + contract tests against Authentik 2026.8.3 in Docker (Testcontainers)
./mvnw verify -Dauthentik.test.image=ghcr.io/goauthentik/server:2026.10.0 # rehearse an upgrade
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures
```
Contract tests start Authentik (server and worker), Postgres and Mailpit, and take about 10 minutes, mostly first boot and blueprint applies. Without Docker they are skipped locally. With `CI=true` they fail instead.
- [Getting Started](docs/00-getting-started.md)
- [Extension Patterns](docs/01-extension-patterns.md)
## License
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.
See [LICENSE](LICENSE) and [NOTICE](NOTICE).
-3
View File
@@ -1,3 +0,0 @@
# Security Policy
* See Project [Security Policy](https://github.com/Kingsrook/qqq/blob/develop/SECURITY.md)
-122
View File
@@ -1,122 +0,0 @@
# 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
carry the qbit's name. The suffixes are unchanged:
| Before | After |
|---|---|
| `identity.*` | `authentik.*` |
| `identity.users.read` | `authentik.users.read` |
| `identity.users.manage` | `authentik.users.manage` |
| `identity.groups.manage` | `authentik.groups.manage` |
| `identity.memberships.manage` | `authentik.memberships.manage` |
| `identity.permissions.manage` | `authentik.permissions.manage` |
| `identity.roles.manage` | `authentik.roles.manage` |
| `identity.credentials.reset` | `authentik.credentials.reset` |
| `identity.sessions.revoke` | `authentik.sessions.revoke` |
| `identity.audit.read` | `authentik.audit.read` |
| `identity.approve` | `authentik.approve` |
| `identity.admin` | `authentik.admin` |
The QQQ session form is the new name plus `.hasAccess`
(`authentik.users.read.hasAccess`).
**Legacy names are not granted.** The resolver lists an old `identity.*` name
among a group's *unknown* permissions, so it grants nothing and fails closed.
Update every stored grant **before** you deploy, or the admins lose the
Identity app until you do:
- **Authentik group attributes:** edit `qqq.io/permissions` on each group, for
example `identity-admins`, in the Authentik UI (Directory → Groups →
Attributes) or in its blueprint. Replace `identity.` with `authentik.`:
```yaml
attributes:
qqq.io/permissions:
- authentik.* # was identity.*
```
To find the groups that still carry old names, run the drift report. Its
"group attributes" check warns about each `identity.*` entry ("grants
nothing in this app").
- **QQQ-side grants:** these are static fallbacks in
`AuthentikGroupPermissionMapper`, host permission lists and role tables. If
a host stores permission names in a table:
```sql
-- example only: use your host's table and column names
UPDATE role_permission
SET permission_name = REPLACE(permission_name, 'identity.', 'authentik.')
WHERE permission_name LIKE 'identity.%';
```
The qbit itself stores no grants in a database. `qqq_user_session` rows hold
resolved permissions, so sign users out (or let their sessions expire)
after the change.
- **Code** that names `identity.*` strings. Use `IdentityPermission`
instead. `IdentityPermission.LEGACY_PREFIX`, `LEGACY_WILDCARD` and
`getLegacyShortName()` (all deprecated) give the old names for upgrade
scripts.
The `identity-admins` group name and the "Identity" app label are unchanged.
## Snake_case table names (unreleased → first release)
Table names now follow the owner naming rule (2026-10-12): lowercase snake_case,
prefixed with the qbit's name and `_`.
| Before | After |
|---|---|
| `authentikUser` | `authentik_user` |
| `authentikGroup` | `authentik_group` |
| `authentikGroupMember` | `authentik_group_member` |
| `authentikSession` | `authentik_session` |
| `authentikEvent` | `authentik_event` |
With a custom `tableNamePrefix` the prefix is snake-cased too: `myAuth` gives
`my_auth_user`. App, widget, backend and process names keep camelCase
(`authentikAdmin`, `authentikDriftReport`, `authentikApi`).
The tables are API-backed, so there is no database DDL. Hosts that ran a
pre-release snapshot should update anything that stores a table name:
- **Permissions:** grants or role definitions that name a table (for example
table-level permission names derived from the table name) and any host code
calling `qInstance.getTable("authentikUser")`.
- **Saved views and reports:** rows whose table name is an old name, for
example `UPDATE saved_view SET table_name = 'authentik_user' WHERE table_name = 'authentikUser';`
(and the same for saved reports and any shared/favorite records).
- **Audit:** `audit_table.name` rows for the old names, if the host audits
these tables (rename them, or leave history under the old name).
- **Frontend links and bookmarks** of the form `/…/authentikUser/…`.
`AuthentikTables.LEGACY_*_TABLE_NAME` hold the old names (deprecated) so
upgrade scripts can find them.
-246
View File
@@ -1,246 +0,0 @@
<?xml version="1.0"?>
<!--
~ QQQ - Low-code Application Framework for Engineers.
~ Copyright (C) 2021-2022. Kingsrook, LLC
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
~ contact@kingsrook.com
~ https://github.com/Kingsrook/
~
~ 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
~
~ https://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.
-->
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
<!--
See reference at https://checkstyle.sourceforge.io/checks.html
-->
<module name = "Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java, properties, xml"/>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<!--
<module name="LineLength">
<property name="max" value="100"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
-->
<module name="TreeWalker">
<module name="SuppressWarningsHolder"/>
<module name="OuterTypeFilename"/>
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
</module>
<module name="AvoidEscapedUnicodeCharacters">
<property name="allowEscapesForControlCharacters" value="true"/>
<property name="allowByTailComment" value="true"/>
<property name="allowNonPrintableEscapes" value="true"/>
</module>
<module name="AvoidStarImport"/>
<module name="OneTopLevelClass"/>
<module name="NoLineWrap"/>
<module name="EmptyBlock">
<property name="option" value="TEXT"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
</module>
<module name="NeedBraces"/>
<module name="LeftCurly">
<property name="option" value="nl"/>
</module>
<module name="RightCurly">
<property name="option" value="alone"/>
</module>
<!--
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
-->
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="ArrayTypeStyle"/>
<module name="MissingSwitchDefault"/>
<module name="FallThrough"/>
<module name="UpperEll"/>
<module name="ModifierOrder"/>
<module name="EmptyLineSeparator">
<property name="allowNoEmptyLineBetweenFields" value="true"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapDot"/>
<property name="tokens" value="DOT"/>
<property name="option" value="nl"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapComma"/>
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
<property name="id" value="SeparatorWrapEllipsis"/>
<property name="tokens" value="ELLIPSIS"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
<property name="id" value="SeparatorWrapArrayDeclarator"/>
<property name="tokens" value="ARRAY_DECLARATOR"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapMethodRef"/>
<property name="tokens" value="METHOD_REF"/>
<property name="option" value="nl"/>
</module>
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="TypeName">
<message key="name.invalidPattern"
value="Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MemberName">
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="CatchParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ClassTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MethodTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="InterfaceTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="NoFinalizer"/>
<module name="GenericWhitespace">
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
</module>
<module name="Indentation">
<property name="basicOffset" value="3"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="3"/>
<property name="throwsIndent" value="6"/>
<property name="lineWrappingIndentation" value="3"/>
<property name="arrayInitIndent" value="6"/>
</module>
<!--
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="1"/>
</module>
<module name="OverloadMethodsDeclarationOrder"/>
<module name="VariableDeclarationUsageDistance"/>
<module name="CustomImportOrder">
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="true"/>
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
</module>
-->
<module name="CustomImportOrder">
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
<property name="specialImportsRegExp" value="^javax\."/>
<property name="standardPackageRegExp" value="^java\."/>
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="false"/>
</module>
<module name="MethodParamPad"/>
<module name="NoWhitespaceBefore">
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
<property name="allowLineBreaks" value="true"/>
</module>
<module name="ParenPad"/>
<module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationMostCases"/>
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationVariables"/>
<property name="tokens" value="VARIABLE_DEF"/>
<property name="allowSamelineMultipleAnnotations" value="true"/>
</module>
<module name="JavadocMethod">
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="allowedAnnotations" value="Override, Test"/>
</module>
<module name="MissingJavadocMethod">
<property name="scope" value="private"/>
</module>
<module name="MissingJavadocType">
<property name="scope" value="private"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="EmptyCatchBlock">
<property name="exceptionVariableName" value="expected"/>
</module>
<module name="CommentsIndentation"/>
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
<module name="MissingOverride"/>
</module>
<module name="Header">
<property name="headerFile" value="checkstyle/license.txt"/>
<property name="fileExtensions" value="java"/>
<property name="ignoreLines" value="3"/>
</module>
<module name="SuppressWarningsFilter"/>
</module>
-19
View File
@@ -1,19 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.
*/
+28
View File
@@ -0,0 +1,28 @@
# Getting Started
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
1. Choose **Use this template** on GitHub, then clone the repository you created.
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
5. Build the generated project:
```bash
mvn clean verify
```
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
## Register with a host application
The following uses the original example names; substitute the names chosen above:
```java
new ExampleExtensionQBitProducer()
.withConfig(new ExampleExtensionQBitConfig()
.withTargetTableName("order"))
.produce(qInstance, "my-extension");
```
Register the target table before producing the extension, then implement its customizer. See [Extension Patterns](01-extension-patterns.md).
+16
View File
@@ -0,0 +1,16 @@
# Extension Patterns
[ExampleExtensionQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java) registers the QBit identity and calls `applyTableCustomizations` for the configured target. That method is the extension point: look up the table, validate that it exists, and call your customized [ExampleTableCustomizer](../src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java).
```java
QTableMetaData table = qInstance.getTable(tableName);
if(table == null)
{
throw new QException("Target table not found: " + tableName);
}
new ExampleTableCustomizer().customize(table);
```
When implementing this method, add `throws QException` to its declaration. Run the config's `validate(QInstance, List<String>)` before registering metadata, and report validation errors rather than silently ignoring a missing target. The uncustomized template contains placeholders for this behavior.
For record hooks, use QQQ's existing table customizer interfaces or `AbstractPreInsertCustomizer`. Its `apply(List<QRecord>)` method returns the records for the next stage; register the customizer through the table's `TableCustomizers.PRE_INSERT_RECORD` role. Check the actual QQQ interface before adding another hook. Add tests proving that the intended table changes and invalid configurations are handled.
-71
View File
@@ -1,71 +0,0 @@
# Using access tokens
An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6).
## Create a token
1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**.
2. Choose **Create token** and fill in:
- a description, for example "nightly export script";
- an expiry. It's required: at most 90 days for people, and 365 for service accounts;
- optionally, a **scope**: read only, and/or only some apps and tables. A scoped token can never do more than you can, and loses access as soon as you do.
3. Copy the key from the dialog. **It is shown once.** The app does not keep it, and nobody can show it again. If you lose it, rotate the token.
The dialog also shows ready-to-run examples for this app, with its real URLs.
## Use a token
Tokens are exchanged for a short-lived access token (a JWT) at the Authentik token endpoint. You then call the app with that JWT.
```sh
# 1. Exchange the token for a JWT (valid for the app's access-token lifetime)
JWT=$(curl -s -X POST "https://auth.example.com/application/o/token/" \
-d grant_type=client_credentials \
-d client_id="$APP_CLIENT_ID" \
-d username="$YOUR_USERNAME" \
-d password="$QQQ_TOKEN" \
-d scope="openid profile email" | jq -r .access_token)
# 2. Call the app
curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData"
```
- Keep the token in a secret store or an environment variable. Never commit it.
- When the JWT expires (the API answers 401), repeat step 1.
- Every exchange is recorded as a sign-in in Authentik. The app shows the token's **last used** time from those records.
## Manage your tokens
From **Preferences → Access tokens**:
| Action | What happens |
|---|---|
| **Rotate** | A new key replaces the old one. The description, expiry and scope stay the same. The old key stops working at once. |
| **Extend** | Moves the expiry. The limit counts from today. |
| **Revoke** | Deletes the token in Authentik. Anything using it stops working at once. |
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 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.
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.
Accounts that can administer Authentik, the break-glass account and the app's own service account can't get tokens from the app at all. Use the Authentik admin UI for them if you really need one.
## Security notes
- A token can do what its owner can do, or less if it's scoped. Prefer a scoped token and a short expiry.
- Tokens for people are created as that person, through Authentik. The app's own service account cannot create a token for a person.
- Every create, rotate, extend and revoke is audited in the app (`authentik_admin_action`, with the channel it came from) and in Authentik's event log.
- The key exists only in Authentik and in the one response that showed it to you.
-1560
View File
File diff suppressed because it is too large. Load diff
-127
View File
@@ -1,127 +0,0 @@
# Phase 0 owner checklist (kof22)
These are the steps only James can take: items 6, 7, 8 and 10 of DESIGN.md section 11.0, plus a few decisions the phase 0 work surfaced. Agents prepared everything else. Nothing below has been run against production.
Links:
- cluster-gitops draft PR: KofTwentyTwo/cluster-gitops#72 (the identity bootstrap blueprint, the removal of the `present` entries, the attribute-driven sign-in policy, inert webhook plumbing, and the blueprint lint)
- Website-Backend and cluster-gitops wiring draft PRs: see `docs/STATUS.md`
- Spec: QRun-IO/qqq#1065 (revision 4: phase 0 findings, review fixes, owner decisions of 2026-10-12)
## 0. Decisions surfaced by phase 0 (read first)
| # | Decision | Recommendation |
|---|---|---|
| D1 | **Repo visibility.** Resolved: `QRun-IO/qbit-authentik` is public. | Check that CircleCI follows the repo (it does for PR CI); nothing else to do. |
| D2 | **Lockout risk.** Authentik stops the service account from *escalating*, but not from *locking out*. With the phase 2/3 permissions it can do all of the following (contract test `SuperuserFloorIT`): remove members from `authentik Admins`, deactivate `kof22-breakglass`, strip `qqq.io/protected`, and rename the superuser group. The qbit guard refuses all of these; Authentik does not. | Accept, with the mitigations in the spec (§5.1, revision 3). The real break-glass for a compromised QQQ is cluster access: `kubectl exec` into the Authentik worker and run `ak shell` to reactivate or re-add. Optionally, phase 2 grants `remove_user_from_group` per group (object permissions) instead of globally. |
| D3 | **Self-minted tokens.** Any authenticated user, including the service account, can create API tokens **for itself** without `add_token`, and read their keys. A compromised QQQ could therefore keep a second token alive after you rotate the first. | Accept. The phase 1 drift report flags service-account tokens beyond the expected one. Check that list after every rotation (item 8). |
| D4 | **Access matrix.** `check_access?for_user=` only works for superusers. For the service account it silently answers *for itself*. The policy-test endpoint returns HTTP 500 for non-superusers on 2026.8.3. | Accepted in the spec: the qbit computes app access itself from `qqq.io/app-access` and the app's bindings, and shows other expression policies as "not evaluated". |
## Item 6: staging rehearsal (needs your go-ahead)
The agent cannot read production data. The rehearsal runs entirely in local Docker, on a dump you take.
1. **Take a dump** of the Authentik database. This is a read-only `pg_dump` of the CNPG cluster that hosts it:
```bash
# CNPG cluster kof22-website-pg (namespace kof22-website-prod) hosts the database "authentik"
P=$(kubectl --context=k8s-galaxy -n kof22-website-prod get pod -l cnpg.io/cluster=kof22-website-pg,cnpg.io/instanceRole=primary -o name)
kubectl --context=k8s-galaxy -n kof22-website-prod exec "$P" -c postgres -- \
pg_dump -Fc -d authentik > authentik-$(date +%F).dump
```
Keep the dump on an encrypted disk. It contains every identity and password hash. Delete it after the rehearsal.
2. **Get both blueprint versions**:
```bash
git -C cluster-gitops show origin/main:tenants/kof22/website-authentik/blueprint-configmap.yaml > before.yaml
git -C cluster-gitops show origin/feat/kof22-identity-bootstrap:tenants/kof22/website-authentik/blueprint-configmap.yaml > after.yaml
```
3. **Run** `docs/cluster-gitops-phase0/rehearse-phase0.sh authentik-<date>.dump before.yaml after.yaml` from macOS, Linux or Git Bash. The script has already passed on a synthetic production-like dump: today's `main` blueprint with James re-pinned by the `present` entry, plus UI-made publishers.
- The script restores the dump into a throwaway Postgres and starts Authentik 2026.8.3 on the current blueprints. It then records every user's groups, switches to the phase 0 blueprints, re-applies both twice, and checks:
- nobody's memberships changed
- `james.maes` keeps `authentik Admins` and `kof22-admins`
- the service account, break-glass and seed groups exist
- It prints `REHEARSAL PASSED` or the differences.
- Containers and the work directory are removed on exit. The script mints a rehearsal-only API token **inside the throwaway copy**, which is deleted with the container.
4. Reply "rehearsal passed" (or paste the output) on cluster-gitops#72.
## Item 7: production apply
Do these in order, in one sitting.
1. **Freeze.** Record the current memberships: *Authentik admin → Directory → Users*, or export each admin group's member list. Note who is in `authentik Admins`, `kof22-admins` and `kof-website-publishers`.
2. **Merge** cluster-gitops#72 (convert it from draft first). ArgoCD syncs the ConfigMap, and the worker sees the file change and applies both blueprints within about a minute.
3. **Verify** in *Authentik admin → Customization → Blueprints*:
- [ ] `kof22-oauth2-apps` and `kof22-identity-bootstrap` both show **successful**
- [ ] `james.maes` still has `authentik Admins` and `kof22-admins`
- [ ] user `qqq-kof22-admin-idadmin` exists: type *Service account*, no groups, role `qqq-kof22-admin-idadmin`
- [ ] user `kof22-breakglass` exists and is a superuser
- [ ] groups `identity-admins`, `kof22-admins`, `kof-website-publishers`, `kof-website-drafters` exist; only `identity-admins` has attributes
- [ ] you can still sign in to `https://admin.kof22.com` (the name fallback in the sign-in policy keeps today's access)
- [ ] *Events → Notification rules*: `qqq-kof22-admin-sync` has **no destination** (inert)
4. **Prove it** (optional, recommended): add a test user to `kof-website-publishers` in the UI. Then *Blueprints → kof22-oauth2-apps → Apply* and the same for `kof22-identity-bootstrap`. Wait a minute and confirm the member is still there. Remove the test user afterwards.
5. **Rollback** if anything is wrong: revert the PR. This re-adds the old `present` entries, which re-pin James's groups. Logins keep working throughout because of the fallback.
## Item 8: service-account token and secrets
1. **Mint the API token**:
- In Authentik admin, go to *Directory → Tokens and App passwords → Create*.
- Identifier: `qqq-kof22-admin-idadmin-api`. User: `qqq-kof22-admin-idadmin`. Intent: **API**. Expiring: **on**, with an expiry **90 days** out.
- Copy the key once.
2. **Store it** in Infisical at `/kof22-website-authentik-api/token`. Paste it straight from the Authentik dialog into Infisical; it must never go in chat, git or a ticket.
3. **Webhook secret** (used in phase 2; create it now so the phase 2 PR can be merged directly): `openssl rand -base64 48` into Infisical at `/kof22-website-authentik/qqq-webhook-secret`.
4. **Check** that *Directory → Tokens* shows **exactly one** token for `qqq-kof22-admin-idadmin` (see D3).
5. **Calendar** a rotation reminder for day 76. The health widget will also warn 14 days before expiry.
## Item 10: seed data (Authentik UI)
1. Add **yourself** (`james.maes`) to **`identity-admins`**.
2. On group **`kof22-admins`** → *Attributes*, set the following. This is today's `ADMIN_PERMISSIONS` from Website-Backend `AuthentikAuthenticationCustomizer`, resolved to literal strings on 2026-10-11 against `origin/main` f936cbd. Re-check if that list changed before you seed, because the phase 1 mapper will grant exactly this:
```yaml
qqq.io/app-access: ["kof22-website-prod"]
qqq.io/permissions:
- authentik.users.read
- esbView.hasAccess
- esbOperate.hasAccess
- esbDelete.hasAccess
- analytics.hasAccess
- quickSearchAdmin.hasAccess
- quickSearchIndex.read
- quickSearchIndex.write
- quickSearchIndexRun.read
- quickSearchIndexRun.write
- quickSearchFailedEvent.read
- quickSearchFailedEvent.write
- dataHealth.hasAccess
```
Keep the existing attributes of the group. Add only these two keys. `authentik.users.read` lets `kof22-admins` open the Identity app; the seed group `identity-admins` already carries `authentik.*` (all identity permissions) from the blueprint, so you get everything through it once you join it (step 1).
3. On group **`kof-website-publishers`** → *Attributes*: `qqq.io/app-access: ["kof22-website-prod"]`. This keeps the `kof-publisher-prod` service account's client-credentials tokens working once the name fallback is removed.
4. **`kof-website-drafters`**: leave it without `qqq.io/app-access` until you decide that drafters should sign in to the admin. Granting it later is that one attribute.
5. **Break-glass password**:
- Set a long random password on `kof22-breakglass` (*Directory → Users → kof22-breakglass → Set password*) and store it in 1Password.
- Sign in with it once in a private window to prove it works, then sign out.
- Its recovery path is `ak create_recovery_key 10 kof22-breakglass` on the worker pod.
## After items 6-10
Tell the agent "phase 0 owner steps done". Next come item 11 (the qbit in read-only mode on staging, behind `AUTHENTIK_QBIT_ENABLED`), then the phase 1 mapper cut-over, then removal of the sign-in name fallback.
## Item 12: turn on the full lifecycle (v1)
The qbit can now create and change users and groups, not just read. Do this only after items 6-11 are done and the read-only screens look right on staging. Nothing here is applied by an agent.
1. **Add the write permissions to the service account's role.** In the identity bootstrap blueprint, append these to `qqq-kof22-admin-idadmin`'s `permissions` list (they are re-asserted additively on every apply; the PR is prepared as a separate commit on cluster-gitops#72):
```
authentik_core.add_user authentik_core.change_user
authentik_core.reset_user_password authentik_core.add_group
authentik_core.change_group authentik_core.add_user_to_group
authentik_core.remove_user_from_group
authentik_core.view_token authentik_core.delete_token
authentik_core.delete_authenticatedsession
authentik_stages_authenticator_{totp,static,webauthn,email,sms,duo}.view_<kind>device
authentik_stages_authenticator_{totp,static,webauthn,email,sms,duo}.delete_<kind>device
```
Never add `enable_group_superuser`, `view_token_key`, `set_token_key`, `add_token`, `impersonate` or `delete_user`. Invitations are not used at kof22 yet (there is no enrollment flow), so the invitation permissions and `view_flow` are left out; add them with the enrollment flow.
2. **Check the brand's recovery flow.** The password reset email (also used by Create User) fails with "No recovery flow set." unless the brand that serves `auth.kof22.com` has a recovery flow. In Authentik admin, *System → Brands*, edit the brand and set *Recovery flow* to `kof22-recovery`. This is a one-time setting.
3. **Create the two local tables** (`authentik_admin_action`, `authentik_approval_request`) with the Liquibase change in the Website-Backend PR (see `docs/schema.sql` for the DDL it follows).
4. **Turn the features on** in the Website-Backend deployment (the draft PR defines the flags; the default stays read-only). Start with `AUTHENTIK_QBIT_WRITES=false`, check the Identity home, then switch writes on.
5. **Try it on a test user**: create one, add them to `kof-website-publishers`, deactivate them. Check that Authentik's *Events* shows `custom_qqq_admin_action` events naming you, and that *Identity → Admin Actions* shows the rows.
6. **Two-person approval.** Granting `authentik.*` to a group needs a second person with `authentik.approve`. While you are the only identity administrator, make those grants in the Authentik UI (the break-glass in the spec), or turn `twoPersonApproval` off until a second administrator exists.
-162
View File
@@ -1,162 +0,0 @@
# QQQ policy format
Version 1.0.0 of the policy documents that `qbit-authentik` reads and writes. The design is in [DESIGN.md §12](DESIGN.md#12-policies-iam-style-permission-statements-revision-5).
A **policy** grants QQQ permissions to the members of an Authentik group. It is stored on the group as an attribute. At sign-in, the policies of all the user's groups, including inherited parent groups, become QQQ session permissions.
## Document
```json
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.0.0",
"Statement": [
{
"Effect": "Allow",
"Action": ["hasAccess"],
"Resource": ["contentApp", "publishPage"]
},
{
"Effect": "Allow",
"Action": ["read", "insert", "edit"],
"Resource": ["cms_page", "cms_media"]
}
]
}
```
| Field | Type | Rule |
|---|---|---|
| `PolicySpec` | string | Must equal the app's configured spec name (`policySpec`, default `qqq-iam`) |
| `PolicyVersion` | string, semver | Must equal the supported version (`policyVersion`, default `1.0.0`) |
| `Statement` | array of statements | May be empty (grants nothing) |
| `Statement[].Effect` | string | `Allow` (the only effect in 1.0.0) |
| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`, or a table feature name registered for every listed resource |
| `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. `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 |
|---|---|---|
| `hasAccess` | `{name}.hasAccess` | apps, processes, reports, widgets, and tables with `HAS_ACCESS` permissions |
| `read` | `{name}.read` | tables (query, get) |
| `insert` | `{name}.insert` | tables |
| `edit` | `{name}.edit` | tables |
| `delete` | `{name}.delete` | tables |
| registered feature (e.g. `analyze`, `comment`) | `{name}.{feature}` | tables that register that feature, including `HAS_ACCESS` and `READ_WRITE` tables |
Examples:
- `{"Action": ["read"], "Resource": ["cms_page"]}` grants `cms_page.read`.
- `{"Action": ["*"], "Resource": ["cms_page"]}` grants `cms_page.hasAccess`, `cms_page.read`, `cms_page.insert`, `cms_page.edit` and `cms_page.delete`.
- `{"Action": ["analyze", "comment"], "Resource": ["person"]}` grants `person.analyze` and `person.comment` when both features are registered for `person`.
Features are generic strings, discovered by matching `PermissionsHelper.getAllAvailablePermissions(qInstance)` entries to the table's effective permission base name. Examples include `analyze` from qbit-data-analysis and `comment`, `attach`, `tag`, `share`, `task`, `assign` from qbit-collaboration. The feature label comes from `AvailablePermission.permissionType`. 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`, 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
A document grants **nothing**, and the parser logs a warning, when any of these is true:
- it is missing, blank, not JSON, or not a JSON object
- `PolicySpec` is missing or is not the configured name
- `PolicyVersion` is missing or is not the supported version
- `Statement` is present but not an array, or a statement is not an object
- a statement's `Effect` is not `Allow` (including `Deny`, which is not supported yet)
- `Action` or `Resource` is missing or not an array of strings
- an action is neither a standard action, `*`, nor a feature registered for every listed resource
- 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
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 `*`
- stored on the group as a compact JSON **string**
Before every save, the qbit checks the round trip: parsing the generated document must give back exactly the permissions it was generated from. A document that fails this check is never written.
## Where it lives
| What | Default | Config |
|---|---|---|
| Group attribute holding a group's document | `qqq.io/policy` | `policyAttribute` |
| Token claim holding the merged document | `qqq_policy` | `policyClaim` |
| Spec name | `qqq-iam` | `policySpec` |
| Version | `1.0.0` | `policyVersion` |
An app that already stores documents of this shape under its own spec name, attribute and claim sets those four values and keeps its data unchanged. The parser accepts the attribute or claim as a JSON string or as a JSON object.
## Getting policies into the token
Authentik puts the merged document into the token through a scope mapping. Steps:
1. Apply the example blueprint, [`src/test/resources/blueprints/qqq-policy-claim.yaml`](../src/test/resources/blueprints/qqq-policy-claim.yaml). Edit its spec, version, attribute and claim name to match your config.
2. Add the mapping to the OAuth2 provider's `property_mappings`. It uses the scope `profile`, which every QQQ sign-in requests.
3. Keep `include_claims_in_id_token: true` on the provider (the default).
4. Register the customizer in your app:
- `AuthentikPolicyClaimMapper` on its own, or
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
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.
The contract test `PolicyClaimMappingIT` runs this blueprint on Authentik 2026.8.3. It puts generated policies on a parent and a child group (one as a string, one as a JSON object), then runs the mapping through Authentik's property-mapping test endpoint for a member of the child group. It checks that the parser turns the claim into the union of the groups' permissions, that a document with another spec is skipped, and that a user without policies gets a valid empty document.
### Freshness
The claim is computed when the token is issued, so a policy change reaches a signed-in user at their next token refresh. In kof22 that is within 1 hour. To apply a change at once, revoke the user's sessions (*Revoke Sessions*).
## Record locks (not yet)
Row-level security keys are not part of the format. `AuthentikPolicyClaimMapper.applySecurityKeys(QSession, JSONObject)` is the extension point. A host overrides it to set security key values from its own explicit claims, and must never infer them from group names.
-59
View File
@@ -1,59 +0,0 @@
# Status
The Authentik qbit's progress through DESIGN.md section 11, with links. Updated with each PR.
## v1 = phases 0-3 (the full user lifecycle and group/permission management)
| Phase | State |
|---|---|
| 0. Foundations and migration | agent items done; **owner items open** (checklist items 6-8, 10, then 12) |
| 1. Read-only plus the permissions model | done, except the mirror tables, reconcile and the access-matrix widget (see "Not built yet") |
| 2. Memberships and permission editing | done, except the webhook route, ESB trigger, inbox and session re-evaluation marking |
| 3. Full user lifecycle | done, except the optional snapshot export |
| 4. RBAC role management | post-v1, not started |
| 5. Policies and full CRUD (DESIGN.md section 12) | 5a policy model (#14), 5b policy editor (#15), 5c user and group CRUD additions; the Next UI widget is a separate qqq-frontend-next PR |
### Built in this line (0.1)
| Area | What |
|---|---|
| People | Create, invite, edit (username, name, email, path, other attributes), deactivate, reactivate, delete (feature `DELETE`), password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
| Groups | Create, edit (parents, other attributes), delete (feature `DELETE`), add and remove members (bulk), edit permissions and app access, edit the group policy (feature `POLICIES`) |
| Safety | Subset rule on live groups, protected accounts and superuser groups, no self-edits, last-admin protection, reasons, typed confirmations, bulk caps, stale checks, two-person approval for `authentik.*` |
| Audit | `authentik_admin_action` (before the call), Authentik `custom_qqq_admin_action` events, approval requests |
| Screens | Home dashboard, main tables in the nav, child lists on parents, every action as a table or record action |
| Tests | Unit tests on an in-memory Authentik (rules, processes, app, home), contract tests on Authentik 2026.8.3 |
### Not built yet (and why)
| Item | Note |
|---|---|
| Authentik webhook, ESB trigger, inbox, session re-evaluation marking | Needs the notification transport in cluster-gitops (inert today); reads are live (30 s cache) so screens are correct without it |
| Mirror tables and reconcile | For Web Users joins and widget counts; the home reads live |
| Access matrix widget, group tree widget | Needs Next UI custom widgets (separate frontend PRs) |
| Snapshot export | Optional in the spec |
| RBAC role management | Phase 4 |
| Alerts producer integration | Optional until the Alerts qbit SPI (QRun-IO/qqq#1090) lands |
| Quick Search for users and groups | They are API-backed (read-through), so they are searched with the table's own search box, which Authentik evaluates live |
| Invitations add groups | Groups for invited people are added after they sign up (needs the webhook) |
## Phase 0: foundations (agent items done; owner items open)
| # | Item | State | Where |
|---|---|---|---|
| 1 | Repo, DESIGN.md, CI, README, CLAUDE.md | done | this repo (`develop`); public |
| 2 | Config, producer, `AuthentikActionUtil`, pinned schema + drift test | done | phase 0 PR |
| 3 | Testcontainers harness, fixture recorder, contract tests (b), (c), (d), (h) | done | `src/test/java/.../contract` |
| 4 | Open points (i)-(iv) resolved | done | DESIGN.md section 0.2 |
| 5 | cluster-gitops PR (bootstrap, removals, sign-in policy, webhook, lint) | draft | KofTwentyTwo/cluster-gitops#72 |
| 6 | Staging rehearsal | **owner** | `docs/PHASE0-OWNER-CHECKLIST.md`, `docs/cluster-gitops-phase0/rehearse-phase0.sh` |
| 7 | Production apply | **owner** | checklist |
| 8 | Service-account token, Infisical, webhook secret | **owner** | checklist |
| 9 | Website-Backend wiring (ExternalSecret + env, qbit behind a disabled flag) | draft | KofTwentyTwo/cluster-gitops#73, Website-Backend#94 |
| 10 | Seed attributes, break-glass password | **owner** | checklist |
| 11 | Read-only on staging | after 6-10 | flip `AUTHENTIK_QBIT_ENABLED` |
| 12 | Turn on the full lifecycle (role permissions, brand recovery flow, tables, flags) | after 11 | checklist item 12 |
## Contract and unit tests (Authentik 2026.8.3)
`CI=true ./mvnw verify`. The numbers are filled in at the end of each PR; see the PR description.
-21
View File
@@ -1,21 +0,0 @@
# cluster-gitops phase 0 support files
The cluster-gitops changes themselves are in **KofTwentyTwo/cluster-gitops#72** (draft). That PR adds:
- the identity bootstrap blueprint
- the removal of the `present` `james.maes` and `kof22-admins` entries
- the attribute-driven sign-in policy
- the inert webhook plumbing
- the lint
The blueprint YAML in that PR is generated from this repo's tested fixtures, `src/test/resources/blueprints/*.yaml`. That way, what goes to cluster-gitops is what the contract tests applied to Authentik 2026.8.3.
| File | Purpose |
|---|---|
| `scripts/lint-authentik-blueprints.py` | The blueprint lint (also in cluster-gitops#72 under `scripts/`). It forbids `present` user/group entries, member lists, roles beyond `permissions`, tokens and literal secrets. `--self-test` runs the built-in cases. |
| `rehearse-phase0.sh` | Owner checklist item 6: replays the migration on a **local, disposable** Authentik restored from a dump you take, and checks that no membership moves. |
To re-check the fixtures with the lint:
```bash
python3 docs/cluster-gitops-phase0/scripts/lint-authentik-blueprints.py src/test/resources/blueprints
```
@@ -1,113 +0,0 @@
#!/usr/bin/env bash
# Rehearse the phase 0 blueprint migration (DESIGN.md section 4.4, owner
# checklist item 6) on a DISPOSABLE local Authentik restored from a dump of the
# production Authentik database. Nothing here talks to production: you supply
# the dump file; everything runs in local Docker and is removed at the end.
#
# Usage:
# rehearse-phase0.sh <authentik-db.dump> <configmap-before.yaml> <configmap-after.yaml>
#
# authentik-db.dump pg_dump -Fc of the production Authentik database
# configmap-before.yaml tenants/kof22/website-authentik/blueprint-configmap.yaml at main
# configmap-after.yaml the same file on branch feat/kof22-identity-bootstrap
#
# Needs: docker, jq, python3 with PyYAML. Takes ~5 minutes.
set -euo pipefail
export MSYS_NO_PATHCONV=1 # Git Bash on Windows: keep container paths as-is
DUMP=${1:?dump file}; BEFORE=${2:?configmap before}; AFTER=${3:?configmap after}
IMAGE=${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server:2026.8.3}
NET=ak-rehearsal; PORT=${PORT:-19900}
WORK=$(mktemp -d); BP="$WORK/blueprints"; mkdir -p "$BP"
BP_MOUNT="$BP"; command -v cygpath >/dev/null 2>&1 && BP_MOUNT=$(cygpath -w "$BP")
ADMIN_TOKEN="rehearsal-$(openssl rand -hex 16)"
API="http://localhost:$PORT/api/v3"
cleanup() { docker rm -f akr-server akr-worker akr-pg >/dev/null 2>&1 || true; docker network rm $NET >/dev/null 2>&1 || true; rm -rf "$WORK"; }
trap cleanup EXIT
extract() { # write every blueprint key of a ConfigMap into $BP
rm -f "$BP"/*.yaml
python3 - "$1" "$BP_MOUNT" <<'PY'
import sys, yaml
doc = yaml.safe_load(open(sys.argv[1], encoding="utf-8"))
for key, value in doc["data"].items():
open(f"{sys.argv[2]}/{key}", "w", encoding="utf-8").write(value)
print(" blueprint file:", key)
PY
}
api() { curl -sf -H "Authorization: Bearer $ADMIN_TOKEN" "$API$1"; }
snapshot() { # users -> sorted groups, as TSV
api "/core/users/?page_size=100&include_groups=true&type=internal&type=external&type=service_account" \
| jq -r '.results[] | [.username, ([.groups_obj[].name] | sort | join(","))] | @tsv' | tr -d '\r' | sort
}
wait_blueprints() { # $1 = how many kof22* blueprint instances must exist
local want=${1:-1}
for _ in $(seq 1 90); do
states=$(api "/managed/blueprints/?page_size=100" | jq -r '[.results[] | select(.name|startswith("kof22")) | .status] | join(" ")' || true)
count=$(wc -w <<<"$states")
if [ "$count" -ge "$want" ] && ! grep -qv successful <<<"$(tr ' ' '\n' <<<"$states")"; then echo " kof22 blueprints: $states"; return 0; fi
sleep 5
done
echo " blueprints did not all reach successful: $states"; api "/managed/blueprints/?page_size=100" | jq -r '.results[] | "\(.name) \(.status)"'; return 1
}
echo "== 1. restore the dump into a disposable Postgres"
docker network create $NET >/dev/null
docker run -d --name akr-pg --network $NET -e POSTGRES_USER=authentik -e POSTGRES_PASSWORD=rehearsal -e POSTGRES_DB=authentik postgres:16-alpine >/dev/null
until docker exec akr-pg pg_isready -U authentik >/dev/null 2>&1; do sleep 1; done
docker cp "$DUMP" akr-pg:/tmp/authentik.dump
docker exec akr-pg pg_restore -U authentik -d authentik --no-owner --no-privileges /tmp/authentik.dump || echo " (pg_restore reported warnings; usually role/ownership notices)"
docker exec akr-pg psql -U authentik -d authentik -tAc "select count(*) from authentik_core_user" | sed 's/^/ users restored: /'
echo "== 2. start Authentik with the CURRENT (main) blueprints"
extract "$BEFORE"
ENV=(-e AUTHENTIK_SECRET_KEY="$(openssl rand -hex 32)" -e AUTHENTIK_POSTGRESQL__HOST=akr-pg -e AUTHENTIK_POSTGRESQL__USER=authentik
-e AUTHENTIK_POSTGRESQL__PASSWORD=rehearsal -e AUTHENTIK_POSTGRESQL__NAME=authentik -e AUTHENTIK_BOOTSTRAP_TOKEN="$ADMIN_TOKEN"
-e KOF22_OAUTH2_CLIENT_SECRET=rehearsal-not-the-real-secret -e AUTHENTIK_ERROR_REPORTING__ENABLED=false
-e AUTHENTIK_DISABLE_UPDATE_CHECK=true -e AUTHENTIK_EMAIL__HOST=localhost -e AUTHENTIK_EMAIL__PORT=1)
docker run -d --name akr-worker --network $NET "${ENV[@]}" -v "$BP_MOUNT:/blueprints/custom" "$IMAGE" worker >/dev/null
docker run -d --name akr-server --network $NET -p "$PORT:9000" "${ENV[@]}" -v "$BP_MOUNT:/blueprints/custom" "$IMAGE" server >/dev/null
until curl -sf "http://localhost:$PORT/-/health/ready/" >/dev/null; do sleep 3; done
# The bootstrap token only applies to an EMPTY instance; on a restored database
# create a rehearsal token for akadmin-equivalent access through the shell.
docker exec akr-worker ak shell -c "
from authentik.core.models import Token, TokenIntents, User
u = next(x for x in User.objects.filter(is_active=True).exclude(username__startswith='ak-').order_by('pk') if x.is_superuser) # is_superuser is derived from groups
Token.objects.update_or_create(identifier='phase0-rehearsal', defaults={'user': u, 'intent': TokenIntents.INTENT_API, 'key': '$ADMIN_TOKEN', 'expiring': False})
print('rehearsal token for', u.username)" 2>&1 | grep -E 'rehearsal token|Error|error' | tail -3 | grep 'rehearsal token' || { echo "could not create the rehearsal API token in the restored copy"; exit 1; }
wait_blueprints 1
snapshot > "$WORK/before.tsv"; echo " $(wc -l < "$WORK/before.tsv") users recorded (step 1, freeze)"
echo "== 3. switch to the phase 0 blueprints (as an ArgoCD sync would)"
extract "$AFTER"
docker restart akr-worker >/dev/null # a restart triggers discovery like a ConfigMap change
wait_blueprints 2
for name in kof22-oauth2-apps kof22-identity-bootstrap; do
pk=$(api "/managed/blueprints/?name=$name" | jq -r '.results[0].pk')
curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$API/managed/blueprints/$pk/apply/" >/dev/null
done
sleep 30; wait_blueprints 2
echo "== 4. checks"
snapshot > "$WORK/after.tsv"
fail=0
if ! diff <(awk -F'\t' '$1 != "qqq-kof22-admin-idadmin" && $1 != "kof22-breakglass"' "$WORK/after.tsv") "$WORK/before.tsv" >/dev/null; then
echo " FAIL memberships changed:"; diff "$WORK/before.tsv" "$WORK/after.tsv" || true; fail=1
else echo " ok existing users' memberships unchanged"; fi
grep -q $'^james.maes\t.*authentik Admins.*kof22-admins' "$WORK/after.tsv" && echo " ok james.maes keeps authentik Admins + kof22-admins" || { echo " FAIL james.maes groups"; fail=1; }
api "/core/users/?username=qqq-kof22-admin-idadmin&include_roles=true" | jq -e '.results[0] | .type=="service_account" and (.groups|length)==0 and ([.roles_obj[].name]==["qqq-kof22-admin-idadmin"])' >/dev/null \
&& echo " ok service account: type, no groups, role" || { echo " FAIL service account"; fail=1; }
api "/core/users/?username=kof22-breakglass" | jq -e '.results[0].is_superuser' >/dev/null && echo " ok break-glass superuser" || { echo " FAIL break-glass"; fail=1; }
for g in identity-admins kof22-admins kof-website-publishers kof-website-drafters; do
api "/core/groups/?name=$g" | jq -e '.pagination.count==1' >/dev/null && echo " ok group $g" || { echo " FAIL group $g"; fail=1; }
done
echo "== 5. re-apply both again (an hourly run): memberships must not move"
for name in kof22-oauth2-apps kof22-identity-bootstrap; do
pk=$(api "/managed/blueprints/?name=$name" | jq -r '.results[0].pk')
curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$API/managed/blueprints/$pk/apply/" >/dev/null
done
sleep 30; wait_blueprints 2
snapshot | diff - "$WORK/after.tsv" >/dev/null && echo " ok stable across re-apply" || { echo " FAIL changed on re-apply"; fail=1; }
[ $fail = 0 ] && echo "REHEARSAL PASSED" || { echo "REHEARSAL FAILED"; exit 1; }
@@ -1,225 +0,0 @@
#!/usr/bin/env python3
"""Lint Authentik blueprints so a re-apply can never revert identity data.
Authentik's blueprint importer re-saves `state: present` entries on every apply
(hourly, and on every file change), and many-to-many attrs (users, groups,
parents, roles) are REPLACED, not merged. A present user or group entry, or a
member list, therefore silently undoes edits made in the Authentik UI or in QQQ
(qbit-authentik DESIGN.md sections 2.2, 4.2 and 4.5; contract test (d)).
Rules (each violation is printed as file:entry: message):
R1 authentik_core.user / authentik_core.group entries must be
`state: created`, `must_created` or `absent` (no state means present).
R2 no member lists: a group entry must not carry `users`.
R3 an `authentik_rbac.role` entry may be `present` only if its attrs are
limited to `permissions` (and `name`): the qbit role pattern, whose
global permissions are re-asserted additively.
R4 no literal secrets: password, password_hash, key, token, client_secret
must be a YAML tag (e.g. !Env) or absent; authentik_core.token entries are
not allowed at all.
Input: Kubernetes ConfigMap manifests (every data value that looks like a
blueprint is checked) and plain blueprint files. Usage:
lint-authentik-blueprints.py FILE_OR_DIR... # exit 1 on violations
lint-authentik-blueprints.py --self-test # run the built-in cases
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
import yaml
IDENTITY_MODELS = {"authentik_core.user", "authentik_core.group"}
SAFE_IDENTITY_STATES = {"created", "must_created", "absent"}
SECRET_KEYS = {"password", "password_hash", "key", "token", "client_secret"}
ROLE_PRESENT_ALLOWED_ATTRS = {"permissions", "name"}
class Tag:
"""Placeholder for an Authentik YAML tag such as !KeyOf, !Find or !Env."""
def __init__(self, tag: str, value):
self.tag = tag
self.value = value
def __repr__(self):
return f"{self.tag} {self.value!r}"
class BlueprintLoader(yaml.SafeLoader):
"""SafeLoader that accepts Authentik's custom tags."""
def _construct_tag(loader: yaml.SafeLoader, tag_suffix: str, node: yaml.Node):
if isinstance(node, yaml.ScalarNode):
value = loader.construct_scalar(node)
elif isinstance(node, yaml.SequenceNode):
value = loader.construct_sequence(node, deep=True)
else:
value = loader.construct_mapping(node, deep=True)
return Tag("!" + tag_suffix, value)
BlueprintLoader.add_multi_constructor("!", _construct_tag)
@dataclass
class Violation:
source: str
entry: str
message: str
def __str__(self):
return f"{self.source}: {self.entry}: {self.message}"
def entry_label(index: int, entry: dict) -> str:
identifiers = entry.get("identifiers") or {}
ident = ", ".join(f"{k}={v}" for k, v in identifiers.items()) if isinstance(identifiers, dict) else str(identifiers)
return f"entry {index} ({entry.get('model')} {ident})"
def lint_blueprint(source: str, document) -> list[Violation]:
violations: list[Violation] = []
if not isinstance(document, dict) or not isinstance(document.get("entries"), list):
return violations
for index, entry in enumerate(document["entries"]):
if not isinstance(entry, dict):
continue
model = entry.get("model")
if isinstance(model, Tag):
violations.append(Violation(source, entry_label(index, entry), "model must be a literal so it can be linted"))
continue
state = entry.get("state", "present")
if isinstance(state, Tag):
violations.append(Violation(source, entry_label(index, entry), "state must be a literal so it can be linted"))
continue
attrs = entry.get("attrs") or {}
label = entry_label(index, entry)
if model in IDENTITY_MODELS and state not in SAFE_IDENTITY_STATES:
violations.append(Violation(source, label, f"R1 {model} must be `state: created` (got `{state}`); present entries revert UI/QQQ edits on every apply"))
if model == "authentik_core.group" and state != "absent" and "users" in attrs:
violations.append(Violation(source, label, "R2 member list (`users:`) on a group; memberships are data, manage them in the Authentik UI or QQQ"))
if model == "authentik_rbac.role" and state not in SAFE_IDENTITY_STATES:
extra = set(attrs) - ROLE_PRESENT_ALLOWED_ATTRS
if extra:
violations.append(Violation(source, label, f"R3 present role may only declare `permissions` (found {sorted(extra)})"))
if model == "authentik_core.token":
violations.append(Violation(source, label, "R4 tokens must never be declared in blueprints"))
for key in SECRET_KEYS:
if key in attrs and attrs[key] is not None and not isinstance(attrs[key], Tag):
violations.append(Violation(source, label, f"R4 literal `{key}`; use !Env or set it outside git"))
return violations
def blueprints_in_manifest(source: str, text: str):
"""Yield (source, parsed blueprint) for a file: a ConfigMap or a blueprint."""
for document in yaml.load_all(text, Loader=BlueprintLoader):
if not isinstance(document, dict):
continue
if document.get("kind") == "ConfigMap" and isinstance(document.get("data"), dict):
for key, value in document["data"].items():
if isinstance(value, str) and "entries:" in value and "version:" in value:
yield f"{source}#{key}", yaml.load(value, Loader=BlueprintLoader)
elif "entries" in document:
yield source, document
def lint_paths(paths: list[str]) -> list[Violation]:
violations: list[Violation] = []
for path in paths:
files = []
if os.path.isdir(path):
for root, _, names in os.walk(path):
files.extend(os.path.join(root, n) for n in names if n.endswith((".yaml", ".yml")))
else:
files.append(path)
for file in sorted(files):
with open(file, encoding="utf-8") as handle:
text = handle.read()
if "entries:" not in text:
continue
try:
for source, blueprint in blueprints_in_manifest(file, text):
violations.extend(lint_blueprint(source, blueprint))
except yaml.YAMLError as error:
violations.append(Violation(file, "-", f"YAML error: {error}"))
return violations
SELF_TEST_GOOD = """
version: 1
entries:
- model: authentik_rbac.role
state: present
identifiers: {name: qqq-x}
attrs: {permissions: [authentik_core.view_user]}
- model: authentik_core.user
state: created
identifiers: {username: sa}
attrs: {type: service_account, roles: [!KeyOf r], groups: [!Find [authentik_core.group, [name, x]]]}
- model: authentik_core.group
state: created
identifiers: {name: g}
attrs: {attributes: {qqq.io/permissions: [a]}}
- model: authentik_providers_oauth2.oauth2provider
state: present
identifiers: {name: p}
attrs: {client_secret: !Env SECRET}
"""
SELF_TEST_BAD = {
"present group": ("model: authentik_core.group\n identifiers: {name: g}", "R1"),
"present user": ("model: authentik_core.user\n state: present\n identifiers: {username: u}\n attrs: {groups: [!KeyOf g]}", "R1"),
"member list": ("model: authentik_core.group\n state: created\n identifiers: {name: g}\n attrs: {users: [1]}", "R2"),
"role with users": ("model: authentik_rbac.role\n identifiers: {name: r}\n attrs: {permissions: [a], users: [1]}", "R3"),
"token": ("model: authentik_core.token\n state: created\n identifiers: {identifier: t}", "R4"),
"literal secret": ("model: authentik_providers_oauth2.oauth2provider\n identifiers: {name: p}\n attrs: {client_secret: hunter2}", "R4"),
"literal password": ("model: authentik_core.user\n state: created\n identifiers: {username: u}\n attrs: {password: hunter2}", "R4"),
}
def self_test() -> int:
failures = 0
good = lint_blueprint("good", yaml.load(SELF_TEST_GOOD, Loader=BlueprintLoader))
if good:
failures += 1
print("FAIL good blueprint flagged:", *good, sep="\n ")
for name, (entry, rule) in SELF_TEST_BAD.items():
found = lint_blueprint(name, yaml.load(f"version: 1\nentries:\n - {entry}\n", Loader=BlueprintLoader))
if not any(v.message.startswith(rule) for v in found):
failures += 1
print(f"FAIL {name}: expected {rule}, got {[str(v) for v in found]}")
configmap = "apiVersion: v1\nkind: ConfigMap\nmetadata: {name: x}\ndata:\n bad.yaml: |\n version: 1\n entries:\n - model: authentik_core.group\n identifiers: {name: g}\n"
if not any("#bad.yaml" in v.source for v in [v for s, b in blueprints_in_manifest("cm.yaml", configmap) for v in lint_blueprint(s, b)]):
failures += 1
print("FAIL ConfigMap data values are not linted")
print("self-test:", "OK" if failures == 0 else f"{failures} failure(s)")
return 1 if failures else 0
def main(argv: list[str]) -> int:
if argv == ["--self-test"]:
return self_test()
if not argv:
print(__doc__)
return 2
violations = lint_paths(argv)
for violation in violations:
print(violation)
print(f"{len(violations)} violation(s)")
return 1 if violations else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
-93
View File
@@ -1,93 +0,0 @@
-- The qbit's own tables (DESIGN.md section 6.2) for the host's local RDBMS backend.
-- Column names are the snake_case of the QQQ field names. PostgreSQL syntax; adapt the
-- types for another database. The tables are written only by the qbit (the generic
-- insert, update and delete capabilities are off); authentik_admin_action is append-only
-- in practice (rows move from RUNNING to a final status, and are never deleted).
-- Retention: the audit trail is kept 2 years (DESIGN.md section 9.4).
CREATE TABLE authentik_admin_action
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
correlation_id VARCHAR(64),
action_name VARCHAR(60) NOT NULL,
operator VARCHAR(255),
reason TEXT,
target_type VARCHAR(30),
target_key VARCHAR(100),
target_label VARCHAR(255),
before_text TEXT,
after_text TEXT,
status VARCHAR(30) NOT NULL,
error_message TEXT,
authentik_event_pk VARCHAR(64),
approver VARCHAR(255)
);
CREATE INDEX idx_authentik_admin_action_create_date ON authentik_admin_action (create_date);
CREATE INDEX idx_authentik_admin_action_target ON authentik_admin_action (target_type, target_key);
CREATE INDEX idx_authentik_admin_action_operator ON authentik_admin_action (operator);
CREATE TABLE authentik_approval_request
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
action_name VARCHAR(60) NOT NULL,
target_key VARCHAR(100),
target_label VARCHAR(255),
summary TEXT,
reason TEXT,
payload_json TEXT NOT NULL,
requester VARCHAR(255) NOT NULL,
approver VARCHAR(255),
status VARCHAR(30) NOT NULL,
expires_date TIMESTAMP WITH TIME ZONE,
decided_date TIMESTAMP WITH TIME ZONE,
error_message TEXT
);
CREATE INDEX idx_authentik_approval_request_status ON authentik_approval_request (status);
-- authentik_policy (DESIGN.md section 12): the QQQ record of each group's last policy save.
-- Authentik holds the live document (group attribute qqq.io/policy); field-level QQQ audit on
-- this table is the policy's change history.
CREATE TABLE authentik_policy
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
group_pk VARCHAR(64) NOT NULL,
group_name VARCHAR(255),
policy_json TEXT,
statement_count INTEGER,
permission_count INTEGER,
saved_by VARCHAR(255),
saved_date TIMESTAMP WITH TIME ZONE,
authentik_fingerprint VARCHAR(64),
CONSTRAINT uk_authentik_policy_group_pk UNIQUE (group_pk)
);
-- Token metadata only (DESIGN section 13.5). No key or key hash is stored.
CREATE TABLE authentik_token_scope
(
id SERIAL PRIMARY KEY,
token_identifier VARCHAR(255) NOT NULL,
owner_username VARCHAR(255),
owner_pk INTEGER,
read_only BOOLEAN,
apps TEXT,
tables TEXT,
processes TEXT,
reports TEXT,
widgets TEXT,
operation_ids TEXT,
created_by VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE,
rotated_at TIMESTAMP WITH TIME ZONE,
last_reminder_7d TIMESTAMP WITH TIME ZONE,
last_reminder_1d TIMESTAMP WITH TIME ZONE,
CONSTRAINT uk_authentik_token_scope_identifier UNIQUE (token_identifier)
);
Vendored
-295
View File
@@ -1,295 +0,0 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
-189
View File
@@ -1,189 +0,0 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+22 -243
View File
@@ -1,106 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ 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.
-->
<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>
<jacoco.version>0.8.15</jacoco.version>
<testcontainers.version>1.21.4</testcontainers.version>
<!-- Contract tests run against this exact Authentik release (DESIGN.md section 10). -->
<!-- Override with -Dauthentik.test.image=... to rehearse an upgrade. -->
<authentik.test.image>ghcr.io/goauthentik/server:2026.8.3</authentik.test.image>
<postgres.test.image>postgres:16-alpine</postgres.test.image>
<mailpit.test.image>axllent/mailpit:v1.27</mailpit.test.image>
<!-- -Dauthentik.fixtures.record=true refreshes src/test/resources/fixtures from the contract tests -->
<authentik.fixtures.record>false</authentik.fixtures.record>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.1.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-authentik</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<name>QBit Authentik</name>
<description>QBit for managing Authentik users, groups, memberships and QQQ permissions from a QQQ app, with Authentik as the source of truth</description>
<url>https://github.com/QRun-IO/qbit-authentik</url>
<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">
<modelVersion>4.0.0</modelVersion>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-example-extension</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<developers>
<developer>
<id>kingsrook</id>
<name>Kingsrook Team</name>
<email>contact@kingsrook.com</email>
<organization>Kingsrook, LLC</organization>
<organizationUrl>https://kingsrook.com</organizationUrl>
</developer>
</developers>
<name>QBit Example Extension</name>
<description>Example Extension QBit - rename this for your QBit</description>
<scm>
<connection>scm:git:git://github.com/QRun-IO/qbit-authentik.git</connection>
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-authentik.git</developerConnection>
<url>https://github.com/QRun-IO/qbit-authentik</url>
<tag>HEAD</tag>
</scm>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.0.0</qqq.version>
</properties>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-api</artifactId>
<version>${qqq.version}</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.220</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -109,171 +39,20 @@
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-authentik.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-authentik.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludes>
<exclude>**/*IntegrationTest.java</exclude>
<exclude>**/*IT.java</exclude>
</excludes>
</configuration>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<systemPropertyVariables>
<authentik.test.image>${authentik.test.image}</authentik.test.image>
<postgres.test.image>${postgres.test.image}</postgres.test.image>
<mailpit.test.image>${mailpit.test.image}</mailpit.test.image>
<authentik.fixtures.record>${authentik.fixtures.record}</authentik.fixtures.record>
</systemPropertyVariables>
<includes>
<include>**/*IntegrationTest.java</include>
<include>**/*IT.java</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<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.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>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
<repository>
<id>sonatype-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</profile>
</profiles>
</project>
</project>
@@ -1,67 +0,0 @@
/*
* 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;
/*******************************************************************************
** Features a host can turn on (DESIGN.md section 3.2). A feature that is off
** produces no metadata, and the guard refuses its operations.
*******************************************************************************/
public enum AuthentikFeature
{
/** Read-only tables: users, groups, memberships, roles, applications, bindings, blueprints, sessions, events. */
READ,
/** Access tokens (DESIGN section 13). */
TOKENS,
/** Create, invite, edit, deactivate users (phase 3). */
USERS,
/** Create and edit groups, permissions and app-access attributes (phase 2/3). */
GROUPS,
/** Edit group policies (IAM-style statements) with the policy editor (DESIGN.md section 12). */
POLICIES,
/** Delete users and groups as an explicit action (DESIGN.md section 12.5). */
DELETE,
/** Add and remove members (phase 2). */
MEMBERSHIP,
/** Recovery email, MFA reset, app-password revoke (phase 3). */
CREDENTIALS,
/** Revoke sessions (phase 3). */
SESSIONS,
/** Authentik RBAC role management (phase 4). */
RBAC,
/** Access matrix widget. */
ACCESS_MATRIX,
/** Local mirror tables (phase 1+). */
MIRROR,
/** Inbound Authentik webhook (phase 2). */
WEBHOOK,
/** Nightly read-only snapshot export (phase 3). */
SNAPSHOT_EXPORT
}
File diff suppressed because it is too large. Load diff
@@ -1,268 +0,0 @@
/*
* 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.io.InputStream;
import java.util.ArrayList;
import java.util.List;
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;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Entry point of the Authentik QBit (QQQ 4.1 QBitMetaDataProducer).
**
** produce() validates the config, registers the AuthentikRuntime for the API
** backend, then lets QQQ run the component producers in the metadata package
** (API backend, read-only tables, the Identity app).
**
** Host usage:
** new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
*******************************************************************************/
public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBitConfig>
{
private static final QLogger LOG = QLogger.getLogger(AuthentikQBitProducer.class);
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "authentik";
private static final String VERSION = loadVersion();
private AuthentikQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
public AuthentikQBitProducer withQBitConfig(AuthentikQBitConfig config)
{
this.qBitConfig = config;
return (this);
}
/*******************************************************************************
** Setter for the config.
*******************************************************************************/
public void setQBitConfig(AuthentikQBitConfig config)
{
this.qBitConfig = config;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public AuthentikQBitConfig getQBitConfig()
{
return (this.qBitConfig);
}
/*******************************************************************************
** Namespace for this QBit instance; also the default tableNamePrefix.
*******************************************************************************/
public AuthentikQBitProducer withNamespace(String namespace)
{
this.namespace = namespace;
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getNamespace()
{
return (this.namespace);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QBitMetaData getQBitMetaData()
{
return (new QBitMetaData()
.withGroupId(GROUP_ID)
.withArtifactId(ARTIFACT_ID)
.withVersion(VERSION)
.withNamespace(getNamespace())
.withConfig(getQBitConfig()));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getPackageNameForFindingMetaDataProducers()
{
return (AuthentikAppMetaDataProducer.class.getPackageName());
}
/*******************************************************************************
** A producer found by package scanning has no config; skip it.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (qBitConfig != null);
}
/*******************************************************************************
** Validate, register the runtime, produce and add everything to the
** instance. Returns an empty output so a host that also calls
** addSelfToInstance on the result adds nothing twice.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
{
if(qBitConfig == null)
{
throw (new QException("AuthentikQBit configuration validation failed: no config was supplied (call withQBitConfig)"));
}
if(StringUtils.hasContent(namespace) && !StringUtils.hasContent(qBitConfig.getTableNamePrefix()))
{
qBitConfig.setTableNamePrefix(namespace);
}
List<String> errors = new ArrayList<>();
qBitConfig.validate(qInstance, errors);
if(!errors.isEmpty())
{
throw (new QException("AuthentikQBit configuration validation failed: " + String.join("; ", errors)));
}
AuthentikRuntime.register(new AuthentikRuntime(qBitConfig));
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.getEffectiveFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
return (new MetaDataProducerMultiOutput());
}
/*******************************************************************************
** Possible-value sources: the user and group tables (for the group pickers
** on the actions) and the local tables' status enums.
*******************************************************************************/
private void addPossibleValueSources(QInstance qInstance)
{
for(String spec : List.of(AuthentikTables.USER, AuthentikTables.GROUP))
{
String name = qBitConfig.applyTablePrefix(spec);
if(qInstance.getPossibleValueSource(name) == null)
{
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(name));
}
}
if(qBitConfig.getLocalBackendName() != null)
{
for(QPossibleValueSource source : AuthentikLocalTables.possibleValueSources())
{
qInstance.addPossibleValueSource(source);
}
}
}
/*******************************************************************************
** The artifact version, from the Maven-filtered qbit-authentik.properties.
*******************************************************************************/
static String loadVersion()
{
try(InputStream inputStream = AuthentikQBitProducer.class.getClassLoader().getResourceAsStream("qbit-authentik.properties"))
{
if(inputStream != null)
{
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(StringUtils.hasContent(version) && !version.contains("${"))
{
return (version);
}
}
}
catch(Exception e)
{
LOG.debug("Could not read qbit-authentik.properties", e);
}
return ("unknown");
}
/*******************************************************************************
** Getter for the artifact version.
*******************************************************************************/
public static String getVersion()
{
return (VERSION);
}
}
@@ -1,333 +0,0 @@
/*
* 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.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
/*******************************************************************************
** Live state of one qbit instance: config, throttle, guard, filter mapper,
** response cache, and the table-name to spec map. Registered by API backend
** name, because AuthentikActionUtil is instantiated by QQQ (QCodeLoader) with
** no constructor arguments and finds its runtime from the backend it serves.
*******************************************************************************/
public class AuthentikRuntime
{
private static final Map<String, AuthentikRuntime> BY_BACKEND = new ConcurrentHashMap<>();
private final AuthentikQBitConfig config;
private final TokenBucketThrottle throttle;
private final AuthentikGuard guard;
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<>();
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AuthentikRuntime(AuthentikQBitConfig config)
{
this.config = config;
this.throttle = new TokenBucketThrottle(config.getThrottleRequestsPerSecond(), config.getThrottleBurst());
this.guard = new AuthentikGuard(config);
this.responseCache = new Memoization<String, String>(Duration.ofSeconds(config.getListCacheTtlSeconds())).withMaxSize(500);
this.groupGraphCache = new Memoization<String, GroupGraph>(Duration.ofSeconds(config.getGroupCacheTtlSeconds())).withMaxSize(1);
}
/*******************************************************************************
** Register a runtime for its config's API backend (replacing any previous).
*******************************************************************************/
public static AuthentikRuntime register(AuthentikRuntime runtime)
{
BY_BACKEND.put(runtime.config.getApiBackendName(), runtime);
return (runtime);
}
/*******************************************************************************
** 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.
*******************************************************************************/
public static AuthentikRuntime forBackend(String backendName)
{
AuthentikRuntime runtime = BY_BACKEND.get(backendName);
if(runtime == null)
{
throw (new IllegalStateException("No Authentik qbit runtime is registered for backend " + backendName + " (was the qbit produced?)"));
}
return (runtime);
}
/*******************************************************************************
** Associate a produced (prefixed) table name with its spec.
*******************************************************************************/
public void registerTable(String tableName, AuthentikTableSpec spec)
{
specsByTableName.put(tableName, spec);
}
/*******************************************************************************
** The spec for a produced table name, or null.
*******************************************************************************/
public AuthentikTableSpec getSpec(String tableName)
{
return (specsByTableName.get(tableName));
}
/*******************************************************************************
** Whether responses should be cached at all.
*******************************************************************************/
public boolean isCacheEnabled()
{
return (config.getListCacheTtlSeconds() != null && config.getListCacheTtlSeconds() > 0);
}
/*******************************************************************************
** Drop every cached response (after writes, or when a webhook arrives).
*******************************************************************************/
public void invalidateCache()
{
responseCache.clear();
tokenScopeCache.clear();
tokenActivityCache.clear();
groupGraphCache.clear();
}
/*******************************************************************************
** Cache for the group graph (groupCacheTtlSeconds; 0 disables).
*******************************************************************************/
public Memoization<String, GroupGraph> getGroupGraphCache()
{
return (groupGraphCache);
}
/*******************************************************************************
** QQQ permission names that qqq.io/permissions may grant: every permission
** 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();
if(qInstance == null)
{
return (java.util.Set.of());
}
java.util.Set<String> catalog = new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
catalog.addAll(currentPolicyCatalog.permissionNames());
permissionCatalog = catalog;
}
return (permissionCatalog);
}
/*******************************************************************************
** What a policy can grant in the current instance (built lazily per instance: the
** instance is complete by the first request).
*******************************************************************************/
public PolicyCatalog getPolicyCatalog()
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(policyCatalog == null || policyCatalog.getQInstance() != qInstance)
{
if(qInstance == null)
{
return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance()));
}
policyCatalog = PolicyCatalog.of(qInstance);
permissionCatalog = null;
}
return (policyCatalog);
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
public AuthentikQBitConfig getConfig()
{
return (config);
}
/*******************************************************************************
** Getter for throttle
*******************************************************************************/
public TokenBucketThrottle getThrottle()
{
return (throttle);
}
/*******************************************************************************
** Getter for guard
*******************************************************************************/
public AuthentikGuard getGuard()
{
return (guard);
}
/*******************************************************************************
** Getter for filterMapper
*******************************************************************************/
public AuthentikFilterMapper getFilterMapper()
{
return (filterMapper);
}
/*******************************************************************************
** Getter for responseCache (session-independent: the API always runs as
** the service account; permission filtering happens after the cache).
*******************************************************************************/
public Memoization<String, String> getResponseCache()
{
return (responseCache);
}
/*******************************************************************************
** The cached mapper state (DESIGN.md section 12.8), or null.
*******************************************************************************/
public AccessComparison.State getMapperState()
{
return (mapperState);
}
/*******************************************************************************
** Cache a mapper state.
*******************************************************************************/
public void setMapperState(AccessComparison.State mapperState)
{
this.mapperState = mapperState;
}
/*******************************************************************************
** Whether the host has run the qbit mapper at sign-in (counts as "enabled"
** for hosts that call the mapper without setting mapperEnabled).
*******************************************************************************/
public boolean isMapperInvoked()
{
return (mapperInvoked);
}
/*******************************************************************************
** Record that the mapper ran.
*******************************************************************************/
public void markMapperInvoked()
{
if(!mapperInvoked)
{
mapperInvoked = true;
mapperState = null;
}
}
/*******************************************************************************
** 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);
}
}
@@ -1,135 +0,0 @@
/*
* 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.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
/*******************************************************************************
** Every change the qbit can make to Authentik (DESIGN.md section 6.3), with
** the QQQ permission and feature it needs and how strongly it is confirmed.
*******************************************************************************/
public enum AdminAction
{
// Null permission means self-service: AuthentikTokens verifies the live owner.
// Processes add no permission beyond their table; cross-owner actions require TOKENS_MANAGE.
CREATE_TOKEN("Create token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
ROTATE_TOKEN("Rotate token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
REVOKE_TOKEN("Revoke token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
EXTEND_TOKEN("Extend expiry", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
CREATE_SERVICE_ACCOUNT("Create service account", "user", IdentityPermission.TOKENS_MANAGE, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
CREATE_USER("Create user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
INVITE_USER("Invite user", "invitation", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
EDIT_USER("Edit user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
DEACTIVATE_USER("Deactivate user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.TYPED),
REACTIVATE_USER("Reactivate user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
CANCEL_INVITATION("Cancel invitation", "invitation", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
ADD_MEMBER("Add to group", "membership", IdentityPermission.MEMBERSHIPS_MANAGE, AuthentikFeature.MEMBERSHIP, Confirmation.CONFIRM),
REMOVE_MEMBER("Remove from group", "membership", IdentityPermission.MEMBERSHIPS_MANAGE, AuthentikFeature.MEMBERSHIP, Confirmation.CONFIRM),
CREATE_GROUP("Create group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_GROUP("Edit group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_PERMISSIONS("Edit permissions and app access", "group", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
SAVE_POLICY("Save policy", "group", IdentityPermission.POLICIES_MANAGE, AuthentikFeature.POLICIES, Confirmation.CONFIRM),
DELETE_USER("Delete user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
DELETE_GROUP("Delete group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
SEND_RECOVERY_EMAIL("Send password reset email", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.CONFIRM),
RESET_MFA("Reset MFA", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
REVOKE_APP_PASSWORDS("Revoke app passwords", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
REVOKE_SESSIONS("Revoke sessions", "user", IdentityPermission.SESSIONS_REVOKE, AuthentikFeature.SESSIONS, Confirmation.CONFIRM);
/** How strongly an action is confirmed (DESIGN.md section 6.3). */
public enum Confirmation
{
/** The Review screen is the confirmation. */
CONFIRM,
/** The operator also types the target's name (or the number of targets). */
TYPED
}
private final String label;
private final String targetType;
private final IdentityPermission permission;
private final AuthentikFeature feature;
private final Confirmation confirmation;
/*******************************************************************************
** Constructor.
*******************************************************************************/
AdminAction(String label, String targetType, IdentityPermission permission, AuthentikFeature feature, Confirmation confirmation)
{
this.label = label;
this.targetType = targetType;
this.permission = permission;
this.feature = feature;
this.confirmation = confirmation;
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Getter for targetType
*******************************************************************************/
public String getTargetType()
{
return (targetType);
}
/*******************************************************************************
** Getter for permission
*******************************************************************************/
public IdentityPermission getPermission()
{
return (permission);
}
/*******************************************************************************
** Getter for feature
*******************************************************************************/
public AuthentikFeature getFeature()
{
return (feature);
}
/*******************************************************************************
** Getter for confirmation
*******************************************************************************/
public Confirmation getConfirmation()
{
return (confirmation);
}
}
@@ -1,114 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Writes the local, append-only audit row (DESIGN.md sections 6.2 and 9.2).
** The row is inserted BEFORE the Authentik call; if that insert fails the call
** is not made. After the call the same row moves to its final status. Rows are
** never deleted.
*******************************************************************************/
public class AdminActionAuditor
{
private final AuthentikQBitConfig config;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AdminActionAuditor(AuthentikQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** Insert the row for a plan before it runs (or is refused / queued) and
** return its id. Throws when the audit trail cannot be written, so the
** caller must not proceed.
*******************************************************************************/
public Serializable begin(AdminPlan plan, String correlationId, String operator, AuthentikLocalTables.ActionStatus status, String approver) throws QException
{
QRecord record = new QRecord()
.withValue("correlationId", correlationId)
.withValue("actionName", plan.getRequest().action().name())
.withValue("operator", operator)
.withValue("reason", plan.getRequest().reason())
.withValue("targetType", plan.getTargetType())
.withValue("targetKey", plan.getRequest().targetKey())
.withValue("targetLabel", plan.getTargetLabel())
.withValue("beforeText", plan.getBefore())
.withValue("afterText", plan.getAfter())
.withValue("status", status.name())
.withValue("approver", approver)
.withValue("errorMessage", status == AuthentikLocalTables.ActionStatus.REFUSED ? plan.getMessage() : null)
.withValue("createDate", Instant.now())
.withValue("modifyDate", Instant.now());
InsertInput insertInput = new InsertInput();
insertInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
insertInput.setRecords(List.of(record));
InsertOutput output = new InsertAction().execute(insertInput);
QRecord saved = output.getRecords().isEmpty() ? null : output.getRecords().get(0);
if(saved == null || saved.getValue("id") == null || !saved.getErrors().isEmpty())
{
throw (new QException("The audit row could not be written, so nothing was changed in Authentik."));
}
return (saved.getValue("id"));
}
/*******************************************************************************
** Move a row to its final status. A failure here is logged by the caller;
** the Authentik change has already happened.
*******************************************************************************/
public void finish(Serializable id, AuthentikLocalTables.ActionStatus status, String errorMessage, String eventPk, String approver) throws QException
{
QRecord update = new QRecord()
.withValue("id", id)
.withValue("status", status.name())
.withValue("errorMessage", errorMessage)
.withValue("authentikEventPk", eventPk)
.withValue("modifyDate", Instant.now());
if(approver != null)
{
update.withValue("approver", approver);
}
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
updateInput.setRecords(List.of(update));
new UpdateAction().execute(updateInput);
}
}
@@ -1,235 +0,0 @@
/*
* 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.ArrayList;
import java.util.List;
/*******************************************************************************
** What a request would do, computed by the guard rules before anything is
** sent: whether it may run, why not, the target, a before and after, and
** whether it needs a second person. The Review screen shows it; the Result
** step executes it (after computing it again against live data).
*******************************************************************************/
public class AdminPlan
{
/** The outcome of planning. */
public enum State
{
/** Will run. */
READY,
/** Already in the wanted state; nothing to do. */
NO_CHANGE,
/** Refused by a rule (the message says which). */
REFUSED,
/** Allowed, but needs a second person to approve first. */
NEEDS_APPROVAL
}
private final AdminRequest request;
private State state = State.READY;
private String message;
private String targetType;
private String targetLabel;
private String before;
private String after;
private final List<String> notes = new ArrayList<>();
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AdminPlan(AdminRequest request)
{
this.request = request;
this.targetType = request.action().getTargetType();
}
/*******************************************************************************
** Refuse, with the reason shown to the operator.
*******************************************************************************/
public AdminPlan refuse(String reason)
{
this.state = State.REFUSED;
this.message = reason;
return (this);
}
/*******************************************************************************
** Mark as already done.
*******************************************************************************/
public AdminPlan noChange(String why)
{
this.state = State.NO_CHANGE;
this.message = why;
return (this);
}
/*******************************************************************************
** Whether the plan will make a change when executed.
*******************************************************************************/
public boolean isRunnable()
{
return (state == State.READY);
}
/*******************************************************************************
** Getter for request
*******************************************************************************/
public AdminRequest getRequest()
{
return (request);
}
/*******************************************************************************
** Getter for state
*******************************************************************************/
public State getState()
{
return (state);
}
/*******************************************************************************
** Setter for state
*******************************************************************************/
public AdminPlan withState(State state)
{
this.state = state;
return (this);
}
/*******************************************************************************
** Getter for message
*******************************************************************************/
public String getMessage()
{
return (message);
}
/*******************************************************************************
** Fluent setter for message
*******************************************************************************/
public AdminPlan withMessage(String message)
{
this.message = message;
return (this);
}
/*******************************************************************************
** Getter for targetType
*******************************************************************************/
public String getTargetType()
{
return (targetType);
}
/*******************************************************************************
** Getter for targetLabel
*******************************************************************************/
public String getTargetLabel()
{
return (targetLabel);
}
/*******************************************************************************
** Fluent setter for targetLabel
*******************************************************************************/
public AdminPlan withTargetLabel(String targetLabel)
{
this.targetLabel = targetLabel;
return (this);
}
/*******************************************************************************
** Getter for before
*******************************************************************************/
public String getBefore()
{
return (before);
}
/*******************************************************************************
** Getter for after
*******************************************************************************/
public String getAfter()
{
return (after);
}
/*******************************************************************************
** Set the before and after text.
*******************************************************************************/
public AdminPlan withChange(String before, String after)
{
this.before = before;
this.after = after;
return (this);
}
/*******************************************************************************
** Notes for the operator (warnings and consequences).
*******************************************************************************/
public List<String> getNotes()
{
return (notes);
}
/*******************************************************************************
** A one-line description for review lines and audit.
*******************************************************************************/
public String describe()
{
return (request.action().getLabel() + ": " + (targetLabel == null ? request.targetKey() : targetLabel)
+ (before != null || after != null ? " (" + (before == null ? "" : before) + " -> " + (after == null ? "" : after) + ")" : ""));
}
}
@@ -1,116 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import org.json.JSONObject;
/*******************************************************************************
** One requested change: the action, its target key (a user pk, a group uuid,
** "groupPk:userPk" for memberships, an invitation uuid, or blank when
** creating), its parameters, and the operator's reason. Serializable to JSON
** so a two-person request can be stored and applied later.
**
** @param action what to do
** @param targetKey the target (see above)
** @param params action parameters (strings and booleans)
** @param reason why (required for every write)
*******************************************************************************/
public record AdminRequest(AdminAction action, String targetKey, Map<String, Serializable> params, String reason)
{
/*******************************************************************************
** A request with no parameters.
*******************************************************************************/
public static AdminRequest of(AdminAction action, String targetKey, String reason)
{
return (new AdminRequest(action, targetKey, new LinkedHashMap<>(), reason));
}
/*******************************************************************************
** Fluent parameter setter (returns this request).
*******************************************************************************/
public AdminRequest with(String name, Serializable value)
{
params.put(name, value);
return (this);
}
/*******************************************************************************
** A parameter as trimmed text, or "" when absent.
*******************************************************************************/
public String str(String name)
{
Serializable value = params.get(name);
return (value == null ? "" : String.valueOf(value).trim());
}
/*******************************************************************************
** A parameter as a boolean (true for true or "true").
*******************************************************************************/
public boolean bool(String name)
{
Serializable value = params.get(name);
return (value instanceof Boolean b ? b : value != null && "true".equalsIgnoreCase(String.valueOf(value).trim()));
}
/*******************************************************************************
** To JSON (for a stored approval request).
*******************************************************************************/
public String toJson()
{
return (new JSONObject()
.put("action", action.name())
.put("targetKey", targetKey)
.put("reason", reason)
.put("params", new JSONObject(params)).toString());
}
/*******************************************************************************
** From JSON written by toJson.
*******************************************************************************/
public static AdminRequest fromJson(String json)
{
JSONObject object = new JSONObject(json);
Map<String, Serializable> params = new LinkedHashMap<>();
JSONObject stored = object.optJSONObject("params");
if(stored != null)
{
for(String key : stored.keySet())
{
Object value = stored.get(key);
params.put(key, value instanceof Boolean || value instanceof Number ? (Serializable) value : String.valueOf(value));
}
}
return (new AdminRequest(AdminAction.valueOf(object.getString("action")), object.optString("targetKey", ""), params, object.optString("reason", "")));
}
}
File diff suppressed because it is too large. Load diff
@@ -1,309 +0,0 @@
/*
* 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.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** The qbit's own tables (DESIGN.md section 6.2), standard visible QQQ tables on
** the host's local RDBMS backend, named authentik_* (lowercase snake_case).
** Both are written only by the qbit: the generic insert, update and delete
** capabilities are off for everyone, and the audit table is append-only.
**
** The host creates the SQL tables (see docs/schema.sql).
*******************************************************************************/
public final class AuthentikLocalTables
{
public static final String TOKEN_SCOPE = "token_scope";
public static final String ADMIN_ACTION = "admin_action";
public static final String APPROVAL_REQUEST = "approval_request";
public static final String POLICY = "policy";
public static final String PVS_ACTION_STATUS = "authentik_action_status";
public static final String PVS_APPROVAL_STATUS = "authentik_approval_status";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikLocalTables()
{
}
/*******************************************************************************
** Status of an audited action.
*******************************************************************************/
public enum ActionStatus implements PossibleValueEnum<String>
{
PENDING_APPROVAL("Waiting for approval"),
RUNNING("Running"),
SUCCEEDED("Succeeded"),
FAILED("Failed"),
REFUSED("Refused");
private final String label;
/*******************************************************************************
** Constructor.
*******************************************************************************/
ActionStatus(String label)
{
this.label = label;
}
/*******************************************************************************
** Getter for possibleValueId.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Getter for possibleValueLabel.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (label);
}
}
/*******************************************************************************
** Status of a two-person approval request.
*******************************************************************************/
public enum ApprovalStatus implements PossibleValueEnum<String>
{
PENDING("Waiting for approval"),
APPROVED("Approved and applied"),
REJECTED("Rejected"),
EXPIRED("Expired"),
FAILED("Approved but failed");
private final String label;
/*******************************************************************************
** Constructor.
*******************************************************************************/
ApprovalStatus(String label)
{
this.label = label;
}
/*******************************************************************************
** Getter for possibleValueId.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Getter for possibleValueLabel.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (label);
}
}
/*******************************************************************************
** The possible-value sources the local tables use.
*******************************************************************************/
public static List<QPossibleValueSource> possibleValueSources()
{
return (List.of(
QPossibleValueSource.newForEnum(PVS_ACTION_STATUS, ActionStatus.values()),
QPossibleValueSource.newForEnum(PVS_APPROVAL_STATUS, ApprovalStatus.values())));
}
/*******************************************************************************
** authentik_admin_action: append-only audit and outbox of every write the
** qbit attempts. The row is inserted BEFORE the API call.
*******************************************************************************/
public static QTableMetaData adminAction(AuthentikQBitConfig config)
{
return (base(config, ADMIN_ACTION, "Admin Actions", "history", IdentityPermission.AUDIT_READ)
.withRecordLabelFormatAndFields("%s %s", "actionName", "targetLabel")
.withField(ro("correlationId", QFieldType.STRING).withMaxLength(64))
.withField(ro("actionName", QFieldType.STRING).withMaxLength(60))
.withField(ro("operator", QFieldType.STRING).withMaxLength(255))
.withField(ro("reason", QFieldType.TEXT))
.withField(ro("targetType", QFieldType.STRING).withMaxLength(30))
.withField(ro("targetKey", QFieldType.STRING).withMaxLength(100))
.withField(ro("targetLabel", QFieldType.STRING).withMaxLength(255))
.withField(ro("beforeText", QFieldType.TEXT))
.withField(ro("afterText", QFieldType.TEXT))
.withField(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_ACTION_STATUS))
.withField(ro("errorMessage", QFieldType.TEXT))
.withField(ro("authentikEventPk", QFieldType.STRING).withMaxLength(64))
.withField(ro("approver", QFieldType.STRING).withMaxLength(255)));
}
/*******************************************************************************
** authentik_approval_request: two-person requests (a second person with
** authentik.approve applies or rejects the stored change).
*******************************************************************************/
public static QTableMetaData approvalRequest(AuthentikQBitConfig config)
{
return (base(config, APPROVAL_REQUEST, "Approval Requests", "how_to_reg", IdentityPermission.APPROVE)
.withRecordLabelFormatAndFields("%s %s", "actionName", "targetLabel")
.withField(ro("actionName", QFieldType.STRING).withMaxLength(60))
.withField(ro("targetKey", QFieldType.STRING).withMaxLength(100))
.withField(ro("targetLabel", QFieldType.STRING).withMaxLength(255))
.withField(ro("summary", QFieldType.TEXT))
.withField(ro("reason", QFieldType.TEXT))
.withField(ro("payloadJson", QFieldType.TEXT))
.withField(ro("requester", QFieldType.STRING).withMaxLength(255))
.withField(ro("approver", QFieldType.STRING).withMaxLength(255))
.withField(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_APPROVAL_STATUS))
.withField(ro("expiresDate", QFieldType.DATE_TIME))
.withField(ro("decidedDate", QFieldType.DATE_TIME))
.withField(ro("errorMessage", QFieldType.TEXT)));
}
/*******************************************************************************
** authentik_policy: one row per group with a policy (DESIGN.md section 12).
** Authentik holds the live document; this row is the QQQ record of the last
** save, and field-level audit is its change history. Written only by the
** Edit Policy action.
*******************************************************************************/
public static QTableMetaData policy(AuthentikQBitConfig config)
{
return (base(config, POLICY, "Policies", "policy", IdentityPermission.USERS_READ)
.withRecordLabelFormatAndFields("%s", "groupName")
.withUniqueKey(new UniqueKey("groupPk"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withField(ro("groupPk", QFieldType.STRING).withLabel("Group Id").withMaxLength(64))
.withField(ro("groupName", QFieldType.STRING).withLabel("Group").withMaxLength(255))
.withField(ro("policyJson", QFieldType.TEXT).withLabel("Policy Document"))
.withField(ro("statementCount", QFieldType.INTEGER).withLabel("Statements"))
.withField(ro("permissionCount", QFieldType.INTEGER).withLabel("Permissions"))
.withField(ro("savedBy", QFieldType.STRING).withLabel("Saved By").withMaxLength(255))
.withField(ro("savedDate", QFieldType.DATE_TIME).withLabel("Saved"))
.withField(ro("authentikFingerprint", QFieldType.STRING).withLabel("Authentik Fingerprint").withMaxLength(64)));
}
/*******************************************************************************
** Common shape: id, create and modify dates, no generic writes.
*******************************************************************************/
private static QTableMetaData base(AuthentikQBitConfig config, String name, String label, String icon, IdentityPermission permission)
{
return (new QTableMetaData()
.withName(config.applyTablePrefix(name))
.withLabel(label)
.withIcon(new QIcon(icon))
.withBackendName(config.getLocalBackendName())
.withPrimaryKeyField("id")
.withPermissionRules(permission.rules())
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_EXPORT)
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE)
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)));
}
/*******************************************************************************
** A read-only field.
*******************************************************************************/
private static QFieldMetaData ro(String name, QFieldType type)
{
return (new QFieldMetaData(name, type).withIsEditable(false));
}
/*******************************************************************************
** Non-secret scope metadata only (DESIGN section 13.5).
*******************************************************************************/
public static QTableMetaData tokenScope(AuthentikQBitConfig config)
{
QTableMetaData table = base(config, TOKEN_SCOPE, "Token Scopes", "key", IdentityPermission.TOKENS_MANAGE)
.withPermissionRules(com.kingsrook.qbits.authentik.security.TokenReadPermission.rules())
.withRecordLabelFormatAndFields("%s", "tokenIdentifier")
.withUniqueKey(new UniqueKey("tokenIdentifier"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
.withField(ro("tokenIdentifier", QFieldType.STRING).withMaxLength(255))
.withField(ro("ownerUsername", QFieldType.STRING).withMaxLength(255))
.withField(ro("ownerPk", QFieldType.INTEGER))
.withField(ro("readOnly", QFieldType.BOOLEAN))
.withField(ro("createdBy", QFieldType.STRING).withMaxLength(255));
table.getFields().remove("createDate");
table.getFields().remove("modifyDate");
for(String name : List.of("apps", "tables", "processes", "reports", "widgets", "operationIds"))
{
table.withField(ro(name, QFieldType.TEXT));
}
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);
}
}
@@ -1,173 +0,0 @@
/*
* 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));
}
}
}
@@ -1,279 +0,0 @@
/*
* 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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** The pure rules of DESIGN.md section 5.4, separated from I/O so they can be
** tested exhaustively: the subset (no escalation) rule, last-admin
** protection, descendant checks and group fingerprints.
*******************************************************************************/
public final class AuthentikSafety
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikSafety()
{
}
/*******************************************************************************
** What a user is, for last-admin protection.
**
** @param pk Authentik user pk
** @param active whether the account is active
** @param human internal or external (not a service account)
** @param protectedAccount break-glass, superuser, service account
*******************************************************************************/
public record UserFacts(int pk, boolean active, boolean human, boolean protectedAccount)
{
}
/*******************************************************************************
** The permissions in needed that held does not contain (the subset rule:
** an operator may only grant what they hold).
*******************************************************************************/
public static Set<String> missing(Collection<String> needed, Collection<String> held)
{
Set<String> rs = new TreeSet<>(needed);
rs.removeAll(held);
return (rs);
}
/*******************************************************************************
** pks of the users who qualify as identity administrators in a graph: active,
** human, not protected, and holding both authentik.memberships.manage and
** authentik.permissions.manage through their groups (inherited included).
*******************************************************************************/
public static Set<Integer> qualifyingAdmins(GroupGraph graph, EffectivePermissionResolver resolver, Collection<UserFacts> users)
{
Set<Integer> rs = new LinkedHashSet<>();
for(UserFacts user : users)
{
if(!user.active() || !user.human() || user.protectedAccount())
{
continue;
}
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(graph.groupsOfUser(user.pk()));
if(EffectivePermissionResolver.holdsAll(resolution, List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE)))
{
rs.add(user.pk());
}
}
return (rs);
}
/*******************************************************************************
** Whether a change leaves the qbit without an administrator that it had
** before: some admin existed, and none does after. A state that already had
** none (for example a bootstrap window) is not made worse, so it never
** blocks.
*******************************************************************************/
public static boolean removesLastAdmin(Set<Integer> before, Set<Integer> after)
{
return (!before.isEmpty() && after.isEmpty());
}
/*******************************************************************************
** A copy of a graph with some nodes replaced (matched by pk), for
** simulating a change.
*******************************************************************************/
public static GroupGraph withNodes(GroupGraph graph, GroupGraph.Node... replacements)
{
GroupGraph copy = new GroupGraph();
graph.nodes().forEach(copy::add);
for(GroupGraph.Node node : replacements)
{
copy.add(node);
}
return (copy);
}
/*******************************************************************************
** A node without a member.
*******************************************************************************/
public static GroupGraph.Node withoutMember(GroupGraph.Node node, int userPk)
{
List<Integer> members = new ArrayList<>(node.memberPks());
members.remove(Integer.valueOf(userPk));
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed(), node.policyPermissions()));
}
/*******************************************************************************
** A node with other permissions and app access.
*******************************************************************************/
public static GroupGraph.Node withPermissions(GroupGraph.Node node, List<String> permissions, List<String> appAccess)
{
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed(), node.policyPermissions()));
}
/*******************************************************************************
** A node with other parents.
*******************************************************************************/
public static GroupGraph.Node withParents(GroupGraph.Node node, List<String> parentPks)
{
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed(), node.policyPermissions()));
}
/*******************************************************************************
** Every group below a group (children, their children, ...), cycle-safe.
*******************************************************************************/
public static Set<String> descendantPks(GroupGraph graph, String pk)
{
Set<String> rs = new HashSet<>();
for(GroupGraph.Node node : graph.nodes())
{
if(!node.pk().equals(pk) && graph.ancestorPks(node.pk()).contains(pk))
{
rs.add(node.pk());
}
}
return (rs);
}
/*******************************************************************************
** Split free text (new lines or commas) into distinct trimmed entries.
*******************************************************************************/
public static List<String> parseList(String text)
{
List<String> rs = new ArrayList<>();
if(text == null)
{
return (rs);
}
for(String part : text.split("[\\r\\n,;]+"))
{
String trimmed = part.trim();
if(!trimmed.isEmpty() && !rs.contains(trimmed))
{
rs.add(trimmed);
}
}
return (rs);
}
/*******************************************************************************
** A short fingerprint of a group's editable state: name, parents,
** qqq.io/* attributes and member count. Authentik groups have no
** last_updated (DESIGN.md section 0.2 vi), so this is how a change made in
** the Authentik UI after a preview is noticed.
*******************************************************************************/
public static String groupFingerprint(JSONObject group)
{
JSONObject attributes = group.optJSONObject("attributes");
Map<String, Object> qqq = new java.util.TreeMap<>();
if(attributes != null)
{
for(String key : attributes.keySet())
{
if(key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX))
{
qqq.put(key, String.valueOf(attributes.get(key)));
}
}
}
List<String> parents = new ArrayList<>();
JSONArray array = group.optJSONArray("parents");
for(int i = 0; array != null && i < array.length(); i++)
{
Object value = array.get(i);
parents.add(value instanceof JSONObject object ? object.optString("pk") : String.valueOf(value));
}
java.util.Collections.sort(parents);
int members = group.optJSONArray("users") == null ? -1 : group.getJSONArray("users").length();
return (sha(group.optString("name") + "|" + parents + "|" + qqq + "|" + members));
}
/*******************************************************************************
** A short fingerprint of a user's editable state (name, email, path, type,
** active, last_updated).
*******************************************************************************/
public static String userFingerprint(JSONObject user)
{
return (sha(user.optString("username") + "|" + user.optString("name") + "|" + user.optString("email") + "|" + user.optString("path") + "|"
+ user.optString("type") + "|" + user.optBoolean("is_active", true) + "|" + user.optString("last_updated")));
}
/*******************************************************************************
**
*******************************************************************************/
private static String sha(String text)
{
try
{
byte[] digest = MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for(int i = 0; i < 8; i++)
{
hex.append(String.format("%02x", digest[i]));
}
return (hex.toString());
}
catch(NoSuchAlgorithmException e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -1,106 +0,0 @@
/*
* 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);
}
}
}
@@ -1,703 +0,0 @@
/*
* 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.security.SecureRandom;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker;
import com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
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.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 org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Access-token actions (DESIGN section 13). All target checks run at Review and
** again at execution; credentials exist only in the action and one-time result.
*******************************************************************************/
public class AuthentikTokens
{
public static final String DELIVERY_UNAVAILABLE = "This QQQ version cannot show a key once; upgrade QQQ (4.1.0-RC.7+)";
public static final String TOKEN_MOVED = "Token moved: owner or intent changed after the update; investigate in Authentik.";
private static final QLogger LOG = QLogger.getLogger(AuthentikTokens.class);
private static final SecureRandom RANDOM = new SecureRandom();
private final AuthentikAdmin admin;
private final AuthentikActionUtil util;
private final AuthentikQBitConfig config;
private final TokenScopeStore scopes;
/*******************************************************************************
** Delegate with the existing admin's live operator and subset checks.
*******************************************************************************/
public AuthentikTokens(AuthentikAdmin admin)
{
this.admin = admin;
util = admin.getUtil();
config = util.getRuntime().getConfig();
scopes = new TokenScopeStore(config);
}
/*******************************************************************************
** Review all constraints, without exchanging or creating a credential.
*******************************************************************************/
public AdminPlan plan(AdminPlan plan) throws QException
{
AdminRequest request = plan.getRequest();
AdminAction action = request.action();
util.getRuntime().invalidateCache();
try
{
if(action == AdminAction.CREATE_SERVICE_ACCOUNT)
{
if(!request.str("name").matches("[A-Za-z0-9][A-Za-z0-9._-]{0,99}"))
{
return (plan.refuse("A service-account name (letters, numbers, dot, dash or underscore) is required."));
}
if(admin.userPkByUsername(request.str("name")) != null)
{
return (plan.refuse("That username already exists."));
}
JSONObject candidate = new JSONObject().put("username", request.str("name")).put("type", "service_account");
checkProtected(candidate);
expiry(request, true);
if(!TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
for(GroupGraph.Node group : serviceGroups(request))
{
String problem = admin.checkGroupGrantable(group);
if(problem != null)
{
return (plan.refuse(problem));
}
checkProtectedGroup(group);
}
plan.withTargetLabel(request.str("name"));
return (approval(plan));
}
JSONObject token = action == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
Integer operatorPk = admin.operator().pk();
int ownerPk = token == null ? (operatorPk == null ? -1 : operatorPk) : ownerPk(token);
if(action == AdminAction.CREATE_TOKEN && (!request.targetKey().isBlank() && !request.targetKey().equals(String.valueOf(operatorPk))))
{
return (plan.refuse("Create Token only creates a token for you."));
}
if(ownerPk < 0)
{
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
}
JSONObject owner = owner(ownerPk);
boolean own = Objects.equals(operatorPk, ownerPk);
boolean service = "service_account".equals(owner.optString("type"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if(service && action == AdminAction.CREATE_TOKEN)
{
return (plan.refuse("Service accounts have one app password. Use Rotate Token or Create Service Account."));
}
if(!own && !manager || service && !manager)
{
return (plan.refuse("You may only manage your own tokens; service accounts need authentik.tokens.manage."));
}
if(!own && !service && action != AdminAction.REVOKE_TOKEN)
{
return (plan.refuse("You cannot create, rotate or extend another person's token."));
}
checkProtected(owner);
plan.withTargetLabel(token == null ? owner.getString("username") : request.targetKey());
if(action != AdminAction.REVOKE_TOKEN && token != null && !"app_password".equals(token.optString("intent")))
{
return (plan.refuse("Only app-password tokens can be rotated or extended here. API tokens can be revoked."));
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.EXTEND_TOKEN)
{
expiry(request, service);
}
if(action == AdminAction.ROTATE_TOKEN)
{
checkExpiry(token, service);
}
if(action == AdminAction.CREATE_TOKEN)
{
if(request.str("description").isBlank())
{
return (plan.refuse("A description is required."));
}
if(!Boolean.TRUE.equals(config.getTokenScopesEnabled()) && TokenScopeStore.SCOPE_FIELDS.stream().anyMatch(request.params()::containsKey))
{
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())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
}
if(own && !service)
{
if(blank(config.getTokenBrokerClientId()) || blank(config.getTokenBrokerClientSecret()))
{
return (plan.refuse("Self-service tokens need a configured Authentik token broker."));
}
if(config.getSubjectTokenSource() == null || blank(config.getSubjectTokenSource().subjectToken(QContext.getQSession())))
{
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
}
}
return (service && action == AdminAction.ROTATE_TOKEN ? approval(plan) : plan);
}
catch(QUserFacingException e)
{
return (plan.refuse(e.getMessage()));
}
}
/*******************************************************************************
** Optional approval for new service accounts and service-account rotation.
*******************************************************************************/
private AdminPlan approval(AdminPlan plan)
{
if(Boolean.TRUE.equals(config.getTokenTwoPersonApproval()) && !plan.getRequest().bool("approved"))
{
plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("Service-account tokens require a second person's approval.");
}
return (plan);
}
/*******************************************************************************
** Resolve requested groups with the same subset rules used for membership.
*******************************************************************************/
private List<GroupGraph.Node> serviceGroups(AdminRequest request) throws QException
{
Set<String> names = new LinkedHashSet<>(AuthentikSafety.parseList(request.str("groups")));
GroupGraph graph = util.groupGraph();
List<GroupGraph.Node> groups = new ArrayList<>();
for(String name : names)
{
GroupGraph.Node node = graph.byName(name);
if(node == null)
{
throw (new QUserFacingException("Group not found: " + name));
}
groups.add(node);
}
return (groups);
}
/*******************************************************************************
** Required expiry measured from now, with separate human and service caps.
*******************************************************************************/
private Instant expiry(AdminRequest request, boolean service) throws QUserFacingException
{
int maximum = service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser();
try
{
int days = Integer.parseInt(request.str("expiresInDays"));
if(days >= 1 && days <= maximum)
{
return (Instant.now().plus(days, ChronoUnit.DAYS));
}
}
catch(NumberFormatException ignored)
{
// Missing, fractional and malformed values are all refused.
}
throw (new QUserFacingException("Expiry is required: expiresInDays must be between 1 and " + maximum + "."));
}
/*******************************************************************************
** Rotation preserves a valid existing expiry, never a non-expiring token.
*******************************************************************************/
private Instant checkExpiry(JSONObject token, boolean service) throws QUserFacingException
{
try
{
Instant expires = Instant.parse(token.getString("expires"));
if(token.optBoolean("expiring") && expires.isAfter(Instant.now())
&& !expires.isAfter(Instant.now().plus(service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser(), ChronoUnit.DAYS)))
{
return (expires);
}
}
catch(RuntimeException ignored)
{
// No expiry is never acceptable for a new credential.
}
throw (new QUserFacingException("Token expiry is missing, expired or exceeds the maximum lifetime."));
}
/*******************************************************************************
** Built-in protected groups cannot be removed by configuration.
*******************************************************************************/
private void checkProtectedGroup(GroupGraph.Node group) throws QException
{
Set<String> protectedNames = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
protectedNames.addAll(List.of("authentik Admins", "identity-admins"));
if(config.getTokenProtectedGroups() != null)
{
protectedNames.addAll(config.getTokenProtectedGroups());
}
for(GroupGraph.Node ancestor : util.groupGraph().selfAndAncestors(group.pk()))
{
if(ancestor.ownSuperuser() || protectedNames.contains(ancestor.name()))
{
throw (protectedAccount("member of " + ancestor.name()));
}
}
}
/*******************************************************************************
** Reuse account protection, adding inherited protected-group membership.
*******************************************************************************/
private void checkProtected(JSONObject owner) throws QException
{
AuthentikGuard.ProtectionResult protection = util.getRuntime().getGuard().protection(owner);
if(protection.isProtected())
{
throw (protectedAccount(protection.reason()));
}
JSONArray groups = owner.optJSONArray("groups_obj");
for(int i = 0; groups != null && i < groups.length(); i++)
{
GroupGraph.Node node = util.groupGraph().byPk(groups.getJSONObject(i).getString("pk"));
if(node == null)
{
throw (protectedAccount("group protection could not be verified"));
}
checkProtectedGroup(node);
}
}
/*******************************************************************************
** Consistent protected-owner refusal, including service-account set_key.
*******************************************************************************/
private static QUserFacingException protectedAccount(String reason)
{
return (new QUserFacingException("Protected account (" + reason + "): tokens for it are managed in the Authentik admin UI."));
}
/*******************************************************************************
** Fetch token metadata without retaining a bearer or key.
*******************************************************************************/
private JSONObject token(String identifier) throws QException
{
util.getRuntime().invalidateCache();
JSONObject token = util.getApiJsonOrNull(path(identifier));
if(token == null)
{
throw (new QUserFacingException("That token no longer exists."));
}
return (token);
}
/*******************************************************************************
** Token owner pk from the pinned schema.
*******************************************************************************/
private static int ownerPk(JSONObject token)
{
return (token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk"));
}
/*******************************************************************************
** Fetch live owner facts, refusing missing owners.
*******************************************************************************/
private JSONObject owner(int pk) throws QException
{
util.getRuntime().invalidateCache();
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
if(owner == null)
{
throw (new QUserFacingException("The token's owner no longer exists."));
}
return (owner);
}
/*******************************************************************************
** Validated token URL component.
*******************************************************************************/
private static String path(String identifier) throws QException
{
return ("/core/tokens/" + AuthentikAdmin.safeKey(identifier) + "/");
}
/*******************************************************************************
** Whether a resolved config string is absent.
*******************************************************************************/
private static boolean blank(String value)
{
return (value == null || value.isBlank());
}
/*******************************************************************************
** Execute only after auditing has begun; return the key outside audit data.
*******************************************************************************/
public AuthentikAdmin.AdminResult perform(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
if((request.action() == AdminAction.CREATE_TOKEN || request.action() == AdminAction.ROTATE_TOKEN || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
&& !TokenKeyDelivery.get().available())
{
throw (new QUserFacingException(DELIVERY_UNAVAILABLE));
}
if(request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
{
return (createServiceAccount(plan, auth));
}
JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before));
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);
if((!own || service) && !manager || !own && !service && request.action() != AdminAction.REVOKE_TOKEN)
{
throw (new QUserFacingException("Token ownership changed; review again."));
}
String bearer = own && !service ? new AuthentikTokenBroker().exchange(util, auth) : config.getResolvedApiToken();
switch(request.action())
{
case CREATE_TOKEN:
return (createPersonal(request, owner, expiry(request, false), null, bearer, auth));
case REVOKE_TOKEN:
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
scopes.delete(request.targetKey());
return (success("Token revoked."));
case EXTEND_TOKEN:
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)
{
return (createPersonal(request, owner, checkExpiry(before, false), before, bearer, auth));
}
String scope = scopeSummary(request.targetKey());
Instant expires = checkExpiry(before, true);
byte[] random = new byte[48];
RANDOM.nextBytes(random);
String key = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
util.writeAs(bearer, auth, "POST", path(request.targetKey()) + "set_key/", new JSONObject().put("key", key));
JSONObject after = token(request.targetKey());
raceGuard(before, after);
return (success("Service-account token rotated. The old key no longer works.")
.withSecret(oneTime(request.targetKey(), key, owner, expires, scope), () ->
{
throw (new QUserFacingException("The old key no longer works and the new key is lost. Rotate it in the Authentik admin UI."));
}));
default:
throw (new QException("Unsupported token action"));
}
}
/*******************************************************************************
** Guard owner and intent after every PATCH or set_key, with a safe warning.
*******************************************************************************/
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)));
}
/*******************************************************************************
** Create as the operator, fetch their key, and optionally replace the old id.
** On partial failure discard the new credential; the old one stays usable.
*******************************************************************************/
private AuthentikAdmin.AdminResult createPersonal(AdminRequest request, JSONObject owner, Instant expires, JSONObject old,
String bearer, WriteAuthorization auth) throws QException
{
String identifier = identifier(owner.getString("username"));
String description = old == null ? request.str("description") : old.optString("description");
util.writeAs(bearer, auth, "POST", "/core/tokens/", new JSONObject().put("identifier", identifier).put("intent", "app_password")
.put("description", description).put("expiring", true).put("expires", expires.toString()));
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));
}
String key = util.getAs(bearer, auth, path(identifier) + "view_key/").getString("key");
Instant actualExpiry = checkExpiry(created, false);
if(old == null)
{
scopes.create(identifier, owner, request, admin.operator().email());
}
else
{
QRecord previous = scopes.find(request.targetKey());
if(previous == null)
{
scopes.create(identifier, owner, AdminRequest.of(request.action(), identifier, request.reason()), admin.operator().email());
}
else
{
scopes.move(request.targetKey(), identifier);
}
}
OneTimeKey secret = oneTime(identifier, key, owner, actualExpiry, scopeSummary(identifier));
if(old != null)
{
try
{
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
}
catch(QException e)
{
scopes.move(identifier, request.targetKey());
throw (new QUserFacingException("Rotation failed: the old token could not be deleted and remains listed."));
}
}
return (success(old == null ? "Token created. The key is shown once." : "Token rotated. The old token was deleted.")
.withSecret(secret, () -> revokeIssued(identifier, bearer, auth)));
}
catch(Exception e)
{
revokeIssued(identifier, bearer, auth);
if(e instanceof QUserFacingException facing)
{
throw (facing);
}
throw (new QUserFacingException("The new token could not be completed and was revoked."));
}
}
/*******************************************************************************
** Create the single service-account app password and grant checked groups.
*******************************************************************************/
private AuthentikAdmin.AdminResult createServiceAccount(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
String identifier = "service-account-" + request.str("name") + "-password";
JSONObject created = util.write(auth, "POST", "/core/users/service_account/", new JSONObject().put("name", request.str("name"))
.put("create_group", false).put("expiring", true).put("expires", expiry(request, true).toString()));
try
{
Integer pk = created.has("user_pk") ? created.getInt("user_pk") : admin.userPkByUsername(created.optString("username", request.str("name")));
if(pk == null)
{
throw (new QException("The service account could not be found."));
}
JSONObject current = owner(pk);
JSONObject attributes = new JSONObject(current.optJSONObject("attributes") == null ? "{}" : current.getJSONObject("attributes").toString());
attributes.put("goauthentik.io/user/token-maximum-lifetime", config.getServiceAccountTokenLifetime());
util.write(auth, "PATCH", "/core/users/" + pk + "/", new JSONObject().put("attributes", attributes));
for(GroupGraph.Node group : serviceGroups(request))
{
checkProtectedGroup(group);
String problem = admin.checkGroupGrantable(group);
if(problem != null)
{
throw (new QUserFacingException(problem));
}
util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk));
}
JSONObject owner = owner(pk);
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"),
() -> revokeIssued(identifier, config.getResolvedApiToken(), auth)));
}
catch(Exception e)
{
revokeIssued(identifier, config.getResolvedApiToken(), auth);
throw (new QUserFacingException("The service account was created, but setup failed. Its token was revoked; finish setup in Authentik."));
}
}
/*******************************************************************************
** Emergency revocation for a newly created token whose key cannot be shown.
*******************************************************************************/
private void revokeIssued(String identifier, String bearer, WriteAuthorization auth) throws QException
{
try
{
util.writeAs(bearer, auth, "DELETE", path(identifier), null);
scopes.delete(identifier);
}
catch(QException e)
{
throw (new QUserFacingException("The new key could not be delivered and token revocation failed. Revoke it in the Authentik admin UI."));
}
}
/*******************************************************************************
** Scope summary contains metadata only.
*******************************************************************************/
private String scopeSummary(String identifier) throws QException
{
QRecord row = scopes.find(identifier);
List<String> parts = new ArrayList<>();
for(String field : TokenScopeStore.SCOPE_FIELDS)
{
if(row != null && row.getValue(field) != null)
{
parts.add(field + ": " + row.getValueString(field));
}
}
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
}
/*******************************************************************************
** Build the isolated one-time object, including its response-only usage text.
*******************************************************************************/
private OneTimeKey oneTime(String identifier, String key, JSONObject owner, Instant expires, String scope)
{
return (new OneTimeKey(identifier, key, TokenUsageText.build(config, owner.getString("username"), key, expires, scope)));
}
/*******************************************************************************
** Non-secret result text.
*******************************************************************************/
private static AuthentikAdmin.AdminResult success(String message)
{
return (new AuthentikAdmin.AdminResult(true, false, false, message, null));
}
/*******************************************************************************
** Collision-resistant identifier, independent of the secret value.
*******************************************************************************/
private static String identifier(String username)
{
String alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder suffix = new StringBuilder();
for(int i = 0; i < 6; i++)
{
suffix.append(alphabet.charAt(RANDOM.nextInt(alphabet.length())));
}
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.");
}
}
}
@@ -1,88 +0,0 @@
/*
* 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;
/*******************************************************************************
** A response-only value. Deliberately not Serializable; never a process value.
*******************************************************************************/
public final class OneTimeKey
{
private final String label;
private final String value;
private final String usageText;
/*******************************************************************************
** Construct the one-time response.
*******************************************************************************/
public OneTimeKey(String label, String value, String usageText)
{
this.label = label;
this.value = value;
this.usageText = usageText;
}
/*******************************************************************************
** Getter for label.
*******************************************************************************/
public String label()
{
return (label);
}
/*******************************************************************************
** Getter for value.
*******************************************************************************/
public String value()
{
return (value);
}
/*******************************************************************************
** Getter for masked.
*******************************************************************************/
public String masked()
{
return ("\u2022\u2022\u2022\u2022" + value.substring(Math.max(0, value.length() - 4)));
}
/*******************************************************************************
** Getter for usageText.
*******************************************************************************/
public String usageText()
{
return (usageText);
}
/*******************************************************************************
** Never render the key or its usage text.
*******************************************************************************/
@Override
public String toString()
{
return ("OneTimeKey{label=" + label + ", value=[one-time]}" );
}
}
@@ -1,192 +0,0 @@
/*
* 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 com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Non-secret bookkeeping. No method accepts a key or a bearer.
*******************************************************************************/
public class TokenScopeStore
{
public static final List<String> SCOPE_FIELDS = List.of("readOnly", "apps", "tables", "processes", "reports", "widgets", "operationIds");
private final AuthentikQBitConfig config;
/*******************************************************************************
** Bind to one qbit's local backend.
*******************************************************************************/
public TokenScopeStore(AuthentikQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** Local table name.
*******************************************************************************/
private String table()
{
return (config.applyTablePrefix(AuthentikLocalTables.TOKEN_SCOPE));
}
/*******************************************************************************
** Fetch the unique scope row as a trusted SYSTEM read (QueryInput default).
*******************************************************************************/
public QRecord find(String identifier) throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput().withTableName(table())
.withFilter(new QQueryFilter(new QFilterCriteria("tokenIdentifier", QCriteriaOperator.EQUALS, identifier)))).getRecords();
return (rows.isEmpty() ? null : rows.get(0));
}
/*******************************************************************************
** Record a new identifier, even when scopes are disabled.
*******************************************************************************/
public void create(String identifier, JSONObject owner, AdminRequest request, String operator) throws QException
{
QRecord row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
.withValue("ownerUsername", owner.getString("username")).withValue("createdBy", operator).withValue("createdAt", Instant.now());
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
for(String field : SCOPE_FIELDS)
{
if(request.params().containsKey(field))
{
row.setValue(field, request.params().get(field));
}
}
}
List<QRecord> rows = new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords();
check(rows);
}
/*******************************************************************************
** Keep all scope and reminder fields when rotating a personal credential.
*******************************************************************************/
public void move(String oldIdentifier, String newIdentifier) throws QException
{
QRecord row = find(oldIdentifier);
if(row != null)
{
row.setValue("tokenIdentifier", newIdentifier);
row.setValue("rotatedAt", Instant.now());
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
/*******************************************************************************
** Revoke removes only the corresponding metadata row.
*******************************************************************************/
public void delete(String identifier) throws QException
{
QRecord row = find(identifier);
if(row != null)
{
new DeleteAction().execute(new DeleteInput().withTableName(table()).withPrimaryKeys(List.of(row.getValue("id"))));
}
}
/*******************************************************************************
** Fail closed if local storage rejects the write.
*******************************************************************************/
private static void check(List<QRecord> rows) throws QException
{
if(rows.isEmpty() || !rows.get(0).getErrors().isEmpty())
{
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());
}
}
}
@@ -1,69 +0,0 @@
/*
* 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 com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
/*******************************************************************************
** Usage instructions held exclusively inside the one-time response.
*******************************************************************************/
public final class TokenUsageText
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TokenUsageText()
{
}
/*******************************************************************************
** Generate shell-quoted exchange and application requests from public URLs.
*******************************************************************************/
public static String build(AuthentikQBitConfig config, String username, String key, Instant expires, String scope)
{
String clientId = config.getAppClientId();
if(clientId == null && QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth)
{
clientId = oauth.getClientId();
}
String endpoint = config.getAuthentikPublicUrl().replaceAll("/+$", "") + "/application/o/token/";
String app = config.getAppPublicUrl() == null ? "{appPublicUrl}" : config.getAppPublicUrl().replaceAll("/+$", "");
return ("Expires: " + expires + "\nScope: " + scope + "\n\n"
+ "curl -X POST " + quote(endpoint) + " --data-urlencode 'grant_type=client_credentials'"
+ " --data-urlencode " + quote("client_id=" + (clientId == null ? "{appClientId}" : clientId))
+ " --data-urlencode " + quote("username=" + username) + " --data-urlencode " + quote("password=" + key)
+ " --data-urlencode 'scope=openid profile email'\n\n"
+ "curl -H 'Authorization: Bearer {jwt}' " + quote(app + "/qqq/v1/metaData"));
}
/*******************************************************************************
** Quote literal shell arguments, including usernames containing apostrophes.
*******************************************************************************/
private static String quote(String value)
{
return ("'" + value.replace("'", "'\"'\"'") + "'");
}
}
@@ -1,557 +0,0 @@
/*
* 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.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.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;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
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.data.QRecord;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Everything the Identity home shows (DESIGN.md section 8.3), read live from
** Authentik and the qbit's own tables. Each section is loaded on its own, so a
** failure shows as "unavailable" for that card instead of hiding the page.
** Sections that expose sensitive data need authentik.audit.read.
*******************************************************************************/
public class AuthentikHomeData
{
private static final QLogger LOG = QLogger.getLogger(AuthentikHomeData.class);
private boolean peopleAvailable;
private int activePeople;
private int inactivePeople;
private int serviceAccounts;
private int neverSignedIn;
private int protectedAccounts;
private boolean groupsAvailable;
private int groups;
private int groupsWithPermissions;
private int groupsWithAppAccess;
private int superuserGroups;
private int emptyGroups;
private boolean approvalsAvailable;
private int pendingApprovals;
private boolean driftAvailable;
private int driftErrors;
private int driftWarnings;
private boolean mayAudit;
private boolean actionsAvailable;
private List<QRecord> recentActions = new ArrayList<>();
private List<String> attention = new ArrayList<>();
private String healthStatus = "UNKNOWN";
private Integer healthComponentId;
private AuthentikQBitConfig config;
/*******************************************************************************
** Load everything for the current session.
*******************************************************************************/
public static AuthentikHomeData load(AuthentikQBitConfig config) throws Exception
{
AuthentikHomeData data = new AuthentikHomeData();
data.config = config;
data.mayAudit = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.AUDIT_READ);
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName());
data.loadPeople(util);
data.loadGroups(util);
data.loadApprovals();
data.loadActions();
data.loadAttention(util);
data.loadHealth();
return (data);
}
/*******************************************************************************
**
*******************************************************************************/
private void loadPeople(AuthentikActionUtil util)
{
try
{
for(JSONObject user : util.allUsers())
{
String type = user.optString("type");
if("service_account".equals(type))
{
serviceAccounts++;
}
else if(user.optBoolean("is_active", true))
{
activePeople++;
}
else
{
inactivePeople++;
}
if(user.isNull("last_login") && !"service_account".equals(type) && user.optBoolean("is_active", true))
{
neverSignedIn++;
}
if(util.getRuntime().getGuard().protection(user).isProtected())
{
protectedAccounts++;
}
}
peopleAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: people unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
**
*******************************************************************************/
private void loadGroups(AuthentikActionUtil util)
{
try
{
GroupGraph graph = util.groupGraphWithMembers();
for(GroupGraph.Node node : graph.nodes())
{
groups++;
if(!node.permissions().isEmpty())
{
groupsWithPermissions++;
}
if(!node.appAccess().isEmpty())
{
groupsWithAppAccess++;
}
if(graph.isSuperuserInherited(node.pk()))
{
superuserGroups++;
}
if(node.memberPks().isEmpty())
{
emptyGroups++;
}
}
groupsAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: groups unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
**
*******************************************************************************/
private void loadApprovals()
{
if(!hasLocal() || !config.getTwoPersonApproval() || !config.hasFeature(AuthentikFeature.GROUPS))
{
return;
}
try
{
CountInput countInput = new CountInput();
countInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.APPROVAL_REQUEST));
countInput.setFilter(new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, AuthentikLocalTables.ApprovalStatus.PENDING.name())));
pendingApprovals = new CountAction().execute(countInput).getCount();
approvalsAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: approvals unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
** The latest admin actions (audit.read only).
*******************************************************************************/
private void loadActions()
{
if(!hasLocal() || !mayAudit)
{
return;
}
try
{
QueryInput queryInput = new QueryInput();
queryInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
queryInput.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false)).withLimit(8));
recentActions = new QueryAction().execute(queryInput).getRecords();
actionsAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: admin actions unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
** Configuration review and bootstrap operators still set. Health is central.
*******************************************************************************/
private void loadAttention(AuthentikActionUtil util)
{
if(!config.getResolvedBootstrapSubjects().isEmpty())
{
attention.add("bootstrapOperatorSubjects is still set: remove it once an identity administrator is set up in Authentik.");
}
if(Boolean.TRUE.equals(config.getReadOnly()))
{
attention.add("The Identity screens are in read-only mode: no change can be made from here.");
}
if(AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN))
{
try
{
for(DriftFinding finding : new AuthentikDriftReport(util, config).run())
{
if(finding.severity() == DriftFinding.Severity.ERROR)
{
driftErrors++;
}
else if(finding.severity() == DriftFinding.Severity.WARNING)
{
driftWarnings++;
}
}
driftAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: drift report unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private boolean hasLocal()
{
return (config.getLocalBackendName() != null && QContext.getQInstance().getTable(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION)) != null);
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
public AuthentikQBitConfig getConfig()
{
return (config);
}
/*******************************************************************************
** Read the central rollup using the current session's table permissions.
*******************************************************************************/
private void loadHealth()
{
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);
}
/*******************************************************************************
** Getter for peopleAvailable
*******************************************************************************/
public boolean isPeopleAvailable()
{
return (peopleAvailable);
}
/*******************************************************************************
** Getter for activePeople
*******************************************************************************/
public int getActivePeople()
{
return (activePeople);
}
/*******************************************************************************
** Getter for inactivePeople
*******************************************************************************/
public int getInactivePeople()
{
return (inactivePeople);
}
/*******************************************************************************
** Getter for serviceAccounts
*******************************************************************************/
public int getServiceAccounts()
{
return (serviceAccounts);
}
/*******************************************************************************
** Getter for neverSignedIn
*******************************************************************************/
public int getNeverSignedIn()
{
return (neverSignedIn);
}
/*******************************************************************************
** Getter for protectedAccounts
*******************************************************************************/
public int getProtectedAccounts()
{
return (protectedAccounts);
}
/*******************************************************************************
** Getter for groupsAvailable
*******************************************************************************/
public boolean isGroupsAvailable()
{
return (groupsAvailable);
}
/*******************************************************************************
** Getter for groups
*******************************************************************************/
public int getGroups()
{
return (groups);
}
/*******************************************************************************
** Getter for groupsWithPermissions
*******************************************************************************/
public int getGroupsWithPermissions()
{
return (groupsWithPermissions);
}
/*******************************************************************************
** Getter for groupsWithAppAccess
*******************************************************************************/
public int getGroupsWithAppAccess()
{
return (groupsWithAppAccess);
}
/*******************************************************************************
** Getter for superuserGroups
*******************************************************************************/
public int getSuperuserGroups()
{
return (superuserGroups);
}
/*******************************************************************************
** Getter for emptyGroups
*******************************************************************************/
public int getEmptyGroups()
{
return (emptyGroups);
}
/*******************************************************************************
** Getter for approvalsAvailable
*******************************************************************************/
public boolean isApprovalsAvailable()
{
return (approvalsAvailable);
}
/*******************************************************************************
** Getter for pendingApprovals
*******************************************************************************/
public int getPendingApprovals()
{
return (pendingApprovals);
}
/*******************************************************************************
** Getter for driftAvailable
*******************************************************************************/
public boolean isDriftAvailable()
{
return (driftAvailable);
}
/*******************************************************************************
** Getter for driftErrors
*******************************************************************************/
public int getDriftErrors()
{
return (driftErrors);
}
/*******************************************************************************
** Getter for driftWarnings
*******************************************************************************/
public int getDriftWarnings()
{
return (driftWarnings);
}
/*******************************************************************************
** Getter for mayAudit
*******************************************************************************/
public boolean isMayAudit()
{
return (mayAudit);
}
/*******************************************************************************
** Getter for actionsAvailable
*******************************************************************************/
public boolean isActionsAvailable()
{
return (actionsAvailable);
}
/*******************************************************************************
** Getter for recentActions
*******************************************************************************/
public List<QRecord> getRecentActions()
{
return (recentActions);
}
/*******************************************************************************
** Getter for attention
*******************************************************************************/
public List<String> getAttention()
{
return (attention);
}
}
@@ -1,292 +0,0 @@
/*
* 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.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Renders the Identity home as one responsive HTML block (no tables, so it
** stays readable at every width): cards stack to one column on narrow
** screens, long values wrap, every card has an honest empty or unavailable
** state, and it follows the app's light and dark theme (inherited colors).
*******************************************************************************/
public final class AuthentikHomeView
{
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr));gap:1rem;";
private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;";
private static final String LIST = "display:grid;grid-template-columns:minmax(8rem,auto) 1fr;gap:.25rem .75rem;margin:0 0 .75rem;font-size:.9rem;";
private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;font-size:.9rem;";
private static final String MUTED = "opacity:.75;";
private static final String WARN = "font-weight:600;";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHomeView()
{
}
/*******************************************************************************
** The whole home.
*******************************************************************************/
public static String render(AuthentikHomeData data)
{
AuthentikQBitConfig config = data.getConfig();
StringBuilder html = new StringBuilder("<div class=\"authentik-home\" style=\"" + GRID + "\">");
attention(html, data, config);
people(html, data, config);
groups(html, data, config);
activity(html, data, config);
health(html, data);
return (html.append("</div>").toString());
}
/*******************************************************************************
** Needs attention: the first thing a person should see.
*******************************************************************************/
private static void attention(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "Needs attention");
boolean any = false;
html.append("<ul style=\"").append(ITEMS).append("\">");
if(data.isApprovalsAvailable() && data.getPendingApprovals() > 0)
{
any = true;
html.append("<li style=\"").append(WARN).append("\">").append(data.getPendingApprovals()).append(" change(s) waiting for a second person to approve. ")
.append(link(config, AuthentikLocalTables.APPROVAL_REQUEST, "Open approval requests")).append("</li>");
}
if(data.isDriftAvailable() && data.getDriftErrors() + data.getDriftWarnings() > 0)
{
any = true;
html.append("<li>").append(data.getDriftErrors()).append(" drift error(s) and ").append(data.getDriftWarnings()).append(" warning(s) in the identity drift report below.</li>");
}
for(String line : data.getAttention())
{
any = true;
html.append("<li>").append(esc(line)).append("</li>");
}
if(data.isPeopleAvailable() && data.getNeverSignedIn() > 0)
{
any = true;
html.append("<li>").append(data.getNeverSignedIn()).append(" active person(s) have never signed in.</li>");
}
html.append("</ul>");
if(!any)
{
empty(html, "Nothing needs attention.");
}
close(html);
}
/*******************************************************************************
**
*******************************************************************************/
private static void people(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "People");
if(!data.isPeopleAvailable())
{
empty(html, "People are unavailable right now (Authentik could not be reached).");
}
else
{
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Active", String.valueOf(data.getActivePeople()));
row(html, "Inactive", String.valueOf(data.getInactivePeople()));
row(html, "Service accounts", String.valueOf(data.getServiceAccounts()));
row(html, "Protected", data.getProtectedAccounts() + " (cannot be changed from here)");
html.append("</dl>");
}
html.append(link(config, AuthentikTables.USER, "Users"));
close(html);
}
/*******************************************************************************
**
*******************************************************************************/
private static void groups(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "Groups and permissions");
if(!data.isGroupsAvailable())
{
empty(html, "Groups are unavailable right now (Authentik could not be reached).");
}
else
{
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Groups", String.valueOf(data.getGroups()));
row(html, "Grant QQQ permissions", String.valueOf(data.getGroupsWithPermissions()));
row(html, "Grant app sign-in", String.valueOf(data.getGroupsWithAppAccess()));
row(html, "Superuser (locked)", String.valueOf(data.getSuperuserGroups()));
row(html, "Empty", String.valueOf(data.getEmptyGroups()));
html.append("</dl>");
}
html.append(link(config, AuthentikTables.GROUP, "Groups"));
close(html);
}
/*******************************************************************************
** Recent changes made from here (audit.read only).
*******************************************************************************/
private static void activity(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "Recent changes");
if(!data.isMayAudit())
{
empty(html, "The change history needs the authentik.audit.read permission.");
}
else if(!data.isActionsAvailable())
{
empty(html, "The change history is unavailable right now.");
}
else if(data.getRecentActions().isEmpty())
{
empty(html, "No changes have been made from here yet.");
}
else
{
html.append("<ul style=\"").append(ITEMS).append("\">");
for(QRecord row : data.getRecentActions())
{
Instant created = ValueUtils.getValueAsInstant(row.getValue("createDate"));
html.append("<li><a href=\"").append(esc(prefix(config) + "/" + config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION) + "/" + row.getValue("id"))).append("\">")
.append(esc(row.getValueString("actionName"))).append("</a> ").append(esc(row.getValueString("targetLabel"))).append(", ")
.append(esc(row.getValueString("status"))).append(created == null ? "" : ", " + WHEN.format(created)).append("</li>");
}
html.append("</ul>");
}
if(data.isMayAudit())
{
html.append(link(config, AuthentikLocalTables.ADMIN_ACTION, "All changes"));
}
close(html);
}
/*******************************************************************************
**
*******************************************************************************/
private static void health(StringBuilder html, AuthentikHomeData data)
{
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);
}
/*******************************************************************************
**
*******************************************************************************/
private static String prefix(AuthentikQBitConfig config)
{
return (config.getLinkPrefix() == null ? "" : config.getLinkPrefix());
}
/*******************************************************************************
**
*******************************************************************************/
private static String link(AuthentikQBitConfig config, String table, String text)
{
String name = AuthentikLocalTables.APPROVAL_REQUEST.equals(table) || AuthentikLocalTables.ADMIN_ACTION.equals(table)
? config.applyTablePrefix(table) : config.applyTablePrefix(table);
return ("<a href=\"" + esc(prefix(config) + "/" + name) + "\">" + esc(text) + "</a>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void open(StringBuilder html, String title)
{
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .5rem;font-size:1rem;\">").append(esc(title)).append("</h3>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void close(StringBuilder html)
{
html.append("</section>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void empty(StringBuilder html, String text)
{
html.append("<p style=\"").append(MUTED).append("\">").append(esc(text)).append("</p>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void row(StringBuilder html, String label, String value)
{
html.append("<dt style=\"").append(MUTED).append("\">").append(esc(label)).append("</dt><dd style=\"margin:0;\">").append(esc(value)).append("</dd>");
}
/*******************************************************************************
** HTML-escape.
*******************************************************************************/
static String esc(String text)
{
if(text == null)
{
return ("");
}
return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"));
}
}
@@ -1,74 +0,0 @@
/*
* 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 com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.metadata.AuthentikHomeWidgetProducer;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Holder of the Identity home widget renderer.
*******************************************************************************/
public final class AuthentikHomeWidget
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHomeWidget()
{
}
/*******************************************************************************
** Renders the home. QQQ checks the widget's permission rules (authentik.users.read);
** each card checks the finer permissions it needs itself.
*******************************************************************************/
public static class Renderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(AuthentikHomeWidgetProducer.BACKEND_NAME_KEY));
AuthentikQBitConfig config = AuthentikRuntime.forBackend(backendName).getConfig();
try
{
return (new RenderWidgetOutput(new RawHTML(null, AuthentikHomeView.render(AuthentikHomeData.load(config)))));
}
catch(QException e)
{
throw (e);
}
catch(Exception e)
{
throw (new QException("The Identity home could not be built: " + e.getMessage(), e));
}
}
}
}
@@ -1,168 +0,0 @@
/*
* 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.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
/*******************************************************************************
** Sub-tables appear on their parents' record screens as child lists, not in
** the nav (owner standard): a user shows its memberships, sessions, tokens and
** events; a group shows its members; an application shows its policy bindings;
** an approval request needs none. The joins are metadata only (the lists
** query the child table filtered by the parent's key), so they work on the
** API backend.
*******************************************************************************/
public final class AuthentikRelations
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikRelations()
{
}
/*******************************************************************************
** Add the joins, child-list widgets and record sections for the tables the
** config produced. Safe to call once per instance.
*******************************************************************************/
public static void apply(QInstance qInstance, AuthentikQBitConfig config)
{
String user = config.applyTablePrefix(AuthentikTables.USER);
String group = config.applyTablePrefix(AuthentikTables.GROUP);
String membership = config.applyTablePrefix(AuthentikTables.GROUP_MEMBER);
String session = config.applyTablePrefix(AuthentikTables.SESSION);
String token = config.applyTablePrefix(AuthentikTables.TOKEN);
String event = config.applyTablePrefix(AuthentikTables.EVENT);
String application = config.applyTablePrefix(AuthentikTables.APPLICATION);
String binding = config.applyTablePrefix(AuthentikTables.POLICY_BINDING);
List<QFieldSection> userSections = new ArrayList<>();
userSections.add(new QFieldSection("identity", "Identity", new QIcon("badge"), Tier.T1,
List.of("username", "name", "email", "isActive", "type", "isProtected", "isSuperuser", "path", "lastLogin", "dateJoined", "lastUpdated", "pk")));
userSections.add(new QFieldSection("access", "Access", new QIcon("admin_panel_settings"), Tier.T2,
List.of("groupNames", "roleNames", "effectivePermissions")));
relate(qInstance, userSections, "memberships", "Memberships", "group_add", user, membership, "pk", "userPk");
relate(qInstance, userSections, "sessions", "Sessions", "devices", user, session, "username", "username");
relate(qInstance, userSections, "tokens", "Tokens and app passwords", "key", user, token, "username", "username");
relate(qInstance, userSections, "events", "History", "history", user, event, "username", "username");
userSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("qqqAttributes", "passwordChangeDate", "attributes")));
applySections(qInstance, user, userSections);
List<QFieldSection> groupSections = new ArrayList<>();
groupSections.add(new QFieldSection("details", "Details", new QIcon("groups"), Tier.T1,
List.of("name", "isSuperuser", "isSuperuserInherited", "parentNames", "childNames", "memberCount", "roleNames", "pk")));
groupSections.add(new QFieldSection("permissions", "Permissions and app access", new QIcon("admin_panel_settings"), Tier.T2,
List.of("permissions", "effectivePermissions", "appAccess")));
relate(qInstance, groupSections, "members", "Members", "group", group, membership, "pk", "groupPk");
enforcement(qInstance, config, groupSections);
relate(qInstance, groupSections, "policy", "Policy", "policy", group, config.applyTablePrefix(AuthentikLocalTables.POLICY), "pk", "groupPk");
groupSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("attributes")));
applySections(qInstance, group, groupSections);
String policyTable = config.applyTablePrefix(AuthentikLocalTables.POLICY);
if(qInstance.getTable(policyTable) != null)
{
List<QFieldSection> policySections = new ArrayList<>();
enforcement(qInstance, config, policySections);
policySections.add(new QFieldSection("policy", "Policy", new QIcon("policy"), Tier.T1,
List.of("groupName", "statementCount", "permissionCount", "savedBy", "savedDate", "policyJson")));
policySections.add(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3, List.of("id", "groupPk", "authentikFingerprint", "createDate", "modifyDate")));
applySections(qInstance, policyTable, policySections);
}
List<QFieldSection> appSections = new ArrayList<>();
appSections.add(new QFieldSection("details", "Details", new QIcon("apps"), Tier.T1,
List.of("name", "slug", "group", "providerName", "policyEngineMode", "launchUrl", "publisher", "hiddenFromLibrary")));
relate(qInstance, appSections, "bindings", "Who can open it (policy bindings)", "rule", application, binding, "bindingTarget", "target");
applySections(qInstance, application, appSections);
}
/*******************************************************************************
** The policy enforcement banner as the first section (when produced).
*******************************************************************************/
private static void enforcement(QInstance qInstance, AuthentikQBitConfig config, List<QFieldSection> sections)
{
String widget = config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.ENFORCEMENT);
if(qInstance.getWidget(widget) != null)
{
sections.add(0, new QFieldSection("enforcement", new QIcon("verified_user"), Tier.T2).withLabel("Policy enforcement").withWidgetName(widget));
}
}
/*******************************************************************************
** One join plus its child-list widget and the section that shows it.
*******************************************************************************/
private static void relate(QInstance qInstance, List<QFieldSection> sections, String key, String label, String icon,
String parentTable, String childTable, String parentField, String childField)
{
if(qInstance.getTable(parentTable) == null || qInstance.getTable(childTable) == null)
{
return;
}
String joinName = parentTable + "_" + key;
QJoinMetaData join = new QJoinMetaData()
.withName(joinName)
.withLeftTable(parentTable)
.withRightTable(childTable)
.withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn(parentField, childField));
qInstance.addJoin(join);
QWidgetMetaData widget = ChildRecordListRenderer.widgetMetaDataBuilder(join).withLabel(label).withMaxRows(20).getWidgetMetaData();
qInstance.addWidget(widget);
sections.add(new QFieldSection(key, new QIcon(icon), Tier.T2).withLabel(label).withWidgetName(widget.getName()));
}
/*******************************************************************************
** Replace a table's sections (the enricher would otherwise skip the field
** sections once a widget section exists).
*******************************************************************************/
private static void applySections(QInstance qInstance, String tableName, List<QFieldSection> sections)
{
QTableMetaData table = qInstance.getTable(tableName);
if(table != null)
{
table.setSections(sections);
}
}
}
@@ -1,180 +0,0 @@
/*
* 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
}
File diff suppressed because it is too large. Load diff
@@ -1,119 +0,0 @@
/*
* 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.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.apache.http.NameValuePair;
/*******************************************************************************
** Every Authentik operation the qbit uses, with the query parameters it
** sends. The schema drift tests check this surface against the pinned
** schema (and, in CI, against new Authentik releases). Table operations are
** derived from AuthentikTables; others are listed here.
*******************************************************************************/
public final class AuthentikApiSurface
{
/*******************************************************************************
** One operation: method, schema path template, and parameters used.
*******************************************************************************/
public record Operation(String method, String path, Set<String> params)
{
}
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private AuthentikApiSurface()
{
}
/*******************************************************************************
** Operations outside the table specs.
*******************************************************************************/
static List<Operation> extraOperations()
{
List<Operation> rs = new ArrayList<>();
rs.add(new Operation("get", "/admin/version/", Set.of()));
rs.add(new Operation("post", "/core/tokens/", Set.of()));
rs.add(new Operation("patch", "/core/tokens/{key}/", Set.of()));
rs.add(new Operation("delete", "/core/tokens/{key}/", Set.of()));
rs.add(new Operation("get", "/core/tokens/{key}/view_key/", Set.of()));
rs.add(new Operation("post", "/core/tokens/{key}/set_key/", Set.of()));
rs.add(new Operation("post", "/core/users/service_account/", Set.of()));
///////////////////////////////////////////////////////////
// virtual membership table, group graph, drift report //
///////////////////////////////////////////////////////////
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", "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", "action", "ordering", "page", "page_size")));
return (rs);
}
/*******************************************************************************
** All operations. Detail paths use "{key}" for the id segment.
*******************************************************************************/
public static List<Operation> operations()
{
List<Operation> rs = new ArrayList<>();
for(AuthentikTableSpec spec : AuthentikTables.all().values())
{
if(spec.isVirtual())
{
continue;
}
Set<String> listParams = new LinkedHashSet<>(List.of("page", "page_size", "ordering"));
for(AuthentikFieldSpec field : spec.getFields())
{
listParams.addAll(field.getFilterParams().values());
if(field.isSearchable())
{
listParams.add("search");
}
}
spec.getDefaultListParams().stream().map(NameValuePair::getName).forEach(listParams::add);
if(AuthentikTables.USER.equals(spec.getName()))
{
listParams.addAll(List.of("type", "path_startswith", "groups_by_name"));
}
rs.add(new Operation("get", spec.getPath(), listParams));
Set<String> getParams = new LinkedHashSet<>();
spec.getDefaultGetParams().stream().map(NameValuePair::getName).forEach(getParams::add);
rs.add(new Operation("get", spec.getPath() + "{key}/", getParams));
}
rs.addAll(extraOperations());
return (rs);
}
}
@@ -1,204 +0,0 @@
/*
* 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.io.Serializable;
import java.util.EnumMap;
import java.util.Map;
import java.util.function.Function;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import org.json.JSONObject;
/*******************************************************************************
** One field of an Authentik-backed table: how to read it from the Authentik
** JSON, which QQQ criteria map to which Authentik query parameters, and
** whether it is sensitive (stripped without authentik.audit.read).
*******************************************************************************/
public class AuthentikFieldSpec
{
private final String name;
private final String label;
private final QFieldType type;
private final Function<JSONObject, Serializable> extractor;
private final Map<QCriteriaOperator, String> filterParams = new EnumMap<>(QCriteriaOperator.class);
private boolean sensitive;
private boolean searchable;
private String orderingName;
private boolean hidden;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AuthentikFieldSpec(String name, String label, QFieldType type, Function<JSONObject, Serializable> extractor)
{
this.name = name;
this.label = label;
this.type = type;
this.extractor = extractor;
}
/*******************************************************************************
** Map a QQQ operator to an Authentik query parameter for this field.
*******************************************************************************/
public AuthentikFieldSpec withFilter(QCriteriaOperator operator, String param)
{
filterParams.put(operator, param);
return (this);
}
/*******************************************************************************
** Mark as sensitive (needs authentik.audit.read to see or filter on).
*******************************************************************************/
public AuthentikFieldSpec withSensitive()
{
this.sensitive = true;
return (this);
}
/*******************************************************************************
** CONTAINS / STARTS_WITH on this field map to Authentik's search param.
*******************************************************************************/
public AuthentikFieldSpec withSearchable()
{
this.searchable = true;
return (this);
}
/*******************************************************************************
** Allow ordering on this field, by the given Authentik ordering name.
*******************************************************************************/
public AuthentikFieldSpec withOrdering(String orderingName)
{
this.orderingName = orderingName;
return (this);
}
/*******************************************************************************
** Hide the field in the UI (it still exists on records).
*******************************************************************************/
public AuthentikFieldSpec withHidden()
{
this.hidden = true;
return (this);
}
/*******************************************************************************
** Read this field's value from an Authentik object.
*******************************************************************************/
public Serializable extract(JSONObject object)
{
return (extractor.apply(object));
}
/*******************************************************************************
** Getter for name
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Getter for type
*******************************************************************************/
public QFieldType getType()
{
return (type);
}
/*******************************************************************************
** Getter for filterParams
*******************************************************************************/
public Map<QCriteriaOperator, String> getFilterParams()
{
return (filterParams);
}
/*******************************************************************************
** Getter for sensitive
*******************************************************************************/
public boolean isSensitive()
{
return (sensitive);
}
/*******************************************************************************
** Getter for searchable
*******************************************************************************/
public boolean isSearchable()
{
return (searchable);
}
/*******************************************************************************
** Getter for orderingName
*******************************************************************************/
public String getOrderingName()
{
return (orderingName);
}
/*******************************************************************************
** Getter for hidden
*******************************************************************************/
public boolean isHidden()
{
return (hidden);
}
}
@@ -1,481 +0,0 @@
/*
* 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.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.AbstractFilterExpression;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import org.apache.http.NameValuePair;
import org.apache.http.message.BasicNameValuePair;
/*******************************************************************************
** Maps a QQQ filter to Authentik query parameters (DESIGN.md section 6.1).
**
** Exactly what Authentik can evaluate server-side is accepted. Everything
** else (OR, sub-filters, NOT_* operators, unmapped fields or operators,
** field-to-field criteria) is REJECTED with a user-facing message: filtering
** client-side over 100-row pages would silently truncate results.
**
** Notes:
** - Authentik date filters are strict (__gt / __lt). GREATER_THAN_OR_EQUALS
** and LESS_THAN_OR_EQUALS are widened by one millisecond; BETWEEN uses both.
** - CONTAINS / STARTS_WITH on a searchable field use Authentik's "search"
** parameter, which matches the term across several fields (a superset of
** the field criterion). Only one search term per query.
** - A filter made only of key EQUALS / IN criteria is returned as a key
** lookup (the caller fetches each record by id), since Authentik has no
** list filter on every table's key.
*******************************************************************************/
public class AuthentikFilterMapper
{
/*******************************************************************************
** Result of mapping a filter.
*******************************************************************************/
public record MappedFilter(List<NameValuePair> params, List<Serializable> keyLookup, Set<String> filteredFields)
{
/*******************************************************************************
** Whether this mapping is a key lookup (fetch-by-id) rather than a list query.
*******************************************************************************/
public boolean isKeyLookup()
{
return (keyLookup != null);
}
}
/*******************************************************************************
** Map a filter for a table.
**
** @param spec the table
** @param filter the QQQ filter (may be null)
** @param mayUseSensitive whether the session may filter on sensitive fields
*******************************************************************************/
public MappedFilter map(AuthentikTableSpec spec, QQueryFilter filter, boolean mayUseSensitive) throws QException
{
List<NameValuePair> params = new ArrayList<>();
Set<String> usedParams = new HashSet<>();
Set<String> filteredFields = new HashSet<>();
if(filter == null)
{
addOrdering(spec, null, params);
return (new MappedFilter(params, null, filteredFields));
}
if(CollectionUtils.nullSafeHasContents(filter.getSubFilters()))
{
throw (new QUserFacingException("Nested filters (sub-filters) are not supported for " + spec.getLabel() + "."));
}
List<QFilterCriteria> criteria = CollectionUtils.nonNullList(filter.getCriteria());
if(criteria.size() > 1 && filter.getBooleanOperator() == QQueryFilter.BooleanOperator.OR)
{
throw (new QUserFacingException("\"Any of\" (OR) filters are not supported for " + spec.getLabel() + "; use \"all of\" (AND)."));
}
List<Serializable> keyLookup = keyLookup(spec, criteria);
if(keyLookup != null)
{
filteredFields.add(spec.getKeyFieldName());
return (new MappedFilter(params, keyLookup, filteredFields));
}
boolean usedSearch = false;
for(QFilterCriteria criterion : criteria)
{
if(criterion == null)
{
continue;
}
if(criterion.getFieldName() == null)
{
throw (new QUserFacingException("A filter criterion without a field is not supported for " + spec.getLabel() + "."));
}
if(criterion.getOtherFieldName() != null || criterion.getFieldFunction() != null)
{
throw (new QUserFacingException("Field-to-field and function criteria are not supported for " + spec.getLabel() + "."));
}
AuthentikFieldSpec field = spec.getField(criterion.getFieldName());
if(field == null)
{
throw (new QUserFacingException("Unknown field for " + spec.getLabel() + ": " + criterion.getFieldName()));
}
if(field.isSensitive() && !mayUseSensitive)
{
throw (new QUserFacingException("You do not have permission to filter " + spec.getLabel() + " by " + field.getLabel() + "."));
}
QCriteriaOperator operator = criterion.getOperator();
List<Serializable> values = evaluateValues(field, criterion.getValues());
filteredFields.add(field.getName());
if((operator == QCriteriaOperator.CONTAINS || operator == QCriteriaOperator.STARTS_WITH) && !field.getFilterParams().containsKey(operator) && field.isSearchable())
{
if(usedSearch)
{
throw (new QUserFacingException("Only one text-search criterion is supported for " + spec.getLabel() + "."));
}
usedSearch = true;
add(params, usedParams, "search", plainSearchTerm(field, singleString(field, operator, values)));
continue;
}
switch(operator)
{
case EQUALS, STARTS_WITH -> add(params, usedParams, param(field, operator), formatValue(field, singleValue(field, operator, values)));
case IN ->
{
String param = param(field, operator);
if(values.isEmpty())
{
throw (new QUserFacingException("An empty \"is any of\" list is not supported for " + field.getLabel() + "."));
}
if(!usedParams.add(param))
{
throw (duplicate(field));
}
for(Serializable value : values)
{
params.add(new BasicNameValuePair(param, formatValue(field, value)));
}
}
case GREATER_THAN, LESS_THAN -> add(params, usedParams, param(field, operator), formatValue(field, singleValue(field, operator, values)));
case GREATER_THAN_OR_EQUALS -> add(params, usedParams, param(field, QCriteriaOperator.GREATER_THAN), formatInstant(shift(field, singleValue(field, operator, values), -1)));
case LESS_THAN_OR_EQUALS -> add(params, usedParams, param(field, QCriteriaOperator.LESS_THAN), formatInstant(shift(field, singleValue(field, operator, values), 1)));
case BETWEEN ->
{
if(values.size() != 2)
{
throw (new QUserFacingException("\"Between\" needs two values for " + field.getLabel() + "."));
}
add(params, usedParams, param(field, QCriteriaOperator.GREATER_THAN), formatInstant(shift(field, values.get(0), -1)));
add(params, usedParams, param(field, QCriteriaOperator.LESS_THAN), formatInstant(shift(field, values.get(1), 1)));
}
case IS_BLANK -> add(params, usedParams, param(field, operator), "true");
case IS_NOT_BLANK -> add(params, usedParams, param(field, operator), "false");
default -> throw (unsupported(field, operator));
}
}
addOrdering(spec, filter.getOrderBys(), params);
return (new MappedFilter(params, null, filteredFields));
}
/*******************************************************************************
** If every criterion is key EQUALS / IN, return the keys; else null.
*******************************************************************************/
private List<Serializable> keyLookup(AuthentikTableSpec spec, List<QFilterCriteria> criteria)
{
if(criteria.isEmpty())
{
return (null);
}
List<Serializable> keys = null;
for(QFilterCriteria criterion : criteria)
{
if(criterion == null || !spec.getKeyFieldName().equals(criterion.getFieldName()) || criterion.getOtherFieldName() != null)
{
return (null);
}
if(criterion.getOperator() != QCriteriaOperator.EQUALS && criterion.getOperator() != QCriteriaOperator.IN)
{
return (null);
}
List<Serializable> these = new ArrayList<>(CollectionUtils.nonNullList(criterion.getValues()));
if(keys == null)
{
keys = these;
}
else
{
keys.retainAll(these);
}
}
return (keys);
}
/*******************************************************************************
**
*******************************************************************************/
private void addOrdering(AuthentikTableSpec spec, List<QFilterOrderBy> orderBys, List<NameValuePair> params) throws QUserFacingException
{
List<String> orderings = new ArrayList<>();
for(QFilterOrderBy orderBy : CollectionUtils.nonNullList(orderBys))
{
AuthentikFieldSpec field = spec.getField(orderBy.getFieldName());
if(field != null && field.getOrderingName() == null && spec.getKeyFieldName().equals(field.getName()))
{
//////////////////////////////////////////////////////////////////
// UIs often sort by the key by default: use the table's default //
//////////////////////////////////////////////////////////////////
continue;
}
if(field == null || field.getOrderingName() == null)
{
throw (new QUserFacingException(spec.getLabel() + " cannot be sorted by " + (field == null ? orderBy.getFieldName() : field.getLabel()) + "."));
}
orderings.add((orderBy.getIsAscending() ? "" : "-") + field.getOrderingName());
}
if(orderings.isEmpty() && spec.getDefaultOrdering() != null)
{
orderings.add(spec.getDefaultOrdering());
}
if(!orderings.isEmpty())
{
params.add(new BasicNameValuePair("ordering", String.join(",", orderings)));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void add(List<NameValuePair> params, Set<String> usedParams, String param, String value) throws QUserFacingException
{
if(!usedParams.add(param))
{
throw (new QUserFacingException("Two criteria map to the same Authentik filter (" + param + "); combine them into one."));
}
params.add(new BasicNameValuePair(param, value));
}
/*******************************************************************************
**
*******************************************************************************/
private static String param(AuthentikFieldSpec field, QCriteriaOperator operator) throws QUserFacingException
{
String param = field.getFilterParams().get(operator);
if(param == null)
{
throw (unsupported(field, operator));
}
return (param);
}
/*******************************************************************************
**
*******************************************************************************/
private static QUserFacingException unsupported(AuthentikFieldSpec field, QCriteriaOperator operator)
{
return (new QUserFacingException("Filtering " + field.getLabel() + " with \"" + operator.name().toLowerCase().replace('_', ' ') + "\" is not supported by Authentik."));
}
/*******************************************************************************
**
*******************************************************************************/
private static QUserFacingException duplicate(AuthentikFieldSpec field)
{
return (new QUserFacingException("Only one criterion per field is supported (" + field.getLabel() + ")."));
}
/*******************************************************************************
**
*******************************************************************************/
private static Serializable singleValue(AuthentikFieldSpec field, QCriteriaOperator operator, List<Serializable> values) throws QUserFacingException
{
if(values.size() != 1 || values.get(0) == null)
{
throw (new QUserFacingException("\"" + operator.name().toLowerCase().replace('_', ' ') + "\" needs exactly one value for " + field.getLabel() + "."));
}
return (values.get(0));
}
/*******************************************************************************
**
*******************************************************************************/
private static String singleString(AuthentikFieldSpec field, QCriteriaOperator operator, List<Serializable> values) throws QUserFacingException
{
String value = String.valueOf(singleValue(field, operator, values)).trim();
if(value.isEmpty())
{
throw (new QUserFacingException("A search term is required for " + field.getLabel() + "."));
}
return (value);
}
/*******************************************************************************
** Authentik's search parameter also accepts a query language (QLSearch),
** which could reach fields the session may not filter on (attributes).
** Only plain text is passed: QL operators, quotes and keywords are refused.
*******************************************************************************/
static String plainSearchTerm(AuthentikFieldSpec field, String term) throws QUserFacingException
{
if(term.matches(".*[=~\"'()<>!\\\\].*") || term.matches("(?i).*\\s(and|or|not|in)\\s.*"))
{
throw (new QUserFacingException("Search " + field.getLabel() + " with plain text only (no operators or quotes)."));
}
return (term);
}
/*******************************************************************************
** Evaluate filter expressions (e.g. "now minus 90 days") into plain values.
*******************************************************************************/
private static List<Serializable> evaluateValues(AuthentikFieldSpec field, List<Serializable> values) throws QException
{
List<Serializable> rs = new ArrayList<>();
for(Serializable value : CollectionUtils.nonNullList(values))
{
if(value instanceof AbstractFilterExpression<?> expression)
{
rs.add(expression.evaluate(new QFieldMetaData(field.getName(), field.getType())));
}
else
{
rs.add(value);
}
}
return (rs);
}
/*******************************************************************************
** Format a value for a query string.
*******************************************************************************/
static String formatValue(AuthentikFieldSpec field, Serializable value) throws QUserFacingException
{
if(field.getType() == QFieldType.DATE_TIME || field.getType() == QFieldType.DATE)
{
return (formatInstant(toInstant(field, value)));
}
if(field.getType() == QFieldType.BOOLEAN)
{
String text = String.valueOf(value).trim().toLowerCase();
if(text.equals("true") || text.equals("1") || text.equals("yes"))
{
return ("true");
}
if(text.equals("false") || text.equals("0") || text.equals("no"))
{
return ("false");
}
throw (new QUserFacingException("Not a valid yes/no value for " + field.getLabel() + ": " + value));
}
return (String.valueOf(value));
}
/*******************************************************************************
**
*******************************************************************************/
private static Instant shift(AuthentikFieldSpec field, Serializable value, long millis) throws QUserFacingException
{
return (toInstant(field, value).plusMillis(millis));
}
/*******************************************************************************
** Convert a date-ish value to an Instant (a date means its start, UTC).
*******************************************************************************/
static Instant toInstant(AuthentikFieldSpec field, Serializable value) throws QUserFacingException
{
if(value instanceof Instant instant)
{
return (instant);
}
if(value instanceof LocalDate localDate)
{
return (localDate.atStartOfDay(ZoneOffset.UTC).toInstant());
}
if(value instanceof java.util.Date date)
{
return (date.toInstant());
}
String text = value == null ? "" : String.valueOf(value).trim();
try
{
return (Instant.parse(text));
}
catch(DateTimeParseException e)
{
try
{
return (AuthentikJson.parseInstant(text));
}
catch(DateTimeParseException e2)
{
try
{
return (LocalDate.parse(text).atStartOfDay(ZoneOffset.UTC).toInstant());
}
catch(DateTimeParseException e3)
{
throw (new QUserFacingException("Not a valid date/time for " + field.getLabel() + ": " + text));
}
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private static String formatInstant(Instant instant)
{
return (instant.toString());
}
}
@@ -1,311 +0,0 @@
/*
* 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.io.Serializable;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.function.Function;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Null-safe readers for Authentik JSON objects, used as field extractors.
*******************************************************************************/
public final class AuthentikJson
{
/** Attribute-key prefix the qbit owns (DESIGN.md section 5.2). */
public static final String QQQ_ATTRIBUTE_PREFIX = "qqq.io/";
public static final String ATTR_PERMISSIONS = "qqq.io/permissions";
public static final String ATTR_APP_ACCESS = "qqq.io/app-access";
public static final String ATTR_PROTECTED = "qqq.io/protected";
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private AuthentikJson()
{
}
/*******************************************************************************
** String value, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> string(String key)
{
return (o -> o.has(key) && !o.isNull(key) ? String.valueOf(o.get(key)) : null);
}
/*******************************************************************************
** Integer value, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> integer(String key)
{
return (o -> o.has(key) && !o.isNull(key) ? o.getInt(key) : null);
}
/*******************************************************************************
** Boolean value, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> bool(String key)
{
return (o -> o.has(key) && !o.isNull(key) ? o.getBoolean(key) : null);
}
/*******************************************************************************
** ISO-8601 date-time as an Instant, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> instant(String key)
{
return (o -> parseInstant(o.has(key) && !o.isNull(key) ? o.getString(key) : null));
}
/*******************************************************************************
** A nested object (or array) rendered as JSON text, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> json(String key)
{
return (o -> o.has(key) && !o.isNull(key) ? o.get(key).toString() : null);
}
/*******************************************************************************
** A value at a nested path (e.g. "user", "username"), as a string, or null.
** Objects and arrays are rendered as JSON text.
*******************************************************************************/
public static Function<JSONObject, Serializable> at(String... path)
{
return (o ->
{
Object current = o;
for(String key : path)
{
if(!(current instanceof JSONObject object) || !object.has(key) || object.isNull(key))
{
return (null);
}
current = object.get(key);
}
return (String.valueOf(current));
});
}
/*******************************************************************************
** An integer at a nested path, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> intAt(String... path)
{
Function<JSONObject, Serializable> text = at(path);
return (o ->
{
Serializable value = text.apply(o);
try
{
return (value == null ? null : Integer.valueOf(String.valueOf(value)));
}
catch(NumberFormatException e)
{
return (null);
}
});
}
/*******************************************************************************
** Length of an array value, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> arrayLength(String key)
{
return (o -> o.optJSONArray(key) == null ? null : o.getJSONArray(key).length());
}
/*******************************************************************************
** Comma-joined values of a property across an array of objects, e.g. the
** names in groups_obj. Null when the array is absent.
*******************************************************************************/
public static Function<JSONObject, Serializable> joined(String arrayKey, String property)
{
return (o ->
{
JSONArray array = o.optJSONArray(arrayKey);
if(array == null)
{
return (null);
}
List<String> values = new ArrayList<>();
for(int i = 0; i < array.length(); i++)
{
Object element = array.get(i);
if(element instanceof JSONObject object && object.has(property) && !object.isNull(property))
{
values.add(String.valueOf(object.get(property)));
}
else if(!(element instanceof JSONObject) && property == null)
{
values.add(String.valueOf(element));
}
}
return (String.join(", ", values));
});
}
/*******************************************************************************
** Comma-joined values of a string array inside "attributes", e.g.
** qqq.io/permissions. Null when absent.
*******************************************************************************/
public static Function<JSONObject, Serializable> attributeList(String attributeKey)
{
return (o ->
{
List<String> values = stringList(o.optJSONObject("attributes"), attributeKey);
return (values == null ? null : String.join(", ", values));
});
}
/*******************************************************************************
** Only the qqq.io/* attribute keys, as JSON text (non-sensitive view).
*******************************************************************************/
public static Function<JSONObject, Serializable> qqqAttributes()
{
return (o ->
{
JSONObject attributes = o.optJSONObject("attributes");
if(attributes == null)
{
return (null);
}
JSONObject rs = new JSONObject();
Iterator<String> keys = attributes.keys();
while(keys.hasNext())
{
String key = keys.next();
if(key.startsWith(QQQ_ATTRIBUTE_PREFIX))
{
rs.put(key, attributes.get(key));
}
}
return (rs.isEmpty() ? null : rs.toString());
});
}
/*******************************************************************************
** Read a list of strings from an object property; null when absent, and
** non-string elements are skipped. A single string is treated as a list of
** one (Authentik attributes are free-form JSON).
*******************************************************************************/
public static List<String> stringList(JSONObject object, String key)
{
if(object == null || !object.has(key) || object.isNull(key))
{
return (null);
}
Object value = object.get(key);
List<String> values = new ArrayList<>();
if(value instanceof JSONArray array)
{
for(int i = 0; i < array.length(); i++)
{
if(array.get(i) instanceof String s && !s.isBlank())
{
values.add(s.trim());
}
}
}
else if(value instanceof String s && !s.isBlank())
{
values.add(s.trim());
}
return (values);
}
/*******************************************************************************
** Entries of a list attribute that stringList() drops: a value that is not a
** string or array, or non-string / blank array elements. Empty when fine.
*******************************************************************************/
public static List<String> malformedEntries(JSONObject object, String key)
{
List<String> rs = new ArrayList<>();
if(object == null || !object.has(key) || object.isNull(key))
{
return (rs);
}
Object value = object.get(key);
if(value instanceof JSONArray array)
{
for(int i = 0; i < array.length(); i++)
{
Object element = array.get(i);
if(!(element instanceof String s) || s.isBlank())
{
rs.add(String.valueOf(element));
}
}
}
else if(!(value instanceof String))
{
rs.add(String.valueOf(value));
}
return (rs);
}
/*******************************************************************************
** Parse an Authentik timestamp (ISO-8601 with offset or Z).
*******************************************************************************/
public static Instant parseInstant(String value)
{
if(value == null || value.isBlank())
{
return (null);
}
return (OffsetDateTime.parse(value).toInstant());
}
}
@@ -1,129 +0,0 @@
/*
* 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.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/*******************************************************************************
** Redaction for anything the qbit logs or stores about Authentik calls
** (DESIGN.md section 9.1):
** - the bearer token is never logged (it is replaced wherever it appears);
** - bodies of sensitive endpoints (recovery, tokens, passwords, service-account
** creation, user create) are dropped entirely;
** - in any other body, values of JSON keys that look like secrets (key, token,
** link, password, secret, client_secret) are masked.
*******************************************************************************/
public final class AuthentikRedactor
{
public static final String MASK = "******";
/** Path fragments whose request and response bodies are never logged. */
private static final List<String> SENSITIVE_PATH_FRAGMENTS = List.of(
"/application/o/token/", "/recovery", "/recovery_email", "/set_password", "/set_password_hash", "/core/tokens/", "/service_account", "/impersonate", "/view_key", "/set_key");
private static final Pattern SECRET_JSON_VALUE = Pattern.compile(
"(\"(?:key|token|access_token|subject_token|link|password|secret|client_secret|password_hash|recovery_link)\"\\s*:\\s*)\"((?:[^\"\\\\]|\\\\.)*)\"", Pattern.CASE_INSENSITIVE);
private static final Pattern BEARER = Pattern.compile("(?i)(bearer\\s+)[A-Za-z0-9._~+/=-]+");
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private AuthentikRedactor()
{
}
/*******************************************************************************
** Whether bodies for this path must never be logged.
*******************************************************************************/
public static boolean isSensitivePath(String path)
{
if(path == null)
{
return (false);
}
for(String fragment : SENSITIVE_PATH_FRAGMENTS)
{
if(path.contains(fragment))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** Redact a request or response body for logging.
**
** @param path the request path (decides whether to drop the body)
** @param body the body (may be null)
** @param token the bearer token in use (may be null)
*******************************************************************************/
public static String redactBody(String path, String body, String token)
{
if(body == null)
{
return (null);
}
if(isSensitivePath(path))
{
return ("[body omitted: sensitive endpoint]");
}
return (redactText(body, token));
}
/*******************************************************************************
** Redact free text (exception messages, log lines): the token, bearer
** values and secret-looking JSON values.
*******************************************************************************/
public static String redactText(String text, String token)
{
if(text == null)
{
return (null);
}
String safe = text;
if(token != null && !token.isBlank())
{
safe = safe.replace(token, MASK);
}
safe = BEARER.matcher(safe).replaceAll("$1" + MASK);
Matcher matcher = SECRET_JSON_VALUE.matcher(safe);
StringBuilder rs = new StringBuilder();
while(matcher.find())
{
matcher.appendReplacement(rs, Matcher.quoteReplacement(matcher.group(1) + "\"" + MASK + "\""));
}
matcher.appendTail(rs);
return (rs.toString());
}
}
@@ -1,34 +0,0 @@
/*
* 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.QException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Supplies the current operator's subject JWT for one token action.
*******************************************************************************/
@FunctionalInterface
public interface AuthentikSubjectTokenSource
{
/*******************************************************************************
** Return a subject JWT without retaining or logging it.
*******************************************************************************/
String subjectToken(QSession session) throws QException;
}
@@ -1,332 +0,0 @@
/*
* 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.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import org.apache.http.NameValuePair;
import org.apache.http.message.BasicNameValuePair;
/*******************************************************************************
** Definition of one Authentik-backed QQQ table: its endpoint, key, fields,
** default query parameters and the permission needed to read it.
*******************************************************************************/
public class AuthentikTableSpec
{
private final String name;
private final String label;
private final String icon;
private final String path;
private final String keyFieldName;
private final Map<String, AuthentikFieldSpec> fields = new LinkedHashMap<>();
private final List<NameValuePair> defaultListParams = new ArrayList<>();
private final List<NameValuePair> defaultGetParams = new ArrayList<>();
private IdentityPermission alternativeReadPermission;
private IdentityPermission readPermission = IdentityPermission.USERS_READ;
private String defaultOrdering;
private List<String> recordLabelFields = new ArrayList<>();
private String requiredFilterField;
private boolean virtual;
/*******************************************************************************
** Constructor.
**
** @param name unprefixed table name, e.g. "user"
** @param label UI label
** @param icon material icon name
** @param path API path below /api/v3, with leading and trailing slash
** @param keyFieldName name of the primary-key field
*******************************************************************************/
public AuthentikTableSpec(String name, String label, String icon, String path, String keyFieldName)
{
this.name = name;
this.label = label;
this.icon = icon;
this.path = path;
this.keyFieldName = keyFieldName;
}
/*******************************************************************************
** Add a field.
*******************************************************************************/
public AuthentikTableSpec withField(AuthentikFieldSpec field)
{
fields.put(field.getName(), field);
return (this);
}
/*******************************************************************************
** Add a query parameter sent with every list request.
*******************************************************************************/
public AuthentikTableSpec withDefaultListParam(String param, String value)
{
defaultListParams.add(new BasicNameValuePair(param, value));
return (this);
}
/*******************************************************************************
** Add a query parameter sent with every single-record GET.
*******************************************************************************/
public AuthentikTableSpec withDefaultGetParam(String param, String value)
{
defaultGetParams.add(new BasicNameValuePair(param, value));
return (this);
}
/*******************************************************************************
** Set the permission needed to read the table.
*******************************************************************************/
public AuthentikTableSpec withReadPermission(IdentityPermission readPermission)
{
this.readPermission = readPermission;
return (this);
}
/*******************************************************************************
** Set the Authentik ordering used when the query has no orderBy.
*******************************************************************************/
public AuthentikTableSpec withDefaultOrdering(String defaultOrdering)
{
this.defaultOrdering = defaultOrdering;
return (this);
}
/*******************************************************************************
** Set the record label fields.
*******************************************************************************/
public AuthentikTableSpec withRecordLabelFields(String... recordLabelFields)
{
this.recordLabelFields = new ArrayList<>(List.of(recordLabelFields));
return (this);
}
/*******************************************************************************
** Require a criterion on this field for every query (e.g. MFA devices are
** only listed per user).
*******************************************************************************/
public AuthentikTableSpec withRequiredFilterField(String requiredFilterField)
{
this.requiredFilterField = requiredFilterField;
return (this);
}
/*******************************************************************************
** Mark as virtual: served by custom logic in AuthentikActionUtil rather than
** by one list endpoint (its filter "params" are field names, not Authentik
** query parameters).
*******************************************************************************/
public AuthentikTableSpec withVirtual()
{
this.virtual = true;
return (this);
}
/*******************************************************************************
** Getter for virtual
*******************************************************************************/
public boolean isVirtual()
{
return (virtual);
}
/*******************************************************************************
** Get a field spec, or null.
*******************************************************************************/
public AuthentikFieldSpec getField(String fieldName)
{
return (fields.get(fieldName));
}
/*******************************************************************************
** All field specs, in display order.
*******************************************************************************/
public Collection<AuthentikFieldSpec> getFields()
{
return (fields.values());
}
/*******************************************************************************
** Getter for name
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Getter for icon
*******************************************************************************/
public String getIcon()
{
return (icon);
}
/*******************************************************************************
** Getter for path
*******************************************************************************/
public String getPath()
{
return (path);
}
/*******************************************************************************
** Getter for keyFieldName
*******************************************************************************/
public String getKeyFieldName()
{
return (keyFieldName);
}
/*******************************************************************************
** Getter for defaultListParams
*******************************************************************************/
public List<NameValuePair> getDefaultListParams()
{
return (defaultListParams);
}
/*******************************************************************************
** Getter for defaultGetParams
*******************************************************************************/
public List<NameValuePair> getDefaultGetParams()
{
return (defaultGetParams);
}
/*******************************************************************************
** Getter for readPermission
*******************************************************************************/
public IdentityPermission getReadPermission()
{
return (readPermission);
}
/*******************************************************************************
** Getter for defaultOrdering
*******************************************************************************/
public String getDefaultOrdering()
{
return (defaultOrdering);
}
/*******************************************************************************
** Getter for recordLabelFields
*******************************************************************************/
public List<String> getRecordLabelFields()
{
return (recordLabelFields);
}
/*******************************************************************************
** Getter for requiredFilterField
*******************************************************************************/
public String getRequiredFilterField()
{
return (requiredFilterField);
}
/*******************************************************************************
** Add a second permission that also permits reads.
*******************************************************************************/
public AuthentikTableSpec withAlternativeReadPermission(IdentityPermission permission)
{
alternativeReadPermission = permission;
return (this);
}
/*******************************************************************************
** Getter for the optional alternative permission.
*******************************************************************************/
public IdentityPermission getAlternativeReadPermission()
{
return (alternativeReadPermission);
}
/*******************************************************************************
** Token tables need OR rules in core as well as in the API guard.
*******************************************************************************/
public com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules readRules()
{
return (alternativeReadPermission == null ? readPermission.rules() : com.kingsrook.qbits.authentik.security.TokenReadPermission.rules());
}
}
@@ -1,450 +0,0 @@
/*
* 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.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.at;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.attributeList;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.bool;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.instant;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.intAt;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.integer;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.joined;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.json;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.qqqAttributes;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.string;
/*******************************************************************************
** The Authentik-backed tables (DESIGN.md section 6.1), mapped against the
** pinned 2026.8.3 schema (src/test/resources/authentik/schema-2026.8.3.yml).
**
** Multi-value query parameters are sent as repeated plain names
** (type=internal&type=external). Authentik silently IGNORES the bracketed
** form (type[]=internal), returning unfiltered results (phase 0 finding).
*******************************************************************************/
public final class AuthentikTables
{
public static final String USER = "user";
public static final String GROUP = "group";
public static final String GROUP_MEMBER = "groupMember";
public static final String SESSION = "session";
public static final String EVENT = "event";
public static final String APPLICATION = "application";
public static final String POLICY_BINDING = "policyBinding";
public static final String BLUEPRINT = "blueprint";
public static final String ROLE = "role";
public static final String TOKEN = "token";
public static final String INVITATION = "invitation";
/*******************************************************************************
** Table names produced before the owner naming rule (2026-10-12), with the
** default prefix. Kept only to find stored references (permissions, saved
** views, audit table names) when upgrading; see UPGRADE.md.
*******************************************************************************/
@Deprecated
public static final String LEGACY_USER_TABLE_NAME = "authentikUser";
@Deprecated
public static final String LEGACY_GROUP_TABLE_NAME = "authentikGroup";
@Deprecated
public static final String LEGACY_GROUP_MEMBER_TABLE_NAME = "authentikGroupMember";
@Deprecated
public static final String LEGACY_SESSION_TABLE_NAME = "authentikSession";
@Deprecated
public static final String LEGACY_EVENT_TABLE_NAME = "authentikEvent";
/** User types the qbit shows by default: everything but outposts' internal service accounts. */
public static final String[] DEFAULT_USER_TYPES = { "internal", "external", "service_account" };
private static final Map<String, AuthentikTableSpec> SPECS = new LinkedHashMap<>();
static
{
register(users());
register(groups());
register(groupMembers());
register(sessions());
register(events());
register(applications());
register(policyBindings());
register(blueprints());
register(roles());
register(tokens());
register(invitations());
}
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private AuthentikTables()
{
}
/*******************************************************************************
** Look up a spec by unprefixed table name, or null.
*******************************************************************************/
public static AuthentikTableSpec get(String name)
{
return (SPECS.get(name));
}
/*******************************************************************************
** All specs.
*******************************************************************************/
public static Map<String, AuthentikTableSpec> all()
{
return (SPECS);
}
/*******************************************************************************
**
*******************************************************************************/
private static void register(AuthentikTableSpec spec)
{
SPECS.put(spec.getName(), spec);
}
/*******************************************************************************
** /core/users/ (int pk). The detail GET takes no include_* parameters and
** always returns groups_obj and roles_obj.
*******************************************************************************/
static AuthentikTableSpec users()
{
AuthentikTableSpec spec = new AuthentikTableSpec(USER, "Users", "person", "/core/users/", "pk")
.withDefaultListParam("include_groups", "true")
.withDefaultListParam("include_roles", "true")
.withDefaultOrdering("username")
.withRecordLabelFields("username");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.INTEGER, integer("pk")));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, string("username"))
.withFilter(QCriteriaOperator.EQUALS, "username").withSearchable().withOrdering("username"));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name"))
.withFilter(QCriteriaOperator.EQUALS, "name").withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, string("email"))
.withFilter(QCriteriaOperator.EQUALS, "email").withSearchable().withOrdering("email"));
spec.withField(new AuthentikFieldSpec("isActive", "Active", QFieldType.BOOLEAN, bool("is_active"))
.withFilter(QCriteriaOperator.EQUALS, "is_active").withOrdering("is_active"));
spec.withField(new AuthentikFieldSpec("type", "Type", QFieldType.STRING, string("type"))
.withFilter(QCriteriaOperator.EQUALS, "type").withFilter(QCriteriaOperator.IN, "type"));
spec.withField(new AuthentikFieldSpec("isSuperuser", "Superuser", QFieldType.BOOLEAN, bool("is_superuser"))
.withFilter(QCriteriaOperator.EQUALS, "is_superuser"));
spec.withField(new AuthentikFieldSpec("isProtected", "Protected", QFieldType.BOOLEAN, o -> null));
spec.withField(new AuthentikFieldSpec("groupNames", "Groups", QFieldType.STRING, joined("groups_obj", "name"))
.withFilter(QCriteriaOperator.EQUALS, "groups_by_name").withFilter(QCriteriaOperator.IN, "groups_by_name"));
spec.withField(new AuthentikFieldSpec("roleNames", "Roles", QFieldType.STRING, joined("roles_obj", "name"))
.withFilter(QCriteriaOperator.EQUALS, "roles_by_name").withFilter(QCriteriaOperator.IN, "roles_by_name"));
spec.withField(new AuthentikFieldSpec("lastLogin", "Last Login", QFieldType.DATE_TIME, instant("last_login"))
.withFilter(QCriteriaOperator.GREATER_THAN, "last_login__gt").withFilter(QCriteriaOperator.LESS_THAN, "last_login__lt")
.withFilter(QCriteriaOperator.IS_BLANK, "last_login__isnull").withFilter(QCriteriaOperator.IS_NOT_BLANK, "last_login__isnull")
.withOrdering("last_login"));
spec.withField(new AuthentikFieldSpec("dateJoined", "Date Joined", QFieldType.DATE_TIME, instant("date_joined"))
.withFilter(QCriteriaOperator.GREATER_THAN, "date_joined__gt").withFilter(QCriteriaOperator.LESS_THAN, "date_joined__lt")
.withOrdering("date_joined"));
spec.withField(new AuthentikFieldSpec("lastUpdated", "Last Updated", QFieldType.DATE_TIME, instant("last_updated"))
.withFilter(QCriteriaOperator.GREATER_THAN, "last_updated__gt").withFilter(QCriteriaOperator.LESS_THAN, "last_updated__lt"));
spec.withField(new AuthentikFieldSpec("path", "Path", QFieldType.STRING, string("path"))
.withFilter(QCriteriaOperator.EQUALS, "path").withFilter(QCriteriaOperator.STARTS_WITH, "path_startswith"));
spec.withField(new AuthentikFieldSpec("uuid", "UUID", QFieldType.STRING, string("uuid"))
.withFilter(QCriteriaOperator.EQUALS, "uuid").withHidden());
spec.withField(new AuthentikFieldSpec("qqqAttributes", "QQQ Attributes", QFieldType.TEXT, qqqAttributes()));
spec.withField(new AuthentikFieldSpec("effectivePermissions", "Effective QQQ Permissions", QFieldType.TEXT, o -> null));
spec.withField(new AuthentikFieldSpec("passwordChangeDate", "Password Changed", QFieldType.DATE_TIME, instant("password_change_date"))
.withSensitive());
spec.withField(new AuthentikFieldSpec("attributes", "Attributes", QFieldType.TEXT, json("attributes"))
.withSensitive());
return (spec);
}
/*******************************************************************************
** /core/groups/ (uuid pk). Note: Group has no last_updated in 2026.8.3.
*******************************************************************************/
static AuthentikTableSpec groups()
{
AuthentikTableSpec spec = new AuthentikTableSpec(GROUP, "Groups", "groups", "/core/groups/", "pk")
.withDefaultListParam("include_users", "true")
.withDefaultListParam("include_parents", "true")
.withDefaultListParam("include_children", "true")
.withDefaultGetParam("include_users", "true")
.withDefaultGetParam("include_parents", "true")
.withDefaultGetParam("include_children", "true")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name"))
.withFilter(QCriteriaOperator.EQUALS, "name").withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("isSuperuser", "Superuser (own flag)", QFieldType.BOOLEAN, bool("is_superuser"))
.withFilter(QCriteriaOperator.EQUALS, "is_superuser"));
spec.withField(new AuthentikFieldSpec("isSuperuserInherited", "Superuser (incl. parents)", QFieldType.BOOLEAN, o -> null));
spec.withField(new AuthentikFieldSpec("parentNames", "Parents", QFieldType.STRING, joined("parents_obj", "name")));
spec.withField(new AuthentikFieldSpec("childNames", "Children", QFieldType.STRING, joined("children_obj", "name")));
spec.withField(new AuthentikFieldSpec("memberCount", "Members", QFieldType.INTEGER, AuthentikJson.arrayLength("users")));
spec.withField(new AuthentikFieldSpec("memberUsername", "Member Username", QFieldType.STRING, o -> null)
.withFilter(QCriteriaOperator.EQUALS, "members_by_username").withFilter(QCriteriaOperator.IN, "members_by_username").withHidden());
spec.withField(new AuthentikFieldSpec("permissions", "QQQ Permissions", QFieldType.TEXT, attributeList(AuthentikJson.ATTR_PERMISSIONS)));
spec.withField(new AuthentikFieldSpec("effectivePermissions", "Effective QQQ Permissions (incl. parents)", QFieldType.TEXT, o -> null));
spec.withField(new AuthentikFieldSpec("appAccess", "App Access", QFieldType.TEXT, attributeList(AuthentikJson.ATTR_APP_ACCESS)));
spec.withField(new AuthentikFieldSpec("roleNames", "Roles", QFieldType.STRING, joined("roles_obj", "name")));
spec.withField(new AuthentikFieldSpec("numPk", "Numeric Id", QFieldType.INTEGER, integer("num_pk")).withHidden());
spec.withField(new AuthentikFieldSpec("parentPks", "Parent Ids", QFieldType.TEXT, json("parents")).withHidden());
spec.withField(new AuthentikFieldSpec("attributes", "Attributes", QFieldType.TEXT, json("attributes")).withSensitive());
return (spec);
}
/*******************************************************************************
** Virtual membership table (group x user). Served by AuthentikActionUtil
** from GET /core/groups/{pk}/?include_users=true, GET /core/users/{pk}/
** (groups_obj) or a group sweep; rows are synthetic objects keyed by these
** field names. Key: "groupPk:userPk".
*******************************************************************************/
static AuthentikTableSpec groupMembers()
{
AuthentikTableSpec spec = new AuthentikTableSpec(GROUP_MEMBER, "Group Memberships", "group_add", "/core/groups/", "id")
.withVirtual()
.withDefaultOrdering("groupName")
.withRecordLabelFields("groupName", "username");
spec.withField(new AuthentikFieldSpec("id", "Id", QFieldType.STRING, string("id")));
spec.withField(new AuthentikFieldSpec("groupName", "Group", QFieldType.STRING, string("groupName"))
.withFilter(QCriteriaOperator.EQUALS, "groupName").withOrdering("groupName"));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, string("username"))
.withFilter(QCriteriaOperator.EQUALS, "username").withOrdering("username"));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")));
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, string("email")));
spec.withField(new AuthentikFieldSpec("isActive", "Active", QFieldType.BOOLEAN, bool("isActive")));
spec.withField(new AuthentikFieldSpec("groupPk", "Group Id", QFieldType.STRING, string("groupPk"))
.withFilter(QCriteriaOperator.EQUALS, "groupPk").withFilter(QCriteriaOperator.IN, "groupPk"));
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, integer("userPk"))
.withFilter(QCriteriaOperator.EQUALS, "userPk"));
return (spec);
}
/*******************************************************************************
** /core/authenticated_sessions/ (uuid). IPs and user agents need audit.read.
*******************************************************************************/
static AuthentikTableSpec sessions()
{
AuthentikTableSpec spec = new AuthentikTableSpec(SESSION, "Sessions", "devices", "/core/authenticated_sessions/", "uuid")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withRecordLabelFields("uuid");
spec.withField(new AuthentikFieldSpec("uuid", "Id", QFieldType.STRING, string("uuid")));
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, integer("user")));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, o -> null)
.withFilter(QCriteriaOperator.EQUALS, "user__username").withHidden());
spec.withField(new AuthentikFieldSpec("lastIp", "Last IP", QFieldType.STRING, string("last_ip"))
.withFilter(QCriteriaOperator.EQUALS, "session__last_ip"));
spec.withField(new AuthentikFieldSpec("lastUserAgent", "Last User Agent", QFieldType.TEXT, string("last_user_agent")));
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, instant("last_used")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("current", "Current", QFieldType.BOOLEAN, bool("current")));
spec.withField(new AuthentikFieldSpec("geoIp", "Geo IP", QFieldType.TEXT, json("geo_ip")));
spec.withField(new AuthentikFieldSpec("asn", "ASN", QFieldType.TEXT, json("asn")));
return (spec);
}
/*******************************************************************************
** /events/events/ (uuid). Event contexts and client IPs need audit.read.
*******************************************************************************/
static AuthentikTableSpec events()
{
AuthentikTableSpec spec = new AuthentikTableSpec(EVENT, "Events", "receipt_long", "/events/events/", "pk")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withDefaultOrdering("-created")
.withRecordLabelFields("action", "created");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("created", "Created", QFieldType.DATE_TIME, instant("created")).withOrdering("created"));
spec.withField(new AuthentikFieldSpec("action", "Action", QFieldType.STRING, string("action"))
.withFilter(QCriteriaOperator.EQUALS, "action").withFilter(QCriteriaOperator.IN, "actions"));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, at("user", "username"))
.withFilter(QCriteriaOperator.EQUALS, "username"));
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, intAt("user", "pk")));
spec.withField(new AuthentikFieldSpec("app", "App", QFieldType.STRING, string("app")));
spec.withField(new AuthentikFieldSpec("clientIp", "Client IP", QFieldType.STRING, string("client_ip"))
.withFilter(QCriteriaOperator.EQUALS, "client_ip"));
spec.withField(new AuthentikFieldSpec("modelApp", "Model App", QFieldType.STRING, at("context", "model", "app"))
.withFilter(QCriteriaOperator.EQUALS, "context_model_app"));
spec.withField(new AuthentikFieldSpec("modelName", "Model", QFieldType.STRING, at("context", "model", "model_name"))
.withFilter(QCriteriaOperator.EQUALS, "context_model_name"));
spec.withField(new AuthentikFieldSpec("modelPk", "Model Id", QFieldType.STRING, at("context", "model", "pk"))
.withFilter(QCriteriaOperator.EQUALS, "context_model_pk"));
spec.withField(new AuthentikFieldSpec("modelLabel", "Object", QFieldType.STRING, at("context", "model", "name")));
spec.withField(new AuthentikFieldSpec("authorizedApp", "Authorized App", QFieldType.STRING, at("context", "authorized_application", "slug"))
.withFilter(QCriteriaOperator.EQUALS, "context_authorized_app"));
spec.withField(new AuthentikFieldSpec("context", "Context", QFieldType.TEXT, json("context")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
return (spec);
}
/*******************************************************************************
** /core/applications/ (slug). Read only. A non-superuser caller (the service
** account) sees only applications it can itself access, so the qbit never
** relies on this list for access decisions (DESIGN.md 0.2 iii).
*******************************************************************************/
static AuthentikTableSpec applications()
{
AuthentikTableSpec spec = new AuthentikTableSpec(APPLICATION, "Applications", "apps", "/core/applications/", "slug")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("slug", "Slug", QFieldType.STRING, string("slug")).withFilter(QCriteriaOperator.EQUALS, "slug"));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("group", "Group", QFieldType.STRING, string("group")).withFilter(QCriteriaOperator.EQUALS, "group"));
spec.withField(new AuthentikFieldSpec("providerName", "Provider", QFieldType.STRING, at("provider_obj", "name")));
spec.withField(new AuthentikFieldSpec("policyEngineMode", "Policy Engine Mode", QFieldType.STRING, string("policy_engine_mode")));
spec.withField(new AuthentikFieldSpec("launchUrl", "Launch URL", QFieldType.STRING, string("launch_url")));
spec.withField(new AuthentikFieldSpec("publisher", "Publisher", QFieldType.STRING, string("meta_publisher")).withFilter(QCriteriaOperator.EQUALS, "meta_publisher"));
spec.withField(new AuthentikFieldSpec("hiddenFromLibrary", "Hidden", QFieldType.BOOLEAN, bool("meta_hide")));
spec.withField(new AuthentikFieldSpec("bindingTarget", "Binding Target", QFieldType.STRING, string("pbm_uuid")).withHidden());
return (spec);
}
/*******************************************************************************
** /policies/bindings/ (uuid): who or what an application's access policy
** binds. Read only.
*******************************************************************************/
static AuthentikTableSpec policyBindings()
{
AuthentikTableSpec spec = new AuthentikTableSpec(POLICY_BINDING, "Policy Bindings", "rule", "/policies/bindings/", "pk")
.withDefaultOrdering("order")
.withRecordLabelFields("pk");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("target", "Target Id", QFieldType.STRING, string("target")).withFilter(QCriteriaOperator.EQUALS, "target").withHidden());
spec.withField(new AuthentikFieldSpec("policyName", "Policy", QFieldType.STRING, at("policy_obj", "name")));
spec.withField(new AuthentikFieldSpec("groupName", "Group", QFieldType.STRING, at("group_obj", "name")));
spec.withField(new AuthentikFieldSpec("username", "User", QFieldType.STRING, at("user_obj", "username")));
spec.withField(new AuthentikFieldSpec("negate", "Negate", QFieldType.BOOLEAN, bool("negate")));
spec.withField(new AuthentikFieldSpec("enabled", "Enabled", QFieldType.BOOLEAN, bool("enabled")).withFilter(QCriteriaOperator.EQUALS, "enabled"));
spec.withField(new AuthentikFieldSpec("order", "Order", QFieldType.INTEGER, integer("order")).withFilter(QCriteriaOperator.EQUALS, "order").withOrdering("order"));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
return (spec);
}
/*******************************************************************************
** /managed/blueprints/ (uuid). Read only; the drift report uses it too.
*******************************************************************************/
static AuthentikTableSpec blueprints()
{
AuthentikTableSpec spec = new AuthentikTableSpec(BLUEPRINT, "Blueprints", "description", "/managed/blueprints/", "pk")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("path", "Path", QFieldType.STRING, string("path")).withFilter(QCriteriaOperator.EQUALS, "path"));
spec.withField(new AuthentikFieldSpec("status", "Status", QFieldType.STRING, string("status")));
spec.withField(new AuthentikFieldSpec("enabled", "Enabled", QFieldType.BOOLEAN, bool("enabled")));
spec.withField(new AuthentikFieldSpec("lastApplied", "Last Applied", QFieldType.DATE_TIME, instant("last_applied")));
return (spec);
}
/*******************************************************************************
** /rbac/roles/ (uuid). Read only in v1 (role management is phase 4);
** system-managed roles are hidden.
*******************************************************************************/
static AuthentikTableSpec roles()
{
AuthentikTableSpec spec = new AuthentikTableSpec(ROLE, "Roles", "admin_panel_settings", "/rbac/roles/", "pk")
.withDefaultListParam("managed__isnull", "true")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
return (spec);
}
/*******************************************************************************
** /core/tokens/ (identifier). Metadata only: there is NO key field, and the
** service account never reads token keys. Auditors and token managers may read.
*******************************************************************************/
static AuthentikTableSpec tokens()
{
AuthentikTableSpec spec = new AuthentikTableSpec(TOKEN, "Tokens", "key", "/core/tokens/", "identifier")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withAlternativeReadPermission(IdentityPermission.TOKENS_MANAGE)
.withDefaultOrdering("identifier")
.withRecordLabelFields("identifier");
spec.withField(new AuthentikFieldSpec("identifier", "Identifier", QFieldType.STRING, string("identifier")).withSearchable().withOrdering("identifier"));
spec.withField(new AuthentikFieldSpec("intent", "Intent", QFieldType.STRING, string("intent")).withFilter(QCriteriaOperator.EQUALS, "intent"));
spec.withField(new AuthentikFieldSpec("username", "User", QFieldType.STRING, at("user_obj", "username")).withFilter(QCriteriaOperator.EQUALS, "user__username"));
spec.withField(new AuthentikFieldSpec("description", "Description", QFieldType.STRING, string("description")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("expiring", "Expiring", QFieldType.BOOLEAN, bool("expiring")).withFilter(QCriteriaOperator.EQUALS, "expiring"));
spec.withField(new AuthentikFieldSpec("ownerPk", "Owner Id", QFieldType.INTEGER, intAt("user_obj", "pk")));
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);
}
/*******************************************************************************
** /stages/invitation/invitations/ (uuid). Created by the Invite User action;
** cancelled by Cancel Invitation.
*******************************************************************************/
static AuthentikTableSpec invitations()
{
AuthentikTableSpec spec = new AuthentikTableSpec(INVITATION, "Invitations", "mail", "/stages/invitation/invitations/", "pk")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, at("fixed_data", "email")));
spec.withField(new AuthentikFieldSpec("singleUse", "Single Use", QFieldType.BOOLEAN, bool("single_use")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("flowSlug", "Flow", QFieldType.STRING, at("flow_obj", "slug")));
spec.withField(new AuthentikFieldSpec("createdBy", "Created By", QFieldType.STRING, at("created_by", "username")));
return (spec);
}
}
@@ -1,112 +0,0 @@
/*
* 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);
}
}
}
@@ -1,101 +0,0 @@
/*
* 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.nio.charset.StandardCharsets;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
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 org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.json.JSONObject;
/*******************************************************************************
** RFC 8693 exchange for a two-minute API bearer, never cached or logged.
*******************************************************************************/
public class AuthentikTokenBroker
{
/*******************************************************************************
** Exchange the operator's JWT through the configured protected-user broker.
*******************************************************************************/
public String exchange(AuthentikActionUtil util, WriteAuthorization auth) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
String subject = config.getSubjectTokenSource().subjectToken(QContext.getQSession());
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()),
new BasicNameValuePair("subject_token", subject),
new BasicNameValuePair("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"),
new BasicNameValuePair("scope", "goauthentik.io/api")), StandardCharsets.UTF_8));
JSONObject result = util.exchangeToken(auth, request);
String bearer = result.optString("access_token", "");
if(bearer.isBlank())
{
throw (new QUserFacingException("Authentik refused to act as you (invalid_response)"));
}
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 + ")"));
}
}
}
@@ -1,51 +0,0 @@
/*
* 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);
}
}
@@ -1,75 +0,0 @@
/*
* 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.util.Map;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
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.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Reads the OAuth2 module's user-session row (uuid and accessToken in RC.2).
** No memoization: the credential lives only for the current action.
*******************************************************************************/
public class OAuth2SubjectTokenSource implements AuthentikSubjectTokenSource
{
public static final String UNAVAILABLE = "Self-service tokens need a signed-in Authentik session.";
/*******************************************************************************
** Look up the exact current session, including its hidden access token.
*******************************************************************************/
@Override
public String subjectToken(QSession session) throws QException
{
if(session != null && session == QContext.getQSession() && !(session instanceof QSystemUserSession) && session.getUser() != null && session.getUuid() != null && QContext.getQInstance() != null
&& QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth
&& oauth.getUserSessionTableName() != null)
{
QRecord row;
QSession previous = QContext.getQSession();
try
{
// Core uses a system read here too, avoiding recursive OAuth validation.
// The UUID is taken only from the already authorized current session.
QContext.setQSession(new QSystemUserSession());
row = new GetAction().executeForRecord(new GetInput(oauth.getUserSessionTableName())
.withUniqueKey(Map.of("uuid", session.getUuid())).withShouldMaskPasswords(false).withShouldOmitHiddenFields(false));
}
catch(QException e)
{
throw (new QUserFacingException(UNAVAILABLE));
}
finally
{
QContext.setQSession(previous);
}
if(row != null && row.getValueString("accessToken") != null && !row.getValueString("accessToken").isBlank())
{
return (row.getValueString("accessToken"));
}
}
throw (new QUserFacingException(UNAVAILABLE));
}
}
@@ -1,159 +0,0 @@
/*
* 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.util.function.LongConsumer;
import java.util.function.LongSupplier;
/*******************************************************************************
** Self-imposed client-side rate limit (DESIGN.md section 9.3). Authentik does
** not throttle authenticated API calls at all, and an Authentik may share the
** Postgres of other applications, so the qbit limits itself: a token bucket refilled at
** requestsPerSecond, holding at most burst tokens. acquire() blocks until a
** token is available. Shared by all requests of one qbit instance.
*******************************************************************************/
public class TokenBucketThrottle
{
private final double tokensPerNano;
private final double burst;
private final LongSupplier nanoClock;
private final LongConsumer nanoSleeper;
private double tokens;
private long lastRefillNanos;
/*******************************************************************************
** Constructor using the system clock.
*******************************************************************************/
public TokenBucketThrottle(int requestsPerSecond, int burst)
{
this(requestsPerSecond, burst, System::nanoTime, TokenBucketThrottle::sleepNanos);
}
/*******************************************************************************
** Constructor with an injectable clock and sleeper (for tests).
*******************************************************************************/
public TokenBucketThrottle(int requestsPerSecond, int burst, LongSupplier nanoClock, LongConsumer nanoSleeper)
{
if(requestsPerSecond < 1)
{
throw (new IllegalArgumentException("requestsPerSecond must be at least 1"));
}
this.tokensPerNano = requestsPerSecond / 1_000_000_000.0;
this.burst = Math.max(1, burst);
this.nanoClock = nanoClock;
this.nanoSleeper = nanoSleeper;
this.tokens = this.burst;
this.lastRefillNanos = nanoClock.getAsLong();
}
/*******************************************************************************
** Take one token, waiting as long as needed.
*******************************************************************************/
public void acquire()
{
long waitNanos;
synchronized(this)
{
refill();
tokens -= 1;
waitNanos = tokens >= 0 ? 0 : (long) Math.ceil(-tokens / tokensPerNano);
}
if(waitNanos > 0)
{
nanoSleeper.accept(waitNanos);
}
}
/*******************************************************************************
** 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).
*******************************************************************************/
public synchronized double getAvailableTokens()
{
refill();
return (tokens);
}
/*******************************************************************************
**
*******************************************************************************/
private void refill()
{
long now = nanoClock.getAsLong();
long elapsed = now - lastRefillNanos;
if(elapsed > 0)
{
tokens = Math.min(burst, tokens + elapsed * tokensPerNano);
lastRefillNanos = now;
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void sleepNanos(long nanos)
{
try
{
Thread.sleep(nanos / 1_000_000, (int) (nanos % 1_000_000));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
}
@@ -1,509 +0,0 @@
/*
* 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.drift;
import java.util.ArrayList;
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;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
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.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONArray;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The identity drift report (DESIGN.md sections 4.5, 5.1 and 8.2): checks
** that Authentik is in the shape the qbit relies on, using only the service
** account's read permissions. Each check is independent; a check that cannot
** run reports why and the rest still run.
**
** Checks:
** 1. version: server major.minor = expectedServerVersion
** 2. blueprints: every blueprint instance applied successfully
** 3. service account: exists, is a service account, has no groups, is not a
** superuser, is protected; exactly one API token (it can mint its own)
** 4. break-glass: at least one active, protected superuser exists
** 5. identity admins: at least one active, unprotected human holds both
** authentik.memberships.manage and authentik.permissions.manage (last-admin)
** 6. group attributes: qqq.io/permissions entries that grant nothing known;
** qqq.io/app-access entries outside applicationsInScope; superuser groups
** (or their descendants) carrying qqq.io/permissions
** 7. service-account activity: events by the service account touching a
** superuser or protected user, or a superuser group (lockout detection)
*******************************************************************************/
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;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AuthentikDriftReport(AuthentikActionUtil client, AuthentikQBitConfig config)
{
this.client = client;
this.config = config;
this.guard = new AuthentikGuard(config);
}
/*******************************************************************************
** Run every check; findings sorted by severity (errors first).
*******************************************************************************/
public List<DriftFinding> run()
{
List<DriftFinding> findings = new ArrayList<>();
runCheck(findings, "version", this::checkVersion);
runCheck(findings, "blueprints", this::checkBlueprints);
runCheck(findings, "service account", this::checkServiceAccount);
runCheck(findings, "break-glass", this::checkBreakGlass);
runCheck(findings, "identity admins", this::checkIdentityAdmins);
runCheck(findings, "group attributes", this::checkGroupAttributes);
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
findings.sort(Comparator.comparing(DriftFinding::severity));
return (findings);
}
/*******************************************************************************
** 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.
*******************************************************************************/
@FunctionalInterface
interface Check
{
/*******************************************************************************
**
*******************************************************************************/
void run(List<DriftFinding> findings) throws QException;
}
/*******************************************************************************
**
*******************************************************************************/
private void runCheck(List<DriftFinding> findings, String name, Check check)
{
try
{
check.run(findings);
}
catch(Exception e)
{
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
LOG.warn("Drift check could not run", logPair("check", name), logPair("error", AuthentikRedactor.redactText(message, config.getResolvedApiToken())));
findings.add(DriftFinding.error(name, "-", "Check could not run, so its result is UNKNOWN: " + AuthentikRedactor.redactText(message, config.getResolvedApiToken())));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static String encode(String value)
{
return (java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8));
}
/*******************************************************************************
** 1
*******************************************************************************/
void checkVersion(List<DriftFinding> findings)
{
String expected = config.getExpectedServerVersion();
String actual = client.fetchServerVersion().orElse(null);
if(expected == null || expected.isBlank())
{
findings.add(DriftFinding.info("version", actual == null ? "Authentik" : "Authentik " + actual, "No expectedServerVersion configured."));
}
else if(actual == null)
{
findings.add(DriftFinding.warning("version", "Authentik", "Server version unavailable (GET /admin/version/ failed)."));
}
else if(!(actual.equals(expected) || actual.startsWith(expected + ".")))
{
findings.add(DriftFinding.warning("version", "Authentik " + actual, "qbit-authentik was tested against " + expected + ". Run its contract tests against " + actual + " before relying on it."));
}
else
{
findings.add(DriftFinding.ok("version", "Authentik " + actual, "Matches the tested version " + expected + "."));
}
}
/*******************************************************************************
** 2
*******************************************************************************/
void checkBlueprints(List<DriftFinding> findings) throws QException
{
int failing = 0;
for(JSONObject blueprint : client.getAllPages("/managed/blueprints/", 1000))
{
String status = blueprint.optString("status", "unknown");
if(!blueprint.optBoolean("enabled", true))
{
continue;
}
if(!"successful".equals(status))
{
failing++;
findings.add(DriftFinding.error("blueprints", blueprint.optString("name"),
"Blueprint status is \"" + status + "\" (last applied " + blueprint.optString("last_applied", "never") + "). A failed blueprint rolls back entirely; check the worker logs."));
}
}
if(failing == 0)
{
findings.add(DriftFinding.ok("blueprints", "all", "Every enabled blueprint applied successfully."));
}
}
/*******************************************************************************
** 3
*******************************************************************************/
void checkServiceAccount(List<DriftFinding> findings) throws QException
{
String username = config.getEffectiveServiceAccountUsername();
JSONArray results = client.getApiJson("/core/users/?include_groups=true&username=" + encode(username)).optJSONArray("results");
JSONObject user = results == null || results.isEmpty() ? null : results.getJSONObject(0);
if(user == null)
{
findings.add(DriftFinding.error("service account", username, "Not found, or not visible to the API token's user. Is the token minted for this account?"));
return;
}
List<String> problems = new ArrayList<>();
if(!"service_account".equals(user.optString("type")))
{
problems.add("type is " + user.optString("type") + ", not service_account");
}
if(user.optBoolean("is_superuser", false))
{
problems.add("it is a SUPERUSER: remove it from every superuser group now");
}
JSONArray groups = user.optJSONArray("groups_obj");
if(groups != null && !groups.isEmpty())
{
problems.add("it is in " + groups.length() + " group(s); it should be in none");
}
if(!user.optBoolean("is_active", true))
{
problems.add("it is inactive");
}
JSONObject attributes = user.optJSONObject("attributes");
if(!AuthentikGuard.isMarkedProtected(user))
{
problems.add("qqq.io/protected is not true");
}
for(String problem : problems)
{
findings.add((problem.contains("SUPERUSER") ? DriftFinding.error("service account", username, problem) : DriftFinding.warning("service account", username, problem)));
}
////////////////////////////////////////////////////////////////////////////
// tokens: listing without view_token returns the caller's own tokens //
////////////////////////////////////////////////////////////////////////////
List<JSONObject> tokens = client.getAllPages("/core/tokens/?intent=api&user__username=" + encode(username), 200);
int apiOwned = 0;
if(tokens.size() >= 200)
{
findings.add(DriftFinding.error("service account", username, "200 or more API tokens for the service account; something is minting tokens."));
}
for(JSONObject token : tokens)
{
JSONObject owner = token.optJSONObject("user_obj");
if(owner != null && username.equals(owner.optString("username")) && "api".equals(token.optString("intent")))
{
apiOwned++;
}
}
if(apiOwned == 0)
{
findings.add(DriftFinding.warning("service account", username, "The service account's own API token is not visible, so extra tokens cannot be detected."));
}
else if(apiOwned > 1)
{
findings.add(DriftFinding.warning("service account", username, apiOwned + " API tokens exist for the service account; expected 1. It can mint its own tokens, so an extra one may be a leftover rotation or persistence after a compromise. Delete the ones not in Infisical."));
}
if(problems.isEmpty() && apiOwned == 1)
{
findings.add(DriftFinding.ok("service account", username, "Service account, no groups, not a superuser, protected, one API token."));
}
}
/*******************************************************************************
** 4
*******************************************************************************/
void checkBreakGlass(List<DriftFinding> findings) throws QException
{
List<JSONObject> protectedUsers = client.getAllPages("/core/users/?attributes=" + java.net.URLEncoder.encode("{\"" + AuthentikJson.ATTR_PROTECTED + "\": true}", java.nio.charset.StandardCharsets.UTF_8), 200);
List<String> breakGlass = new ArrayList<>();
for(JSONObject user : protectedUsers)
{
if(user.optBoolean("is_superuser", false) && user.optBoolean("is_active", true) && AuthentikGuard.isMarkedProtected(user))
{
breakGlass.add(user.optString("username"));
}
}
if(breakGlass.isEmpty())
{
findings.add(DriftFinding.error("break-glass", "-", "No active superuser marked qqq.io/protected. Without one, a lockout can only be undone from the cluster (ak shell)."));
}
else
{
findings.add(DriftFinding.ok("break-glass", String.join(", ", breakGlass), "Active, protected superuser present."));
}
}
/*******************************************************************************
** 5
*******************************************************************************/
void checkIdentityAdmins(List<DriftFinding> findings) throws QException
{
List<JSONObject> groups = client.getAllPages("/core/groups/?include_users=true&ordering=name", AuthentikActionUtil.MAX_SWEEP_OBJECTS);
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat(), client.getRuntime().getPolicyCatalog());
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
Set<Integer> humans = new HashSet<>();
for(JSONObject human : client.getAllPages("/core/users/?type=internal&type=external", AuthentikActionUtil.MAX_SWEEP_OBJECTS))
{
humans.add(human.getInt("pk"));
}
Set<Integer> checked = new HashSet<>();
List<String> admins = new ArrayList<>();
for(JSONObject group : groups)
{
JSONArray users = group.optJSONArray("users_obj");
for(int i = 0; users != null && i < users.length(); i++)
{
JSONObject user = users.getJSONObject(i);
if(!checked.add(user.getInt("pk")) || !user.optBoolean("is_active", true) || !humans.contains(user.getInt("pk")))
{
continue;
}
//////////////////////////////////////////////////////////////////////
// members are partial users (no is_superuser): derive it from the //
// groups, so superusers (who cannot be managed from QQQ) and other //
// protected accounts are not counted //
//////////////////////////////////////////////////////////////////////
boolean superuser = graph.groupsOfUser(user.getInt("pk")).stream().anyMatch(graph::isSuperuserInherited);
if(superuser || guard.protection(user).isProtected())
{
continue;
}
if(EffectivePermissionResolver.holdsAll(resolver.resolveUser(user.getInt("pk")), required))
{
admins.add(user.optString("username"));
}
}
}
if(admins.isEmpty())
{
String bootstrap = config.getResolvedBootstrapSubjects().isEmpty() ? "" : " bootstrapOperatorSubjects is set, which covers sign-in for now.";
findings.add(DriftFinding.error("identity admins", "-", "Nobody can administer identities from QQQ: no active, unprotected human user holds authentik.memberships.manage and authentik.permissions.manage. Add an administrator to a group carrying authentik.* (e.g. identity-admins) in the Authentik UI." + bootstrap));
}
else if(admins.size() == 1)
{
findings.add(DriftFinding.info("identity admins", admins.get(0), "Only one identity administrator. Two-person approval of authentik.* grants needs a second one (or the Authentik UI)."));
}
else
{
findings.add(DriftFinding.ok("identity admins", String.join(", ", admins), admins.size() + " identity administrators."));
}
}
/*******************************************************************************
** 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
*******************************************************************************/
void checkGroupAttributes(List<DriftFinding> findings) throws QException
{
GroupGraph graph = client.groupGraph();
EffectivePermissionResolver resolver = client.resolver();
Set<String> inScope = new HashSet<>(config.getApplicationsInScope() == null ? List.of() : config.getApplicationsInScope());
int issues = 0;
for(GroupGraph.Node node : graph.nodes())
{
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.")
.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; 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())
{
if(!inScope.isEmpty() && !inScope.contains(app))
{
issues++;
findings.add(DriftFinding.info("group attributes", node.name(), "qqq.io/app-access lists \"" + app + "\", which is not in applicationsInScope."));
}
}
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.")
.withReason("superuser group carries qqq.io/permissions"));
}
}
if(issues == 0)
{
findings.add(DriftFinding.ok("group attributes", graph.nodes().size() + " groups", "Every qqq.io/* attribute is valid."));
}
}
/*******************************************************************************
** 7
*******************************************************************************/
void checkServiceAccountActivity(List<DriftFinding> findings) throws QException
{
String username = config.getEffectiveServiceAccountUsername();
List<JSONObject> events = client.getAllPages("/events/events/?ordering=-created&username=" + encode(username), 200);
GroupGraph graph = client.groupGraph();
int flagged = 0;
for(JSONObject event : events)
{
JSONObject context = event.optJSONObject("context");
JSONObject model = context == null ? null : context.optJSONObject("model");
if(model == null)
{
continue;
}
String modelName = model.optString("model_name");
String pk = dashed(model.optString("pk"));
boolean sensitive = false;
if(!"user".equals(modelName) && !"group".equals(modelName))
{
continue;
}
if("model_deleted".equals(event.optString("action")))
{
sensitive = true;
}
else if("group".equals(modelName))
{
sensitive = graph.byPk(pk) == null || graph.isSuperuserInherited(pk);
}
else if(pk.matches("[0-9]+"))
{
JSONObject user = client.getApiJsonOrNull("/core/users/" + pk + "/");
sensitive = user == null || (guard.protection(user).isProtected() && !username.equals(user.optString("username")));
}
if(sensitive)
{
flagged++;
findings.add(DriftFinding.error("service-account activity", model.optString("name", pk),
"The qbit's service account changed a protected or superuser object, deleted one, or changed one that no longer exists (" + event.optString("action") + " at " + event.optString("created") + "). QQQ never does this; investigate as a possible compromise."));
}
}
if(flagged == 0)
{
findings.add(DriftFinding.ok("service-account activity", username, "No changes to protected or superuser objects in the last " + events.size() + " service-account events."));
}
}
/*******************************************************************************
** Authentik events carry group pks as 32 hex characters without dashes;
** the graph is keyed by the dashed uuid.
*******************************************************************************/
static String dashed(String pk)
{
if(pk != null && pk.matches("[0-9a-fA-F]{32}"))
{
return (pk.substring(0, 8) + "-" + pk.substring(8, 12) + "-" + pk.substring(12, 16) + "-" + pk.substring(16, 20) + "-" + pk.substring(20));
}
return (pk);
}
}
@@ -1,103 +0,0 @@
/*
* 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.drift;
/*******************************************************************************
** One line of the drift report.
**
** @param severity ERROR (identity management is broken or unsafe), WARNING
** (needs attention), INFO (worth knowing), OK (a check passed)
** @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, 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.
*******************************************************************************/
public enum Severity
{
ERROR, WARNING, INFO, OK
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding error(String check, String subject, String message)
{
return (new DriftFinding(Severity.ERROR, check, subject, message));
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding warning(String check, String subject, String message)
{
return (new DriftFinding(Severity.WARNING, check, subject, message));
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding info(String check, String subject, String message)
{
return (new DriftFinding(Severity.INFO, check, subject, message));
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding ok(String check, String subject, String message)
{
return (new DriftFinding(Severity.OK, check, subject, message));
}
}
@@ -1,260 +0,0 @@
/*
* 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,121 +0,0 @@
/*
* 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.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikFieldSpec;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
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.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
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.module.api.model.metadata.APITableBackendDetails;
/*******************************************************************************
** Builds a read-only QQQ table from an AuthentikTableSpec and registers it
** with the runtime. Subclasses name the spec.
*******************************************************************************/
public abstract class AbstractAuthentikTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** The unprefixed spec name, e.g. AuthentikTables.USER.
*******************************************************************************/
protected abstract String getSpecName();
/*******************************************************************************
** Tables need READ; subclasses may add conditions.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.READ));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (buildTable(getQBitConfig(), AuthentikTables.get(getSpecName())));
}
/*******************************************************************************
** Build the table and register it with the config's runtime.
*******************************************************************************/
public static QTableMetaData buildTable(AuthentikQBitConfig config, AuthentikTableSpec spec)
{
String tableName = config.applyTablePrefix(spec.getName());
QTableMetaData table = new QTableMetaData()
.withName(tableName)
.withLabel(spec.getLabel())
.withIcon(new QIcon(spec.getIcon()))
.withBackendName(config.getApiBackendName())
.withPrimaryKeyField(spec.getKeyFieldName())
.withBackendDetails(new APITableBackendDetails().withTablePath(spec.getPath()))
.withPermissionRules(spec.readRules())
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_EXPORT)
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE, Capability.QUERY_STATS);
for(AuthentikFieldSpec field : spec.getFields())
{
QFieldMetaData fieldMetaData = new QFieldMetaData(field.getName(), field.getType()).withLabel(field.getLabel());
if(field.isHidden())
{
fieldMetaData.withIsHidden(true);
}
table.withField(fieldMetaData);
}
if(!spec.getRecordLabelFields().isEmpty())
{
table.withRecordLabelFormat(String.join(" ", spec.getRecordLabelFields().stream().map(f -> "%s").toList()));
table.withRecordLabelFields(spec.getRecordLabelFields());
}
AuthentikRuntime.forBackend(config.getApiBackendName()).registerTable(tableName, spec);
return (table);
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (200);
}
}
@@ -1,65 +0,0 @@
/*
* 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.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** authentik_admin_action: the append-only audit trail of every change made from QQQ.
*******************************************************************************/
public class AuthentikAdminActionTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.adminAction(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -1,125 +0,0 @@
/*
* 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.AuthentikQBitConfig;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** The "Identity" app (DESIGN.md section 8.1): the qbit's tables (and, from
** phase 1, its widgets), gated by authentik.users.read. Hosts may re-parent it,
** for example under an Admin app; its name stays the same. Processes are only
** ever table or record actions, never navigation entries.
*******************************************************************************/
public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<QAppMetaData, AuthentikQBitConfig>
{
public static final String APP_NAME = "admin";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QAppMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
QAppMetaData app = new QAppMetaData()
.withName(config.applyPrefix(APP_NAME))
.withLabel("Identity")
.withIcon(new QIcon("badge"))
.withPermissionRules(IdentityPermission.USERS_READ.rules());
MetaDataProducerMultiOutput produced = QBitProductionContext.peekMetaDataProducerMultiOutput();
if(produced != null)
{
///////////////////////////////////////////////////////////////////////////
// the nav lists only the main tables; memberships, sessions, tokens, //
// events, bindings, roles and blueprints are reached from their parent //
// records as child lists //
///////////////////////////////////////////////////////////////////////////
addSection(app, produced, config, "people", "People", "person", AuthentikTables.USER, AuthentikTables.INVITATION);
addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikLocalTables.POLICY, AuthentikTables.APPLICATION);
addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION);
List<String> widgets = new ArrayList<>();
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikTokenWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME),
config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.COMPARE)))
{
if(produced.get(QWidgetMetaData.class, widgetName) != null)
{
widgets.add(widgetName);
}
}
app.withWidgets(widgets);
}
return (app);
}
/*******************************************************************************
** Add a nav section with the main tables that were produced.
*******************************************************************************/
private static void addSection(QAppMetaData app, MetaDataProducerMultiOutput produced, AuthentikQBitConfig config, String name, String label, String icon, String... specNames)
{
List<String> names = new ArrayList<>();
for(String specName : specNames)
{
String tableName = config.applyTablePrefix(specName);
QTableMetaData table = produced.get(QTableMetaData.class, tableName);
if(table != null)
{
app.withChild(table);
names.add(tableName);
}
}
if(!names.isEmpty())
{
app.withSection(new QAppSection().withName(name).withLabel(label).withIcon(new QIcon(icon)).withTables(names));
}
}
/*******************************************************************************
** Last, so the tables exist in the output.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (900);
}
}
@@ -1,38 +0,0 @@
/*
* 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.client.AuthentikTables;
/*******************************************************************************
** authentik_application: /core/applications/.
*******************************************************************************/
public class AuthentikApplicationTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.APPLICATION);
}
}
@@ -1,65 +0,0 @@
/*
* 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.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** authentik_approval_request: changes waiting for a second person.
*******************************************************************************/
public class AuthentikApprovalRequestTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.approvalRequest(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -1,65 +0,0 @@
/*
* 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.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.module.api.model.AuthorizationType;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
/*******************************************************************************
** The API backend all Authentik tables use. Authorization is CUSTOM: the
** action util adds the bearer token itself, from the qbit config, so the token
** never sits in backend metadata (which QQQ can serialize).
*******************************************************************************/
public class AuthentikBackendMetaDataProducer extends QBitComponentMetaDataProducer<APIBackendMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public APIBackendMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
APIBackendMetaData backend = new APIBackendMetaData()
.withBaseUrl(config.getApiRoot())
.withAuthorizationType(AuthorizationType.CUSTOM)
.withContentType("application/json")
.withActionUtil(new QCodeReference(AuthentikActionUtil.class));
backend.setName(config.getApiBackendName());
return (backend);
}
/*******************************************************************************
** First: the tables reference it.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (100);
}
}
@@ -1,38 +0,0 @@
/*
* 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.client.AuthentikTables;
/*******************************************************************************
** authentik_blueprint: /managed/blueprints/.
*******************************************************************************/
public class AuthentikBlueprintTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.BLUEPRINT);
}
}
@@ -1,72 +0,0 @@
/*
* 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.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The "Identity Drift Report" widget: a standard TABLE widget (generic UI, no
** frontend work) listing the AuthentikDriftReport findings. Needs
** authentik.admin. The renderer finds the qbit through the "backendName"
** default value.
*******************************************************************************/
public class AuthentikDriftReportWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
public static final String NAME = "driftReport";
public static final String BACKEND_NAME_KEY = "backendName";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(NAME))
.withLabel("Identity Drift Report")
.withType(WidgetType.TABLE.getType())
.withGridColumns(12)
.withIsCard(true)
.withCodeReference(new QCodeReference(AuthentikDriftReportWidgetRenderer.class))
.withPermissionRules(IdentityPermission.ADMIN.rules())
.withDefaultValue(BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (500);
}
}
@@ -1,86 +0,0 @@
/*
* 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.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
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.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Renders the drift report as a TableData. Re-checks authentik.admin itself
** for direct renderer calls as well as calls through core RenderWidgetAction.
*******************************************************************************/
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN))
{
throw (new QPermissionDeniedException("You do not have permission to view the identity drift report."));
}
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(AuthentikDriftReportWidgetProducer.BACKEND_NAME_KEY));
AuthentikRuntime runtime = AuthentikRuntime.forBackend(backendName);
AuthentikActionUtil client = AuthentikActionUtil.forBackend(backendName);
List<DriftFinding> findings = new AuthentikDriftReport(client, runtime.getConfig()).run();
List<Map<String, Object>> rows = new ArrayList<>();
for(DriftFinding finding : findings)
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("severity", finding.severity().name());
row.put("check", finding.check());
row.put("subject", finding.subject());
row.put("message", finding.message());
rows.add(row);
}
List<TableData.Column> columns = List.of(
new TableData.Column("default", "Severity", "severity", "10%", "left"),
new TableData.Column("default", "Check", "check", "15%", "left"),
new TableData.Column("default", "Subject", "subject", "20%", "left"),
new TableData.Column("default", "Finding", "message", "55%", "left"));
TableData tableData = new TableData("Identity Drift Report", columns, rows)
.withRowsPerPage(25)
.withNoRowsFoundHTML("No findings.");
return (new RenderWidgetOutput(tableData));
}
}
@@ -1,38 +0,0 @@
/*
* 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.client.AuthentikTables;
/*******************************************************************************
** authentik_event: /events/events/ (needs authentik.audit.read).
*******************************************************************************/
public class AuthentikEventTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.EVENT);
}
}
@@ -1,38 +0,0 @@
/*
* 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.client.AuthentikTables;
/*******************************************************************************
** authentik_group_member: virtual membership table (group x user).
*******************************************************************************/
public class AuthentikGroupMemberTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.GROUP_MEMBER);
}
}
@@ -1,38 +0,0 @@
/*
* 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.client.AuthentikTables;
/*******************************************************************************
** authentik_group: /core/groups/.
*******************************************************************************/
public class AuthentikGroupTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.GROUP);
}
}
@@ -1,159 +0,0 @@
/*
* 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())));
}));
}
}
@@ -1,71 +0,0 @@
/*
* 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.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.app.AuthentikHomeWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The Identity home widget: the single dashboard of this qbit (what needs
** attention, people, groups, recent changes). Other screens only link to it.
*******************************************************************************/
public class AuthentikHomeWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
public static final String NAME = "home";
public static final String BACKEND_NAME_KEY = "backendName";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(NAME))
.withLabel("Identity")
.withType(WidgetType.HTML.getType())
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(AuthentikHomeWidget.Renderer.class))
.withPermissionRules(IdentityPermission.USERS_READ.rules())
.withDefaultValue(BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (450);
}
}
@@ -1,48 +0,0 @@
/*
* 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.client.AuthentikTables;
/*******************************************************************************
** authentik_invitation: /stages/invitation/invitations/.
*******************************************************************************/
public class AuthentikInvitationTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
** Only when invitations are configured (an enrollment flow).
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (super.isEnabled() && getQBitConfig().getInvitationFlow() != null && !getQBitConfig().getInvitationFlow().isBlank());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.INVITATION);
}
}
@@ -1,38 +0,0 @@
/*
* 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.client.AuthentikTables;
/*******************************************************************************
** authentik_policy_binding: /policies/bindings/.
*******************************************************************************/
public class AuthentikPolicyBindingTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.POLICY_BINDING);
}
}
@@ -1,77 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** Compare Access (DESIGN.md section 12.8): today's access against what the policies would grant, per active person. Read only.
*******************************************************************************/
public class AuthentikPolicyCompareWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyCutoverWidgets.COMPARE))
.withLabel("Compare Access")
.withType(WidgetType.TABLE.getType())
.withGridColumns(12)
.withIsCard(true)
.withCodeReference(new QCodeReference(PolicyCutoverWidgets.CompareRenderer.class))
.withPermissionRules(IdentityPermission.ADMIN.rules())
.withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (470);
}
}
@@ -1,77 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The policy editor widget (DESIGN.md section 12.4), shown on the Edit Policy
** step. Gated by authentik.policies.manage.
*******************************************************************************/
public class AuthentikPolicyEditorWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyEditorWidget.NAME))
.withLabel("Policy")
.withType(PolicyEditorWidget.TYPE)
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(PolicyEditorWidget.Renderer.class))
.withPermissionRules(IdentityPermission.POLICIES_MANAGE.rules())
.withDefaultValue(PolicyEditorWidget.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (460);
}
}
@@ -1,77 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The policy enforcement banner (DESIGN.md section 12.8): whether saved policies take effect at sign-in on this instance.
*******************************************************************************/
public class AuthentikPolicyEnforcementWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyCutoverWidgets.ENFORCEMENT))
.withLabel("Policy Enforcement")
.withType(WidgetType.HTML.getType())
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(PolicyCutoverWidgets.EnforcementRenderer.class))
.withPermissionRules(IdentityPermission.USERS_READ.rules())
.withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (465);
}
}
@@ -1,66 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** authentik_policy: the QQQ record (with change history) of each group policy.
*******************************************************************************/
public class AuthentikPolicyTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend and the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.policy(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -1,38 +0,0 @@
/*
* 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.client.AuthentikTables;
/*******************************************************************************
** authentik_role: /rbac/roles/.
*******************************************************************************/
public class AuthentikRoleTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.ROLE);
}
}
@@ -1,38 +0,0 @@
/*
* 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.client.AuthentikTables;
/*******************************************************************************
** authentik_session: /core/authenticated_sessions/ (needs authentik.audit.read).
*******************************************************************************/
public class AuthentikSessionTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.SESSION);
}
}
@@ -1,72 +0,0 @@
/*
* 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);
}
}
@@ -1,66 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Token scope metadata, with no credential material.
*******************************************************************************/
public class AuthentikTokenScopeTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend and the TOKENS feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.tokenScope(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -1,38 +0,0 @@
/*
* 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.client.AuthentikTables;
/*******************************************************************************
** authentik_token: /core/tokens/ (metadata only, never a key).
*******************************************************************************/
public class AuthentikTokenTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.TOKEN);
}
}
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff. Show more