Compare commits

..
Author SHA1 Message Date
forge-recovery 5ba505aa0f Merge pull request 'ci: move the QRun release caller to the thin v2 contract' (#56) from ci-qrun-release-caller-main-v2 into main
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 20s
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 15s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
Shared Java Maven CI with Docker / verify (push) Successful in 48s
Shared Java Maven CI with Docker / gate (push) Successful in 11s
qrun-release / release (push) Successful in 1h16m33s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 41s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 11s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 1h17m4s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
2026-11-02 18:20:06 +00:00
fcea48de32 ci: add the isolated postpublish job to the QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 14s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 17s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
Shared Java Maven CI with Docker / verify (push) Successful in 1m8s
Shared Java Maven CI with Docker / gate (push) Successful in 8s
qrun-release / release (push) Successful in 2h23m45s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 40s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 2h24m26s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:45:40 +00:00
916bb012dc ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 15s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 17s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
qrun-release / release (push) Canceled after 1h25m33s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 1h25m25s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:15:59 +00:00
james.maesandCodex 77c39f7f22 ci: enroll in the shared QQQ orb (#52)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 19:49:53 -05:00
james.maesandCodex f99988fc27 ci: use the shared orb pipeline on main (#46)
* ci: use the shared orb pipeline on main

Co-Authored-By: Codex <noreply@openai.com>

* ci: use the shared branch-aware CodeQL worker

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:47:15 -05:00
161 changed files with 360 additions and 91228 deletions

No files matched your search

+11 -68
View File
@@ -1,71 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only: [/hotfix\/.*/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
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
@@ -0,0 +1,25 @@
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
name: qrun-release
on:
push: {}
workflow_dispatch: {}
permissions:
contents: read
actions: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1
with:
runner: linux-small
docker-runner: container-build
browsers: true
browser-runner: browser-build
postpublish:
if: ${{ needs.release.outputs.capture == 'true' }}
needs: release
permissions:
contents: write
actions: write
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
with:
runner: linux-small
-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: ['*']
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
-42
View File
@@ -1,42 +0,0 @@
# Weekly: does the newest Authentik release still have every operation and
# query parameter qbit-authentik uses? Downloads the release's schema.yml from
# goauthentik/authentik and runs SchemaDriftTest against it (no Docker, no
# credentials). A failure means: review the release notes before upgrading.
name: authentik-schema-drift
on:
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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
cache: maven
- name: Resolve version and fetch its schema
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ inputs.version }}
run: |
version="${INPUT_VERSION}"
if [ -z "$version" ]; then
version=$(gh release view --repo goauthentik/authentik --json tagName -q .tagName | sed 's#^version/##')
fi
echo "Authentik $version"
curl -fsSL "https://raw.githubusercontent.com/goauthentik/authentik/version/${version}/schema.yml" -o "$RUNNER_TEMP/schema.yml"
echo "SCHEMA=$RUNNER_TEMP/schema.yml" >> "$GITHUB_ENV"
- name: Drift test
run: ./mvnw -B -q test -Dtest=SchemaDriftTest -Dsurefire.failIfNoSpecifiedTests=false -Dauthentik.schema.file="$SCHEMA"
+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
+15
View File
@@ -0,0 +1,15 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: none
test_branch_policy: all
publish:
snapshot: false
release_candidate: false
release: false
asciidoc: false
-66
View File
@@ -1,66 +0,0 @@
# AGENTS.md
This file provides guidance to Codex and other agents when working with code in this repository. It mirrors CLAUDE.md; keep them 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 (from phase 2) writes through the Authentik API. Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = read-only (phases 0-1).
## 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
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.
- Writes go to the guard, which refuses them in 0.1.
- `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 and write permission checks, the user scope, and protected-account detection. `security/IdentityPermission` is the `identity.*` catalog (session form `<name>.hasAccess`).
- `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 `identity.admin`.
## Invariants (do not break)
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
- 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.
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions.
## 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.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
## 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.
-63
View File
@@ -1,63 +0,0 @@
# Changelog
All notable changes to this project are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added (policies, phase 5a: DESIGN.md section 12, docs/POLICY-FORMAT.md)
- **Policy documents** (IAM-style statements) on Authentik groups: `PolicyParser` (fails closed: unknown spec or version, non-Allow effect, unknown action, bad resource or malformed JSON grant nothing), `PolicyGenerator` (canonical form with a round-trip check), `PolicyFormat` and the config `policySpec`, `policyVersion`, `policyAttribute`, `policyClaim`, `policyTwoPersonApproval`.
- **Runtime**: `AuthentikPolicyClaimMapper` (reads the `qqq_policy` claim from the ID token or access token; `applySecurityKeys` hook for record locks) and `PolicyPermissions.fromClaims` for a host's own customizer. `AuthentikGroupPermissionMapper` also adds the claim, and the group graph and `EffectivePermissionResolver` read each group's `qqq.io/policy`, so effective permissions, the subset rule and the last-admin check see policy grants. An invalid policy on a group is listed as malformed.
- **Blueprint example** for the scope mapping that puts the merged policies into the token, pinned by `PolicyClaimMappingIT` on Authentik 2026.8.3.
### Added (v1: full user lifecycle and group/permission management, phases 2-3)
- **Writes through the Authentik API**, behind the features `USERS`, `GROUPS`, `MEMBERSHIP`, `CREDENTIALS` and `SESSIONS` (read-only stays the default; `readOnly` is a hard kill switch). Every change goes through `AuthentikAdmin`: it plans against live Authentik data (the guardrails of DESIGN.md 5.4), audits first, calls Authentik, posts a `custom_qqq_admin_action` event naming the operator, and finishes the audit row. `AuthentikActionUtil.write` needs a `WriteAuthorization`, which only `AuthentikGuard.authorize` can mint.
- **Actions** (table and record actions with Review and Result steps; none in the nav): Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject.
- **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 -64
View File
@@ -1,71 +1,17 @@
# CLAUDE.md
# qbit-template-extension
This file provides guidance to Claude Code when working with code in this repository.
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
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. The only way to change Authentik is `write(WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins that only `AuthentikAdmin` calls it).
- `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`).
- `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 (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
- 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.
- Nothing but `AuthentikAdmin` may call `AuthentikActionUtil.write`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions.
## 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.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
## 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 -87
View File
@@ -1,102 +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). 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
| | |
|---|---|
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. No hard delete. |
| **Groups and permissions** | Create and edit groups (parents), add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, connection), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
## 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))
.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_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 |
## Documentation
### 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.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.
### Authentik side
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, token-key, impersonate or delete permissions. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. The contract tests grant exactly this list.
## 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 (see `pom.xml` for the exact RC). 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` and `authentik_approval_request` 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
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)
-96
View File
@@ -1,96 +0,0 @@
# Upgrading qbit-authentik
## 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.
-1238
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.
-114
View File
@@ -1,114 +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`, `*` |
| `Statement[].Resource` | array of strings | Each one a QQQ permission base name matching `[A-Za-z0-9_]+`: a table, process, app, report or widget name (or its custom `permissionBaseName`) |
## Meaning
Each statement grants `{resource}.{action}` for every resource and action it lists. `*` stands for all five actions. The result is the union over all statements, and over all of the user's groups.
| Action | QQQ permission | Applies to |
|---|---|---|
| `hasAccess` | `{name}.hasAccess` | apps, processes, reports, widgets, and tables with `HAS_ACCESS` permissions |
| `read` | `{name}.read` | tables (query, get) |
| `insert` | `{name}.insert` | tables |
| `edit` | `{name}.edit` | tables |
| `delete` | `{name}.delete` | tables |
Examples:
- `{"Action": ["read"], "Resource": ["cms_page"]}` grants `cms_page.read`.
- `{"Action": ["*"], "Resource": ["cms_page"]}` grants `cms_page.hasAccess`, `cms_page.read`, `cms_page.insert`, `cms_page.edit` and `cms_page.delete`.
A grant that QQQ never checks (for example `hasAccess` on a table with insert/edit/delete permissions) is harmless.
**Not in 1.0.0:** `Deny`, wildcard or prefix resources, conditions, and field-level rules. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots. They stay in the group attribute `qqq.io/permissions`.
## Parsing fails closed
A document grants **nothing**, and the parser logs a warning, when any of these is true:
- it is missing, blank, not JSON, or not a JSON object
- `PolicySpec` is missing or is not the configured name
- `PolicyVersion` is missing or is not the supported version
- `Statement` is present but not an array, or a statement is not an object
- a statement's `Effect` is not `Allow` (including `Deny`, which is not supported yet)
- `Action` or `Resource` is missing or not an array of strings
- an action is not one of the six above
- a resource does not match `[A-Za-z0-9_]+`
There is no partial credit: one bad statement voids the whole document. When the qbit reads policies from the group graph, each group's document is parsed on its own, so one bad group voids only that group. In the merged token claim, one bad statement voids the claim.
## Canonical form
The qbit always writes the canonical form:
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`
- each statement lists its resources sorted
- no `*`
- stored on the group as a compact JSON **string**
Before every save, the qbit checks the round trip: parsing the generated document must give back exactly the permissions it was generated from. A document that fails this check is never written.
## Where it lives
| What | Default | Config |
|---|---|---|
| Group attribute holding a group's document | `qqq.io/policy` | `policyAttribute` |
| Token claim holding the merged document | `qqq_policy` | `policyClaim` |
| Spec name | `qqq-iam` | `policySpec` |
| Version | `1.0.0` | `policyVersion` |
An app that already stores documents of this shape under its own spec name, attribute and claim sets those four values and keeps its data unchanged. The parser accepts the attribute or claim as a JSON string or as a JSON object.
## Getting policies into the token
Authentik puts the merged document into the token through a scope mapping. Steps:
1. Apply the example blueprint, [`src/test/resources/blueprints/qqq-policy-claim.yaml`](../src/test/resources/blueprints/qqq-policy-claim.yaml). Edit its spec, version, attribute and claim name to match your config.
2. Add the mapping to the OAuth2 provider's `property_mappings`. It uses the scope `profile`, which every QQQ sign-in requests.
3. Keep `include_claims_in_id_token: true` on the provider (the default).
4. Register the customizer in your app:
- `AuthentikPolicyClaimMapper` on its own, or
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat())`.
The mapping merges the statements of every group in `request.user.all_groups()` (direct groups and their ancestors) whose document has the right spec and version. It always emits a document, with an empty `Statement` array when the user has no policies.
The contract test `PolicyClaimMappingIT` runs this blueprint on Authentik 2026.8.3. It puts generated policies on a parent and a child group (one as a string, one as a JSON object), then runs the mapping through Authentik's property-mapping test endpoint for a member of the child group. It checks that the parser turns the claim into the union of the groups' permissions, that a document with another spec is skipped, and that a user without policies gets a valid empty document.
### Freshness
The claim is computed when the token is issued, so a policy change reaches a signed-in user at their next token refresh. In kof22 that is within 1 hour. To apply a change at once, revoke the user's sessions (*Revoke Sessions*).
## Record locks (not yet)
Row-level security keys are not part of the format. `AuthentikPolicyClaimMapper.applySecurityKeys(QSession, JSONObject)` is the extension point. A host overrides it to set security key values from its own explicit claims, and must never infer them from group names.
-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) | spec approved 2026-10-13; 5a policy model, 5b editor, 5c CRUD additions in progress |
### Built in this line (0.1)
| Area | What |
|---|---|
| People | Create, invite, edit, deactivate, reactivate, password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
| Groups | Create, edit (parents), add and remove members (bulk), edit permissions and app access |
| 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:]))
-51
View File
@@ -1,51 +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);
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 -239
View File
@@ -1,104 +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>
<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.0.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>
@@ -107,169 +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>qbit-authentik.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<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.2 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.2</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,58 +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,
/** Create, invite, edit, deactivate users (phase 3). */
USERS,
/** Create and edit groups, permissions and app-access attributes (phase 2/3). */
GROUPS,
/** 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,261 +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.metadata.AuthentikAppMetaDataProducer;
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.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);
addPossibleValueSources(qInstance);
AuthentikRelations.apply(qInstance, qBitConfig);
for(QProcessMetaData process : AuthentikProcesses.processes(qBitConfig))
{
qInstance.addProcess(process);
}
LOG.info("Authentik QBit produced", logPair("version", VERSION), logPair("appKey", qBitConfig.getAppKey()),
logPair("features", qBitConfig.getFeatures()), 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,209 +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.GroupGraph;
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 volatile java.util.Set<String> permissionCatalog;
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);
}
/*******************************************************************************
** 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();
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 once.
*******************************************************************************/
public java.util.Set<String> getPermissionCatalog()
{
if(permissionCatalog == null)
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
permissionCatalog = qInstance == null ? java.util.Set.of()
: new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
}
return (permissionCatalog);
}
/*******************************************************************************
** 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);
}
}
@@ -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.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
{
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),
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,244 +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.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;
/*******************************************************************************
** 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 ADMIN_ACTION = "admin_action";
public static final String APPROVAL_REQUEST = "approval_request";
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)));
}
/*******************************************************************************
** 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));
}
}
@@ -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,540 +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.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.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);
public static final int TOKEN_WARNING_DAYS = 14;
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 AuthentikQBitConfig config;
private String version;
/*******************************************************************************
** 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.version = util.fetchServerVersion().orElse(null);
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()));
}
}
/*******************************************************************************
** Things that need a person: drift findings, blueprint state, tokens about
** to expire, bootstrap operators still set.
*******************************************************************************/
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()));
}
}
if(mayAudit)
{
try
{
Instant soon = Instant.now().plus(TOKEN_WARNING_DAYS, ChronoUnit.DAYS);
for(JSONObject token : util.getAllPages("/core/tokens/?user__username=" + java.net.URLEncoder.encode(config.getEffectiveServiceAccountUsername(), java.nio.charset.StandardCharsets.UTF_8), 50))
{
Instant expires = AuthentikJson.parseInstant(token.optString("expires", null));
if(token.optBoolean("expiring", true) && expires != null && expires.isBefore(soon))
{
attention.add("The qbit's API token (" + token.optString("identifier") + ") expires on "
+ java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(java.time.ZoneOffset.UTC).format(expires) + ": rotate it (owner checklist).");
}
}
}
catch(Exception e)
{
LOG.debug("Identity home: token expiry not checked", e);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private boolean hasLocal()
{
return (config.getLocalBackendName() != null && QContext.getQInstance().getTable(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION)) != null);
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
public AuthentikQBitConfig getConfig()
{
return (config);
}
/*******************************************************************************
** Getter for version
*******************************************************************************/
public String getVersion()
{
return (version);
}
/*******************************************************************************
** 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,295 +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);
connection(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 connection(StringBuilder html, AuthentikHomeData data)
{
open(html, "Connection");
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Authentik", data.getConfig().getBaseUrl());
row(html, "Version", data.getVersion() == null ? "Not reachable" : data.getVersion());
row(html, "Service account", data.getConfig().getEffectiveServiceAccountUsername());
row(html, "Changes", Boolean.TRUE.equals(data.getConfig().getReadOnly()) ? "Off (read-only)" : "On, every change is audited here and in Authentik");
html.append("</dl>");
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,140 +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.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");
groupSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("attributes")));
applySections(qInstance, group, groupSections);
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);
}
/*******************************************************************************
** 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);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,113 +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()));
///////////////////////////////////////////////////////////
// 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", "attributes", "type", "path_startswith", "groups_by_name", "page", "page_size")));
rs.add(new Operation("get", "/core/users/{key}/", Set.of()));
rs.add(new Operation("get", "/managed/blueprints/", Set.of("page", "page_size")));
rs.add(new Operation("get", "/core/tokens/", Set.of("user__username", "intent", "page", "page_size")));
rs.add(new Operation("get", "/events/events/", Set.of("username", "ordering", "page", "page_size")));
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(
"/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|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,301 +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 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);
}
}
@@ -1,441 +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 is never given token-key permissions. Needs audit.read.
*******************************************************************************/
static AuthentikTableSpec tokens()
{
AuthentikTableSpec spec = new AuthentikTableSpec(TOKEN, "Tokens", "key", "/core/tokens/", "identifier")
.withReadPermission(IdentityPermission.AUDIT_READ)
.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"));
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,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.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);
}
}
/*******************************************************************************
** 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,476 +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 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 static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** 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 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);
}
/*******************************************************************************
** 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());
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."));
}
}
/*******************************************************************************
** 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."));
}
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)."));
}
}
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."));
}
}
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,80 +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
*******************************************************************************/
public record DriftFinding(Severity severity, String check, String subject, String message)
{
/*******************************************************************************
** 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,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.getReadPermission().rules())
.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,124 +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, AuthentikTables.APPLICATION);
addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION);
List<String> widgets = new ArrayList<>();
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME)))
{
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
** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
*******************************************************************************/
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,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,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,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);
}
}
@@ -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_user: /core/users/.
*******************************************************************************/
public class AuthentikUserTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.USER);
}
}
@@ -1,274 +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.permissions;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.security.BootstrapOperators;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Sign-in permissions from Authentik groups (DESIGN.md section 5.6). Use as
** (or extend in) the host's OAuth2 authentication customizer.
**
** 1. Group names come from the token's "groups" claim (a typical profile
** mapping emits direct groups only).
** 2. Each group is resolved with its ancestors through the cached group
** graph; the union of qqq.io/permissions becomes session permissions,
** expanded and checked against the instance's permission catalog.
** 3. Static fallbacks (withGroupPermissions) and bootstrapOperatorSubjects are added.
** 4. If Authentik cannot be read, only the static fallbacks (and bootstrap
** operators) apply: it never grants everything.
**
** The host keeps its own user/full-name handling; this only adds permissions.
** QQQ instantiates customizers with no arguments, so the API backend is found
** by name (default "authentikApi"; override getApiBackendName for a prefix).
*******************************************************************************/
public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCustomizerInterface
{
private static final QLogger LOG = QLogger.getLogger(AuthentikGroupPermissionMapper.class);
public static final String CLAIMS_CONTEXT_KEY = "jwtPayloadJsonObject";
private final Map<String, List<String>> staticGroupPermissions = new LinkedHashMap<>();
/*******************************************************************************
** The qbit's API backend name (default: the unprefixed-namespace default).
*******************************************************************************/
protected String getApiBackendName()
{
return ("authentikApi");
}
/*******************************************************************************
** Static fallback: always grant these permissions to members of a group
** (useful in tests and dev, and as the floor when Authentik is down).
*******************************************************************************/
public AuthentikGroupPermissionMapper withGroupPermissions(String groupName, List<String> permissions)
{
staticGroupPermissions.put(groupName, new ArrayList<>(permissions));
return (this);
}
/*******************************************************************************
** Add permissions from the session's token claims.
*******************************************************************************/
@Override
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
{
JSONObject claims = context == null ? null : asClaims(context.get(CLAIMS_CONTEXT_KEY));
if(claims == null)
{
return;
}
List<String> groups = groupNames(claims);
String email = claims.optString("email", "").trim();
String sub = claims.optString("sub", "").trim();
boolean verified = claims.optBoolean("email_verified", false);
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, sub, email, verified));
//////////////////////////////////////////////////////////////////////////
// the policy claim (docs/POLICY-FORMAT.md) carries the same group data //
// as the graph read above; the union also covers an unreachable API //
//////////////////////////////////////////////////////////////////////////
AuthentikRuntime runtime = runtime();
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format));
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
}
/*******************************************************************************
** The permissions for a user's group names and token identity (sub, email,
** email_verified). Bootstrap operators are matched on immutable ids only.
*******************************************************************************/
public Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
{
Set<String> rs = new TreeSet<>();
for(String group : groupNames)
{
rs.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
}
AuthentikRuntime runtime = runtime();
if(runtime != null && !runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, emailVerified))
{
LOG.warn("bootstrapOperatorSubjects grants all identity permissions; empty it once an identity administrator is set up in Authentik", logPair("sub", sub));
for(IdentityPermission permission : IdentityPermission.values())
{
rs.add(permission.getSessionPermission());
}
}
try
{
AuthentikActionUtil client = client(qInstance);
GroupGraph graph = client.groupGraph();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog());
List<String> pks = new ArrayList<>();
for(String name : groupNames)
{
GroupGraph.Node node = graph.byName(name);
if(node != null)
{
pks.add(node.pk());
}
}
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(pks);
if(!resolution.unknown().isEmpty())
{
LOG.info("Group attributes name permissions this app does not have", logPair("unknown", resolution.unknown()));
}
rs.addAll(resolution.sessionPermissions());
}
catch(Exception e)
{
LOG.warn("Authentik groups could not be read at sign-in; granting only the static fallbacks", logPair("error", AuthentikRedactor.redactText(e.getMessage(), runtime == null ? null : runtime.getConfig().getResolvedApiToken())));
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private boolean isBootstrap(QInstance qInstance, AuthentikRuntime runtime, String sub, String email, boolean emailVerified)
{
try
{
return (BootstrapOperators.matches(runtime.getConfig().getResolvedBootstrapSubjects(), client(qInstance), sub, email, emailVerified));
}
catch(Exception e)
{
return (false);
}
}
/*******************************************************************************
**
*******************************************************************************/
private AuthentikRuntime runtime()
{
try
{
return (AuthentikRuntime.forBackend(getApiBackendName()));
}
catch(IllegalStateException e)
{
return (null);
}
}
/*******************************************************************************
** A client bound to the qbit backend of the given instance.
*******************************************************************************/
protected AuthentikActionUtil client(QInstance qInstance)
{
if(qInstance == null || !(qInstance.getBackend(getApiBackendName()) instanceof APIBackendMetaData backend))
{
throw (new IllegalStateException("No Authentik API backend named " + getApiBackendName()));
}
BaseAPIActionUtil util = QCodeLoader.getAdHoc(BaseAPIActionUtil.class, backend.getActionUtil());
util.setBackendMetaData(backend);
return ((AuthentikActionUtil) util);
}
/*******************************************************************************
**
*******************************************************************************/
private static JSONObject asClaims(Object value)
{
if(value instanceof JSONObject object)
{
return (object);
}
if(value instanceof Map<?, ?> map)
{
return (new JSONObject(map));
}
return (null);
}
/*******************************************************************************
** Group names from the "groups" claim (array, or a single string).
*******************************************************************************/
static List<String> groupNames(JSONObject claims)
{
List<String> rs = new ArrayList<>();
Object groups = claims.opt("groups");
if(groups instanceof JSONArray array)
{
for(int i = 0; i < array.length(); i++)
{
String name = array.optString(i, "").trim();
if(!name.isEmpty())
{
rs.add(name);
}
}
}
else if(groups instanceof String name && !name.isBlank())
{
rs.add(name.trim());
}
return (rs);
}
}
@@ -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.permissions;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
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.security.IdentityPermission;
/*******************************************************************************
** Turns groups (and their ancestors) into QQQ permissions (DESIGN.md
** section 5.6). Pure: give it a GroupGraph and a permission catalog.
**
** Rules:
** - A group confers the union of qqq.io/permissions on itself and every
** ancestor ("inherited parents").
** - "authentik.*" expands to every identity permission; an identity short
** name ("authentik.users.read") becomes its session form
** ("authentik.users.read.hasAccess").
** - Any other name ending in ".*" expands against the catalog (prefix match);
** with no catalog it is dropped, never granted as a literal wildcard.
** - Names not in the catalog are reported as unknown and NOT granted (when a
** catalog is given); with no catalog they pass through unchanged.
*******************************************************************************/
public class EffectivePermissionResolver
{
private final GroupGraph graph;
private final Set<String> catalog;
/*******************************************************************************
** Result for a set of groups.
*******************************************************************************/
public record Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess)
{
}
/*******************************************************************************
** Constructor.
**
** @param graph the group hierarchy
** @param catalog QQQ permission names that may be granted (session form,
** e.g. from PermissionsHelper.getAllAvailablePermissionNames);
** null to pass non-identity names through unchecked
*******************************************************************************/
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog)
{
this.graph = graph;
this.catalog = catalog == null ? null : new TreeSet<>(catalog);
if(this.catalog != null)
{
for(IdentityPermission permission : IdentityPermission.values())
{
this.catalog.add(permission.getSessionPermission());
}
}
}
/*******************************************************************************
** Effective permissions of a set of groups (e.g. a user's direct groups).
*******************************************************************************/
public Resolution resolveGroups(Collection<String> groupPks)
{
Set<String> permissions = new TreeSet<>();
Map<String, Set<String>> grantedBy = new LinkedHashMap<>();
Set<String> unknown = new TreeSet<>();
Set<String> appAccess = new TreeSet<>();
Set<String> visited = new LinkedHashSet<>();
for(String pk : groupPks)
{
for(GroupGraph.Node node : graph.selfAndAncestors(pk))
{
if(!visited.add(node.pk()))
{
continue;
}
appAccess.addAll(node.appAccess());
List<String> raws = new ArrayList<>(node.permissions());
raws.addAll(node.policyPermissions());
for(String raw : raws)
{
Set<String> expanded = expand(raw);
if(expanded.isEmpty())
{
unknown.add(raw);
}
for(String permission : expanded)
{
permissions.add(permission);
grantedBy.computeIfAbsent(permission, k -> new TreeSet<>()).add(node.name());
}
}
}
}
return (new Resolution(permissions, grantedBy, unknown, appAccess));
}
/*******************************************************************************
** Effective permissions of a user from the graph's memberships.
*******************************************************************************/
public Resolution resolveUser(int userPk)
{
return (resolveGroups(graph.groupsOfUser(userPk)));
}
/*******************************************************************************
** Expand one attribute entry into session permissions (empty when it
** grants nothing known).
*******************************************************************************/
public Set<String> expand(String raw)
{
Set<String> rs = new TreeSet<>();
if(raw == null || raw.isBlank())
{
return (rs);
}
String name = raw.trim();
if(name.equals(IdentityPermission.WILDCARD))
{
for(IdentityPermission permission : IdentityPermission.values())
{
rs.add(permission.getSessionPermission());
}
return (rs);
}
if(IdentityPermission.fromName(name).isPresent())
{
rs.add(IdentityPermission.fromName(name).get().getSessionPermission());
return (rs);
}
if(name.endsWith(".*"))
{
String prefix = name.substring(0, name.length() - 1);
if(catalog != null)
{
catalog.stream().filter(c -> c.startsWith(prefix)).forEach(rs::add);
}
return (rs);
}
if(catalog == null || catalog.contains(name))
{
rs.add(name);
}
return (rs);
}
/*******************************************************************************
** Whether a resolution holds every given identity permission.
*******************************************************************************/
public static boolean holdsAll(Resolution resolution, List<IdentityPermission> required)
{
return (required.stream().allMatch(p -> resolution.sessionPermissions().contains(p.getSessionPermission())));
}
}
@@ -1,338 +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.permissions;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Snapshot of Authentik's group hierarchy: groups by pk with their parents
** (many-to-many since 2025.12), own superuser flag, qqq.io/* attributes and
** member pks. Ancestry closures are computed on demand and cycle-safe.
*******************************************************************************/
public class GroupGraph
{
/*******************************************************************************
** One group.
*******************************************************************************/
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed,
List<String> policyPermissions)
{
/*******************************************************************************
** A group without a policy document.
*******************************************************************************/
public Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
{
this(pk, name, ownSuperuser, parentPks, permissions, appAccess, memberPks, malformed, List.of());
}
}
private final Map<String, Node> byPk = new LinkedHashMap<>();
private final Map<String, Node> byName = new LinkedHashMap<>();
private final Map<String, Set<String>> ancestors = new java.util.concurrent.ConcurrentHashMap<>();
/*******************************************************************************
** Build from Authentik group objects (as returned by /core/groups/).
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups)
{
return (fromGroups(groups, PolicyFormat.DEFAULT));
}
/*******************************************************************************
** Build from Authentik group objects, reading each group's policy document
** (docs/POLICY-FORMAT.md) from the format's attribute. A document that does
** not parse grants nothing and is listed in the node's malformed entries.
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
GroupGraph graph = new GroupGraph();
for(JSONObject group : groups)
{
JSONObject attributes = group.optJSONObject("attributes");
List<String> malformed = malformed(attributes);
List<String> policy = policyPermissions(attributes, f, malformed);
graph.add(new Node(
group.getString("pk"),
group.optString("name", ""),
group.optBoolean("is_superuser", false),
strings(group.optJSONArray("parents")),
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)),
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)),
ints(group.optJSONArray("users")),
malformed,
policy));
}
return (graph);
}
/*******************************************************************************
** Add a node.
*******************************************************************************/
public GroupGraph add(Node node)
{
byPk.put(node.pk(), node);
byName.put(node.name(), node);
ancestors.clear();
return (this);
}
/*******************************************************************************
** All groups.
*******************************************************************************/
public Collection<Node> nodes()
{
return (byPk.values());
}
/*******************************************************************************
** A group by pk, or null.
*******************************************************************************/
public Node byPk(String pk)
{
return (byPk.get(pk));
}
/*******************************************************************************
** A group by name, or null.
*******************************************************************************/
public Node byName(String name)
{
return (byName.get(name));
}
/*******************************************************************************
** All ancestors of a group (parents, their parents, ...), not including the
** group itself. Unknown parents are ignored; cycles terminate.
*******************************************************************************/
public Set<String> ancestorPks(String pk)
{
Set<String> cached = ancestors.get(pk);
if(cached != null)
{
return (cached);
}
Set<String> seen = new LinkedHashSet<>();
Deque<String> queue = new ArrayDeque<>();
Node start = byPk.get(pk);
if(start != null)
{
queue.addAll(start.parentPks());
}
while(!queue.isEmpty())
{
String next = queue.removeFirst();
if(next.equals(pk) || !seen.add(next))
{
continue;
}
Node parent = byPk.get(next);
if(parent != null)
{
queue.addAll(parent.parentPks());
}
}
Set<String> immutable = java.util.Collections.unmodifiableSet(seen);
ancestors.put(pk, immutable);
return (immutable);
}
/*******************************************************************************
** The group and all of its ancestors.
*******************************************************************************/
public List<Node> selfAndAncestors(String pk)
{
List<Node> rs = new ArrayList<>();
Node self = byPk.get(pk);
if(self != null)
{
rs.add(self);
}
for(String ancestor : ancestorPks(pk))
{
Node node = byPk.get(ancestor);
if(node != null)
{
rs.add(node);
}
}
return (rs);
}
/*******************************************************************************
** Whether a group grants superuser status, itself or through any ancestor
** (Authentik's User.all_groups() includes ancestors).
*******************************************************************************/
public boolean isSuperuserInherited(String pk)
{
return (selfAndAncestors(pk).stream().anyMatch(Node::ownSuperuser));
}
/*******************************************************************************
** pks of the groups a user is a direct member of.
*******************************************************************************/
public List<String> groupsOfUser(int userPk)
{
List<String> rs = new ArrayList<>();
for(Node node : byPk.values())
{
if(node.memberPks().contains(userPk))
{
rs.add(node.pk());
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static List<String> strings(JSONArray array)
{
List<String> rs = new ArrayList<>();
if(array != null)
{
for(int i = 0; i < array.length(); i++)
{
Object value = array.get(i);
rs.add(value instanceof JSONObject object ? object.optString("pk") : String.valueOf(value));
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static List<Integer> ints(JSONArray array)
{
List<Integer> rs = new ArrayList<>();
if(array != null)
{
for(int i = 0; i < array.length(); i++)
{
Object value = array.get(i);
if(value instanceof Number number)
{
rs.add(number.intValue());
}
else if(value instanceof JSONObject object && object.has("pk"))
{
rs.add(object.getInt("pk"));
}
}
}
return (rs);
}
/*******************************************************************************
** The permissions of a group's policy document; none (and a malformed entry)
** when it does not parse.
*******************************************************************************/
private static List<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed)
{
Object document = attributes == null ? null : attributes.opt(format.attribute());
if(document == null || JSONObject.NULL.equals(document) || "".equals(document))
{
return (List.of());
}
try
{
return (List.copyOf(PolicyParser.parseStrict(document, format).permissions()));
}
catch(PolicyParseException e)
{
malformed.add(format.attribute() + ": " + e.getMessage());
return (List.of());
}
}
/*******************************************************************************
**
*******************************************************************************/
private static List<String> malformed(JSONObject attributes)
{
List<String> rs = new ArrayList<>();
for(String key : List.of(AuthentikJson.ATTR_PERMISSIONS, AuthentikJson.ATTR_APP_ACCESS))
{
for(String bad : AuthentikJson.malformedEntries(attributes, key))
{
rs.add(key + ": " + bad);
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static List<String> nonNull(List<String> list)
{
return (list == null ? List.of() : list);
}
}
@@ -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.policy;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Authentication customizer that adds the QQQ permissions granted by the
** policy claim of the sign-in token (docs/POLICY-FORMAT.md). It needs no
** Authentik API access: Authentik merges the user's group policies into the
** claim at sign-in (the scope mapping in the format doc).
**
** Order: applySecurityKeys (a no-op hook for hosts), then permissions. The
** permissions are added to whatever the session already holds. A missing or
** invalid claim adds nothing (fail closed).
*******************************************************************************/
public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomizerInterface
{
private static final QLogger LOG = QLogger.getLogger(AuthentikPolicyClaimMapper.class);
private PolicyFormat format = PolicyFormat.DEFAULT;
/*******************************************************************************
** Use these names (the qbit config's getPolicyFormat()) instead of the
** defaults.
*******************************************************************************/
public AuthentikPolicyClaimMapper withFormat(PolicyFormat format)
{
this.format = format == null ? PolicyFormat.DEFAULT : format;
return (this);
}
/*******************************************************************************
** Getter for format.
*******************************************************************************/
public PolicyFormat getFormat()
{
return (format);
}
/*******************************************************************************
** Add the permissions from the policy claim.
*******************************************************************************/
@Override
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
{
JSONObject claims = PolicyPermissions.claimsWithPolicy(context, format);
JSONObject any = claims;
if(any == null && context != null)
{
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ID_TOKEN_CONTEXT_KEY));
if(any == null)
{
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY));
}
}
if(any != null)
{
applySecurityKeys(qSession, any);
}
if(claims == null)
{
LOG.info("No policy claim in the token; no policy permissions added", logPair("claim", format.claim()));
return;
}
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format));
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
}
/*******************************************************************************
** Extension point for record locks (security keys); a no-op here. A host
** overrides it to set security key values from its OWN explicit claims, and
** must never infer them from group names.
*******************************************************************************/
protected void applySecurityKeys(QSession qSession, JSONObject claims)
{
////////////////////////////////////////////////////////
// no record locks in policy format 1.0.0 (by design) //
////////////////////////////////////////////////////////
}
}
@@ -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.policy;
import java.util.Optional;
/*******************************************************************************
** The actions a policy statement can grant, in canonical statement order. Each
** maps to the QQQ permission suffix of the same name ({resource}.{action}).
*******************************************************************************/
public enum PolicyAction
{
HAS_ACCESS("hasAccess"),
READ("read"),
INSERT("insert"),
EDIT("edit"),
DELETE("delete");
/** "All five actions" in a statement's Action array. */
public static final String WILDCARD = "*";
private final String jsonName;
/*******************************************************************************
** Constructor.
*******************************************************************************/
PolicyAction(String jsonName)
{
this.jsonName = jsonName;
}
/*******************************************************************************
** The name used in documents and as the QQQ permission suffix.
*******************************************************************************/
public String getJsonName()
{
return (jsonName);
}
/*******************************************************************************
** The QQQ permission this action grants on a resource.
*******************************************************************************/
public String permission(String resource)
{
return (resource + "." + jsonName);
}
/*******************************************************************************
** Look up by document name (case-sensitive, like QQQ permission names).
*******************************************************************************/
public static Optional<PolicyAction> fromJsonName(String name)
{
for(PolicyAction action : values())
{
if(action.jsonName.equals(name))
{
return (Optional.of(action));
}
}
return (Optional.empty());
}
}
@@ -1,85 +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.policy;
import java.util.regex.Pattern;
/*******************************************************************************
** The names a host uses for its policy documents (docs/POLICY-FORMAT.md): the
** PolicySpec it accepts, the one supported PolicyVersion, the Authentik group
** attribute that holds a group's document, and the token claim that holds the
** merged document.
**
** @param spec PolicySpec, e.g. "qqq-iam"
** @param version PolicyVersion (semver), e.g. "1.0.0"
** @param attribute group attribute key, e.g. "qqq.io/policy"
** @param claim token claim name, e.g. "qqq_policy"
*******************************************************************************/
public record PolicyFormat(String spec, String version, String attribute, String claim)
{
/** Default PolicySpec. */
public static final String DEFAULT_SPEC = "qqq-iam";
/** Default (and only) PolicyVersion of this qbit's format. */
public static final String DEFAULT_VERSION = "1.0.0";
/** Default group attribute. */
public static final String DEFAULT_ATTRIBUTE = "qqq.io/policy";
/** Default token claim. */
public static final String DEFAULT_CLAIM = "qqq_policy";
/** The generic defaults. */
public static final PolicyFormat DEFAULT = new PolicyFormat(DEFAULT_SPEC, DEFAULT_VERSION, DEFAULT_ATTRIBUTE, DEFAULT_CLAIM);
/** A valid spec name: letters, digits, dot, dash and underscore. */
public static final Pattern SPEC_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
/** Semver MAJOR.MINOR.PATCH. */
public static final Pattern VERSION_PATTERN = Pattern.compile("(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)");
/** A valid claim name. */
public static final Pattern CLAIM_PATTERN = Pattern.compile("[A-Za-z_][A-Za-z0-9_.-]{0,63}");
/*******************************************************************************
** Whether every name is well formed (used by the config's validate()).
*******************************************************************************/
public String problem()
{
if(spec == null || !SPEC_PATTERN.matcher(spec).matches())
{
return ("policySpec must be letters, digits, '.', '-' or '_': " + spec);
}
if(version == null || !VERSION_PATTERN.matcher(version).matches())
{
return ("policyVersion must be semver MAJOR.MINOR.PATCH: " + version);
}
if(attribute == null || attribute.isBlank())
{
return ("policyAttribute is required");
}
if(claim == null || !CLAIM_PATTERN.matcher(claim).matches())
{
return ("policyClaim must be a plain claim name: " + claim);
}
return (null);
}
}
@@ -1,202 +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.policy;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Builds the canonical policy document for a set of QQQ permissions
** (docs/POLICY-FORMAT.md, "Canonical form"): one Allow statement per granted
** action in the order hasAccess, read, insert, edit, delete; resources sorted;
** no "*". Every generated document is checked to round-trip through
** PolicyParser to exactly the permissions it came from; one that does not is
** never returned.
*******************************************************************************/
public final class PolicyGenerator
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyGenerator()
{
}
/*******************************************************************************
** The canonical document for these permissions ({resource}.{action} names).
**
** @throws PolicyParseException when a permission is not {resource}.{action}
** with a known action and a valid resource, or the document does not
** round-trip
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format) throws PolicyParseException
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
Map<PolicyAction, Set<String>> byAction = new LinkedHashMap<>();
for(PolicyAction action : PolicyAction.values())
{
byAction.put(action, new TreeSet<>());
}
Set<String> expected = new TreeSet<>();
for(String permission : permissions)
{
int dot = permission == null ? -1 : permission.lastIndexOf('.');
if(dot <= 0 || dot == permission.length() - 1)
{
throw (new PolicyParseException("\"" + permission + "\" is not {resource}.{action}"));
}
String resource = permission.substring(0, dot);
String name = permission.substring(dot + 1);
PolicyAction action = PolicyAction.fromJsonName(name).orElseThrow(() -> new PolicyParseException("\"" + permission + "\": unknown action \"" + name + "\""));
if(!PolicyParser.RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException("\"" + permission + "\": resource must match [A-Za-z0-9_]+"));
}
byAction.get(action).add(resource);
expected.add(permission);
}
JSONArray statements = new JSONArray();
for(Map.Entry<PolicyAction, Set<String>> entry : byAction.entrySet())
{
if(!entry.getValue().isEmpty())
{
statements.put(new JSONObject()
.put("Effect", PolicyParser.ALLOW)
.put("Action", new JSONArray().put(entry.getKey().getJsonName()))
.put("Resource", new JSONArray(entry.getValue())));
}
}
JSONObject document = new JSONObject()
.put("PolicySpec", f.spec())
.put("PolicyVersion", f.version())
.put("Statement", statements);
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f).permissions();
if(!parsed.equals(expected))
{
Set<String> missing = new TreeSet<>(expected);
missing.removeAll(parsed);
Set<String> extra = new TreeSet<>(parsed);
extra.removeAll(expected);
throw (new PolicyParseException("The generated document does not round-trip. Missing: " + missing + "; unexpected: " + extra));
}
return (document);
}
/*******************************************************************************
** Re-generate any valid document in canonical form (parse, then generate).
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format) throws PolicyParseException
{
return (generate(PolicyParser.parseStrict(document, format).permissions(), format));
}
/*******************************************************************************
** Compact JSON in a fixed key order (PolicySpec, PolicyVersion, Statement;
** Effect, Action, Resource), as stored in the group attribute.
*******************************************************************************/
public static String toCompact(JSONObject document)
{
return (write(document, false));
}
/*******************************************************************************
** Pretty JSON (2-space indent, LF, trailing newline) in the fixed key order.
*******************************************************************************/
public static String toPretty(JSONObject document)
{
return (write(document, true));
}
/*******************************************************************************
** Write in the fixed key order (org.json does not keep insertion order).
*******************************************************************************/
private static String write(JSONObject document, boolean pretty)
{
String nl = pretty ? "\n" : "";
String i1 = pretty ? " " : "";
String i2 = pretty ? " " : "";
String i3 = pretty ? " " : "";
String i4 = pretty ? " " : "";
String sep = pretty ? ": " : ":";
StringBuilder sb = new StringBuilder("{").append(nl);
sb.append(i1).append(JSONObject.quote("PolicySpec")).append(sep).append(JSONObject.quote(document.optString("PolicySpec"))).append(",").append(nl);
sb.append(i1).append(JSONObject.quote("PolicyVersion")).append(sep).append(JSONObject.quote(document.optString("PolicyVersion"))).append(",").append(nl);
sb.append(i1).append(JSONObject.quote("Statement")).append(sep).append("[");
JSONArray statements = document.optJSONArray("Statement");
int count = statements == null ? 0 : statements.length();
for(int s = 0; s < count; s++)
{
JSONObject statement = statements.getJSONObject(s);
sb.append(s == 0 ? nl : "," + nl).append(i2).append("{").append(nl);
sb.append(i3).append(JSONObject.quote("Effect")).append(sep).append(JSONObject.quote(statement.optString("Effect"))).append(",").append(nl);
sb.append(i3).append(JSONObject.quote("Action")).append(sep).append(inline(statement.optJSONArray("Action"), pretty)).append(",").append(nl);
sb.append(i3).append(JSONObject.quote("Resource")).append(sep).append("[");
JSONArray resources = statement.optJSONArray("Resource");
int n = resources == null ? 0 : resources.length();
for(int r = 0; r < n; r++)
{
sb.append(r == 0 ? nl : "," + nl).append(i4).append(JSONObject.quote(resources.optString(r)));
}
sb.append(n == 0 ? "" : nl + i3).append("]").append(nl);
sb.append(i2).append("}");
}
sb.append(count == 0 ? "" : nl + i1).append("]").append(nl);
sb.append("}").append(nl);
return (sb.toString());
}
/*******************************************************************************
** A short string array on one line.
*******************************************************************************/
private static String inline(JSONArray array, boolean pretty)
{
StringBuilder sb = new StringBuilder("[");
int n = array == null ? 0 : array.length();
for(int i = 0; i < n; i++)
{
sb.append(i == 0 ? "" : (pretty ? ", " : ",")).append(JSONObject.quote(array.optString(i)));
}
return (sb.append("]").toString());
}
}
@@ -1,35 +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.policy;
/*******************************************************************************
** A policy document that cannot be accepted; the message names the problem
** in plain words (shown to operators in the editor's Review step).
*******************************************************************************/
public class PolicyParseException extends Exception
{
/*******************************************************************************
** Constructor.
*******************************************************************************/
public PolicyParseException(String message)
{
super(message);
}
}
@@ -1,247 +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.policy;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Parses a policy document (docs/POLICY-FORMAT.md) into QQQ permission names.
**
** parse() FAILS CLOSED: any problem (unknown spec, unsupported version, a
** statement that is not Allow, an unknown action, a bad resource, malformed
** JSON, ...) grants zero permissions and logs a warning. parseStrict() throws
** a PolicyParseException naming the problem instead (for the editor).
*******************************************************************************/
public final class PolicyParser
{
private static final QLogger LOG = QLogger.getLogger(PolicyParser.class);
/** A valid resource: a QQQ permission base name. */
public static final Pattern RESOURCE_PATTERN = Pattern.compile("[A-Za-z0-9_]+");
/** The only effect in 1.0.0. */
public static final String ALLOW = "Allow";
/*******************************************************************************
** The result of a successful parse.
**
** @param permissions QQQ permission names ({resource}.{action}), sorted
** @param resources every resource named, in document order
*******************************************************************************/
public record Parsed(Set<String> permissions, Set<String> resources)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyParser()
{
}
/*******************************************************************************
** The permissions a document grants, or none (with a warning) when it is
** missing or invalid in any way.
**
** @param document a JSONObject, a Map, or a JSON string (as stored in a group
** attribute or a token claim); null grants nothing
*******************************************************************************/
public static Set<String> parse(Object document, PolicyFormat format)
{
if(document == null || JSONObject.NULL.equals(document))
{
return (Collections.emptySet());
}
try
{
return (parseStrict(document, format).permissions());
}
catch(PolicyParseException e)
{
LOG.warn("Policy document rejected; it grants no permissions", logPair("reason", e.getMessage()));
return (Collections.emptySet());
}
}
/*******************************************************************************
** Parse, throwing a PolicyParseException that names the first problem.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format) throws PolicyParseException
{
JSONObject json = asObject(document);
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
Object spec = json.opt("PolicySpec");
if(!(spec instanceof String s) || !s.equals(f.spec()))
{
throw (new PolicyParseException("PolicySpec must be \"" + f.spec() + "\" (found " + describe(spec) + ")"));
}
Object version = json.opt("PolicyVersion");
if(!(version instanceof String v) || !v.equals(f.version()))
{
throw (new PolicyParseException("PolicyVersion must be \"" + f.version() + "\" (found " + describe(version) + ")"));
}
Set<String> permissions = new TreeSet<>();
Set<String> resources = new LinkedHashSet<>();
Object statements = json.opt("Statement");
if(statements == null)
{
return (new Parsed(permissions, resources));
}
if(!(statements instanceof JSONArray array))
{
throw (new PolicyParseException("Statement must be an array"));
}
for(int i = 0; i < array.length(); i++)
{
final int number = i + 1;
if(!(array.opt(i) instanceof JSONObject statement))
{
throw (new PolicyParseException("Statement " + (i + 1) + " must be an object"));
}
Object effect = statement.opt("Effect");
if(!ALLOW.equals(effect))
{
throw (new PolicyParseException("Statement " + (i + 1) + ": Effect must be \"Allow\" (found " + describe(effect) + "); Deny is not supported yet"));
}
Set<PolicyAction> actions = EnumSet.noneOf(PolicyAction.class);
for(String name : strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action"))
{
if(PolicyAction.WILDCARD.equals(name))
{
actions.addAll(EnumSet.allOf(PolicyAction.class));
}
else
{
actions.add(PolicyAction.fromJsonName(name).orElseThrow(() -> new PolicyParseException("Statement " + number + ": unknown action \"" + name + "\"")));
}
}
for(String resource : strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource"))
{
if(!RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException("Statement " + (i + 1) + ": resource \"" + resource + "\" must match [A-Za-z0-9_]+"));
}
resources.add(resource);
for(PolicyAction action : actions)
{
permissions.add(action.permission(resource));
}
}
}
return (new Parsed(permissions, resources));
}
/*******************************************************************************
** The document as a JSON object.
*******************************************************************************/
static JSONObject asObject(Object document) throws PolicyParseException
{
if(document instanceof JSONObject object)
{
return (object);
}
if(document instanceof Map<?, ?> map)
{
return (new JSONObject(map));
}
if(document instanceof String text)
{
if(text.isBlank())
{
throw (new PolicyParseException("The document is empty"));
}
try
{
return (new JSONObject(text));
}
catch(JSONException e)
{
throw (new PolicyParseException("The document is not a JSON object"));
}
}
throw (new PolicyParseException("The document is not a JSON object"));
}
/*******************************************************************************
** An array of strings, or a PolicyParseException.
*******************************************************************************/
private static Set<String> strings(Object value, String what) throws PolicyParseException
{
if(!(value instanceof JSONArray array))
{
throw (new PolicyParseException(what + " must be an array of strings"));
}
Set<String> rs = new LinkedHashSet<>();
for(int i = 0; i < array.length(); i++)
{
if(!(array.opt(i) instanceof String text))
{
throw (new PolicyParseException(what + " must be an array of strings"));
}
rs.add(text);
}
return (rs);
}
/*******************************************************************************
** A short description of a found value for messages (never the whole value).
*******************************************************************************/
private static String describe(Object value)
{
if(value == null)
{
return ("nothing");
}
if(value instanceof String text)
{
return ("\"" + (text.length() > 40 ? text.substring(0, 40) + "..." : text) + "\"");
}
return (value.getClass().getSimpleName());
}
}
@@ -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.policy;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import org.json.JSONObject;
/*******************************************************************************
** Helpers a host's own authentication customizer can call to turn the policy
** claim of a sign-in token into QQQ permissions (docs/POLICY-FORMAT.md).
*******************************************************************************/
public final class PolicyPermissions
{
/** Customizer context key of the ID token claims (QQQ OAuth2 module). */
public static final String ID_TOKEN_CONTEXT_KEY = "idToken";
/** Customizer context key of the access token claims. */
public static final String ACCESS_TOKEN_CONTEXT_KEY = "jwtPayloadJsonObject";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyPermissions()
{
}
/*******************************************************************************
** The permissions granted by the policy claim in these token claims; none
** when the claim is missing or invalid (fail closed).
*******************************************************************************/
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(claims == null || !claims.has(f.claim()))
{
return (Collections.emptySet());
}
return (PolicyParser.parse(claims.opt(f.claim()), f));
}
/*******************************************************************************
** The claims that carry the policy in a customizer context: the ID token's
** when it has the claim (Authentik puts scope-mapping claims there), else the
** access token's (the only ones on a session resume). Null when neither has
** the claim.
*******************************************************************************/
public static JSONObject claimsWithPolicy(Map<String, Object> context, PolicyFormat format)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(context == null)
{
return (null);
}
for(String key : new String[] { ID_TOKEN_CONTEXT_KEY, ACCESS_TOKEN_CONTEXT_KEY })
{
JSONObject claims = asClaims(context.get(key));
if(claims != null && claims.has(f.claim()))
{
return (claims);
}
}
return (null);
}
/*******************************************************************************
** Claims as a JSONObject (QQQ passes a JSONObject; tests may pass a Map).
*******************************************************************************/
public static JSONObject asClaims(Object value)
{
if(value instanceof JSONObject object)
{
return (object);
}
if(value instanceof Map<?, ?> map)
{
return (new JSONObject(map));
}
return (null);
}
}
@@ -1,865 +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.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.admin.AdminPlan;
import com.kingsrook.qbits.authentik.admin.AdminRequest;
import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.admin.AuthentikSafety;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Every change the qbit makes is a table or record action (DESIGN.md section
** 6.3); none is ever in the nav. Each is a standard streamed-ETL process with
** an optional details screen first, a Review step (the dry run: what each
** target would become, and which are refused and why) and a Result step
** (summary lines per outcome). The rules live in AuthentikAdmin; these steps
** only ask it to plan (Review) and to execute (Result).
*******************************************************************************/
public final class AuthentikProcesses
{
/** What the process is launched on. */
public enum Kind
{
/** Selected users (authentik_user). */
USERS,
/** Selected groups, each with a list of usernames (members actions). */
GROUP_USERNAMES,
/** Selected groups (authentik_group). */
GROUPS,
/** No records: a form creates something (table action). */
CREATE,
/** Selected invitations. */
INVITATIONS,
/** Selected approval requests. */
APPROVALS
}
/*******************************************************************************
** One process definition.
**
** @param key short key; the process is named applyPrefix("&lt;key&gt;")
** @param label plain label shown in the Actions menu
** @param action what it does (null for approvals)
** @param kind what it is launched on
** @param table unprefixed table name it is bound to
** @param icon material icon
** @param prefill whether a prepare step fills the form from the record
** @param fields details screen fields
** @param help help text on the details screen
*******************************************************************************/
public record Def(String key, String label, AdminAction action, Kind kind, String table, String icon, boolean prefill, List<QFieldMetaData> fields, String help)
{
}
private static final Map<String, Def> DEFS_BY_PROCESS = new HashMap<>();
private static final Map<String, AuthentikQBitConfig> CONFIGS_BY_PROCESS = new HashMap<>();
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikProcesses()
{
}
/*******************************************************************************
** The definitions enabled by the config's features.
*******************************************************************************/
public static List<Def> definitions(AuthentikQBitConfig config)
{
List<Def> defs = new ArrayList<>();
QFieldMetaData reason = new QFieldMetaData("reason", QFieldType.TEXT).withLabel("Reason").withIsRequired(true);
QFieldMetaData typed = new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Type the username to confirm").withIsRequired(true);
QFieldMetaData group = new QFieldMetaData("group", QFieldType.STRING).withLabel("Group").withIsRequired(true)
.withPossibleValueSourceName(config.applyTablePrefix(AuthentikTables.GROUP));
String users = AuthentikTables.USER;
String groups = AuthentikTables.GROUP;
defs.add(new Def("createUser", "Create User", AdminAction.CREATE_USER, Kind.CREATE, users, "person_add", false, List.of(
new QFieldMetaData("username", QFieldType.STRING).withLabel("Username").withIsRequired(true),
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name").withIsRequired(true),
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email").withIsRequired(true),
new QFieldMetaData("groups", QFieldType.TEXT).withLabel("Groups (names, one per line)"),
new QFieldMetaData("sendRecovery", QFieldType.BOOLEAN).withLabel("Email them a link to set their own password").withDefaultValue(true),
reason), "No password is set here. Authentik emails the person a link to set their own."));
defs.add(new Def("inviteUser", "Invite User", AdminAction.INVITE_USER, Kind.CREATE, users, "mail", false, List.of(
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email").withIsRequired(true),
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"),
reason), "Authentik emails a single-use sign-up invitation that expires in 7 days."));
defs.add(new Def("editUser", "Edit User", AdminAction.EDIT_USER, Kind.USERS, users, "edit", true, List.of(
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"),
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email"),
new QFieldMetaData("path", QFieldType.STRING).withLabel("Path"),
hidden("expectedFingerprint"), reason), "Change a person's name, email or path."));
defs.add(new Def("deactivateUser", "Deactivate User", AdminAction.DEACTIVATE_USER, Kind.USERS, users, "block", false, List.of(
new QFieldMetaData("revokeAppPasswords", QFieldType.BOOLEAN).withLabel("Also revoke their app passwords").withDefaultValue(false),
typed, reason), "Deactivating ends their sessions immediately and deletes nothing. Type the username to confirm (when you select several people, type the username of the first one)."));
defs.add(new Def("reactivateUser", "Reactivate User", AdminAction.REACTIVATE_USER, Kind.USERS, users, "check_circle", false, List.of(reason), null));
defs.add(new Def("addToGroup", "Add To Group", AdminAction.ADD_MEMBER, Kind.USERS, users, "group_add", false, List.of(group,
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason),
"You can only add people to a group whose permissions you hold yourself."));
defs.add(new Def("removeFromGroup", "Remove From Group", AdminAction.REMOVE_MEMBER, Kind.USERS, users, "group_remove", false, List.of(group,
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null));
defs.add(new Def("sendPasswordReset", "Send Password Reset Email", AdminAction.SEND_RECOVERY_EMAIL, Kind.USERS, users, "lock_reset", false, List.of(reason),
"Authentik emails the person a link. You never see the link."));
defs.add(new Def("resetMfa", "Reset MFA", AdminAction.RESET_MFA, Kind.USERS, users, "phonelink_erase", false, List.of(typed, reason),
"Removes every MFA device; the person sets MFA up again at next sign-in."));
defs.add(new Def("revokeAppPasswords", "Revoke App Passwords", AdminAction.REVOKE_APP_PASSWORDS, Kind.USERS, users, "key_off", false, List.of(typed, reason),
"Revokes app passwords only; API tokens are never touched."));
defs.add(new Def("revokeSessions", "Revoke Sessions", AdminAction.REVOKE_SESSIONS, Kind.USERS, users, "logout", false, List.of(reason), "Signs the person out everywhere."));
defs.add(new Def("createGroup", "Create Group", AdminAction.CREATE_GROUP, Kind.CREATE, groups, "group_add", false, List.of(
new QFieldMetaData("name", QFieldType.STRING).withLabel("Group Name").withIsRequired(true),
new QFieldMetaData("parents", QFieldType.TEXT).withLabel("Parent groups (names, one per line)"),
new QFieldMetaData("permissions", QFieldType.TEXT).withLabel("QQQ permissions (one per line)"),
new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"),
reason), "A group's QQQ permissions apply to everyone in it, and to groups below it."));
defs.add(new Def("editGroup", "Edit Group", AdminAction.EDIT_GROUP, Kind.GROUPS, groups, "edit", true, List.of(
new QFieldMetaData("name", QFieldType.STRING).withLabel("Group Name"),
new QFieldMetaData("parents", QFieldType.TEXT).withLabel("Parent groups (names, one per line; none to clear)"),
hidden("expectedFingerprint"), reason), "Rename a group or change its parents."));
defs.add(new Def("editPermissions", "Edit Permissions And App Access", AdminAction.EDIT_PERMISSIONS, Kind.GROUPS, groups, "admin_panel_settings", true, List.of(
new QFieldMetaData("permissions", QFieldType.TEXT).withLabel("QQQ permissions (one per line)"),
new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"),
hidden("expectedFingerprint"), reason),
"You can only add permissions you hold yourself. Adding authentik.* needs a second person to approve."));
defs.add(new Def("addMembers", "Add Members", AdminAction.ADD_MEMBER, Kind.GROUP_USERNAMES, groups, "person_add", false, List.of(
new QFieldMetaData("usernames", QFieldType.TEXT).withLabel("Usernames (one per line)").withIsRequired(true),
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null));
defs.add(new Def("removeMembers", "Remove Members", AdminAction.REMOVE_MEMBER, Kind.GROUP_USERNAMES, groups, "person_remove", false, List.of(
new QFieldMetaData("usernames", QFieldType.TEXT).withLabel("Usernames (one per line)").withIsRequired(true),
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null));
defs.add(new Def("cancelInvitation", "Cancel Invitation", AdminAction.CANCEL_INVITATION, Kind.INVITATIONS, "invitation", "cancel", false, List.of(reason), null));
defs.add(new Def("approve", "Approve And Apply", null, Kind.APPROVALS, AuthentikLocalTables.APPROVAL_REQUEST, "check", false, List.of(), "A second person applies the change. The change is checked again against live data."));
defs.add(new Def("reject", "Reject", null, Kind.APPROVALS, AuthentikLocalTables.APPROVAL_REQUEST, "close", false, List.of(), null));
defs.removeIf(def -> def.action() != null && !config.hasFeature(def.action().getFeature()));
if(!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS) || !config.getTwoPersonApproval())
{
defs.removeIf(def -> def.kind() == Kind.APPROVALS);
}
if(StringUtils.hasContent(config.getInvitationFlow()))
{
return (defs);
}
defs.removeIf(def -> def.action() == AdminAction.INVITE_USER || def.action() == AdminAction.CANCEL_INVITATION);
return (defs);
}
/*******************************************************************************
** A hidden form field (carries a stale-check fingerprint).
*******************************************************************************/
private static QFieldMetaData hidden(String name)
{
return (new QFieldMetaData(name, QFieldType.STRING).withIsHidden(true));
}
/*******************************************************************************
** The process names for a config's definitions (for the app and tests).
*******************************************************************************/
public static String processName(AuthentikQBitConfig config, Def def)
{
return (config.applyPrefix(def.key()));
}
/*******************************************************************************
** All processes for a config.
*******************************************************************************/
public static List<QProcessMetaData> processes(AuthentikQBitConfig config)
{
List<QProcessMetaData> processes = new ArrayList<>();
for(Def def : definitions(config))
{
processes.add(build(config, def));
}
return (processes);
}
/*******************************************************************************
** Build one process.
*******************************************************************************/
static QProcessMetaData build(AuthentikQBitConfig config, Def def)
{
String name = processName(config, def);
String table = config.applyTablePrefix(def.table());
DEFS_BY_PROCESS.put(name, def);
CONFIGS_BY_PROCESS.put(name, config);
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "This is a preview of what will change. Nothing has been sent to Authentik yet.");
Class<? extends AbstractExtractStep> extract = def.kind() == Kind.CREATE ? SingleRecordExtractStep.class : ExtractViaQueryStep.class;
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(extract, ActionTransformStep.class, ActionLoadStep.class, values);
process.setName(name);
process.setLabel(def.label());
process.setTableName(table);
process.setIcon(new QIcon(def.icon()));
process.setPermissionRules((def.action() == null ? IdentityPermission.APPROVE : def.action().getPermission()).rules());
switch(def.kind())
{
case CREATE -> process.setMaxInputRecords(0);
case USERS, GROUP_USERNAMES, GROUPS, INVITATIONS, APPROVALS ->
{
process.setMinInputRecords(1);
process.setMaxInputRecords(def.action() == AdminAction.EDIT_USER || def.action() == AdminAction.EDIT_GROUP || def.action() == AdminAction.EDIT_PERMISSIONS
? 1 : AuthentikAdmin.MAX_BULK);
}
default ->
{
}
}
if(!def.fields().isEmpty())
{
QFrontendStepMetaData details = new QFrontendStepMetaData().withName("details").withLabel(def.label()).withFormFields(def.fields());
if(StringUtils.hasContent(def.help()))
{
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.HELP_TEXT).withValue("text", def.help()));
}
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
process.withStep(0, details);
if(def.prefill())
{
process.withStep(0, new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(PrepareStep.class))
.withInputData(new QFunctionInputMetaData().withRecordListMetaData(new QRecordListMetaData().withTableName(table))));
}
}
return (process);
}
/*******************************************************************************
** The definition behind a process name.
*******************************************************************************/
static Def defFor(String processName)
{
Def def = DEFS_BY_PROCESS.get(processName);
if(def == null)
{
throw (new IllegalStateException("Not an Authentik qbit process: " + processName));
}
return (def);
}
/*******************************************************************************
** The summary-line wording of an action: future (Review) and past (Result).
*******************************************************************************/
static String[] wording(AdminAction action)
{
if(action == null)
{
return (new String[] { "approval request will be decided", "approval requests will be decided", "approval request was decided", "approval requests were decided" });
}
return switch(action)
{
case CREATE_USER -> w("user will be created", "users will be created", "user was created", "users were created");
case INVITE_USER -> w("invitation will be sent", "invitations will be sent", "invitation was sent", "invitations were sent");
case EDIT_USER -> w("user will be updated", "users will be updated", "user was updated", "users were updated");
case DEACTIVATE_USER -> w("user will be deactivated", "users will be deactivated", "user was deactivated", "users were deactivated");
case REACTIVATE_USER -> w("user will be reactivated", "users will be reactivated", "user was reactivated", "users were reactivated");
case CANCEL_INVITATION -> w("invitation will be cancelled", "invitations will be cancelled", "invitation was cancelled", "invitations were cancelled");
case ADD_MEMBER -> w("membership will be added", "memberships will be added", "membership was added", "memberships were added");
case REMOVE_MEMBER -> w("membership will be removed", "memberships will be removed", "membership was removed", "memberships were removed");
case CREATE_GROUP -> w("group will be created", "groups will be created", "group was created", "groups were created");
case EDIT_GROUP -> w("group will be updated", "groups will be updated", "group was updated", "groups were updated");
case EDIT_PERMISSIONS -> w("group's permissions will be updated", "groups' permissions will be updated", "group's permissions were updated", "groups' permissions were updated");
case SEND_RECOVERY_EMAIL -> w("password reset email will be sent", "password reset emails will be sent", "password reset email was sent", "password reset emails were sent");
case RESET_MFA -> w("user's MFA will be reset", "users' MFA will be reset", "user's MFA was reset", "users' MFA were reset");
case REVOKE_APP_PASSWORDS -> w("user's app passwords will be revoked", "users' app passwords will be revoked", "user's app passwords were revoked", "users' app passwords were revoked");
case REVOKE_SESSIONS -> w("user's sessions will be revoked", "users' sessions will be revoked", "user's sessions were revoked", "users' sessions were revoked");
};
}
/*******************************************************************************
**
*******************************************************************************/
private static String[] w(String a, String b, String c, String d)
{
return (new String[] { a, b, c, d });
}
/*******************************************************************************
** A summary line with future (Review) and past (Result) wording.
*******************************************************************************/
static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
{
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
line.setSingularFutureMessage(singularFuture);
line.setPluralFutureMessage(pluralFuture);
line.setSingularPastMessage(singularPast);
line.setPluralPastMessage(pluralPast);
return (line);
}
/*******************************************************************************
** Build the requests a record stands for, from the process's form values.
*******************************************************************************/
static List<AdminRequest> requests(Def def, RunBackendStepInput input, QRecord record, AuthentikAdmin admin) throws QException
{
String reason = input.getValueString("reason");
List<AdminRequest> rs = new ArrayList<>();
switch(def.kind())
{
case CREATE -> rs.add(withParams(AdminRequest.of(def.action(), "", reason), def, input));
case USERS ->
{
String userPk = String.valueOf(record.getValue("pk"));
if(def.action() == AdminAction.ADD_MEMBER || def.action() == AdminAction.REMOVE_MEMBER)
{
rs.add(withParams(AdminRequest.of(def.action(), input.getValueString("group") + ":" + userPk, reason), def, input));
}
else
{
rs.add(withParams(AdminRequest.of(def.action(), userPk, reason), def, input));
}
}
case GROUP_USERNAMES ->
{
String groupPk = String.valueOf(record.getValue("pk"));
for(String username : AuthentikSafety.parseList(input.getValueString("usernames")))
{
Integer userPk = admin.userPkByUsername(username);
AdminRequest request = AdminRequest.of(def.action(), groupPk + ":" + (userPk == null ? "0" : userPk), reason);
withParams(request, def, input);
request.with("username", username);
rs.add(request);
}
}
case GROUPS, INVITATIONS -> rs.add(withParams(AdminRequest.of(def.action(), String.valueOf(record.getValue("pk") != null ? record.getValue("pk") : record.getValue("uuid")), reason), def, input));
default ->
{
}
}
return (rs);
}
/*******************************************************************************
** Copy the details-screen values onto a request.
*******************************************************************************/
private static AdminRequest withParams(AdminRequest request, Def def, RunBackendStepInput input)
{
for(QFieldMetaData field : def.fields())
{
if(field.getName().equals("reason"))
{
continue;
}
Serializable value = input.getValue(field.getName());
if(value != null)
{
request.with(field.getName(), value);
}
}
return (request);
}
/*******************************************************************************
** How many records the run covers: counted from the same filter the extract
** step uses (the preview step's count is not carried into later steps).
*******************************************************************************/
static Integer countTargets(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(defFor(input.getProcessName()).kind() == Kind.CREATE)
{
return (1);
}
ExtractViaQueryStep extract = new ExtractViaQueryStep();
extract.preRun(input, output);
return (extract.doCount(input));
}
/*******************************************************************************
** The admin for the current backend.
*******************************************************************************/
static AuthentikAdmin admin(RunBackendStepInput input) throws QException
{
AuthentikQBitConfig config = CONFIGS_BY_PROCESS.get(input.getProcessName());
if(config == null)
{
throw (new QException("The Authentik qbit is not set up in this instance."));
}
return (AuthentikAdmin.forBackend(config.getApiBackendName()));
}
/*******************************************************************************
** The details screen is filled from the selected record (edit actions).
*******************************************************************************/
public static class PrepareStep implements BackendStep
{
/*******************************************************************************
** Fill the form fields and the stale-check fingerprint.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Def def = defFor(input.getProcessName());
if(input.getRecords() == null || input.getRecords().isEmpty())
{
return;
}
QRecord record = input.getRecords().get(0);
AuthentikAdmin admin = admin(input);
String pk = String.valueOf(record.getValue("pk"));
switch(def.action())
{
case EDIT_USER ->
{
JSONObject user = admin.getUtil().getObject(AuthentikTables.get(AuthentikTables.USER), pk);
if(user != null)
{
output.addValue("name", user.optString("name"));
output.addValue("email", user.optString("email"));
output.addValue("path", user.optString("path"));
output.addValue("expectedFingerprint", AuthentikSafety.userFingerprint(user));
}
}
case EDIT_GROUP, EDIT_PERMISSIONS ->
{
JSONObject group = admin.getUtil().getApiJsonOrNull("/core/groups/" + pk + "/?include_users=true&include_parents=true&include_children=true");
if(group != null)
{
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
if(def.action() == AdminAction.EDIT_GROUP)
{
output.addValue("name", group.optString("name"));
JSONArray parents = group.optJSONArray("parents_obj");
List<String> names = new ArrayList<>();
for(int i = 0; parents != null && i < parents.length(); i++)
{
names.add(parents.getJSONObject(i).optString("name"));
}
output.addValue("parents", String.join("\n", names));
}
else
{
output.addValue("permissions", String.join("\n", AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS) == null
? List.of() : AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)));
output.addValue("appAccess", String.join("\n", AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS) == null
? List.of() : AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)));
}
}
}
default ->
{
}
}
}
}
/*******************************************************************************
** The extract step of actions that create something: one placeholder record
** (the form values carry the data).
*******************************************************************************/
public static class SingleRecordExtractStep extends AbstractExtractStep
{
/*******************************************************************************
** One record, so the transform and load run once.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
getRecordPipe().addRecord(new QRecord().withValue("pk", "new"));
}
/*******************************************************************************
** One record.
*******************************************************************************/
@Override
public Integer doCount(RunBackendStepInput input)
{
return (1);
}
}
/*******************************************************************************
** Review (and the full validation): plan every target against live data.
*******************************************************************************/
public static class ActionTransformStep extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final Map<String, ProcessSummaryLine> lines = new LinkedHashMap<>();
private final List<String> details = new ArrayList<>();
private final Map<String, List<String>> targetsByLine = new LinkedHashMap<>();
private final List<String> notes = new ArrayList<>();
private AuthentikAdmin admin;
private Integer totalTargets;
/*******************************************************************************
** Remember how many records the run covers (for the more-than-25 rule).
*******************************************************************************/
@Override
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
totalTargets = countTargets(input, output);
}
/*******************************************************************************
** Plan each record's requests.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Def def = defFor(input.getProcessName());
if(admin == null)
{
admin = admin(input);
}
for(QRecord record : input.getRecords())
{
List<AdminRequest> requests = def.kind() == Kind.APPROVALS ? List.of() : requests(def, input, record, admin);
if(def.kind() == Kind.APPROVALS)
{
add(Status.OK, "approval request will be decided", "approval requests will be decided", "approval request was decided", "approval requests were decided", "approve");
output.getRecords().add(record);
continue;
}
int total = requests.size();
for(AdminRequest request : requests)
{
AdminPlan plan = admin.plan(request);
String problem = bulkProblem(def, request, total, input);
if(problem != null && plan.isRunnable())
{
plan.refuse(problem);
}
report(def, plan);
output.getRecords().add(record);
}
}
}
/*******************************************************************************
** More than 25 targets in one run need the count typed.
*******************************************************************************/
private String bulkProblem(Def def, AdminRequest request, int total, RunBackendStepInput input)
{
Integer count = totalTargets;
int n = Math.max(total, count == null ? 0 : count);
if(n > AuthentikAdmin.TYPED_ABOVE && (def.action() == AdminAction.ADD_MEMBER || def.action() == AdminAction.REMOVE_MEMBER)
&& !String.valueOf(n).equals(request.str("confirmText")))
{
return ("More than " + AuthentikAdmin.TYPED_ABOVE + " people: type " + n + " in the confirmation field.");
}
return (null);
}
/*******************************************************************************
** Add a plan to the summary.
*******************************************************************************/
private void report(Def def, AdminPlan plan)
{
String[] w = wording(def.action());
switch(plan.getState())
{
case READY ->
{
add(Status.OK, w[0], w[1], w[2], w[3], "ready");
name("ready", plan);
plan.getNotes().forEach(note ->
{
if(!notes.contains(note))
{
notes.add(note);
}
});
}
case NEEDS_APPROVAL ->
{
add(Status.WARNING, "change needs a second person's approval and will be requested", "changes need a second person's approval and will be requested",
"change was sent for approval", "changes were sent for approval", "approval");
name("approval", plan);
}
case NO_CHANGE -> add(Status.INFO, "target needs no change: " + plan.getMessage(), "targets need no change: " + plan.getMessage(),
"target needed no change: " + plan.getMessage(), "targets needed no change: " + plan.getMessage(), "nochange:" + plan.getMessage());
default -> {
add(Status.ERROR, "target is refused: " + plan.getMessage(), "targets are refused: " + plan.getMessage(),
"target was refused: " + plan.getMessage(), "targets were refused: " + plan.getMessage(), "refused:" + plan.getMessage());
name("refused:" + plan.getMessage(), plan);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private void add(Status status, String sf, String pf, String sp, String pp, String key)
{
lines.computeIfAbsent(key, k -> line(status, sf, pf, sp, pp)).incrementCount();
}
/*******************************************************************************
** Remember which target a line is about (shown as bullets, first 10).
*******************************************************************************/
private void name(String key, AdminPlan plan)
{
List<String> names = targetsByLine.computeIfAbsent(key, k -> new ArrayList<>());
if(names.size() < 10)
{
names.add(plan.getBefore() != null || plan.getAfter() != null ? plan.describe() : (plan.getTargetLabel() == null ? plan.getRequest().targetKey() : plan.getTargetLabel()));
}
}
/*******************************************************************************
** Review lines: one per outcome, then the before and after of the first
** targets and any notes.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
for(Map.Entry<String, ProcessSummaryLine> entry : lines.entrySet())
{
List<String> names = targetsByLine.get(entry.getKey());
if(names != null && !names.isEmpty() && !isForResultScreen)
{
entry.getValue().setBulletsOfText(new ArrayList<>(names));
}
entry.getValue().addSelfToListIfAnyCount(rs);
}
for(String note : notes)
{
ProcessSummaryLine noteLine = new ProcessSummaryLine(Status.INFO, note);
noteLine.setCount(null);
rs.add(noteLine);
}
return (rs);
}
}
/*******************************************************************************
** Result: execute each target (the rules are checked again against live
** data inside), with a line per outcome.
*******************************************************************************/
public static class ActionLoadStep extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final Map<String, ProcessSummaryLine> lines = new LinkedHashMap<>();
private AuthentikAdmin admin;
private Integer totalTargets;
/*******************************************************************************
** Remember how many records the run covers (for the more-than-25 rule).
*******************************************************************************/
@Override
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
totalTargets = countTargets(input, output);
}
/*******************************************************************************
** Execute each record's requests.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Def def = defFor(input.getProcessName());
if(admin == null)
{
admin = admin(input);
}
String[] w = wording(def.action());
for(QRecord record : input.getRecords())
{
if(def.kind() == Kind.APPROVALS)
{
AuthentikAdmin.AdminResult result = "approve".equals(def.key()) ? admin.approve(record.getValue("id")) : admin.reject(record.getValue("id"));
tally(result, w);
output.getRecords().add(record);
continue;
}
List<AdminRequest> requests = requests(def, input, record, admin);
int total = requests.size();
for(AdminRequest request : requests)
{
Integer count = totalTargets;
int n = Math.max(total, count == null ? 0 : count);
AuthentikAdmin.AdminResult result;
if(n > AuthentikAdmin.TYPED_ABOVE && (def.action() == AdminAction.ADD_MEMBER || def.action() == AdminAction.REMOVE_MEMBER)
&& !String.valueOf(n).equals(request.str("confirmText")))
{
result = new AuthentikAdmin.AdminResult(false, false, false, "More than " + AuthentikAdmin.TYPED_ABOVE + " people: type " + n + " in the confirmation field.", null);
}
else
{
result = admin.execute(request);
}
tally(result, w);
output.getRecords().add(record);
pace(admin);
}
}
}
/*******************************************************************************
** Pace bulk work (Authentik shares its database with the app).
*******************************************************************************/
private void pace(AuthentikAdmin admin)
{
Integer millis = admin.getUtil().getRuntime().getConfig().getBulkPacingMillis();
if(millis != null && millis > 0)
{
com.kingsrook.qqq.backend.core.utils.SleepUtils.sleep(millis, java.util.concurrent.TimeUnit.MILLISECONDS);
}
}
/*******************************************************************************
**
*******************************************************************************/
private void tally(AuthentikAdmin.AdminResult result, String[] w)
{
if(result.ok() && result.pending())
{
lines.computeIfAbsent("pending", k -> line(Status.WARNING, "change needs approval", "changes need approval", "change was sent for approval", "changes were sent for approval")).incrementCount();
}
else if(result.ok() && result.noChange())
{
lines.computeIfAbsent("nochange", k -> line(Status.INFO, "target needed no change", "targets needed no change", "target needed no change", "targets needed no change")).incrementCount();
}
else if(result.ok())
{
lines.computeIfAbsent("ok", k -> line(Status.OK, w[0], w[1], w[2], w[3])).incrementCount();
}
else
{
lines.computeIfAbsent("error:" + result.message(), k -> line(Status.ERROR, "target was not changed: " + result.message(), "targets were not changed: " + result.message(),
"target was not changed: " + result.message(), "targets were not changed: " + result.message())).incrementCount();
}
}
/*******************************************************************************
** Result lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
lines.values().forEach(line -> line.addSelfToListIfAnyCount(rs));
return (rs);
}
}
}
@@ -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.security;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** The qbit's own enforcement point (DESIGN.md section 5.3). QQQ core actions
** do not enforce permissions for non-HTTP callers (QRun-IO/qqq#1061), so every
** call into AuthentikActionUtil is checked here first:
** - reads need the table's authentik.* permission (system sessions may read,
** for the mirror sync);
** - sensitive fields need authentik.audit.read;
** - writes are refused in this release (read-only MVP), and always refused
** for system sessions, in readOnly mode, or without the feature;
** - records outside userScope are invisible.
**
** Protection rules for targets (protected accounts, superuser ancestry) are
** computed here too, so the UI can show them before writes exist.
*******************************************************************************/
public class AuthentikGuard
{
private final AuthentikQBitConfig config;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AuthentikGuard(AuthentikQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** Check that the current session may read a table.
*******************************************************************************/
public void checkRead(AuthentikTableSpec spec) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(!config.hasFeature(AuthentikFeature.READ))
{
throw (new QPermissionDeniedException("Authentik reads are not enabled."));
}
if(session instanceof QSystemUserSession)
{
return;
}
requireUser(session);
if(!has(session, spec.getReadPermission()))
{
throw (new QPermissionDeniedException("You do not have permission to view " + spec.getLabel() + "."));
}
}
/*******************************************************************************
** Writes: refused in this release. Kept as the single gate later phases
** extend (feature flags, readOnly, system sessions, per-target rules).
*******************************************************************************/
public void checkWrite(AuthentikTableSpec spec, String operation) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(session instanceof QSystemUserSession)
{
throw (new QPermissionDeniedException("System sessions may not change Authentik data."));
}
if(Boolean.TRUE.equals(config.getReadOnly()))
{
throw (new QPermissionDeniedException("The Identity screens are in read-only mode."));
}
throw (new QPermissionDeniedException(spec.getLabel() + " cannot be changed from QQQ in this release (" + operation + " is not available; use the Authentik admin UI)."));
}
/*******************************************************************************
** Authorize one change to Authentik for the current session: a signed-in
** user (never a system session), the action's feature on, not read-only,
** and the action's authentik.* permission. The returned authorization is
** what AuthentikActionUtil.write requires (DESIGN.md section 5.3).
*******************************************************************************/
public WriteAuthorization authorize(AdminAction action) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(session instanceof QSystemUserSession)
{
throw (new QPermissionDeniedException("System sessions may not change Authentik data."));
}
requireUser(session);
if(Boolean.TRUE.equals(config.getReadOnly()))
{
throw (new QPermissionDeniedException("The Identity screens are in read-only mode."));
}
if(!config.hasFeature(action.getFeature()))
{
throw (new QPermissionDeniedException(action.getLabel() + " is not enabled (feature " + action.getFeature() + ")."));
}
if(!has(session, action.getPermission()))
{
throw (new QPermissionDeniedException("You do not have permission to do this (" + action.getPermission().getShortName() + ")."));
}
return (new WriteAuthorization(action.name(), operatorEmail()));
}
/*******************************************************************************
** The current operator: the session user's id reference (an email in apps
** that sign in through Authentik), or null.
*******************************************************************************/
public String operatorEmail()
{
QSession session = QContext.getQSession();
return (session == null || session.getUser() == null ? null : session.getUser().getIdReference());
}
/*******************************************************************************
** Whether the current session may see and filter sensitive fields.
*******************************************************************************/
public boolean maySeeSensitive()
{
QSession session = QContext.getQSession();
return (session instanceof QSystemUserSession || has(session, IdentityPermission.AUDIT_READ));
}
/*******************************************************************************
** Whether a user object is inside the configured user scope. Applied to
** every record returned, in addition to the server-side scope filters.
*******************************************************************************/
public boolean isUserInScope(JSONObject user)
{
String type = user.optString("type", "");
if(!List.of(AuthentikTables.DEFAULT_USER_TYPES).contains(type))
{
return (false);
}
if(StringUtils.hasContent(config.getUserScopePathStartsWith()) && !user.optString("path", "").startsWith(config.getUserScopePathStartsWith()))
{
return (false);
}
List<String> scopeGroups = CollectionUtils.nonNullList(config.getUserScopeGroupNames());
if(!scopeGroups.isEmpty())
{
JSONArray groups = user.optJSONArray("groups_obj");
if(groups == null)
{
return (false);
}
for(int i = 0; i < groups.length(); i++)
{
JSONObject group = groups.optJSONObject(i);
if(group != null && scopeGroups.contains(group.optString("name")))
{
return (true);
}
}
return (false);
}
return (true);
}
/*******************************************************************************
** Whether a user is protected from all qbit actions (DESIGN.md section
** 5.4 rule 1): the service account, users with qqq.io/protected: true,
** configured protectedUsernames, and superusers (including superuser
** status inherited through group parents, which Authentik reports in
** is_superuser).
*******************************************************************************/
public ProtectionResult protection(JSONObject user)
{
String username = user.optString("username", "");
if(username.equalsIgnoreCase(config.getEffectiveServiceAccountUsername()))
{
return (ProtectionResult.of("the qbit's own service account"));
}
if(user.optBoolean("is_superuser", false))
{
return (ProtectionResult.of("Authentik superuser"));
}
if(isMarkedProtected(user))
{
return (ProtectionResult.of("marked qqq.io/protected"));
}
for(String protectedName : CollectionUtils.nonNullList(config.getProtectedUsernames()))
{
if(username.equalsIgnoreCase(protectedName))
{
return (ProtectionResult.of("listed in protectedUsernames"));
}
}
if("internal_service_account".equals(user.optString("type")))
{
return (ProtectionResult.of("Authentik internal service account"));
}
return (ProtectionResult.NOT_PROTECTED);
}
/*******************************************************************************
** Whether a user carries qqq.io/protected: true (boolean or "true").
*******************************************************************************/
public static boolean isMarkedProtected(JSONObject user)
{
JSONObject attributes = user == null ? null : user.optJSONObject("attributes");
return (attributes != null && isTrue(attributes.opt(AuthentikJson.ATTR_PROTECTED)));
}
/*******************************************************************************
** Whether the session holds an identity permission.
*******************************************************************************/
public static boolean has(QSession session, IdentityPermission permission)
{
return (session != null && session.hasPermission(permission.getSessionPermission()));
}
/*******************************************************************************
**
*******************************************************************************/
private static void requireUser(QSession session) throws QPermissionDeniedException
{
if(session == null || session.getUser() == null)
{
throw (new QPermissionDeniedException("A signed-in user is required."));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean isTrue(Object value)
{
if(value instanceof Boolean b)
{
return (b);
}
return (value != null && String.valueOf(value).trim().toLowerCase(Locale.ROOT).equals("true"));
}
/*******************************************************************************
** Result of a protection check.
*******************************************************************************/
public record ProtectionResult(boolean isProtected, String reason)
{
public static final ProtectionResult NOT_PROTECTED = new ProtectionResult(false, null);
/*******************************************************************************
** A protected result with a reason.
*******************************************************************************/
public static ProtectionResult of(String reason)
{
return (new ProtectionResult(true, reason));
}
}
}
@@ -1,109 +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.security;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import org.json.JSONObject;
/*******************************************************************************
** Decides whether a person is a bootstrap operator (the lockout escape hatch).
** Users can change their own email in Authentik and emails are not unique, so
** an email is NEVER enough. Someone is a bootstrap operator only when:
** - the token sub is a configured subject (a hashed or opaque id; a sub that
** looks like an email is ignored, since Authentik's user_email sub mode makes
** it mutable); or
** - the email is verified, exactly ONE Authentik user has it, that user is
** active, and that user's pk or uuid is a configured subject.
** Anything else is denied.
*******************************************************************************/
public final class BootstrapOperators
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BootstrapOperators()
{
}
/*******************************************************************************
** Whether a token (sub, email, email_verified) is a bootstrap operator.
**
** @param subjects the configured immutable subjects
** @param client an Authentik client for the lookup
** @param sub the sub claim (may be null)
** @param email the email claim (may be null)
** @param emailVerified the email_verified claim
*******************************************************************************/
public static boolean matches(Set<String> subjects, AuthentikActionUtil client, String sub, String email, boolean emailVerified)
{
if(subjects == null || subjects.isEmpty())
{
return (false);
}
if(sub != null && !sub.isBlank() && !sub.contains("@") && subjects.contains(sub.trim()))
{
return (true);
}
if(!emailVerified || email == null || email.isBlank() || client == null)
{
return (false);
}
return (matchesUniqueUser(subjects, client, email.trim()));
}
/*******************************************************************************
** The email-based path without the verified claim (an admin session has no
** claims): still requires exactly one active user with that email whose pk
** or uuid is a configured subject.
*******************************************************************************/
public static boolean matchesUniqueUser(Set<String> subjects, AuthentikActionUtil client, String email)
{
if(subjects == null || subjects.isEmpty())
{
return (false);
}
try
{
JSONObject found = null;
int count = 0;
for(JSONObject user : client.getAllPages("/core/users/?email=" + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&include_groups=false&include_roles=false", 10))
{
if(email.equalsIgnoreCase(user.optString("email")))
{
found = user;
count++;
}
}
return (count == 1 && found.optBoolean("is_active", true)
&& (subjects.contains(String.valueOf(found.optInt("pk"))) || subjects.contains(found.optString("uuid", "-"))));
}
catch(Exception e)
{
return (false);
}
}
}
@@ -1,139 +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.security;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
/*******************************************************************************
** The qbit's own QQQ permissions (DESIGN.md section 5.2).
**
** Operators write the short name (e.g. "authentik.users.read") into a group's
** qqq.io/permissions attribute; "authentik.*" means all of them. In a QQQ
** session each one is held as its HAS_ACCESS form, the short name plus
** ".hasAccess", which is what QQQ's PermissionsHelper checks for metadata
** guarded by {@link #rules()}.
*******************************************************************************/
public enum IdentityPermission
{
USERS_READ("authentik.users.read"),
USERS_MANAGE("authentik.users.manage"),
GROUPS_MANAGE("authentik.groups.manage"),
MEMBERSHIPS_MANAGE("authentik.memberships.manage"),
PERMISSIONS_MANAGE("authentik.permissions.manage"),
ROLES_MANAGE("authentik.roles.manage"),
CREDENTIALS_RESET("authentik.credentials.reset"),
SESSIONS_REVOKE("authentik.sessions.revoke"),
AUDIT_READ("authentik.audit.read"),
APPROVE("authentik.approve"),
ADMIN("authentik.admin");
/** The wildcard operators write for "all identity permissions". */
public static final String WILDCARD = "authentik.*";
/** Suffix QQQ uses for HAS_ACCESS permissions. */
public static final String HAS_ACCESS_SUFFIX = ".hasAccess";
/** Prefix of the permission names before the owner naming rule (2026-10-12); see UPGRADE.md. */
@Deprecated
public static final String LEGACY_PREFIX = "identity.";
/** Wildcard before the owner naming rule (2026-10-12); no longer granted. */
@Deprecated
public static final String LEGACY_WILDCARD = "identity.*";
private final String shortName;
/*******************************************************************************
** Constructor.
*******************************************************************************/
IdentityPermission(String shortName)
{
this.shortName = shortName;
}
/*******************************************************************************
** The name written in qqq.io/permissions, e.g. "authentik.users.read".
*******************************************************************************/
public String getShortName()
{
return (shortName);
}
/*******************************************************************************
** The name this permission had before the owner naming rule (2026-10-12),
** e.g. "identity.users.read". Only for finding stored grants when upgrading;
** legacy names are never granted.
*******************************************************************************/
@Deprecated
public String getLegacyShortName()
{
return (LEGACY_PREFIX + shortName.substring(shortName.indexOf('.') + 1));
}
/*******************************************************************************
** The QQQ session permission, e.g. "authentik.users.read.hasAccess".
*******************************************************************************/
public String getSessionPermission()
{
return (shortName + HAS_ACCESS_SUFFIX);
}
/*******************************************************************************
** Permission rules guarding metadata by this permission.
*******************************************************************************/
public QPermissionRules rules()
{
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(shortName));
}
/*******************************************************************************
** Look up by short name or by session permission name.
*******************************************************************************/
public static Optional<IdentityPermission> fromName(String name)
{
if(name == null)
{
return (Optional.empty());
}
for(IdentityPermission permission : values())
{
if(permission.shortName.equals(name) || permission.getSessionPermission().equals(name))
{
return (Optional.of(permission));
}
}
return (Optional.empty());
}
}
@@ -1,62 +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.security;
/*******************************************************************************
** Proof that {@link AuthentikGuard} authorized one write. AuthentikActionUtil
** refuses to send any request that changes Authentik data without one, and the
** constructor is package-private, so only the guard can mint it (DESIGN.md
** section 5.3: every write path goes through the guard).
*******************************************************************************/
public final class WriteAuthorization
{
private final String action;
private final String operator;
/*******************************************************************************
** Constructor (package-private: only AuthentikGuard authorizes).
*******************************************************************************/
WriteAuthorization(String action, String operator)
{
this.action = action;
this.operator = operator;
}
/*******************************************************************************
** Getter for action
*******************************************************************************/
public String getAction()
{
return (action);
}
/*******************************************************************************
** Getter for operator
*******************************************************************************/
public String getOperator()
{
return (operator);
}
}
@@ -0,0 +1,75 @@
/*******************************************************************************
** Configuration for the Example Extension QBit.
*******************************************************************************/
package com.kingsrook.qbits.example;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
/*******************************************************************************
** Configuration class for the Example Extension QBit.
*******************************************************************************/
public class ExampleExtensionQBitConfig implements QBitConfig
{
private String targetTableName;
private Boolean enableFeatureX = true;
/*******************************************************************************
** Validate configuration before QBit is produced.
*******************************************************************************/
@Override
public void validate(QInstance qInstance, List<String> errors)
{
if(targetTableName != null && qInstance.getTable(targetTableName) == null)
{
errors.add("Target table not found: " + targetTableName);
}
}
/*******************************************************************************
** Getter for targetTableName
*******************************************************************************/
public String getTargetTableName()
{
return targetTableName;
}
/*******************************************************************************
** Fluent setter for targetTableName
*******************************************************************************/
public ExampleExtensionQBitConfig withTargetTableName(String targetTableName)
{
this.targetTableName = targetTableName;
return this;
}
/*******************************************************************************
** Getter for enableFeatureX
*******************************************************************************/
public Boolean getEnableFeatureX()
{
return enableFeatureX;
}
/*******************************************************************************
** Fluent setter for enableFeatureX
*******************************************************************************/
public ExampleExtensionQBitConfig withEnableFeatureX(Boolean enableFeatureX)
{
this.enableFeatureX = enableFeatureX;
return this;
}
}
@@ -0,0 +1,66 @@
/*******************************************************************************
** Producer for the Example Extension QBit.
*******************************************************************************/
package com.kingsrook.qbits.example;
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.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
/*******************************************************************************
** Producer class for the Example Extension QBit.
*******************************************************************************/
public class ExampleExtensionQBitProducer implements QBitProducer
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-example-extension";
public static final String VERSION = "0.1.0";
private ExampleExtensionQBitConfig config;
/*******************************************************************************
** Produce this QBit into the given QInstance.
*******************************************************************************/
@Override
public void produce(QInstance qInstance, String namespace) throws QException
{
QBitMetaData qBitMetaData = new QBitMetaData()
.withGroupId(GROUP_ID)
.withArtifactId(ARTIFACT_ID)
.withVersion(VERSION)
.withNamespace(namespace)
.withConfig(config);
qInstance.addQBit(qBitMetaData);
if(config != null && config.getTargetTableName() != null)
{
applyTableCustomizations(qInstance, config.getTargetTableName());
}
}
/*******************************************************************************
** Apply customizations to the target table.
*******************************************************************************/
private void applyTableCustomizations(QInstance qInstance, String tableName)
{
// Implement your extension logic here
}
/*******************************************************************************
** Fluent setter for config
*******************************************************************************/
public ExampleExtensionQBitProducer withConfig(ExampleExtensionQBitConfig config)
{
this.config = config;
return this;
}
}
@@ -0,0 +1,23 @@
/*******************************************************************************
** Example table customizer.
*******************************************************************************/
package com.kingsrook.qbits.example.customizers;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Example table customizer that adds behavior to existing tables.
*******************************************************************************/
public class ExampleTableCustomizer
{
/*******************************************************************************
** Apply customizations to the given table.
*******************************************************************************/
public void customize(QTableMetaData table)
{
// Example: Add audit tracking fields
// table.withField(new QFieldMetaData("createdAt", QFieldType.DATE_TIME));
}
}
@@ -1 +0,0 @@
version=${project.version}
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more