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
116 changed files with 360 additions and 79918 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.*/]
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.
-37
View File
@@ -1,37 +0,0 @@
# Changelog
All notable changes to this project are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### 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 -59
View File
@@ -1,66 +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 (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).
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.
- 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.
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 -65
View File
@@ -1,80 +1,42 @@
# QBit Authentik
# QBit Template: Extension
A QQQ QBit for managing [Authentik](https://goauthentik.io) identities (users, groups, memberships, and the QQQ permissions a group grants) from a QQQ app. **Authentik stays the source of truth.** The qbit reads Authentik live and will write through the Authentik API. Changes made in the Authentik admin UI are equally valid.
Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers.
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (copy of the approved spec, QRun-IO/qqq#1065). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Status and linked PRs: [`docs/STATUS.md`](docs/STATUS.md).
## What is an Extension QBit?
## Status
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
`0.1.0-SNAPSHOT`, the **read-only** release line (phases 0 and 1 of DESIGN.md section 11):
## Quick Start
| Available | Not yet (refused by config validation) |
|---|---|
| `READ`: Authentik tables over the API | `MEMBERSHIP`, `GROUPS`, `USERS`, `CREDENTIALS`, `SESSIONS` writes (phases 2-3) |
| `MIRROR` (phase 1) | `RBAC` (phase 4), `WEBHOOK`, `SNAPSHOT_EXPORT` |
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).
Every write is refused by `AuthentikGuard` in this release.
## Structure
## Usage
```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
.withApplicationsInScope(List.of("my-app-prod"));
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
```
This produces:
- an API backend `authentikApi`
- read-only tables `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session` and `authentik_event`
- the Identity Drift Report widget
- an app `authentikAdmin`, labelled "Identity", that hosts can nest, for example under Admin
## Key Characteristics
Access is controlled by QQQ permissions:
- **No QAppSection** - Pure infrastructure
- **No user-facing tables** (or minimal operational)
- **Wraps existing tables/processes** with new behavior
- **Validates dependencies** on other QQQ components
| Permission (held as `<name>.hasAccess`) | Grants |
|---|---|
| `authentik.users.read` | the Identity app and its tables |
| `authentik.audit.read` | sensitive fields and tables |
## Documentation
The full `authentik.*` set is in DESIGN.md section 5.2.
### Authentik side
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the read permissions it needs. The bootstrap blueprint that creates it lives in the host's GitOps repo. One host's version, which the contract tests exercise, is [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml); treat it as an example, not something to apply as is.
## 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 candidate or release is on Maven Central yet; CircleCI publishes snapshots from `develop`. Build from source or use a snapshot until `0.1.0` ships. |
| 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` (`/api/v3` is appended). |
| Authentik setup | A dedicated service account (default username `qqq-<appKey>-idadmin`) whose role holds only the read permissions the QBit needs, created by a bootstrap blueprint in the host's GitOps repository. `src/test/resources/blueprints/kof22-identity-bootstrap.yaml` is a worked example from one host, not something to apply as is. |
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. Produces the `authentikApi` API backend, read-only tables (`authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event`), the drift report widget, and the `authentikAdmin` ("Identity") app, which the host may nest. |
| Required config | `baseUrl`, `apiToken`, `appKey` (lower-case letters, digits, dashes; chooses the service-account name), `applicationsInScope` (Authentik application slugs). Optional: `userScopePathStartsWith`, `groupScopeNamePrefix`, `mirrorBackendName` (phase 1 `MIRROR`), `throttleRequestsPerSecond` (default 10). |
| Secrets / env | `apiToken` as a `${env.X}` reference (the example uses `AUTHENTIK_API_TOKEN`); `bootstrapOperators` defaults to `${env.AUTHENTIK_QBIT_BOOTSTRAP_OPERATORS}` and `webhookSecret` to `${env.AUTHENTIK_WEBHOOK_SECRET}`. Keep them in the host's secret store, never in config files. |
| Permissions | `authentik.users.read` (Identity app and tables), `authentik.audit.read` (sensitive fields and tables); the full `authentik.*` set is in DESIGN.md section 5.2. Granted through the Authentik group attribute `qqq.io/permissions`, read by `AuthentikGroupPermissionMapper`. |
| Storage | None for the read-only line, except the mirror tables when `MIRROR` is enabled on `mirrorBackendName`. |
| Known limitations | Read-only: every write is refused by `AuthentikGuard` until phases 2-3. `RBAC`, `WEBHOOK` and `SNAPSHOT_EXPORT` are refused by config validation. |
| Upgrading | First release line; nothing to migrate. |
## Build
```bash
./mvnw test # unit tests (recorded 2026.8.3 fixtures, 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.
-1061
View File
File diff suppressed because it is too large. Load diff
-103
View File
@@ -1,103 +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 3 records the phase 0 findings)
## 0. Decisions surfaced by phase 0 (read first)
| # | Decision | Recommendation |
|---|---|---|
| D1 | **Repo visibility.** `QRun-IO/qbit-authentik` was created **private**, because creating a public repo was blocked for the agent. Other generic qbits (quick-search, sql-workbench) are public, and the spec is already public in qqq#1065. CI publishes `develop` snapshots and `release/*` RCs to Maven Central / Sonatype (with sources jars), which would make the code public anyway. | Make it public before the first publish: *Settings → General → Change visibility*. Then check that CircleCI follows the repo (it must, for PR CI). |
| 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:
- 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.
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.
-42
View File
@@ -1,42 +0,0 @@
# Status
The Authentik qbit's progress through DESIGN.md section 11, with links. Updated with each PR.
## Phase 0: foundations (agent items done; owner items open)
| # | Item | State | Where |
|---|---|---|---|
| 1 | Repo, DESIGN.md, CI, README, CLAUDE.md | done | this repo (`develop`); private for now, see owner checklist D1 |
| 2 | Config, producer, `AuthentikActionUtil`, pinned schema + drift test | done | phase 0 PR |
| 3 | Testcontainers harness, fixture recorder, contract tests (b), (c), (d), (h) | done | phase 0 PR, `src/test/java/.../contract` |
| 4 | Open points (i)-(iv) resolved | done | DESIGN.md section 0.2; QRun-IO/qqq#1065 revision 3 |
| 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 draft PR |
| 10 | Seed attributes, break-glass password | **owner** | checklist |
| 11 | Read-only on staging | after 6-10 | flip `AUTHENTIK_QBIT_ENABLED` |
## Phase 1: read-only MVP
| Item | State |
|---|---|
| Users, groups, memberships, sessions, events tables | done (phase 1 PR) |
| Effective permissions (resolver), inherited superuser | done |
| Identity drift report (widget) | done |
| Identity app under Admin, after Web Users (kof22) | Website-Backend draft PR #94 |
| `AuthentikGroupPermissionMapper` (sign-in permissions from groups) | done in the qbit; the kof22 cut-over (replacing `AuthentikAuthenticationCustomizer`) waits for owner item 10 |
| Code review (general + silent-failure) | done; all high and most medium findings fixed, with regression tests |
| Mirror tables, group tree, access-matrix widgets | next PRs |
## Contract test results (Authentik 2026.8.3)
`CI=true ./mvnw verify`:
| Phase | Unit tests | Contract tests |
|---|---|---|
| 0 | 48 | 27 |
| 1 | 68 | 31 |
All green, JaCoCo gate met. CircleCI is not enabled for this repo yet (owner checklist D1).
-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:]))
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,223 +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.metadata.AuthentikAppMetaDataProducer;
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.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);
LOG.info("Authentik QBit produced", logPair("version", VERSION), logPair("appKey", qBitConfig.getAppKey()),
logPair("features", qBitConfig.getFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
return (new MetaDataProducerMultiOutput());
}
/*******************************************************************************
** 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);
}
}
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,306 +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";
/*******************************************************************************
** 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());
}
/*******************************************************************************
** 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", "false")
.withDefaultListParam("include_parents", "true")
.withDefaultListParam("include_children", "true")
.withDefaultGetParam("include_users", "false")
.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);
}
}
@@ -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,461 +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);
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.getResolvedBootstrapOperators().isEmpty() ? "" : " bootstrapOperators 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 = 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."));
}
}
}
@@ -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,91 +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.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.layout.QAppMetaData;
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)
{
for(String specName : AuthentikTables.all().keySet())
{
QTableMetaData table = produced.get(QTableMetaData.class, config.applyTablePrefix(specName));
if(table != null)
{
app.withChild(table);
}
}
String driftReport = config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME);
if(produced.get(com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData.class, driftReport) != null)
{
app.withWidgets(java.util.List.of(driftReport));
}
}
return (app);
}
/*******************************************************************************
** Last, so the tables exist in the output.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (900);
}
}
@@ -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,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,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_user: /core/users/.
*******************************************************************************/
public class AuthentikUserTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.USER);
}
}
@@ -1,243 +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.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 bootstrapOperators 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().toLowerCase();
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, email));
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
}
/*******************************************************************************
** The permissions for a user's group names and email.
*******************************************************************************/
public Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String email)
{
Set<String> rs = new TreeSet<>();
for(String group : groupNames)
{
rs.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
}
AuthentikRuntime runtime = runtime();
if(runtime != null && email != null && runtime.getConfig().getResolvedBootstrapOperators().contains(email))
{
LOG.warn("bootstrapOperators grants all identity permissions; remove it once an identity administrator is set up in Authentik", logPair("email", email));
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 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,189 +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.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());
for(String raw : node.permissions())
{
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,286 +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 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)
{
}
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)
{
GroupGraph graph = new GroupGraph();
for(JSONObject group : groups)
{
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(group.optJSONObject("attributes"))));
}
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);
}
/*******************************************************************************
**
*******************************************************************************/
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,265 +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.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)."));
}
/*******************************************************************************
** 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,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());
}
}
@@ -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}
@@ -1,175 +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.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Tests for AuthentikQBitConfig.
*******************************************************************************/
class AuthentikQBitConfigTest
{
/*******************************************************************************
**
*******************************************************************************/
private static List<String> errors(AuthentikQBitConfig config)
{
List<String> errors = new ArrayList<>();
config.validate(new QInstance(), errors);
return (errors);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void validConfigHasNoErrors()
{
assertThat(errors(AuthentikTestSupport.unitConfig())).isEmpty();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void requiredSettings()
{
List<String> errors = errors(new AuthentikQBitConfig());
assertThat(errors).anyMatch(e -> e.contains("baseUrl is required"));
assertThat(errors).anyMatch(e -> e.contains("apiToken is required"));
assertThat(errors).anyMatch(e -> e.contains("appKey is required"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void baseUrlMustBeHostRoot()
{
assertThat(errors(AuthentikTestSupport.unitConfig().withBaseUrl("ftp://x"))).anyMatch(e -> e.contains("absolute http(s)"));
assertThat(errors(AuthentikTestSupport.unitConfig().withBaseUrl("https://auth.example.test/api/v3"))).anyMatch(e -> e.contains("host root"));
assertThat(errors(AuthentikTestSupport.unitConfig().withBaseUrl("not a url at all"))).isNotEmpty();
assertThat(AuthentikTestSupport.unitConfig().withBaseUrl("https://auth.example.test//").getApiRoot()).isEqualTo("https://auth.example.test/api/v3");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void unavailableFeaturesAndDependencies()
{
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MEMBERSHIP))))
.anyMatch(e -> e.contains("MEMBERSHIP is not available"));
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.MIRROR)))).anyMatch(e -> e.contains("READ is required"));
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MIRROR)))).anyMatch(e -> e.contains("mirrorBackendName"));
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MIRROR)).withMirrorBackendName("nope")))
.anyMatch(e -> e.contains("backend not found"));
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS)))).anyMatch(e -> e.contains("recoveryEmailStage"));
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.WEBHOOK)).withWebhookSecret(null))).anyMatch(e -> e.contains("webhookSecret"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void numericAndNameSettings()
{
assertThat(errors(AuthentikTestSupport.unitConfig().withThrottleRequestsPerSecond(0))).anyMatch(e -> e.contains("at least 1"));
assertThat(errors(AuthentikTestSupport.unitConfig().withListCacheTtlSeconds(-1))).anyMatch(e -> e.contains("non-negative"));
assertThat(errors(AuthentikTestSupport.unitConfig().withTableNamePrefix("9bad"))).anyMatch(e -> e.contains("tableNamePrefix"));
assertThat(errors(AuthentikTestSupport.unitConfig().withAppKey("Bad Key"))).anyMatch(e -> e.contains("appKey"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void secretsResolveAndNeverPrint() throws Exception
{
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withApiToken("${env.QBIT_AUTHENTIK_TEST_UNSET_VARIABLE}");
assertThat(config.getResolvedApiToken()).isNull();
assertThat(errors(config)).anyMatch(e -> e.contains("apiToken"));
AuthentikQBitConfig literal = AuthentikTestSupport.unitConfig();
assertThat(literal.getResolvedApiToken()).isEqualTo(AuthentikTestSupport.TEST_TOKEN);
assertThat(literal.toString()).doesNotContain(AuthentikTestSupport.TEST_TOKEN).contains("******");
String json = new ObjectMapper().writeValueAsString(literal.withWebhookSecret("hook-secret-value"));
assertThat(json).doesNotContain(AuthentikTestSupport.TEST_TOKEN).doesNotContain("hook-secret-value");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void derivedNames()
{
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig();
assertThat(config.applyPrefix("admin")).isEqualTo("authentikAdmin");
assertThat(config.applyTablePrefix("user")).isEqualTo("authentik_user");
assertThat(config.applyTablePrefix("groupMember")).isEqualTo("authentik_group_member");
assertThat(config.getApiBackendName()).isEqualTo("authentikApi");
assertThat(config.getEffectiveServiceAccountUsername()).isEqualTo("qqq-kof22-admin-idadmin");
assertThat(config.withServiceAccountUsername("custom-sa").getEffectiveServiceAccountUsername()).isEqualTo("custom-sa");
assertThat(new AuthentikQBitConfig().withTableNamePrefix("").applyTablePrefix("user")).isEqualTo("user");
assertThat(new AuthentikQBitConfig().withTableNamePrefix("").applyTablePrefix("groupMember")).isEqualTo("group_member");
assertThat(new AuthentikQBitConfig().withTableNamePrefix("myAuth").applyTablePrefix("groupMember")).isEqualTo("my_auth_group_member");
assertThat(config.withBootstrapOperators(" A@x.test, ,b@x.test ").getResolvedBootstrapOperators()).containsExactly("a@x.test", "b@x.test");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void toSnakeCase()
{
assertThat(AuthentikQBitConfig.toSnakeCase(null)).isNull();
assertThat(AuthentikQBitConfig.toSnakeCase("")).isEqualTo("");
assertThat(AuthentikQBitConfig.toSnakeCase("user")).isEqualTo("user");
assertThat(AuthentikQBitConfig.toSnakeCase("groupMember")).isEqualTo("group_member");
assertThat(AuthentikQBitConfig.toSnakeCase("GroupMember")).isEqualTo("group_member");
assertThat(AuthentikQBitConfig.toSnakeCase("group_member")).isEqualTo("group_member");
assertThat(AuthentikQBitConfig.toSnakeCase("mfaTOTPDevice")).isEqualTo("mfa_totp_device");
assertThat(AuthentikQBitConfig.toSnakeCase("auth2Factor")).isEqualTo("auth2_factor");
}
}
@@ -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;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
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.layout.QAppMetaData;
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.AuthorizationType;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Tests for AuthentikQBitProducer.
*******************************************************************************/
class AuthentikQBitProducerTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void producesBackendTablesAndApp() throws QException
{
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig();
QInstance qInstance = AuthentikTestSupport.produce(config);
APIBackendMetaData backend = (APIBackendMetaData) qInstance.getBackend("authentikApi");
assertThat(backend.getBaseUrl()).isEqualTo("https://authentik.example.test/api/v3");
assertThat(backend.getAuthorizationType()).isEqualTo(AuthorizationType.CUSTOM);
assertThat(backend.getApiKey()).isNull();
assertThat(backend.getActionUtil().getName()).isEqualTo(AuthentikActionUtil.class.getName());
QTableMetaData users = qInstance.getTable("authentik_user");
assertThat(users.getBackendName()).isEqualTo("authentikApi");
assertThat(users.getPrimaryKeyField()).isEqualTo("pk");
assertThat(users.getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.users.read");
assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_QUERY)).isTrue();
assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_INSERT)).isFalse();
assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_DELETE)).isFalse();
assertThat(users.getField("uuid").getIsHidden()).isTrue();
assertThat(qInstance.getTable("authentik_group")).isNotNull();
QAppMetaData app = qInstance.getApp("authentikAdmin");
assertThat(app.getLabel()).isEqualTo("Identity");
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("authentik_user", "authentik_group", "authentik_group_member", "authentik_session", "authentik_event");
assertThat(app.getWidgets()).containsExactly("authentikDriftReport");
assertThat(qInstance.getTable("authentik_session").getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.audit.read");
assertThat(qInstance.getWidget("authentikDriftReport").getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.admin");
assertThat(PermissionsHelper.getAllAvailablePermissionNames(qInstance)).contains(IdentityPermission.USERS_READ.getSessionPermission());
assertThat(qInstance.getQBits()).containsKey("com.kingsrook.qbits:authentik");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void namespaceBecomesPrefix() throws QException
{
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withTableNamePrefix(null);
QInstance qInstance = new QInstance();
new AuthentikQBitProducer().withQBitConfig(config).withNamespace("idp").produce(qInstance);
assertThat(qInstance.getTable("idp_user")).isNotNull();
assertThat(qInstance.getBackend("idpApi")).isNotNull();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void invalidConfigFailsWithAllErrors()
{
assertThatThrownBy(() -> new AuthentikQBitProducer().produce(new QInstance())).hasMessageContaining("no config");
assertThatThrownBy(() -> new AuthentikQBitProducer().withQBitConfig(new AuthentikQBitConfig()).produce(new QInstance()))
.hasMessageContaining("baseUrl").hasMessageContaining("apiToken").hasMessageContaining("appKey");
assertThat(new AuthentikQBitProducer().isEnabled()).isFalse();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void identityAndVersion()
{
AuthentikQBitProducer producer = new AuthentikQBitProducer();
producer.setQBitConfig(AuthentikTestSupport.unitConfig());
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("authentik");
assertThat(AuthentikQBitProducer.getVersion()).isNotBlank();
assertThat(IdentityPermission.fromName("authentik.audit.read")).contains(IdentityPermission.AUDIT_READ);
assertThat(IdentityPermission.fromName("authentik.audit.read.hasAccess")).contains(IdentityPermission.AUDIT_READ);
assertThat(IdentityPermission.fromName("nope")).isEmpty();
assertThat(IdentityPermission.fromName(null)).isEmpty();
}
}
@@ -1,142 +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.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
/*******************************************************************************
** Shared test helpers: build a QInstance with the qbit, swap in a test action
** util, and log in with chosen identity permissions.
*******************************************************************************/
public final class AuthentikTestSupport
{
public static final String TEST_TOKEN = "unit-test-token-abcdef0123456789";
public static final String OPERATOR = "operator@example.test";
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private AuthentikTestSupport()
{
}
/*******************************************************************************
** A valid config for unit tests (fake host, literal token).
*******************************************************************************/
public static AuthentikQBitConfig unitConfig()
{
return (new AuthentikQBitConfig()
.withBaseUrl("https://authentik.example.test")
.withApiToken(TEST_TOKEN)
.withAppKey("kof22-admin")
.withListCacheTtlSeconds(0)
.withThrottleRequestsPerSecond(1000)
.withThrottleBurst(1000));
}
/*******************************************************************************
** A QInstance (MOCK auth) with the qbit produced from the config.
*******************************************************************************/
public static QInstance produce(AuthentikQBitConfig config) throws QException
{
QInstance qInstance = new QInstance();
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
return (qInstance);
}
/*******************************************************************************
** Point the qbit's API backend at a different action util class.
*******************************************************************************/
public static void useActionUtil(QInstance qInstance, AuthentikQBitConfig config, Class<?> actionUtilClass)
{
((APIBackendMetaData) qInstance.getBackend(config.getApiBackendName())).setActionUtil(new QCodeReference(actionUtilClass));
}
/*******************************************************************************
** Log in as the operator with the given identity permissions.
*******************************************************************************/
public static QSession loginWith(QInstance qInstance, IdentityPermission... permissions)
{
Set<String> names = new HashSet<>();
Arrays.stream(permissions).forEach(p -> names.add(p.getSessionPermission()));
return (loginWithPermissions(qInstance, names));
}
/*******************************************************************************
** Log in as the operator with raw QQQ permission names.
*******************************************************************************/
public static QSession loginWithPermissions(QInstance qInstance, Set<String> permissionNames)
{
QSession session = new QSession();
session.setUser(new QUser().withIdReference(OPERATOR).withFullName("Test Operator"));
session.setPermissions(new HashSet<>(permissionNames));
QContext.init(qInstance, session);
return (session);
}
/*******************************************************************************
** Read a classpath resource as text.
*******************************************************************************/
public static String resource(String path)
{
try(InputStream inputStream = AuthentikTestSupport.class.getClassLoader().getResourceAsStream(path))
{
if(inputStream == null)
{
throw (new IllegalArgumentException("No such test resource: " + path));
}
return (new String(inputStream.readAllBytes(), StandardCharsets.UTF_8));
}
catch(IOException e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -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;
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.DockerClientFactory;
/*******************************************************************************
** JUnit condition for integration tests that need Docker.
**
** Without Docker, the tests are skipped on a developer machine, but fail when
** CI=true, so a CI build can never pass with its integration tests silently
** skipped. Register it ahead of @Testcontainers, so no container is started
** when the tests are disabled.
*******************************************************************************/
public class RequiresDockerCondition implements ExecutionCondition
{
/*******************************************************************************
** Evaluate against the local Docker environment and the CI env var.
*******************************************************************************/
@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context)
{
return (evaluate(DockerClientFactory.instance().isDockerAvailable(), System.getenv("CI")));
}
/*******************************************************************************
** Decide whether Docker-backed tests run, skip, or fail.
**
** @param dockerAvailable whether a usable Docker environment was found
** @param ciValue value of the CI environment variable (may be null)
** @return enabled when Docker is available; disabled when it is not and CI
** is not "true"
** @throws IllegalStateException when Docker is unavailable and CI is "true"
*******************************************************************************/
static ConditionEvaluationResult evaluate(Boolean dockerAvailable, String ciValue)
{
if(Boolean.TRUE.equals(dockerAvailable))
{
return (ConditionEvaluationResult.enabled("Docker is available"));
}
if("true".equalsIgnoreCase(ciValue))
{
throw (new IllegalStateException("Docker is required for integration tests when CI=true, but no usable Docker environment was found"));
}
return (ConditionEvaluationResult.disabled("Docker is not available; skipping (with CI=true these tests fail instead)"));
}
}
@@ -1,75 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Tests for RequiresDockerCondition.
*******************************************************************************/
class RequiresDockerConditionTest
{
/*******************************************************************************
** Docker available: integration tests run, whether or not CI is set.
*******************************************************************************/
@Test
void testEvaluate_dockerAvailable_enabled()
{
ConditionEvaluationResult local = RequiresDockerCondition.evaluate(true, null);
ConditionEvaluationResult ci = RequiresDockerCondition.evaluate(true, "true");
assertThat(local.isDisabled()).isFalse();
assertThat(ci.isDisabled()).isFalse();
}
/*******************************************************************************
** No Docker outside CI: integration tests are skipped.
*******************************************************************************/
@Test
void testEvaluate_noDockerOutsideCi_disabled()
{
assertThat(RequiresDockerCondition.evaluate(false, null).isDisabled()).isTrue();
assertThat(RequiresDockerCondition.evaluate(false, "").isDisabled()).isTrue();
assertThat(RequiresDockerCondition.evaluate(false, "false").isDisabled()).isTrue();
}
/*******************************************************************************
** No Docker with CI=true: evaluation throws, so the tests fail instead of
** silently skipping.
*******************************************************************************/
@Test
void testEvaluate_noDockerInCi_throws()
{
assertThatThrownBy(() -> RequiresDockerCondition.evaluate(false, "true"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("CI=true");
assertThatThrownBy(() -> RequiresDockerCondition.evaluate(false, "TRUE"))
.isInstanceOf(IllegalStateException.class);
}
}
@@ -1,179 +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.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureRouter;
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.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
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.metadata.QInstance;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Regression tests for the phase 0/1 review findings.
*******************************************************************************/
class ReviewFixesTest
{
private final AuthentikFilterMapper mapper = new AuthentikFilterMapper();
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
** Search is plain text only (QLSearch could reach sensitive fields).
*******************************************************************************/
@Test
void searchIsPlainTextOnly() throws QException
{
for(String term : List.of("attributes.x = \"y\"", "a ~ b", "(x)", "it's", "a and b", "x OR y", "a\\b"))
{
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, term)), true))
.as(term).isInstanceOf(QUserFacingException.class).hasMessageContaining("plain text");
}
assertThat(mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, "read.probe-1")), false).params().get(0).getValue())
.isEqualTo("read.probe-1");
}
/*******************************************************************************
** Sorting by the key falls back to the default; booleans are strict; a
** criterion without a field is refused.
*******************************************************************************/
@Test
void orderingBooleansAndNullFields() throws QException
{
assertThat(mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter().withOrderBy(new QFilterOrderBy("pk", false)), false).params().get(0).getValue()).isEqualTo("username");
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, "maybe")), false))
.isInstanceOf(QUserFacingException.class);
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria(null, QCriteriaOperator.EQUALS, "x")), false))
.isInstanceOf(QUserFacingException.class);
}
/*******************************************************************************
** Config: https unless loopback or explicitly allowed; groupScope refused.
*******************************************************************************/
@Test
void configChecks()
{
List<String> errors = new ArrayList<>();
AuthentikTestSupport.unitConfig().withBaseUrl("http://auth.example.test").validate(new QInstance(), errors);
assertThat(errors).anyMatch(e -> e.contains("https"));
for(String ok : List.of("http://localhost:9000", "http://127.0.0.1:9000"))
{
List<String> none = new ArrayList<>();
AuthentikTestSupport.unitConfig().withBaseUrl(ok).validate(new QInstance(), none);
assertThat(none).as(ok).isEmpty();
}
List<String> allowed = new ArrayList<>();
AuthentikTestSupport.unitConfig().withBaseUrl("http://auth.internal").withAllowInsecureHttp(true).validate(new QInstance(), allowed);
assertThat(allowed).isEmpty();
List<String> scope = new ArrayList<>();
AuthentikTestSupport.unitConfig().withGroupScopeNamePrefix("kof").validate(new QInstance(), scope);
assertThat(scope).anyMatch(e -> e.contains("groupScopeNamePrefix"));
}
/*******************************************************************************
** Malformed qqq.io/* attributes are kept as findings, not silently dropped.
*******************************************************************************/
@Test
void malformedAttributes()
{
GroupGraph graph = GroupGraph.fromGroups(List.of(new JSONObject().put("pk", "g").put("name", "g").put("parents", new JSONArray())
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("cms.edit").put(5).put(new JSONObject())).put("qqq.io/app-access", 12))));
assertThat(graph.byPk("g").permissions()).containsExactly("cms.edit");
assertThat(graph.byPk("g").malformed()).hasSize(3);
}
/*******************************************************************************
** Through the action util: limit 0, ".." keys, scope widening refused,
** malformed attributes reported by the drift report.
*******************************************************************************/
@Test
void actionUtilAndDrift() throws QException
{
FixtureActionUtil.reset();
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0).withUserScopePathStartsWith("users");
QInstance qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
FixtureRouter router = new FixtureRouter().install();
QueryInput zero = new QueryInput(config.applyTablePrefix("user"));
zero.setFilter(new QQueryFilter().withLimit(0));
assertThat(new QueryAction().execute(zero).getRecords()).isEmpty();
assertThat(new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("group")).withPrimaryKey(".."))).isNull();
assertThat(FixtureActionUtil.REQUESTS.stream().noneMatch(r -> r.getURI().getPath().contains(".."))).isTrue();
QueryInput widen = new QueryInput(config.applyTablePrefix("user"));
widen.setFilter(new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.EQUALS, "internal_service_account")));
assertThatThrownBy(() -> new QueryAction().execute(widen)).hasMessageContaining("outside the users");
QueryInput otherPath = new QueryInput(config.applyTablePrefix("user"));
otherPath.setFilter(new QQueryFilter(new QFilterCriteria("path", QCriteriaOperator.STARTS_WITH, "service-accounts")));
assertThatThrownBy(() -> new QueryAction().execute(otherPath)).hasMessageContaining("outside the users");
router.groups.get(0).put("attributes", new JSONObject().put("qqq.io/permissions", 42));
router.refreshGraph();
List<DriftFinding> findings = new AuthentikDriftReport(com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run();
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.message().startsWith("Malformed entry"));
}
}
@@ -1,177 +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.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.authentik.client.AuthentikApiSurface;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.LoaderOptions;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Schema drift test (DESIGN.md section 10): every operation and query
** parameter the qbit uses exists in the Authentik OpenAPI schema.
**
** By default it reads the pinned 2026.8.3 schema
** (src/test/resources/authentik/schema-2026.8.3.yml, fetched unauthenticated
** from https://auth.kof22.com/api/v3/schema/). The scheduled schema-drift
** workflow runs it with -Dauthentik.schema.file=<newer release schema>.
*******************************************************************************/
public class SchemaDriftTest
{
public static final String PINNED_VERSION = "2026.8.3";
/*******************************************************************************
** Parse an OpenAPI YAML file (the schema is ~1.8 MB).
*******************************************************************************/
public static JsonNode loadSchema(String text) throws IOException
{
LoaderOptions options = new LoaderOptions();
options.setCodePointLimit(64 * 1024 * 1024);
YAMLFactory factory = YAMLFactory.builder().loaderOptions(options).build();
return (new ObjectMapper(factory).readTree(text));
}
/*******************************************************************************
**
*******************************************************************************/
private static JsonNode schemaUnderTest() throws IOException
{
String file = System.getProperty("authentik.schema.file");
if(file != null && !file.isBlank())
{
return (loadSchema(java.nio.file.Files.readString(new File(file).toPath())));
}
return (loadSchema(AuthentikTestSupport.resource("authentik/schema-" + PINNED_VERSION + ".yml")));
}
/*******************************************************************************
** The pinned file is the version we claim.
*******************************************************************************/
@Test
void pinnedSchemaIsTheTestedVersion() throws IOException
{
JsonNode schema = loadSchema(AuthentikTestSupport.resource("authentik/schema-" + PINNED_VERSION + ".yml"));
assertThat(schema.at("/info/version").asText()).isEqualTo(PINNED_VERSION);
}
/*******************************************************************************
** Every used operation and parameter exists.
*******************************************************************************/
@Test
void everyUsedOperationAndParameterExists() throws IOException
{
List<String> problems = findDrift(schemaUnderTest());
assertThat(problems).as("Authentik API drift").isEmpty();
}
/*******************************************************************************
** Compare the used surface against a schema; returns human-readable problems.
*******************************************************************************/
public static List<String> findDrift(JsonNode schema)
{
List<String> problems = new ArrayList<>();
JsonNode paths = schema.get("paths");
for(AuthentikApiSurface.Operation operation : AuthentikApiSurface.operations())
{
String schemaPath = findPath(paths, operation.path());
JsonNode op = schemaPath == null ? null : paths.get(schemaPath).get(operation.method());
if(op == null)
{
problems.add("missing operation " + operation.method().toUpperCase() + " " + operation.path());
continue;
}
Set<String> available = parameterNames(schema, op);
for(String param : operation.params())
{
if(!available.contains(param))
{
problems.add("missing parameter " + param + " on " + operation.method().toUpperCase() + " " + schemaPath);
}
}
}
return (problems);
}
/*******************************************************************************
** Parameters of an operation, resolving $refs.
*******************************************************************************/
public static Set<String> parameterNames(JsonNode schema, JsonNode operation)
{
Set<String> rs = new LinkedHashSet<>();
for(JsonNode parameter : operation.path("parameters"))
{
JsonNode resolved = parameter;
if(parameter.has("$ref"))
{
String ref = parameter.get("$ref").asText();
resolved = schema.at(ref.substring(1));
}
rs.add(resolved.path("name").asText());
}
return (rs);
}
/*******************************************************************************
** Find the schema path for a surface path; "{key}" matches any {param}.
*******************************************************************************/
static String findPath(JsonNode paths, String surfacePath)
{
if(paths.has(surfacePath))
{
return (surfacePath);
}
String regex = surfacePath.replace("{key}", "\\{[^/]+\\}");
Iterator<Map.Entry<String, JsonNode>> fields = paths.fields();
while(fields.hasNext())
{
String candidate = fields.next().getKey();
if(candidate.matches(regex))
{
return (candidate);
}
}
return (null);
}
}
@@ -1,482 +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.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.apache.http.client.methods.HttpRequestBase;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Unit tests for AuthentikActionUtil over recorded 2026.8.3 fixtures
** (src/test/resources/fixtures/2026.8.3, captured by the contract tests).
*******************************************************************************/
class AuthentikActionUtilTest
{
private QInstance qInstance;
private AuthentikQBitConfig config;
private String userTable;
private String groupTable;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
FixtureActionUtil.reset();
config = AuthentikTestSupport.unitConfig();
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
userTable = config.applyTablePrefix("user");
groupTable = config.applyTablePrefix("group");
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
** The recorded users page, sliced into Authentik-shaped pages.
*******************************************************************************/
private static JSONArray recordedUsers()
{
return (new JSONObject(FixtureActionUtil.fixture("users-list")).getJSONArray("results"));
}
/*******************************************************************************
** Serve /core/users/ with real paging semantics over the recorded users.
*******************************************************************************/
private static void serveUsersPaged()
{
JSONArray all = recordedUsers();
FixtureActionUtil.handler = request ->
{
int page = Integer.parseInt(param(request, "page", "1"));
int pageSize = Integer.parseInt(param(request, "page_size", "20"));
int from = (page - 1) * pageSize;
JSONArray slice = new JSONArray();
for(int i = from; i < Math.min(all.length(), from + pageSize); i++)
{
slice.put(all.get(i));
}
int totalPages = (int) Math.ceil(all.length() / (double) pageSize);
JSONObject body = new JSONObject()
.put("pagination", new JSONObject().put("count", all.length()).put("current", page).put("total_pages", totalPages))
.put("results", slice);
return (FixtureActionUtil.response(200, body.toString()));
};
}
/*******************************************************************************
**
*******************************************************************************/
private static String param(HttpRequestBase request, String name, String defaultValue)
{
String query = request.getURI().getRawQuery();
if(query != null)
{
for(String pair : query.split("&"))
{
String[] parts = pair.split("=", 2);
if(parts[0].equals(name))
{
return (URLDecoder.decode(parts[1], StandardCharsets.UTF_8));
}
}
}
return (defaultValue);
}
/*******************************************************************************
**
*******************************************************************************/
private static List<String> params(HttpRequestBase request, String name)
{
String query = request.getURI().getRawQuery();
return (query == null ? List.of() : java.util.Arrays.stream(query.split("&")).filter(p -> p.startsWith(name + "=")).map(p -> URLDecoder.decode(p.substring(name.length() + 1), StandardCharsets.UTF_8)).toList());
}
/*******************************************************************************
** The last /core/users/ list request (the once-per-JVM version check may
** come after it).
*******************************************************************************/
private static HttpRequestBase lastUsersRequest()
{
List<HttpRequestBase> users = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/core/users/")).toList();
return (users.get(users.size() - 1));
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> query(QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(userTable);
input.setFilter(filter);
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
** Requests carry the bearer token, the default scope types, and
** include_groups; pages are page/page_size based.
*******************************************************************************/
@Test
void queryBuildsAuthentikRequests() throws QException
{
serveUsersPaged();
List<QRecord> records = query(new QQueryFilter().withLimit(2).withSkip(2));
assertThat(records).hasSize(2);
HttpRequestBase request = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/core/users/")).findFirst().orElseThrow();
assertThat(request.getFirstHeader("Authorization").getValue()).isEqualTo("Bearer " + AuthentikTestSupport.TEST_TOKEN);
assertThat(request.getURI().toString()).startsWith("https://authentik.example.test/api/v3/core/users/?");
assertThat(params(request, "type")).containsExactly("internal", "external", "service_account");
assertThat(param(request, "include_groups", null)).isEqualTo("true");
assertThat(param(request, "page", null)).isEqualTo("2");
assertThat(param(request, "page_size", null)).isEqualTo("2");
assertThat(param(request, "ordering", null)).isEqualTo("username");
}
/*******************************************************************************
** Non-aligned skip: 100-row pages from the right page and offset; an
** unlimited query walks every page.
*******************************************************************************/
@Test
void unalignedSkipAndUnlimitedQueries() throws QException
{
serveUsersPaged();
JSONArray all = recordedUsers();
List<QRecord> odd = query(new QQueryFilter().withLimit(3).withSkip(1));
assertThat(odd).extracting(r -> r.getValueString("username")).containsExactly(
all.getJSONObject(1).getString("username"), all.getJSONObject(2).getString("username"), all.getJSONObject(3).getString("username"));
assertThat(param(lastUsersRequest(), "page_size", null)).isEqualTo("100");
assertThat(query(new QQueryFilter())).hasSize(all.length());
}
/*******************************************************************************
** Count reads pagination.count from a page_size=1 request.
*******************************************************************************/
@Test
void countUsesPaginationCount() throws QException
{
serveUsersPaged();
assertThat(new CountAction().execute(new CountInput(userTable)).getCount()).isEqualTo(recordedUsers().length());
assertThat(param(lastUsersRequest(), "page_size", null)).isEqualTo("1");
}
/*******************************************************************************
** Record mapping from the recorded user (computed and joined fields).
*******************************************************************************/
@Test
void getMapsRecordedUser() throws QException
{
JSONObject user = new JSONObject(FixtureActionUtil.fixture("user-get"));
FixtureActionUtil.handler = r -> FixtureActionUtil.response(200, user.toString());
QRecord record = new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(user.getInt("pk")));
assertThat(record.getValueInteger("pk")).isEqualTo(user.getInt("pk"));
assertThat(record.getValueString("username")).isEqualTo(user.getString("username"));
assertThat(record.getValueBoolean("isActive")).isEqualTo(user.getBoolean("is_active"));
assertThat(record.getValue("dateJoined")).isInstanceOf(java.time.Instant.class);
assertThat(record.getValueBoolean("isProtected")).isFalse();
assertThat(record.getValues()).doesNotContainKey("attributes").doesNotContainKey("passwordChangeDate");
assertThat(FixtureActionUtil.REQUESTS.get(0).getURI().getPath()).isEqualTo("/api/v3/core/users/" + user.getInt("pk") + "/");
}
/*******************************************************************************
** 404 and out-of-scope records come back as "no record".
*******************************************************************************/
@Test
void getMissingAndOutOfScope() throws QException
{
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(424242))).isNull();
JSONObject outpost = new JSONObject(FixtureActionUtil.fixture("user-get")).put("type", "internal_service_account");
FixtureActionUtil.handler = r -> FixtureActionUtil.response(200, outpost.toString());
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(1))).isNull();
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey("../../admin"))).isNull();
}
/*******************************************************************************
** Protection: superusers, qqq.io/protected, configured names, the service
** account.
*******************************************************************************/
@Test
void protectionMarkers() throws QException
{
JSONObject base = new JSONObject(FixtureActionUtil.fixture("user-get"));
for(JSONObject variant : List.of(
new JSONObject(base.toString()).put("is_superuser", true),
new JSONObject(base.toString()).put("attributes", new JSONObject().put("qqq.io/protected", "true")),
new JSONObject(base.toString()).put("username", "qqq-kof22-admin-idadmin")))
{
FixtureActionUtil.handler = r -> FixtureActionUtil.response(200, variant.toString());
QRecord record = new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(1));
assertThat(record.getValueBoolean("isProtected")).isTrue();
assertThat(record.getDisplayValue("isProtected")).startsWith("Protected: ");
}
}
/*******************************************************************************
** Key lookups fetch each id; sensitive fields appear with audit.read.
*******************************************************************************/
@Test
void keyLookupAndSensitiveFields() throws QException
{
JSONObject user = new JSONObject(FixtureActionUtil.fixture("user-get"));
FixtureActionUtil.handler = r -> r.getURI().getPath().endsWith("/" + user.getInt("pk") + "/") ? FixtureActionUtil.response(200, user.toString()) : FixtureActionUtil.response(404, "{}");
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
List<QRecord> records = query(new QQueryFilter(new QFilterCriteria("pk", QCriteriaOperator.IN, List.of(user.getInt("pk"), 999))));
assertThat(records).hasSize(1);
assertThat(records.get(0).getValues()).containsKey("attributes");
assertThat(new CountAction().execute(new CountInput(userTable).withFilter(new QQueryFilter(new QFilterCriteria("pk", QCriteriaOperator.IN, List.of(user.getInt("pk"), 999))))).getCount()).isEqualTo(1);
}
/*******************************************************************************
** Groups from the recorded list: joined names and qqq.io attributes.
*******************************************************************************/
@Test
void groupsFromRecordedList() throws QException
{
String body = FixtureActionUtil.fixture("groups-list");
FixtureActionUtil.handler = r -> FixtureActionUtil.response(200, body);
QueryInput input = new QueryInput(groupTable);
List<QRecord> groups = new QueryAction().execute(input).getRecords();
QRecord identityAdmins = groups.stream().filter(g -> "identity-admins".equals(g.getValueString("name"))).findFirst().orElseThrow();
assertThat(identityAdmins.getValueString("permissions")).isEqualTo("authentik.*");
assertThat(identityAdmins.getValueString("appAccess")).isEqualTo("kof22-website-prod");
assertThat(identityAdmins.getValueBoolean("isSuperuser")).isFalse();
assertThat(param(FixtureActionUtil.REQUESTS.get(0), "include_users", null)).isEqualTo("false");
}
/*******************************************************************************
** Permissions: none, system session, writes.
*******************************************************************************/
@Test
void guardDecisions() throws QException
{
serveUsersPaged();
AuthentikTestSupport.loginWith(qInstance);
assertThatThrownBy(() -> query(new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> new CountAction().execute(new CountInput(userTable))).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> new GetAction().execute(new GetInput(userTable).withPrimaryKey(1))).isInstanceOf(QPermissionDeniedException.class);
QContext.init(qInstance, new QSystemUserSession());
assertThat(query(new QQueryFilter().withLimit(1))).hasSize(1);
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(userTable).withRecord(new QRecord().withValue("username", "x"))))
.hasMessageContaining("System sessions");
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(userTable).withRecord(new QRecord().withValue("username", "x")))).hasMessageContaining("cannot be changed");
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(userTable).withRecord(new QRecord().withValue("pk", 1)))).hasMessageContaining("cannot be changed");
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(userTable).withPrimaryKey(1))).hasMessageContaining("cannot be changed");
config.withReadOnly(true);
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(userTable).withPrimaryKey(1))).hasMessageContaining("read-only");
}
/*******************************************************************************
** Recorded error bodies map to user-facing / permission exceptions, with
** the token never in a message.
*******************************************************************************/
@Test
void errorMapping() throws QException
{
String badFilter = FixtureActionUtil.fixture("error-400-bad-filter");
FixtureActionUtil.handler = r -> FixtureActionUtil.response(400, badFilter);
assertThatThrownBy(() -> query(new QQueryFilter())).isInstanceOf(QUserFacingException.class).hasMessageContaining("last_login__lt");
String forbidden = FixtureActionUtil.fixture("error-403");
FixtureActionUtil.handler = r -> FixtureActionUtil.response(403, forbidden);
assertThatThrownBy(() -> query(new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class).hasMessageContaining("403");
FixtureActionUtil.handler = r -> FixtureActionUtil.response(401, "{\"detail\":\"Token invalid " + AuthentikTestSupport.TEST_TOKEN + "\"}");
assertThatThrownBy(() -> query(new QQueryFilter())).hasMessageContaining("401").satisfies(e -> assertThat(e.getMessage()).doesNotContain(AuthentikTestSupport.TEST_TOKEN));
FixtureActionUtil.handler = r -> FixtureActionUtil.response(418, "not json " + AuthentikTestSupport.TEST_TOKEN);
assertThatThrownBy(() -> query(new QQueryFilter())).hasMessageContaining("418").satisfies(e -> assertThat(e.getMessage()).doesNotContain(AuthentikTestSupport.TEST_TOKEN));
FixtureActionUtil.handler = r -> FixtureActionUtil.response(200, "<html>");
assertThatThrownBy(() -> query(new QQueryFilter())).hasMessageContaining("non-JSON");
}
/*******************************************************************************
** 429 honours Retry-After; GET 5xx and transport errors are retried.
*******************************************************************************/
@Test
void retries() throws QException
{
AtomicInteger calls = new AtomicInteger();
String body = FixtureActionUtil.fixture("users-list");
FixtureActionUtil.handler = r -> switch(calls.incrementAndGet())
{
case 1 -> FixtureActionUtil.response(429, "{}", FixtureActionUtil.retryAfter(0));
case 2 -> FixtureActionUtil.response(502, "bad gateway");
default -> FixtureActionUtil.response(200, body);
};
assertThat(query(new QQueryFilter().withLimit(1))).hasSize(1);
assertThat(calls.get()).isEqualTo(3);
calls.set(0);
FixtureActionUtil.handler = r ->
{
calls.incrementAndGet();
throw (new IllegalStateException("connection reset; Bearer " + AuthentikTestSupport.TEST_TOKEN));
};
assertThatThrownBy(() -> query(new QQueryFilter())).hasMessageContaining("Could not reach Authentik").satisfies(e -> assertThat(e.getMessage()).doesNotContain(AuthentikTestSupport.TEST_TOKEN));
assertThat(calls.get()).isEqualTo(3);
}
/*******************************************************************************
** Missing token fails before any request.
*******************************************************************************/
@Test
void missingTokenFailsFast() throws QException
{
config.withApiToken("${env.QBIT_AUTHENTIK_TEST_UNSET_VARIABLE}");
assertThatThrownBy(() -> query(new QQueryFilter())).hasMessageContaining("token is not configured");
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
}
/*******************************************************************************
** The response cache serves repeated identical reads.
*******************************************************************************/
@Test
void responseCache() throws QException
{
config.withListCacheTtlSeconds(30);
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
serveUsersPaged();
query(new QQueryFilter().withLimit(2));
int afterFirst = FixtureActionUtil.REQUESTS.size();
query(new QQueryFilter().withLimit(2));
assertThat(FixtureActionUtil.REQUESTS.size()).isEqualTo(afterFirst);
com.kingsrook.qbits.authentik.AuthentikRuntime.forBackend(config.getApiBackendName()).invalidateCache();
query(new QQueryFilter().withLimit(2));
assertThat(FixtureActionUtil.REQUESTS.size()).isGreaterThan(afterFirst);
}
/*******************************************************************************
** Error-detail flattening and path stripping.
*******************************************************************************/
@Test
void helpers()
{
assertThat(AuthentikActionUtil.errorDetail("{\"detail\":\"Nope.\"}")).isEqualTo("Nope.");
assertThat(AuthentikActionUtil.errorDetail("{\"name\":[\"a\",\"b\"],\"non_field_errors\":[\"c\"]}")).contains("name: a b").contains("c");
assertThat(AuthentikActionUtil.errorDetail(null)).isEqualTo("(no details)");
assertThat(AuthentikActionUtil.errorDetail("x".repeat(300))).endsWith("...");
assertThat(AuthentikActionUtil.pathOf("https://h/api/v3/core/users/?search=alice%40x")).isEqualTo("/api/v3/core/users/");
assertThat(AuthentikActionUtil.pathOf("not a uri ?q=1")).isEqualTo("not a uri ");
}
}
@@ -1,225 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
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.NowWithOffset;
import org.apache.http.NameValuePair;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Tests for AuthentikFilterMapper.
*******************************************************************************/
class AuthentikFilterMapperTest
{
private final AuthentikFilterMapper mapper = new AuthentikFilterMapper();
private final AuthentikTableSpec users = AuthentikTables.get(AuthentikTables.USER);
private final AuthentikTableSpec groups = AuthentikTables.get(AuthentikTables.GROUP);
/*******************************************************************************
**
*******************************************************************************/
private List<String> params(AuthentikTableSpec spec, QQueryFilter filter) throws QException
{
return (mapper.map(spec, filter, false).params().stream().map(p -> p.getName() + "=" + p.getValue()).toList());
}
/*******************************************************************************
** Appendix A example of the design.
*******************************************************************************/
@Test
void designAppendixExample() throws QException
{
QQueryFilter filter = new QQueryFilter(
new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, false),
new QFilterCriteria("lastLogin", QCriteriaOperator.LESS_THAN, LocalDate.of(2026, 7, 13)))
.withOrderBy(new QFilterOrderBy("username"));
assertThat(params(users, filter)).containsExactly("is_active=false", "last_login__lt=2026-07-13T00:00:00Z", "ordering=username");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void nullFilterUsesDefaultOrdering() throws QException
{
assertThat(params(users, null)).containsExactly("ordering=username");
assertThat(params(groups, new QQueryFilter())).containsExactly("ordering=name");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void inUsesRepeatedPlainNames() throws QException
{
assertThat(params(users, new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.IN, List.of("internal", "external")))))
.containsExactly("type=internal", "type=external", "ordering=username");
assertThat(params(groups, new QQueryFilter(new QFilterCriteria("memberUsername", QCriteriaOperator.IN, List.of("a", "b")))))
.containsExactly("members_by_username=a", "members_by_username=b", "ordering=name");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void inclusiveDateBoundsAndBetween() throws QException
{
Instant at = Instant.parse("2026-01-01T00:00:00Z");
assertThat(params(users, new QQueryFilter(new QFilterCriteria("dateJoined", QCriteriaOperator.GREATER_THAN_OR_EQUALS, at))))
.contains("date_joined__gt=2025-12-31T23:59:59.999Z");
assertThat(params(users, new QQueryFilter(new QFilterCriteria("dateJoined", QCriteriaOperator.LESS_THAN_OR_EQUALS, "2026-01-01T00:00:00Z"))))
.contains("date_joined__lt=2026-01-01T00:00:00.001Z");
assertThat(params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.BETWEEN, List.of("2026-01-01", "2026-02-01")))))
.contains("last_login__gt=2025-12-31T23:59:59.999Z", "last_login__lt=2026-02-01T00:00:00.001Z");
assertThat(params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.GREATER_THAN, "2026-03-04T05:06:07+02:00"))))
.contains("last_login__gt=2026-03-04T03:06:07Z");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void expressionsAreEvaluated() throws QException
{
List<String> params = params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.LESS_THAN, NowWithOffset.minus(90, java.time.temporal.ChronoUnit.DAYS))));
assertThat(params.get(0)).startsWith("last_login__lt=20");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void blankChecksAndSearch() throws QException
{
assertThat(params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.IS_BLANK)))).contains("last_login__isnull=true");
assertThat(params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.IS_NOT_BLANK)))).contains("last_login__isnull=false");
assertThat(params(users, new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.CONTAINS, " kof22 ")))).contains("search=kof22");
assertThat(params(users, new QQueryFilter(new QFilterCriteria("path", QCriteriaOperator.STARTS_WITH, "users/")))).contains("path_startswith=users/");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void orderingDescendingAndMultiple() throws QException
{
assertThat(params(users, new QQueryFilter().withOrderBy(new QFilterOrderBy("lastLogin", false)).withOrderBy(new QFilterOrderBy("username"))))
.containsExactly("ordering=-last_login,username");
assertThatThrownBy(() -> params(users, new QQueryFilter().withOrderBy(new QFilterOrderBy("groupNames")))).isInstanceOf(QUserFacingException.class);
}
/*******************************************************************************
** Key-only filters become key lookups (users have no pk list filter).
*******************************************************************************/
@Test
void keyLookups() throws QException
{
AuthentikFilterMapper.MappedFilter mapped = mapper.map(users, new QQueryFilter(new QFilterCriteria("pk", QCriteriaOperator.IN, List.of(1, 2, 3)), new QFilterCriteria("pk", QCriteriaOperator.EQUALS, 2)), false);
assertThat(mapped.isKeyLookup()).isTrue();
assertThat(mapped.keyLookup()).containsExactly(2);
assertThat(mapper.map(users, new QQueryFilter(new QFilterCriteria("pk", QCriteriaOperator.EQUALS, 7)), false).keyLookup()).containsExactly(7);
}
/*******************************************************************************
** Everything Authentik cannot evaluate is rejected, never post-filtered.
*******************************************************************************/
@Test
void unsupportedFiltersAreRejected()
{
QFilterCriteria a = new QFilterCriteria("username", QCriteriaOperator.EQUALS, "a");
QFilterCriteria b = new QFilterCriteria("email", QCriteriaOperator.EQUALS, "b");
assertThatThrownBy(() -> params(users, new QQueryFilter(a, b).withBooleanOperator(QQueryFilter.BooleanOperator.OR))).isInstanceOf(QUserFacingException.class).hasMessageContaining("OR");
assertThatThrownBy(() -> params(users, new QQueryFilter(a).withSubFilters(List.of(new QQueryFilter(b))))).isInstanceOf(QUserFacingException.class).hasMessageContaining("sub-filters");
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.NOT_EQUALS, "a")))).isInstanceOf(QUserFacingException.class);
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("nope", QCriteriaOperator.EQUALS, "a")))).isInstanceOf(QUserFacingException.class).hasMessageContaining("Unknown field");
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "a"), new QFilterCriteria("username", QCriteriaOperator.EQUALS, "b")))).isInstanceOf(QUserFacingException.class);
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, "a"), new QFilterCriteria("email", QCriteriaOperator.CONTAINS, "b")))).isInstanceOf(QUserFacingException.class).hasMessageContaining("one text-search");
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.IN, List.of())))).isInstanceOf(QUserFacingException.class);
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.BETWEEN, List.of("2026-01-01"))))).isInstanceOf(QUserFacingException.class);
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.LESS_THAN, "yesterday-ish")))).isInstanceOf(QUserFacingException.class).hasMessageContaining("valid date");
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS)))).isInstanceOf(QUserFacingException.class);
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, " ")))).isInstanceOf(QUserFacingException.class);
QFilterCriteria fieldToField = new QFilterCriteria("username", QCriteriaOperator.EQUALS, List.of());
fieldToField.setOtherFieldName("email");
assertThatThrownBy(() -> params(users, new QQueryFilter(fieldToField))).isInstanceOf(QUserFacingException.class);
}
/*******************************************************************************
** Sensitive fields can't be filtered without authentik.audit.read.
*******************************************************************************/
@Test
void sensitiveFieldFilters() throws QException
{
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("passwordChangeDate", QCriteriaOperator.IS_BLANK));
assertThatThrownBy(() -> mapper.map(users, filter, false)).isInstanceOf(QUserFacingException.class).hasMessageContaining("permission");
assertThatThrownBy(() -> mapper.map(users, filter, true)).isInstanceOf(QUserFacingException.class).hasMessageContaining("not supported");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void booleanFormatting() throws QException
{
for(java.io.Serializable value : List.<java.io.Serializable>of(true, "TRUE", 1, "yes"))
{
List<NameValuePair> mapped = mapper.map(users, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, value)), false).params();
assertThat(mapped.get(0).getValue()).isEqualTo("true");
}
assertThat(mapper.map(users, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, "no")), false).params().get(0).getValue()).isEqualTo("false");
}
}
@@ -1,70 +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 org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Tests for AuthentikRedactor.
*******************************************************************************/
class AuthentikRedactorTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void tokenAndBearerValuesAreMasked()
{
String text = "auth failed for token abc123secret with header Authorization: Bearer xyz.789-other";
String safe = AuthentikRedactor.redactText(text, "abc123secret");
assertThat(safe).doesNotContain("abc123secret").doesNotContain("xyz.789-other").contains("Bearer ******");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void secretLookingJsonValuesAreMasked()
{
String body = "{\"identifier\":\"t\",\"key\":\"s3cr3t\",\"link\":\"https://x/recover?token=abc\",\"Password\":\"p\\\"w\",\"name\":\"ok\"}";
String safe = AuthentikRedactor.redactText(body, null);
assertThat(safe).doesNotContain("s3cr3t").doesNotContain("recover?token").doesNotContain("p\\\"w").contains("\"name\":\"ok\"").contains("\"key\":\"******\"");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void sensitiveEndpointBodiesAreDropped()
{
assertThat(AuthentikRedactor.redactBody("/api/v3/core/users/4/recovery_email/", "{\"email_stage\":\"x\"}", null)).doesNotContain("email_stage");
assertThat(AuthentikRedactor.redactBody("/api/v3/core/tokens/abc/view_key/", "{\"key\":\"k\"}", null)).contains("omitted");
assertThat(AuthentikRedactor.redactBody("/api/v3/core/users/", "{\"username\":\"u\"}", null)).contains("\"username\":\"u\"");
assertThat(AuthentikRedactor.redactBody("/api/v3/core/users/", null, null)).isNull();
assertThat(AuthentikRedactor.redactText(null, "t")).isNull();
assertThat(AuthentikRedactor.isSensitivePath(null)).isFalse();
}
}
@@ -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.client;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
import org.apache.http.Header;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.message.BasicHeader;
/*******************************************************************************
** AuthentikActionUtil with the network replaced: requests are captured and
** answered by a handler (usually serving recorded 2026.8.3 fixtures).
*******************************************************************************/
public class FixtureActionUtil extends AuthentikActionUtil
{
public static final String EMPTY_PAGE = "{\"pagination\":{\"count\":0,\"current\":1,\"total_pages\":1},\"results\":[]}";
public static final List<HttpRequestBase> REQUESTS = new ArrayList<>();
public static Function<HttpRequestBase, QHttpResponse> handler = r -> response(404, "{\"detail\":\"Not found.\"}");
/** Body served for the group-graph sweep (GET /core/groups/?include_users=false&include_parents=false...). */
public static String groupGraphBody = EMPTY_PAGE;
/*******************************************************************************
** Reset captured requests and the handler.
*******************************************************************************/
public static void reset()
{
REQUESTS.clear();
handler = r -> response(404, "{\"detail\":\"Not found.\"}");
groupGraphBody = EMPTY_PAGE;
AuthentikActionUtil.resetVersionCheck();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
protected QHttpResponse send(HttpRequestBase request)
{
REQUESTS.add(request);
if(request.getURI().getPath().endsWith("/admin/version/"))
{
return (response(200, AuthentikTestSupport.resource("fixtures/2026.8.3/admin-version.json")));
}
if(request.getURI().getPath().endsWith("/core/groups/") && String.valueOf(request.getURI().getRawQuery()).contains("include_parents=false"))
{
return (response(200, groupGraphBody));
}
return (handler.apply(request));
}
/*******************************************************************************
** Build a response.
*******************************************************************************/
public static QHttpResponse response(int status, String body, Header... headers)
{
return (new QHttpResponse().withStatusCode(status).withContent(body).withHeaderList(List.of(headers)));
}
/*******************************************************************************
** A Retry-After header.
*******************************************************************************/
public static Header retryAfter(int seconds)
{
return (new BasicHeader("Retry-After", String.valueOf(seconds)));
}
/*******************************************************************************
** A recorded fixture.
*******************************************************************************/
public static String fixture(String name)
{
return (AuthentikTestSupport.resource("fixtures/2026.8.3/" + name + ".json"));
}
}
@@ -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.client;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
import org.apache.http.client.methods.HttpRequestBase;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** A FixtureActionUtil handler that answers like a small Authentik built from
** the recorded 2026.8.3 fixtures: users (with groups), groups (with users),
** tokens, blueprints, events and sessions, honouring the query parameters
** the qbit sends. Tests mutate the JSON to build scenarios.
*******************************************************************************/
public class FixtureRouter implements Function<HttpRequestBase, QHttpResponse>
{
public final List<JSONObject> users = results("users-list");
public final List<JSONObject> groups = results("groups-list-with-users");
public final List<JSONObject> tokens = results("tokens-list");
public final List<JSONObject> blueprints = results("blueprints-list");
public final List<JSONObject> events = results("events-by-service-account");
public final List<JSONObject> sessions = results("sessions-list");
/*******************************************************************************
** Results of a recorded list fixture (mutable copies).
*******************************************************************************/
public static List<JSONObject> results(String fixture)
{
JSONArray array = new JSONObject(FixtureActionUtil.fixture(fixture)).getJSONArray("results");
List<JSONObject> rs = new ArrayList<>();
for(int i = 0; i < array.length(); i++)
{
rs.add(new JSONObject(array.getJSONObject(i).toString()));
}
return (rs);
}
/*******************************************************************************
** Install as the FixtureActionUtil handler (and as the group graph).
*******************************************************************************/
public FixtureRouter install()
{
FixtureActionUtil.handler = this;
FixtureActionUtil.groupGraphBody = page(groups).toString();
return (this);
}
/*******************************************************************************
** Refresh the group-graph body after mutating groups.
*******************************************************************************/
public void refreshGraph()
{
FixtureActionUtil.groupGraphBody = page(groups).toString();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QHttpResponse apply(HttpRequestBase request)
{
String path = request.getURI().getPath().replaceFirst("^/api/v3", "");
if(path.equals("/core/users/"))
{
return (ok(page(filter(users, request, "username", "username"))));
}
if(path.matches("/core/users/[0-9]+/"))
{
int pk = Integer.parseInt(path.split("/")[3]);
return (users.stream().filter(u -> u.getInt("pk") == pk).findFirst().map(FixtureRouter::ok).orElse(FixtureActionUtil.response(404, "{}")));
}
if(path.equals("/core/groups/"))
{
return (ok(page(filter(groups, request, "name", "name"))));
}
if(path.matches("/core/groups/[^/]+/"))
{
String pk = path.split("/")[3];
return (groups.stream().filter(g -> g.getString("pk").equals(pk)).findFirst().map(FixtureRouter::ok).orElse(FixtureActionUtil.response(404, "{}")));
}
if(path.equals("/core/tokens/"))
{
return (ok(page(tokens)));
}
if(path.equals("/managed/blueprints/"))
{
return (ok(page(blueprints)));
}
if(path.equals("/events/events/"))
{
return (ok(page(filter(events, request, "username", null))));
}
if(path.equals("/core/authenticated_sessions/"))
{
return (ok(page(sessions)));
}
return (FixtureActionUtil.response(404, "{\"detail\":\"Not found.\"}"));
}
/*******************************************************************************
** Apply a simple equality query parameter, plus the users attributes filter.
*******************************************************************************/
private static List<JSONObject> filter(List<JSONObject> objects, HttpRequestBase request, String param, String property)
{
String value = param(request, param);
String attrs = param(request, "attributes");
Predicate<JSONObject> keep = o -> true;
if(value != null && property != null)
{
keep = keep.and(o -> value.equals(o.optString(property)));
}
if(attrs != null)
{
JSONObject wanted = new JSONObject(attrs);
keep = keep.and(o ->
{
JSONObject attributes = o.optJSONObject("attributes");
return (attributes != null && wanted.keySet().stream().allMatch(k -> String.valueOf(wanted.get(k)).equals(String.valueOf(attributes.opt(k)))));
});
}
return (objects.stream().filter(keep).toList());
}
/*******************************************************************************
**
*******************************************************************************/
private static String param(HttpRequestBase request, String name)
{
String query = request.getURI().getRawQuery();
if(query == null)
{
return (null);
}
for(String pair : query.split("&"))
{
String[] parts = pair.split("=", 2);
if(parts[0].equals(name) && parts.length == 2)
{
return (URLDecoder.decode(parts[1], StandardCharsets.UTF_8));
}
}
return (null);
}
/*******************************************************************************
**
*******************************************************************************/
public static JSONObject page(List<JSONObject> objects)
{
return (new JSONObject()
.put("pagination", new JSONObject().put("count", objects.size()).put("current", 1).put("total_pages", 1))
.put("results", new JSONArray(objects)));
}
/*******************************************************************************
**
*******************************************************************************/
private static QHttpResponse ok(JSONObject body)
{
return (FixtureActionUtil.response(200, body.toString()));
}
}
@@ -1,216 +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 com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
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 com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Phase 1 tables over the recorded fixtures: the virtual membership table,
** sessions, events, and the graph-derived fields on users and groups.
*******************************************************************************/
class Phase1TablesTest
{
private QInstance qInstance;
private AuthentikQBitConfig config;
private FixtureRouter router;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
FixtureActionUtil.reset();
config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0);
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
router = new FixtureRouter().install();
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> query(String table, QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(config.applyTablePrefix(table));
input.setFilter(filter);
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
**
*******************************************************************************/
private JSONObject group(String name)
{
return (router.groups.stream().filter(g -> g.getString("name").equals(name)).findFirst().orElseThrow());
}
/*******************************************************************************
** Memberships: by group name / pk, by user, by key, sweep, ordering, paging.
*******************************************************************************/
@Test
void memberships() throws QException
{
JSONObject child = group("phase1-child-of-identity-admins");
JSONObject member = child.getJSONArray("users_obj").getJSONObject(0);
int userPk = member.getInt("pk");
String groupPk = child.getString("pk");
////////////////////////////////////////////////////////////////////////////
// the recorded users list predates this user: add it, with its groups //
////////////////////////////////////////////////////////////////////////////
router.users.add(new JSONObject(member.toString()).put("type", "internal").put("path", "users")
.put("groups_obj", new org.json.JSONArray().put(new JSONObject().put("pk", groupPk).put("name", child.getString("name")))));
List<QRecord> byName = query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")));
assertThat(byName).extracting(r -> r.getValueString("username")).containsExactly("phase1.member");
assertThat(byName.get(0).getValueString("id")).isEqualTo(groupPk + ":" + userPk);
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("groupPk", QCriteriaOperator.IN, List.of(groupPk, "missing"))))).hasSize(1);
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, userPk)))).extracting(r -> r.getValueString("groupName")).contains("phase1-child-of-identity-admins");
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "phase1.member")))).isNotEmpty();
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "nobody")))).isEmpty();
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, 999999)))).isEmpty();
QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk));
assertThat(byKey.getValueString("username")).isEqualTo("phase1.member");
assertThat(new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("groupMember")).withPrimaryKey("no-colon"))).isNull();
List<QRecord> all = query("groupMember", new QQueryFilter());
int count = new CountAction().execute(new CountInput(config.applyTablePrefix("groupMember"))).getCount();
assertThat(all).hasSize(count).isNotEmpty();
assertThat(all).extracting(r -> r.getValueString("groupName")).isSortedAccordingTo(String.CASE_INSENSITIVE_ORDER);
List<QRecord> byUserDesc = query("groupMember", new QQueryFilter().withOrderBy(new QFilterOrderBy("username", false)).withLimit(2).withSkip(1));
assertThat(byUserDesc).hasSize(2);
assertThatThrownBy(() -> query("groupMember", new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.EQUALS, "x")))).isInstanceOf(QUserFacingException.class);
}
/*******************************************************************************
** With a user scope, membership rows are checked member by member.
*******************************************************************************/
@Test
void membershipsHonourUserScope() throws QException
{
config.withUserScopePathStartsWith("nowhere/");
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")))).isEmpty();
}
/*******************************************************************************
** Graph-derived fields: effective permissions (inherited) and inherited
** superuser.
*******************************************************************************/
@Test
void derivedFields() throws QException
{
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/core/groups/")
? FixtureActionUtil.response(200, FixtureRouter.page(router.groups).toString())
: router.apply(request);
List<QRecord> groups = query("group", new QQueryFilter());
QRecord child = groups.stream().filter(g -> "phase1-child-of-identity-admins".equals(g.getValueString("name"))).findFirst().orElseThrow();
assertThat(child.getValueString("effectivePermissions")).contains("authentik.memberships.manage.hasAccess");
assertThat(child.getValueBoolean("isSuperuserInherited")).isFalse();
QRecord admins = groups.stream().filter(g -> "authentik Admins".equals(g.getValueString("name"))).findFirst().orElseThrow();
assertThat(admins.getValueBoolean("isSuperuserInherited")).isTrue();
JSONObject member = router.users.stream().filter(u -> u.getString("username").equals("read.probe1")).findFirst().orElseThrow();
FixtureActionUtil.handler = router;
QRecord user = new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("user")).withPrimaryKey(member.getInt("pk")));
assertThat(user.getValue("effectivePermissions")).isNotNull();
}
/*******************************************************************************
** Sessions and events need authentik.audit.read and map recorded rows.
*******************************************************************************/
@Test
void sessionsAndEvents() throws QException
{
assertThatThrownBy(() -> query("session", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/events/events/")
? FixtureActionUtil.response(200, FixtureActionUtil.fixture("events-list"))
: router.apply(request);
List<QRecord> sessions = query("session", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")));
assertThat(sessions).isNotEmpty();
assertThat(sessions.get(0).getValueString("uuid")).isNotBlank();
assertThat(sessions.get(0).getValue("lastUsed")).isInstanceOf(java.time.Instant.class);
List<QRecord> events = query("event", new QQueryFilter(new QFilterCriteria("modelName", QCriteriaOperator.EQUALS, "group")).withLimit(5));
assertThat(events).isNotEmpty().allMatch(e -> "group".equals(e.getValueString("modelName")));
assertThat(events.get(0).getValueString("modelApp")).isEqualTo("authentik_core");
assertThat(events.get(0).getValue("created")).isInstanceOf(java.time.Instant.class);
}
}
@@ -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.client;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Tests for TokenBucketThrottle, on a fake clock.
*******************************************************************************/
class TokenBucketThrottleTest
{
/*******************************************************************************
** 10 rps, burst 20: the first 20 calls are free, then each waits 100 ms of
** clock time; sleeping advances the fake clock.
*******************************************************************************/
@Test
void defaultTenPerSecondWithBurstTwenty()
{
AtomicLong clock = new AtomicLong(0);
List<Long> sleeps = new ArrayList<>();
TokenBucketThrottle throttle = new TokenBucketThrottle(10, 20, clock::get, nanos ->
{
sleeps.add(nanos);
clock.addAndGet(nanos);
});
for(int i = 0; i < 20; i++)
{
throttle.acquire();
}
assertThat(sleeps).isEmpty();
throttle.acquire();
assertThat(sleeps).hasSize(1);
assertThat(sleeps.get(0)).isBetween(99_000_000L, 101_000_000L);
for(int i = 0; i < 9; i++)
{
throttle.acquire();
}
long total = sleeps.stream().mapToLong(Long::longValue).sum();
assertThat(total).isBetween(990_000_000L, 1_010_000_000L);
}
/*******************************************************************************
** Idle time refills, capped at the burst.
*******************************************************************************/
@Test
void refillsWhileIdleUpToBurst()
{
AtomicLong clock = new AtomicLong(0);
TokenBucketThrottle throttle = new TokenBucketThrottle(10, 5, clock::get, clock::addAndGet);
for(int i = 0; i < 5; i++)
{
throttle.acquire();
}
assertThat(throttle.getAvailableTokens()).isLessThan(0.01);
clock.addAndGet(60_000_000_000L);
assertThat(throttle.getAvailableTokens()).isEqualTo(5.0);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void rejectsZeroRate()
{
assertThatThrownBy(() -> new TokenBucketThrottle(0, 1)).isInstanceOf(IllegalArgumentException.class);
}
/*******************************************************************************
** The real clock: a burst of 1 at 1000 rps makes the second call wait a
** little, without failing.
*******************************************************************************/
@Test
void systemClockConstructor()
{
TokenBucketThrottle throttle = new TokenBucketThrottle(1000, 1);
long start = System.nanoTime();
throttle.acquire();
throttle.acquire();
assertThat(System.nanoTime() - start).isLessThan(1_000_000_000L);
}
}
@@ -1,126 +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.contract;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.APP_SLUG;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Open point (iii): what the access matrix can rely on.
**
** - check_access honours for_user ONLY for superusers. For the service
** account it silently evaluates the CALLER instead: the answer would be
** wrong, not refused. No permission fixes this, and the service account must
** never be a superuser, so the qbit computes access itself.
** - POST /policies/all/{uuid}/test/ (the alternative) fails with HTTP 500
** (WrongAppError) for non-superusers on expression policies in 2026.8.3.
** - The application LIST shows a non-superuser only the apps it can itself
** access (here: none); GET by slug works with view_application. So
** applicationsInScope must be explicit slugs.
** - The app's policy bindings are readable with view_policybinding.
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(40)
class AccessCheckIT
{
private static TestApi admin;
private static TestApi sa;
private static int publisher;
/*******************************************************************************
**
*******************************************************************************/
@BeforeAll
static void beforeAll()
{
AuthentikTestStack stack = AuthentikTestStack.get();
admin = stack.admin();
sa = stack.serviceAccount();
admin.post("/core/users/", new JSONObject().put("username", "access.publisher").put("name", "Access Publisher").put("type", "internal").put("path", "users"));
publisher = admin.userPk("access.publisher");
admin.post("/core/groups/" + admin.groupPk("kof-website-publishers") + "/add_user/", new JSONObject().put("pk", publisher));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void checkAccessForUserIsIgnoredForNonSuperusers()
{
assertThat(admin.getOk("/core/applications/" + APP_SLUG + "/check_access/?for_user=" + publisher).getBoolean("passing")).isTrue();
TestApi.Response asServiceAccount = sa.get("/core/applications/" + APP_SLUG + "/check_access/?for_user=" + publisher);
assertThat(asServiceAccount.status()).isEqualTo(200);
assertThat(asServiceAccount.json().getBoolean("passing")).as("evaluated for the service account itself, not for_user").isFalse();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void policyTestEndpointFailsForNonSuperusers()
{
String policy = admin.first("/policies/all/?search=kof22-require-admins-group").getString("pk");
assertThat(admin.post("/policies/all/" + policy + "/test/", new JSONObject().put("user", publisher)).json().getBoolean("passing")).isTrue();
String role = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
org.json.JSONArray permission = new org.json.JSONArray().put("authentik_policies.view_policy");
admin.post("/rbac/permissions/assigned_by_roles/" + role + "/assign/", new JSONObject().put("permissions", permission));
try
{
assertThat(sa.post("/policies/all/" + policy + "/test/", new JSONObject().put("user", publisher)).status()).isEqualTo(500);
}
finally
{
admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", new JSONObject().put("permissions", permission));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void applicationsAreReadableBySlugButNotListed()
{
assertThat(sa.getOk("/core/applications/").getJSONArray("results")).isEmpty();
assertThat(sa.getOk("/core/applications/?superuser_full_list=true").getJSONArray("results")).isEmpty();
JSONObject application = sa.getOk("/core/applications/" + APP_SLUG + "/");
assertThat(application.getString("policy_engine_mode")).isEqualTo("any");
JSONObject bindings = sa.getOk("/policies/bindings/?target=" + application.getString("pbm_uuid"));
assertThat(bindings.getJSONArray("results").length()).isEqualTo(1);
assertThat(bindings.getJSONArray("results").getJSONObject(0).getJSONObject("policy_obj").getString("name")).isEqualTo("kof22-require-admins-group");
}
}
@@ -1,350 +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.contract;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BooleanSupplier;
import org.json.JSONObject;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;
/*******************************************************************************
** Disposable Authentik for contract tests (DESIGN.md section 10): Authentik
** server + worker (2026.8.3 unless -Dauthentik.test.image), Postgres, and
** Mailpit, on a private network. Started once per JVM and shared by every
** contract test class.
**
** The two kof22-shaped blueprints from src/test/resources/blueprints are in
** /blueprints/custom BEFORE the first boot, so the stack starts as an EMPTY
** instance applying the bootstrap: that is contract test (h).
**
** Credentials here are test-only constants for a throwaway container: the
** bootstrap admin token (AUTHENTIK_BOOTSTRAP_TOKEN), and a service-account
** token minted through the admin API at start-up.
*******************************************************************************/
public final class AuthentikTestStack
{
public static final String BOOTSTRAP_TOKEN = "contract-test-bootstrap-token-0123456789";
public static final String BOOTSTRAP_PASSWORD = "contract-test-password-123";
public static final String SERVICE_ACCOUNT = "qqq-kof22-admin-idadmin";
public static final String BREAK_GLASS = "kof22-breakglass";
public static final String SUPERUSER_GROUP = "authentik Admins";
public static final String INFRA_BLUEPRINT = "kof22-oauth2-apps";
public static final String BOOTSTRAP_BLUEPRINT = "kof22-identity-bootstrap";
public static final String APP_SLUG = "kof22-website-prod";
private static final String SECRET_KEY = "contract-test-secret-key-0123456789abcdefghijklmnop";
private static final String SA_TOKEN_ID = "qqq-contract-test";
private static AuthentikTestStack instance;
private final Network network = Network.newNetwork();
private final PostgreSQLContainer<?> postgres;
private final GenericContainer<?> mailpit;
private final GenericContainer<?> server;
private final GenericContainer<?> worker;
private String serviceAccountToken;
/*******************************************************************************
** The shared, started stack.
*******************************************************************************/
public static synchronized AuthentikTestStack get()
{
if(instance == null)
{
AuthentikTestStack stack = new AuthentikTestStack();
stack.start();
instance = stack;
Runtime.getRuntime().addShutdownHook(new Thread(stack::stop));
}
return (instance);
}
/*******************************************************************************
**
*******************************************************************************/
@SuppressWarnings("resource")
private AuthentikTestStack()
{
String authentikImage = System.getProperty("authentik.test.image", "ghcr.io/goauthentik/server:2026.8.3");
String postgresImage = System.getProperty("postgres.test.image", "postgres:16-alpine");
String mailpitImage = System.getProperty("mailpit.test.image", "axllent/mailpit:v1.27");
postgres = new PostgreSQLContainer<>(DockerImageName.parse(postgresImage).asCompatibleSubstituteFor("postgres"))
.withNetwork(network)
.withNetworkAliases("postgresql")
.withDatabaseName("authentik")
.withUsername("authentik")
.withPassword("authentik-test");
mailpit = new GenericContainer<>(DockerImageName.parse(mailpitImage))
.withNetwork(network)
.withNetworkAliases("mailpit")
.withExposedPorts(8025)
.waitingFor(Wait.forHttp("/api/v1/info").forPort(8025));
Map<String, String> env = new HashMap<>();
env.put("AUTHENTIK_SECRET_KEY", SECRET_KEY);
env.put("AUTHENTIK_POSTGRESQL__HOST", "postgresql");
env.put("AUTHENTIK_POSTGRESQL__USER", "authentik");
env.put("AUTHENTIK_POSTGRESQL__PASSWORD", "authentik-test");
env.put("AUTHENTIK_POSTGRESQL__NAME", "authentik");
env.put("AUTHENTIK_BOOTSTRAP_TOKEN", BOOTSTRAP_TOKEN);
env.put("AUTHENTIK_BOOTSTRAP_PASSWORD", BOOTSTRAP_PASSWORD);
env.put("AUTHENTIK_BOOTSTRAP_EMAIL", "akadmin@example.test");
env.put("AUTHENTIK_EMAIL__HOST", "mailpit");
env.put("AUTHENTIK_EMAIL__PORT", "1025");
env.put("AUTHENTIK_EMAIL__FROM", "authentik@example.test");
env.put("AUTHENTIK_ERROR_REPORTING__ENABLED", "false");
env.put("AUTHENTIK_DISABLE_UPDATE_CHECK", "true");
env.put("AUTHENTIK_DISABLE_STARTUP_ANALYTICS", "true");
env.put("KOF22_OAUTH2_CLIENT_SECRET", "contract-test-oauth2-client-secret");
server = authentik(authentikImage, env, "server")
.withNetworkAliases("authentik")
.withExposedPorts(9000)
.waitingFor(Wait.forHttp("/-/health/ready/").forPort(9000).forStatusCode(200).withStartupTimeout(Duration.ofMinutes(8)));
worker = authentik(authentikImage, env, "worker");
}
/*******************************************************************************
**
*******************************************************************************/
@SuppressWarnings("resource")
private GenericContainer<?> authentik(String image, Map<String, String> env, String command)
{
return (new GenericContainer<>(DockerImageName.parse(image))
.withNetwork(network)
.withEnv(env)
.withCommand(command)
.withCopyFileToContainer(MountableFile.forClasspathResource("blueprints/" + INFRA_BLUEPRINT + ".yaml"), "/blueprints/custom/" + INFRA_BLUEPRINT + ".yaml")
.withCopyFileToContainer(MountableFile.forClasspathResource("blueprints/" + BOOTSTRAP_BLUEPRINT + ".yaml"), "/blueprints/custom/" + BOOTSTRAP_BLUEPRINT + ".yaml"));
}
/*******************************************************************************
**
*******************************************************************************/
private void start()
{
postgres.start();
mailpit.start();
worker.start();
server.start();
TestApi admin = admin();
waitFor(Duration.ofMinutes(8), "both kof22 blueprints applied", () ->
isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT)
&& admin.first("/core/users/?username=" + SERVICE_ACCOUNT) != null);
int serviceAccountPk = admin.userPk(SERVICE_ACCOUNT);
TestApi.Response created = admin.post("/core/tokens/", new JSONObject().put("identifier", SA_TOKEN_ID).put("user", serviceAccountPk).put("intent", "api").put("expiring", false));
if(created.status() != 201)
{
throw (new IllegalStateException("Could not create the service-account test token: " + created.status() + " " + created.body()));
}
serviceAccountToken = admin.getOk("/core/tokens/" + SA_TOKEN_ID + "/view_key/").getString("key");
}
/*******************************************************************************
**
*******************************************************************************/
private void stop()
{
for(GenericContainer<?> container : new GenericContainer<?>[] { server, worker, mailpit, postgres })
{
try
{
container.stop();
}
catch(Exception e)
{
// best effort at JVM exit
}
}
network.close();
}
/*******************************************************************************
** Host root, e.g. http://localhost:32768 (the qbit's baseUrl).
*******************************************************************************/
public String baseUrl()
{
return ("http://" + server.getHost() + ":" + server.getMappedPort(9000));
}
/*******************************************************************************
** API root, baseUrl + /api/v3.
*******************************************************************************/
public String apiRoot()
{
return (baseUrl() + "/api/v3");
}
/*******************************************************************************
** Mailpit API root.
*******************************************************************************/
public String mailpitRoot()
{
return ("http://" + mailpit.getHost() + ":" + mailpit.getMappedPort(8025));
}
/*******************************************************************************
** Client acting as the bootstrap superuser (akadmin), for setup.
*******************************************************************************/
public TestApi admin()
{
return (new TestApi(apiRoot(), BOOTSTRAP_TOKEN));
}
/*******************************************************************************
** Client acting as the qbit's service account.
*******************************************************************************/
public TestApi serviceAccount()
{
return (new TestApi(apiRoot(), serviceAccountToken));
}
/*******************************************************************************
** The service account's token (for configuring the qbit under test).
*******************************************************************************/
public String serviceAccountToken()
{
return (serviceAccountToken);
}
/*******************************************************************************
** The blueprint instance with this name.
*******************************************************************************/
public JSONObject blueprint(String name)
{
return (admin().first("/managed/blueprints/?name=" + TestApi.encode(name)));
}
/*******************************************************************************
** Re-apply a blueprint instance (POST .../apply/, which is asynchronous) and
** wait until it has applied again successfully.
*******************************************************************************/
public void applyAndWait(String name)
{
JSONObject before = blueprint(name);
String lastApplied = before.optString("last_applied", "");
admin().post("/managed/blueprints/" + before.getString("pk") + "/apply/", new JSONObject());
waitFor(Duration.ofMinutes(3), "blueprint " + name + " re-applied", () ->
{
JSONObject now = blueprint(name);
return (!now.optString("last_applied", "").equals(lastApplied) && "successful".equals(now.optString("status")));
});
}
/*******************************************************************************
** Create a content-based blueprint instance and wait for it to apply.
*******************************************************************************/
public JSONObject createContentBlueprint(String name, String yaml)
{
TestApi.Response created = admin().post("/managed/blueprints/", new JSONObject().put("name", name).put("content", yaml).put("enabled", true).put("path", ""));
if(created.status() != 201)
{
throw (new IllegalStateException("Could not create blueprint " + name + ": " + created.status() + " " + created.body()));
}
applyAndWait(name);
return (blueprint(name));
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean isSuccessful(TestApi admin, String name)
{
try
{
JSONObject blueprint = admin.first("/managed/blueprints/?name=" + TestApi.encode(name));
return (blueprint != null && "successful".equals(blueprint.optString("status")));
}
catch(Exception e)
{
return (false);
}
}
/*******************************************************************************
** Poll until a condition holds.
*******************************************************************************/
public static void waitFor(Duration timeout, String what, BooleanSupplier condition)
{
Instant deadline = Instant.now().plus(timeout);
while(Instant.now().isBefore(deadline))
{
if(condition.getAsBoolean())
{
return;
}
try
{
Thread.sleep(2_000);
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
break;
}
}
throw (new IllegalStateException("Timed out waiting for " + what));
}
}
@@ -1,197 +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.contract;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.BOOTSTRAP_BLUEPRINT;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.BREAK_GLASS;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.INFRA_BLUEPRINT;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.SERVICE_ACCOUNT;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Contract tests (c) and (d): the whole design rests on these.
**
** (c) With identity objects declared `state: created` and no member lists,
** re-applying BOTH blueprints changes nothing made through the API or
** the UI (memberships, group attributes, the service account's role
** assignment), and re-asserts the qbit role's global permissions after
** one was removed by hand.
**
** (d) The hazard the cluster-gitops lint exists for: a `present` group with
** `users:` wipes API-added members, a `present` user with `groups:`
** wipes API-added memberships (today's james.maes entry), and `present`
** `attributes:` wipes qqq.io/* edits, on every apply.
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(30)
class BlueprintReapplyIT
{
private static AuthentikTestStack stack;
private static TestApi admin;
/*******************************************************************************
**
*******************************************************************************/
@BeforeAll
static void beforeAll()
{
stack = AuthentikTestStack.get();
admin = stack.admin();
}
/*******************************************************************************
** (c)
*******************************************************************************/
@Test
void apiEditsSurviveReapplyOfCreatedEntries()
{
admin.post("/core/users/", new JSONObject().put("username", "reapply.member").put("name", "Reapply Member").put("type", "internal").put("path", "users"));
int member = admin.userPk("reapply.member");
///////////////////////////////////////////////////////////////////////
// edits as QQQ (the service account) or the UI (admin) would make //
///////////////////////////////////////////////////////////////////////
String identityAdmins = admin.groupPk("identity-admins");
String kof22Admins = admin.groupPk("kof22-admins");
assertThat(admin.post("/core/groups/" + identityAdmins + "/add_user/", new JSONObject().put("pk", member)).status()).isEqualTo(204);
assertThat(admin.post("/core/groups/" + kof22Admins + "/add_user/", new JSONObject().put("pk", member)).status()).isEqualTo(204);
assertThat(admin.patch("/core/groups/" + kof22Admins + "/", new JSONObject().put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray().put("adminEsb.hasAccess")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod")))).status()).isEqualTo(200);
assertThat(admin.patch("/core/groups/" + identityAdmins + "/", new JSONObject().put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.*").put("cms.publish")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod")))).status()).isEqualTo(200);
String roleUuid = admin.first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
try
{
assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + roleUuid + "/unassign/", new JSONObject().put("permissions", new JSONArray().put("authentik_core.view_user"))).status()).isEqualTo(204);
assertThat(stack.serviceAccount().get("/core/users/?page_size=1").status()).as("view_user removed in the UI").isEqualTo(403);
}
finally
{
stack.applyAndWait(INFRA_BLUEPRINT);
stack.applyAndWait(BOOTSTRAP_BLUEPRINT);
}
assertThat(memberNames("identity-admins")).contains("reapply.member");
assertThat(memberNames("kof22-admins")).contains("reapply.member");
assertThat(admin.first("/core/groups/?name=kof22-admins").getJSONObject("attributes").getJSONArray("qqq.io/permissions").toList()).containsExactly("adminEsb.hasAccess");
assertThat(admin.first("/core/groups/?name=identity-admins").getJSONObject("attributes").getJSONArray("qqq.io/permissions").toList()).containsExactly("authentik.*", "cms.publish");
////////////////////////////////////////////////////////////////////////
// role permission re-asserted; role assignment and break-glass kept //
////////////////////////////////////////////////////////////////////////
assertThat(stack.serviceAccount().get("/core/users/?page_size=1").status()).as("view_user re-asserted by the re-apply").isEqualTo(200);
JSONObject serviceAccount = admin.first("/core/users/?username=" + SERVICE_ACCOUNT + "&include_roles=true");
assertThat(BootstrapBlueprintIT.names(serviceAccount.getJSONArray("roles_obj"))).containsExactly(SERVICE_ACCOUNT);
assertThat(admin.first("/core/users/?username=" + BREAK_GLASS).getBoolean("is_superuser")).isTrue();
///////////////////////////////////////////////////////////
// put the seed groups back as the bootstrap created them //
///////////////////////////////////////////////////////////
admin.post("/core/groups/" + identityAdmins + "/remove_user/", new JSONObject().put("pk", member));
admin.post("/core/groups/" + kof22Admins + "/remove_user/", new JSONObject().put("pk", member));
admin.patch("/core/groups/" + kof22Admins + "/", new JSONObject().put("attributes", new JSONObject()));
admin.patch("/core/groups/" + identityAdmins + "/", new JSONObject().put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.*")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod"))));
}
/*******************************************************************************
** (d)
*******************************************************************************/
@Test
void presentEntriesWipeApiEdits()
{
String yaml = """
version: 1
metadata:
name: lint-demo-present
entries:
- model: authentik_core.group
state: present
id: demo-group
identifiers:
name: lint-demo-group
attrs:
attributes:
note: declared in git
users:
- !Find [authentik_core.user, [username, akadmin]]
- model: authentik_core.user
state: present
identifiers:
username: lint.demo.user
attrs:
name: Lint Demo User
type: internal
path: users
groups:
- !KeyOf demo-group
""";
stack.createContentBlueprint("lint-demo-present", yaml);
admin.post("/core/users/", new JSONObject().put("username", "lint.added").put("name", "Added Via API").put("type", "internal").put("path", "users"));
int added = admin.userPk("lint.added");
int demoUser = admin.userPk("lint.demo.user");
String demoGroup = admin.groupPk("lint-demo-group");
String publishers = admin.groupPk("kof-website-publishers");
assertThat(admin.post("/core/groups/" + demoGroup + "/add_user/", new JSONObject().put("pk", added)).status()).isEqualTo(204);
assertThat(admin.post("/core/groups/" + publishers + "/add_user/", new JSONObject().put("pk", demoUser)).status()).isEqualTo(204);
assertThat(admin.patch("/core/groups/" + demoGroup + "/", new JSONObject().put("attributes", new JSONObject().put("note", "declared in git").put("qqq.io/permissions", new JSONArray().put("cms.publish")))).status()).isEqualTo(200);
assertThat(memberNames("lint-demo-group")).contains("lint.added");
stack.applyAndWait("lint-demo-present");
assertThat(memberNames("lint-demo-group")).as("present users: replaces the member set").doesNotContain("lint.added").contains("akadmin");
assertThat(memberNames("kof-website-publishers")).as("present groups: on a user replaces its memberships").doesNotContain("lint.demo.user");
assertThat(admin.first("/core/groups/?name=lint-demo-group").getJSONObject("attributes").has("qqq.io/permissions")).as("present attributes: replaces the dict").isFalse();
}
/*******************************************************************************
**
*******************************************************************************/
private List<String> memberNames(String group)
{
JSONArray users = admin.first("/core/groups/?name=" + TestApi.encode(group) + "&include_users=true").getJSONArray("users_obj");
List<String> rs = new ArrayList<>();
for(int i = 0; i < users.length(); i++)
{
rs.add(users.getJSONObject(i).getString("username"));
}
return (rs);
}
}
@@ -1,175 +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.contract;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.BOOTSTRAP_BLUEPRINT;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.BREAK_GLASS;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.INFRA_BLUEPRINT;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.SERVICE_ACCOUNT;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Contract test (h): the bootstrap blueprint on an EMPTY Authentik, ordered
** by its metaapplyblueprint entry, applies cleanly alongside the
** infrastructure blueprint, and creates exactly the bootstrap objects of
** DESIGN.md section 4.3. Also open point (i): the role's GLOBAL permissions
** come from attrs.permissions on a present role entry.
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(10)
class BootstrapBlueprintIT
{
private static AuthentikTestStack stack;
private static TestApi admin;
/*******************************************************************************
**
*******************************************************************************/
@BeforeAll
static void beforeAll()
{
stack = AuthentikTestStack.get();
admin = stack.admin();
}
/*******************************************************************************
** Both blueprints, present in /blueprints/custom at first boot, applied.
*******************************************************************************/
@Test
void bothBlueprintsAppliedOnEmptyInstance()
{
assertThat(stack.blueprint(INFRA_BLUEPRINT).getString("status")).isEqualTo("successful");
assertThat(stack.blueprint(BOOTSTRAP_BLUEPRINT).getString("status")).isEqualTo("successful");
}
/*******************************************************************************
** The service account: type, path, protected marker, role, and no groups.
*******************************************************************************/
@Test
void serviceAccountCreatedWithRoleAndNoGroups()
{
JSONObject user = admin.first("/core/users/?username=" + SERVICE_ACCOUNT + "&include_roles=true&include_groups=true");
assertThat(user.getString("type")).isEqualTo("service_account");
assertThat(user.getString("path")).isEqualTo("service-accounts/qqq");
assertThat(user.getJSONObject("attributes").getBoolean("qqq.io/protected")).isTrue();
assertThat(user.getBoolean("is_superuser")).isFalse();
assertThat(user.getJSONArray("groups")).isEmpty();
assertThat(names(user.getJSONArray("roles_obj"))).containsExactly(SERVICE_ACCOUNT);
}
/*******************************************************************************
** Break-glass: a protected superuser through authentik Admins.
*******************************************************************************/
@Test
void breakGlassIsProtectedSuperuser()
{
JSONObject user = admin.first("/core/users/?username=" + BREAK_GLASS + "&include_groups=true");
assertThat(user.getBoolean("is_superuser")).isTrue();
assertThat(user.getJSONObject("attributes").getBoolean("qqq.io/protected")).isTrue();
assertThat(names(user.getJSONArray("groups_obj"))).containsExactly(AuthentikTestStack.SUPERUSER_GROUP);
}
/*******************************************************************************
** The four seed groups exist, with no members; only identity-admins has
** attributes, and drafters has no app access (owner decision pending).
*******************************************************************************/
@Test
void seedGroupsCreatedWithoutMembers()
{
for(String name : List.of("identity-admins", "kof22-admins", "kof-website-publishers", "kof-website-drafters"))
{
JSONObject group = admin.first("/core/groups/?name=" + name + "&include_users=true");
assertThat(group).as(name).isNotNull();
assertThat(group.getBoolean("is_superuser")).as(name).isFalse();
assertThat(group.getJSONArray("parents")).as(name).isEmpty();
}
JSONObject identityAdmins = admin.first("/core/groups/?name=identity-admins").getJSONObject("attributes");
assertThat(identityAdmins.getJSONArray("qqq.io/permissions").toList()).containsExactly("authentik.*");
assertThat(identityAdmins.getJSONArray("qqq.io/app-access").toList()).containsExactly(AuthentikTestStack.APP_SLUG);
JSONObject drafters = admin.first("/core/groups/?name=kof-website-drafters").getJSONObject("attributes");
assertThat(drafters.has("qqq.io/app-access")).isFalse();
}
/*******************************************************************************
** Open point (i): global permissions from attrs.permissions, plus the
** object permission on the recovery email stage from `permissions:`.
*******************************************************************************/
@Test
void roleHasGlobalAndObjectPermissions()
{
TestApi sa = stack.serviceAccount();
assertThat(sa.get("/core/users/?page_size=1").status()).isEqualTo(200);
assertThat(sa.get("/core/groups/?page_size=1").status()).isEqualTo(200);
assertThat(sa.get("/events/events/?page_size=1").status()).isEqualTo(200);
assertThat(sa.get("/managed/blueprints/?page_size=1").status()).isEqualTo(200);
assertThat(sa.get("/core/authenticated_sessions/?page_size=1").status()).isEqualTo(200);
JSONObject stage = admin.first("/stages/email/?name=kof22-recovery-email");
assertThat(sa.get("/stages/email/" + stage.getString("pk") + "/").status()).isEqualTo(200);
///////////////////////////////////////////////////
// and nothing beyond the phase 0/1 read set //
///////////////////////////////////////////////////
JSONArray tokens = sa.getOk("/core/tokens/?page_size=100").getJSONArray("results");
for(int i = 0; i < tokens.length(); i++)
{
assertThat(tokens.getJSONObject(i).getJSONObject("user_obj").getString("username")).as("only its own tokens are visible").isEqualTo(SERVICE_ACCOUNT);
}
assertThat(sa.post("/core/groups/", new JSONObject().put("name", "should-not-exist")).status()).isEqualTo(403);
}
/*******************************************************************************
**
*******************************************************************************/
static List<String> names(JSONArray array)
{
List<String> rs = new ArrayList<>();
for(int i = 0; i < array.length(); i++)
{
rs.add(array.getJSONObject(i).getString("name"));
}
return (rs);
}
}
@@ -1,128 +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.contract;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Records real Authentik responses from contract tests as unit-test fixtures
** (DESIGN.md section 10). Always writes to target/recorded-fixtures/<version>;
** with -Dauthentik.fixtures.record=true it also refreshes
** src/test/resources/fixtures/<version>, which the unit tests read.
**
** Scrubbing: avatars (data URIs) blanked, secret-looking values masked,
** ids left as they are (they come from a throwaway container).
*******************************************************************************/
public final class FixtureRecorder
{
public static final String VERSION = "2026.8.3";
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private FixtureRecorder()
{
}
/*******************************************************************************
** Scrub and save a JSON response under a name.
*******************************************************************************/
public static void record(String name, String body)
{
String scrubbed = scrub(body);
write(Path.of("target", "recorded-fixtures", VERSION, name + ".json"), scrubbed);
if(Boolean.getBoolean("authentik.fixtures.record"))
{
write(Path.of("src", "test", "resources", "fixtures", VERSION, name + ".json"), scrubbed);
}
}
/*******************************************************************************
** Scrub a response body.
*******************************************************************************/
static String scrub(String body)
{
Object parsed = body.trim().startsWith("[") ? new JSONArray(body) : new JSONObject(body);
scrubValue(parsed);
String text = parsed instanceof JSONObject object ? object.toString(2) : ((JSONArray) parsed).toString(2);
return (AuthentikRedactor.redactText(text, null) + "\n");
}
/*******************************************************************************
**
*******************************************************************************/
private static void scrubValue(Object value)
{
if(value instanceof JSONObject object)
{
for(String key : object.keySet())
{
Object child = object.get(key);
if(key.equals("avatar") && child instanceof String)
{
object.put(key, "");
}
else
{
scrubValue(child);
}
}
}
else if(value instanceof JSONArray array)
{
for(int i = 0; i < array.length(); i++)
{
scrubValue(array.get(i));
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void write(Path path, String text)
{
try
{
Files.createDirectories(path.getParent());
Files.writeString(path, text, StandardCharsets.UTF_8);
}
catch(IOException e)
{
throw (new IllegalStateException("Could not write fixture " + path, e));
}
}
}
@@ -1,93 +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.contract;
import java.net.CookieManager;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.json.JSONObject;
/*******************************************************************************
** Signs a user in through Authentik's default authentication flow (flow
** executor API), creating a real AuthenticatedSession and login event in the
** disposable test instance. Test-only.
*******************************************************************************/
public final class FlowLogin
{
private static final String FLOW = "default-authentication-flow";
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private FlowLogin()
{
}
/*******************************************************************************
** Run identification + password; returns the final challenge component
** (e.g. "xak-flow-redirect" on success).
*******************************************************************************/
public static String login(String baseUrl, String username, String password) throws Exception
{
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.cookieHandler(new CookieManager())
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(10))
.build();
String executor = baseUrl + "/api/v3/flows/executor/" + FLOW + "/?query=";
JSONObject challenge = send(client, HttpRequest.newBuilder(URI.create(executor)).GET());
challenge = send(client, post(executor, new JSONObject().put("component", "ak-stage-identification").put("uid_field", username)));
if("ak-stage-password".equals(challenge.optString("component")))
{
challenge = send(client, post(executor, new JSONObject().put("component", "ak-stage-password").put("password", password)));
}
return (challenge.optString("component"));
}
/*******************************************************************************
**
*******************************************************************************/
private static HttpRequest.Builder post(String url, JSONObject body)
{
return (HttpRequest.newBuilder(URI.create(url)).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body.toString())));
}
/*******************************************************************************
**
*******************************************************************************/
private static JSONObject send(HttpClient client, HttpRequest.Builder request) throws Exception
{
HttpResponse<String> response = client.send(request.header("Accept", "application/json").timeout(Duration.ofSeconds(30)).build(), HttpResponse.BodyHandlers.ofString());
return (response.body() == null || response.body().isBlank() || !response.body().trim().startsWith("{") ? new JSONObject() : new JSONObject(response.body()));
}
}
@@ -1,242 +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.contract;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Phase 1 read MVP against Authentik 2026.8.3, as the service account with
** the phase 0/1 role: memberships (virtual table), sessions and events
** (authentik.audit.read), effective permissions and inherited superuser on
** groups, and the drift report. Runs after SuperuserFloorIT, whose lockout
** probes are exactly what the drift report must flag. Records fixtures.
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(70)
class Phase1ReadContractIT
{
private static AuthentikTestStack stack;
private QInstance qInstance;
private AuthentikQBitConfig config;
/*******************************************************************************
** A member of a child of identity-admins (inherited authentik.*), and a real
** session for akadmin.
*******************************************************************************/
@BeforeAll
static void beforeAll() throws Exception
{
stack = AuthentikTestStack.get();
TestApi admin = stack.admin();
admin.post("/core/groups/", new JSONObject().put("name", "phase1-child-of-identity-admins").put("parents", new JSONArray().put(admin.groupPk("identity-admins"))));
admin.post("/core/users/", new JSONObject().put("username", "phase1.member").put("name", "Phase One").put("email", "phase1@example.test").put("type", "internal").put("path", "users"));
admin.post("/core/groups/" + admin.groupPk("phase1-child-of-identity-admins") + "/add_user/", new JSONObject().put("pk", admin.userPk("phase1.member")));
admin.post("/core/groups/", new JSONObject().put("name", "phase1-bad-attributes").put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray().put("no.such.permission")).put("qqq.io/app-access", new JSONArray().put("not-in-scope-app"))));
assertThat(FlowLogin.login(stack.baseUrl(), "akadmin", AuthentikTestStack.BOOTSTRAP_PASSWORD)).isEqualTo("xak-flow-redirect");
}
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
config = new AuthentikQBitConfig()
.withBaseUrl(stack.baseUrl())
.withApiToken(stack.serviceAccountToken())
.withAppKey("kof22-admin")
.withApplicationsInScope(List.of(AuthentikTestStack.APP_SLUG))
.withListCacheTtlSeconds(0)
.withGroupCacheTtlSeconds(0);
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, QbitReadContractIT.RecordingActionUtil.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> query(String table, QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(config.applyTablePrefix(table));
input.setFilter(filter);
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
** Memberships by group, by user, by key, and a full sweep.
*******************************************************************************/
@Test
void memberships() throws QException
{
TestApi admin = stack.admin();
String groupPk = admin.groupPk("phase1-child-of-identity-admins");
int userPk = admin.userPk("phase1.member");
List<QRecord> byGroup = query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")));
assertThat(byGroup).extracting(r -> r.getValueString("username")).containsExactly("phase1.member");
assertThat(byGroup.get(0).getValueString("id")).isEqualTo(groupPk + ":" + userPk);
assertThat(byGroup.get(0).getValueString("email")).isEqualTo("phase1@example.test");
FixtureRecorder.record("group-with-users", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/"));
List<QRecord> byUser = query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, userPk)));
assertThat(byUser).extracting(r -> r.getValueString("groupName")).containsExactly("phase1-child-of-identity-admins");
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, AuthentikTestStack.BREAK_GLASS))))
.extracting(r -> r.getValueString("groupName")).containsExactly(AuthentikTestStack.SUPERUSER_GROUP);
QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk));
assertThat(byKey.getValueString("username")).isEqualTo("phase1.member");
int all = new CountAction().execute(new CountInput(config.applyTablePrefix("groupMember"))).getCount();
assertThat(all).isGreaterThanOrEqualTo(3);
FixtureRecorder.record("groups-list-with-users", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/"));
}
/*******************************************************************************
** Groups: inherited superuser and effective permissions (incl. parents);
** users: effective permissions through a child group.
*******************************************************************************/
@Test
void effectivePermissionsAndInheritedSuperuser() throws QException
{
QRecord child = query("group", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins"))).get(0);
assertThat(child.getValueString("permissions")).isNull();
assertThat(child.getValueString("effectivePermissions")).contains("authentik.memberships.manage.hasAccess", "authentik.users.read.hasAccess");
assertThat(child.getValueBoolean("isSuperuserInherited")).isFalse();
QRecord admins = query("group", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, AuthentikTestStack.SUPERUSER_GROUP))).get(0);
assertThat(admins.getValueBoolean("isSuperuserInherited")).isTrue();
QRecord member = query("user", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "phase1.member"))).get(0);
assertThat(member.getValueString("effectivePermissions")).contains("authentik.permissions.manage.hasAccess");
}
/*******************************************************************************
** Sessions and events: audit.read only.
*******************************************************************************/
@Test
void sessionsAndEvents() throws QException
{
assertThatThrownBy(() -> query("session", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> query("event", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
List<QRecord> sessions = query("session", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")));
assertThat(sessions).isNotEmpty();
assertThat(sessions.get(0).getValueString("lastIp")).isNotBlank();
FixtureRecorder.record("sessions-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/authenticated_sessions/"));
List<QRecord> logins = query("event", new QQueryFilter(new QFilterCriteria("action", QCriteriaOperator.EQUALS, "login"), new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")).withLimit(5));
assertThat(logins).isNotEmpty();
assertThat(logins.get(0).getValueString("action")).isEqualTo("login");
List<QRecord> modelEvents = query("event", new QQueryFilter(new QFilterCriteria("action", QCriteriaOperator.IN, List.of("model_created", "model_updated")),
new QFilterCriteria("modelName", QCriteriaOperator.EQUALS, "group")).withLimit(10));
assertThat(modelEvents).isNotEmpty().allMatch(r -> "group".equals(r.getValueString("modelName")));
FixtureRecorder.record("events-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/events/events/"));
}
/*******************************************************************************
** The drift report on the contract instance. Expected: blueprints, service
** account and break-glass OK; the bad attributes flagged; and the
** service account's lockout probes on break-glass (SuperuserFloorIT)
** flagged as an ERROR.
*******************************************************************************/
@Test
void driftReport() throws QException
{
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
List<DriftFinding> findings = new AuthentikDriftReport(AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run();
findings.forEach(f -> System.out.println("[drift] " + f));
assertThat(findings).anyMatch(f -> f.check().equals("version") && f.severity() == DriftFinding.Severity.OK);
assertThat(findings).anyMatch(f -> f.check().equals("break-glass") && f.severity() == DriftFinding.Severity.OK && f.subject().contains(AuthentikTestStack.BREAK_GLASS));
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.severity() == DriftFinding.Severity.OK);
assertThat(findings).anyMatch(f -> f.check().equals("identity admins") && f.subject().contains("phase1.member"));
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.subject().equals("phase1-bad-attributes") && f.message().contains("no.such.permission"));
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.subject().equals("phase1-bad-attributes") && f.message().contains("not-in-scope-app"));
assertThat(findings).anyMatch(f -> f.check().equals("service-account activity") && f.severity() == DriftFinding.Severity.ERROR);
assertThat(findings.get(0).severity()).isEqualTo(DriftFinding.Severity.ERROR);
FixtureRecorder.record("blueprints-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/managed/blueprints/"));
FixtureRecorder.record("tokens-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/tokens/"));
FixtureRecorder.record("events-by-service-account", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/events/events/"));
}
}
@@ -1,319 +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.contract;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
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 com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
import org.apache.http.client.methods.HttpRequestBase;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The qbit's own read path (AuthentikActionUtil through QQQ actions) against
** a real Authentik, as the service account with the phase 0/1 role: paging,
** count, get, key lookups, filters, scope, protection and sensitive-field
** stripping. Records the responses it sees as unit-test fixtures.
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(60)
class QbitReadContractIT
{
private static AuthentikTestStack stack;
private static int probeCount = 4;
private QInstance qInstance;
private AuthentikQBitConfig config;
private String userTable;
private String groupTable;
/*******************************************************************************
** Records the last response body per request path.
*******************************************************************************/
public static class RecordingActionUtil extends AuthentikActionUtil
{
static final Map<String, String> LAST_BODIES = new ConcurrentHashMap<>();
/*******************************************************************************
**
*******************************************************************************/
@Override
protected QHttpResponse send(HttpRequestBase request) throws Exception
{
QHttpResponse response = super.send(request);
LAST_BODIES.put(request.getMethod() + " " + request.getURI().getPath(), response.getContent() == null ? "" : response.getContent());
return (response);
}
}
/*******************************************************************************
**
*******************************************************************************/
@BeforeAll
static void beforeAll()
{
stack = AuthentikTestStack.get();
TestApi admin = stack.admin();
for(int i = 1; i <= probeCount; i++)
{
admin.post("/core/users/", new JSONObject().put("username", "read.probe" + i).put("name", "Read Probe " + i).put("email", "read.probe" + i + "@example.test").put("type", "internal").put("path", "users"));
}
admin.post("/core/groups/" + admin.groupPk("kof-website-publishers") + "/add_user/", new JSONObject().put("pk", admin.userPk("read.probe1")));
admin.patch("/core/users/" + admin.userPk("read.probe4") + "/", new JSONObject().put("is_active", false));
}
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
config = new AuthentikQBitConfig()
.withBaseUrl(stack.baseUrl())
.withApiToken(stack.serviceAccountToken())
.withAppKey("kof22-admin")
.withListCacheTtlSeconds(0);
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, RecordingActionUtil.class);
userTable = config.applyTablePrefix("user");
groupTable = config.applyTablePrefix("group");
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
}
/*******************************************************************************
** Paging: aligned pages are disjoint and add up to the count; a
** non-aligned skip uses 100-row pages and still lands on the right rows.
*******************************************************************************/
@Test
void pagingAndCount() throws QException
{
int total = new CountAction().execute(new CountInput(userTable)).getCount();
assertThat(total).isGreaterThanOrEqualTo(probeCount + 3);
List<QRecord> all = query(new QQueryFilter().withOrderBy(new QFilterOrderBy("username")));
assertThat(all).hasSize(total);
FixtureRecorder.record("users-list", RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/users/"));
List<Serializable> pageOne = keys(query(new QQueryFilter().withOrderBy(new QFilterOrderBy("username")).withLimit(2).withSkip(0)));
List<Serializable> pageTwo = keys(query(new QQueryFilter().withOrderBy(new QFilterOrderBy("username")).withLimit(2).withSkip(2)));
assertThat(pageOne).hasSize(2).doesNotContainAnyElementsOf(pageTwo);
assertThat(pageTwo).containsExactlyElementsOf(keys(all).subList(2, 4));
List<Serializable> odd = keys(query(new QQueryFilter().withOrderBy(new QFilterOrderBy("username")).withLimit(3).withSkip(1)));
assertThat(odd).containsExactlyElementsOf(keys(all).subList(1, 4));
}
/*******************************************************************************
** Filters that map to Authentik query parameters.
*******************************************************************************/
@Test
void filters() throws QException
{
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, false))))).contains("read.probe4").doesNotContain("read.probe1");
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.IN, List.of("service_account")))))).containsExactly(AuthentikTestStack.SERVICE_ACCOUNT);
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("groupNames", QCriteriaOperator.EQUALS, "kof-website-publishers"))))).contains("read.probe1").doesNotContain("read.probe2");
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, "read.probe"))))).contains("read.probe1", "read.probe2", "read.probe3", "read.probe4");
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.IS_BLANK))))).contains("read.probe2");
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("dateJoined", QCriteriaOperator.GREATER_THAN_OR_EQUALS, "2000-01-01"))))).contains("read.probe3");
assertThatThrownBy(() -> query(new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "a"), new QFilterCriteria("email", QCriteriaOperator.EQUALS, "b")).withBooleanOperator(QQueryFilter.BooleanOperator.OR)))
.isInstanceOf(QUserFacingException.class);
assertThatThrownBy(() -> query(new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.NOT_EQUALS, "a"))))
.isInstanceOf(QUserFacingException.class);
}
/*******************************************************************************
** Get by key, key lookups, scope (internal service accounts are invisible),
** and protection markers.
*******************************************************************************/
@Test
void getKeyLookupScopeAndProtection() throws QException
{
TestApi admin = stack.admin();
int probe = admin.userPk("read.probe2");
int breakGlass = admin.userPk(AuthentikTestStack.BREAK_GLASS);
int sa = admin.userPk(AuthentikTestStack.SERVICE_ACCOUNT);
int outpost = admin.first("/core/users/?type=internal_service_account").getInt("pk");
QRecord record = new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(probe));
assertThat(record.getValueString("username")).isEqualTo("read.probe2");
assertThat(record.getValueBoolean("isProtected")).isFalse();
FixtureRecorder.record("user-get", RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/users/" + probe + "/"));
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(breakGlass)).getValueBoolean("isProtected")).isTrue();
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(sa)).getValueBoolean("isProtected")).isTrue();
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(outpost))).as("internal service accounts are out of scope").isNull();
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(999_999))).isNull();
List<QRecord> lookup = query(new QQueryFilter(new QFilterCriteria("pk", QCriteriaOperator.IN, List.of(probe, breakGlass, outpost))));
assertThat(usernames(lookup)).containsExactlyInAnyOrder("read.probe2", AuthentikTestStack.BREAK_GLASS);
}
/*******************************************************************************
** Sensitive fields need authentik.audit.read; reading needs users.read.
*******************************************************************************/
@Test
void permissionsAndSensitiveFields() throws QException
{
QRecord plain = query(new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, AuthentikTestStack.BREAK_GLASS))).get(0);
assertThat(plain.getValues()).doesNotContainKey("attributes");
assertThat(plain.getValueString("qqqAttributes")).contains("qqq.io/protected");
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
QRecord audit = query(new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, AuthentikTestStack.BREAK_GLASS))).get(0);
assertThat(audit.getValueString("attributes")).contains("qqq.io/protected");
AuthentikTestSupport.loginWith(qInstance);
assertThatThrownBy(() -> query(new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** Groups: list with parents/children, member filter.
*******************************************************************************/
@Test
void groups() throws QException
{
QueryInput input = new QueryInput(groupTable);
input.setFilter(new QQueryFilter(new QFilterCriteria("memberUsername", QCriteriaOperator.EQUALS, "read.probe1")));
List<QRecord> groups = new QueryAction().execute(input).getRecords();
assertThat(groups.stream().map(r -> r.getValueString("name")).toList()).containsExactly("kof-website-publishers");
input.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "identity-admins")));
QRecord identityAdmins = new QueryAction().execute(input).getRecords().get(0);
assertThat(identityAdmins.getValueString("permissions")).isEqualTo("authentik.*");
FixtureRecorder.record("groups-list", RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/"));
}
/*******************************************************************************
** Error and version responses, for the unit fixtures.
*******************************************************************************/
@Test
void recordErrorAndVersionFixtures()
{
TestApi sa = stack.serviceAccount();
TestApi.Response badDate = sa.get("/core/users/?last_login__lt=not-a-date");
assertThat(badDate.status()).isEqualTo(400);
FixtureRecorder.record("error-400-bad-filter", badDate.body());
TestApi.Response forbidden = sa.post("/core/groups/", new JSONObject().put("name", "x"));
assertThat(forbidden.status()).isEqualTo(403);
FixtureRecorder.record("error-403", forbidden.body());
FixtureRecorder.record("admin-version", sa.getOk("/admin/version/").toString());
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> query(QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(userTable);
input.setFilter(filter);
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
**
*******************************************************************************/
private static List<Serializable> keys(List<QRecord> records)
{
return (records.stream().map(r -> r.getValue("pk")).toList());
}
/*******************************************************************************
**
*******************************************************************************/
private static List<String> usernames(List<QRecord> records)
{
List<String> rs = new ArrayList<>();
records.forEach(r -> rs.add(r.getValueString("username")));
return (rs);
}
}
@@ -1,100 +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.contract;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import com.kingsrook.qbits.authentik.SchemaDriftTest;
import com.kingsrook.qbits.authentik.client.AuthentikApiSurface;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The schema the running container serves (unauthenticated, as for the
** pinned file) has the same parameters as the pinned schema for every
** operation the qbit uses. Run with -Dauthentik.test.image=... to rehearse an
** upgrade: any difference fails with a list.
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(20)
class SchemaDriftIT
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void containerSchemaMatchesPinnedForUsedOperations() throws Exception
{
AuthentikTestStack stack = AuthentikTestStack.get();
HttpResponse<String> response = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build().send(
HttpRequest.newBuilder(URI.create(stack.apiRoot() + "/schema/")).header("Accept", "application/yaml").build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
JsonNode live = SchemaDriftTest.loadSchema(response.body());
JsonNode pinned = SchemaDriftTest.loadSchema(AuthentikTestSupport.resource("authentik/schema-" + SchemaDriftTest.PINNED_VERSION + ".yml"));
assertThat(SchemaDriftTest.findDrift(live)).isEmpty();
List<String> differences = new ArrayList<>();
for(AuthentikApiSurface.Operation operation : AuthentikApiSurface.operations())
{
Set<String> liveParams = params(live, operation);
Set<String> pinnedParams = params(pinned, operation);
if(!liveParams.equals(pinnedParams))
{
differences.add(operation.path() + ": pinned " + pinnedParams + " vs running " + liveParams);
}
}
assertThat(differences).as("parameter differences between the pinned and the running schema").isEmpty();
assertThat(live.at("/info/version").asText()).isEqualTo(System.getProperty("authentik.expected.version", SchemaDriftTest.PINNED_VERSION));
}
/*******************************************************************************
**
*******************************************************************************/
private static Set<String> params(JsonNode schema, AuthentikApiSurface.Operation operation)
{
String path = operation.path();
JsonNode paths = schema.get("paths");
for(java.util.Iterator<String> it = paths.fieldNames(); it.hasNext(); )
{
String candidate = it.next();
if(candidate.equals(path) || candidate.matches(path.replace("{key}", "\\{[^/]+\\}")))
{
return (SchemaDriftTest.parameterNames(schema, paths.get(candidate).get(operation.method())));
}
}
return (Set.of());
}
}
@@ -1,148 +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.contract;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.APP_SLUG;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The proposed cluster-gitops infrastructure changes, on the real importer:
** - the attribute-driven sign-in policy (qqq.io/app-access, inherited from
** parent groups, string or list, no substring matches) with the group-name
** fallback (DESIGN.md section 4.4 step 2), evaluated by the admin's
** check_access?for_user (superusers may use for_user);
** - the webhook plumbing (section 7.1) exists and is INERT: its rule has no
** destination, so Authentik creates no notifications (events/models.py
** NotificationRule.destination_users yields nobody).
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(45)
class SignInPolicyIT
{
private static TestApi admin;
/*******************************************************************************
**
*******************************************************************************/
@BeforeAll
static void beforeAll()
{
admin = AuthentikTestStack.get().admin();
}
/*******************************************************************************
** Create a user in a new group with the given attributes (and parent).
*******************************************************************************/
private int userInGroup(String name, JSONObject attributes, String parentPk)
{
JSONObject group = new JSONObject().put("name", "signin-" + name).put("attributes", attributes);
if(parentPk != null)
{
group.put("parents", new JSONArray().put(parentPk));
}
admin.post("/core/groups/", group);
admin.post("/core/users/", new JSONObject().put("username", "signin." + name).put("name", "Sign-in " + name).put("type", "internal").put("path", "users"));
int user = admin.userPk("signin." + name);
admin.post("/core/groups/" + admin.groupPk("signin-" + name) + "/add_user/", new JSONObject().put("pk", user));
return (user);
}
/*******************************************************************************
**
*******************************************************************************/
private boolean passes(int user)
{
return (admin.getOk("/core/applications/" + APP_SLUG + "/check_access/?for_user=" + user).getBoolean("passing"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void appAccessAttributeGrantsSignIn()
{
assertThat(passes(userInGroup("list", new JSONObject().put("qqq.io/app-access", new JSONArray().put("other-app").put(APP_SLUG)), null))).isTrue();
assertThat(passes(userInGroup("string", new JSONObject().put("qqq.io/app-access", APP_SLUG), null))).isTrue();
assertThat(passes(userInGroup("prefix", new JSONObject().put("qqq.io/app-access", APP_SLUG + "-old"), null))).as("no substring match").isFalse();
assertThat(passes(userInGroup("none", new JSONObject(), null))).isFalse();
String parent = admin.groupPk("signin-list");
assertThat(passes(userInGroup("child", new JSONObject(), parent))).as("inherited from a parent group").isTrue();
}
/*******************************************************************************
** Transition fallback by group name; drafters get nothing until the owner
** sets qqq.io/app-access on that group.
*******************************************************************************/
@Test
void groupNameFallbackAndDrafters()
{
admin.post("/core/users/", new JSONObject().put("username", "signin.fallback").put("name", "Fallback").put("type", "internal").put("path", "users"));
int fallback = admin.userPk("signin.fallback");
admin.post("/core/groups/" + admin.groupPk("kof22-admins") + "/add_user/", new JSONObject().put("pk", fallback));
assertThat(passes(fallback)).isTrue();
admin.post("/core/users/", new JSONObject().put("username", "signin.drafter").put("name", "Drafter").put("type", "internal").put("path", "users"));
int drafter = admin.userPk("signin.drafter");
admin.post("/core/groups/" + admin.groupPk("kof-website-drafters") + "/add_user/", new JSONObject().put("pk", drafter));
assertThat(passes(drafter)).isFalse();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void webhookPlumbingIsPresentAndInert()
{
JSONObject transport = admin.first("/events/transports/?name=qqq-kof22-admin");
assertThat(transport.getString("mode")).isEqualTo("webhook");
assertThat(transport.getString("webhook_url")).isEqualTo("https://admin.kof22.com/qqq/v1/authentik/webhook");
assertThat(transport.getBoolean("send_once")).isTrue();
assertThat(transport.isNull("webhook_mapping_body")).isFalse();
assertThat(transport.isNull("webhook_mapping_headers")).isFalse();
JSONObject rule = admin.first("/events/rules/?name=qqq-kof22-admin-sync");
assertThat(rule.getBoolean("destination_event_user")).isFalse();
assertThat(rule.isNull("destination_group")).isTrue();
assertThat(rule.getJSONArray("transports").toList()).containsExactly(transport.getString("pk"));
JSONArray bindings = admin.getOk("/policies/bindings/?target=" + rule.getString("pk") + "&page_size=50").getJSONArray("results");
assertThat(bindings.length()).isEqualTo(11);
}
}
@@ -1,266 +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.contract;
import java.util.List;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.BREAK_GLASS;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.SERVICE_ACCOUNT;
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.SUPERUSER_GROUP;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Contract test (b) and open point (ii): what Authentik itself refuses the
** qbit's service account, holding the phase 2/3 write permissions it will
** eventually get, but never enable_group_superuser.
**
** ESCALATION is refused in every form (add to a superuser group or a
** descendant, re-parent under one, user groups PATCH, group users PATCH,
** create under one, set is_superuser).
**
** LOCKOUT is NOT refused: with remove_user_from_group / change_user /
** change_group the service account can remove a superuser from the superuser
** group, deactivate break-glass, strip qqq.io/protected, or rename the
** superuser group. Those tests pin today's behaviour so an Authentik change
** is noticed; the qbit's guard is the only control (DESIGN.md section 5.1).
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(50)
class SuperuserFloorIT
{
private static final List<String> PHASE_2_3_PERMISSIONS = List.of(
"authentik_core.add_user_to_group", "authentik_core.remove_user_from_group",
"authentik_core.add_group", "authentik_core.change_group", "authentik_core.add_user", "authentik_core.change_user");
private static AuthentikTestStack stack;
private static TestApi admin;
private static TestApi sa;
private static String roleUuid;
private static int probeUser;
private static String superuserGroup;
private static String childOfSuperuser;
private static String plainGroup;
/*******************************************************************************
** Grant the later-phase write permissions (test-only, additive) and create
** probe objects.
*******************************************************************************/
@BeforeAll
static void beforeAll()
{
stack = AuthentikTestStack.get();
admin = stack.admin();
sa = stack.serviceAccount();
roleUuid = admin.first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
admin.post("/rbac/permissions/assigned_by_roles/" + roleUuid + "/assign/", new JSONObject().put("permissions", new JSONArray(PHASE_2_3_PERMISSIONS)));
admin.post("/core/users/", new JSONObject().put("username", "floor.probe").put("name", "Floor Probe").put("type", "internal").put("path", "users"));
probeUser = admin.userPk("floor.probe");
superuserGroup = admin.groupPk(SUPERUSER_GROUP);
admin.post("/core/groups/", new JSONObject().put("name", "floor-child-of-admins").put("parents", new JSONArray().put(superuserGroup)));
childOfSuperuser = admin.groupPk("floor-child-of-admins");
admin.post("/core/groups/", new JSONObject().put("name", "floor-plain"));
plainGroup = admin.groupPk("floor-plain");
}
/*******************************************************************************
** Remove the test-only grants again.
*******************************************************************************/
@AfterAll
static void afterAll()
{
if(admin != null && roleUuid != null)
{
admin.patch("/rbac/permissions/assigned_by_roles/" + roleUuid + "/unassign/", new JSONObject().put("permissions", new JSONArray(PHASE_2_3_PERMISSIONS)));
}
}
/*******************************************************************************
** Sanity: an ordinary membership change is allowed.
*******************************************************************************/
@Test
void ordinaryMembershipAllowed()
{
assertThat(sa.post("/core/groups/" + plainGroup + "/add_user/", new JSONObject().put("pk", probeUser)).status()).isEqualTo(204);
assertThat(sa.post("/core/groups/" + plainGroup + "/remove_user/", new JSONObject().put("pk", probeUser)).status()).isEqualTo(204);
}
/*******************************************************************************
** (b) add_user to a superuser group, or to a group under one: 403.
*******************************************************************************/
@Test
void addToSuperuserAncestryRefused()
{
TestApi.Response direct = sa.post("/core/groups/" + superuserGroup + "/add_user/", new JSONObject().put("pk", probeUser));
assertThat(direct.status()).isEqualTo(403);
assertThat(direct.body()).contains("superuser group");
TestApi.Response child = sa.post("/core/groups/" + childOfSuperuser + "/add_user/", new JSONObject().put("pk", probeUser));
assertThat(child.status()).isEqualTo(403);
}
/*******************************************************************************
** (b) / open point (ii): re-parenting under a superuser group or one of its
** descendants, creating a group under one, and setting is_superuser: 400.
*******************************************************************************/
@Test
void reparentUnderSuperuserRefused()
{
TestApi.Response underAdmins = sa.patch("/core/groups/" + plainGroup + "/", new JSONObject().put("parents", new JSONArray().put(superuserGroup)));
assertThat(underAdmins.status()).isEqualTo(400);
assertThat(underAdmins.body()).contains("superuser parent group");
TestApi.Response underChild = sa.patch("/core/groups/" + plainGroup + "/", new JSONObject().put("parents", new JSONArray().put(childOfSuperuser)));
assertThat(underChild.status()).isEqualTo(400);
TestApi.Response create = sa.post("/core/groups/", new JSONObject().put("name", "floor-new-under-admins").put("parents", new JSONArray().put(superuserGroup)));
assertThat(create.status()).isEqualTo(400);
TestApi.Response flag = sa.patch("/core/groups/" + plainGroup + "/", new JSONObject().put("is_superuser", true));
assertThat(flag.status()).isEqualTo(400);
TestApi.Response unflag = sa.patch("/core/groups/" + superuserGroup + "/", new JSONObject().put("is_superuser", false));
assertThat(unflag.status()).isEqualTo(400);
}
/*******************************************************************************
** (b) the bulk forms: user.groups and group.users PATCH.
*******************************************************************************/
@Test
void bulkMembershipFormsRefused()
{
assertThat(sa.patch("/core/users/" + probeUser + "/", new JSONObject().put("groups", new JSONArray().put(superuserGroup))).status()).isEqualTo(400);
assertThat(sa.patch("/core/groups/" + superuserGroup + "/", new JSONObject().put("users", new JSONArray().put(probeUser))).status()).isEqualTo(400);
}
/*******************************************************************************
** Tokens, impersonation, deletion and recovery links stay out of reach.
*******************************************************************************/
@Test
void hardFloorEndpointsRefused()
{
assertThat(sa.post("/core/users/" + probeUser + "/impersonate/", new JSONObject().put("reason", "contract test")).status()).isEqualTo(403);
assertThat(sa.delete("/core/users/" + probeUser + "/").status()).isEqualTo(403);
assertThat(sa.post("/core/users/" + probeUser + "/recovery/", new JSONObject()).status()).isEqualTo(403);
}
/*******************************************************************************
** Residual risk (pinned): any authenticated user, the service account
** included, may create API tokens FOR ITSELF without add_token; a `user`
** in the request is ignored. A compromised QQQ could mint a second
** service-account token that outlives a rotation, so the drift report
** flags service-account tokens beyond the expected one. Creating a token
** for another user is not possible.
*******************************************************************************/
@Test
void serviceAccountCanMintTokensOnlyForItself()
{
int saPk = admin.userPk(SERVICE_ACCOUNT);
try
{
TestApi.Response created = sa.post("/core/tokens/", new JSONObject().put("identifier", "floor-token").put("user", probeUser).put("intent", "api").put("expiring", true));
assertThat(created.status()).isEqualTo(201);
JSONObject token = admin.getOk("/core/tokens/floor-token/");
assertThat(token.getInt("user")).as("owner forced to the caller").isEqualTo(saPk);
assertThat(sa.get("/core/tokens/floor-token/view_key/").status()).as("and it can read that token's key").isEqualTo(200);
}
finally
{
admin.delete("/core/tokens/floor-token/");
}
}
/*******************************************************************************
** Residual risk (pinned): Authentik does NOT stop the lockout direction.
** Each change is reverted immediately by the admin.
*******************************************************************************/
@Test
void lockoutDirectionIsNotRefusedByAuthentik()
{
int breakGlass = admin.userPk(BREAK_GLASS);
try
{
assertThat(sa.post("/core/groups/" + superuserGroup + "/remove_user/", new JSONObject().put("pk", breakGlass)).status()).isEqualTo(204);
}
finally
{
admin.post("/core/groups/" + superuserGroup + "/add_user/", new JSONObject().put("pk", breakGlass));
}
try
{
assertThat(sa.patch("/core/users/" + breakGlass + "/", new JSONObject().put("is_active", false)).status()).isEqualTo(200);
}
finally
{
admin.patch("/core/users/" + breakGlass + "/", new JSONObject().put("is_active", true));
}
try
{
assertThat(sa.patch("/core/users/" + breakGlass + "/", new JSONObject().put("attributes", new JSONObject())).status()).isEqualTo(200);
}
finally
{
admin.patch("/core/users/" + breakGlass + "/", new JSONObject().put("attributes", new JSONObject().put("qqq.io/protected", true)));
}
try
{
assertThat(sa.patch("/core/groups/" + superuserGroup + "/", new JSONObject().put("name", SUPERUSER_GROUP + " renamed")).status()).isEqualTo(200);
}
finally
{
admin.patch("/core/groups/" + superuserGroup + "/", new JSONObject().put("name", SUPERUSER_GROUP));
}
JSONObject restored = admin.first("/core/users/?username=" + BREAK_GLASS + "&include_groups=true");
assertThat(restored.getBoolean("is_active")).isTrue();
assertThat(restored.getBoolean("is_superuser")).isTrue();
assertThat(restored.getJSONObject("attributes").getBoolean("qqq.io/protected")).isTrue();
}
}
@@ -1,219 +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.contract;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Minimal Authentik API client for contract tests, acting as one token's
** user (the test-only bootstrap admin, or the qbit's service account). Used
** for setup and for asserting Authentik's own behaviour; the qbit's code
** path is AuthentikActionUtil.
**
** Only ever points at the disposable Testcontainers instance.
*******************************************************************************/
public class TestApi
{
private static final HttpClient CLIENT = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(10)).build();
private final String apiRoot;
private final String token;
/*******************************************************************************
** Response: status plus raw body.
*******************************************************************************/
public record Response(int status, String body)
{
/*******************************************************************************
** Body as a JSON object (empty object for an empty body).
*******************************************************************************/
public JSONObject json()
{
return (body == null || body.isBlank() ? new JSONObject() : new JSONObject(body));
}
/*******************************************************************************
** Shorthand for json().getJSONArray("results").
*******************************************************************************/
public JSONArray results()
{
return (json().getJSONArray("results"));
}
}
/*******************************************************************************
** Constructor.
*******************************************************************************/
public TestApi(String apiRoot, String token)
{
this.apiRoot = apiRoot;
this.token = token;
}
/*******************************************************************************
**
*******************************************************************************/
public Response get(String path)
{
return (send("GET", path, null));
}
/*******************************************************************************
**
*******************************************************************************/
public Response post(String path, Object body)
{
return (send("POST", path, body));
}
/*******************************************************************************
**
*******************************************************************************/
public Response patch(String path, Object body)
{
return (send("PATCH", path, body));
}
/*******************************************************************************
**
*******************************************************************************/
public Response delete(String path)
{
return (send("DELETE", path, null));
}
/*******************************************************************************
** GET that must succeed, as JSON.
*******************************************************************************/
public JSONObject getOk(String path)
{
Response response = get(path);
if(response.status() != 200)
{
throw (new IllegalStateException("GET " + path + " -> " + response.status() + ": " + response.body()));
}
return (response.json());
}
/*******************************************************************************
** The first result of a list GET, or null.
*******************************************************************************/
public JSONObject first(String path)
{
JSONArray results = getOk(path).getJSONArray("results");
return (results.isEmpty() ? null : results.getJSONObject(0));
}
/*******************************************************************************
** pk of a group by name.
*******************************************************************************/
public String groupPk(String name)
{
JSONObject group = first("/core/groups/?name=" + encode(name));
if(group == null)
{
throw (new IllegalStateException("No group " + name));
}
return (group.getString("pk"));
}
/*******************************************************************************
** pk of a user by username.
*******************************************************************************/
public int userPk(String username)
{
JSONObject user = first("/core/users/?username=" + encode(username));
if(user == null)
{
throw (new IllegalStateException("No user " + username));
}
return (user.getInt("pk"));
}
/*******************************************************************************
** URL-encode a query value.
*******************************************************************************/
public static String encode(String value)
{
return (URLEncoder.encode(value, StandardCharsets.UTF_8));
}
/*******************************************************************************
**
*******************************************************************************/
private Response send(String method, String path, Object body)
{
try
{
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(apiRoot + path))
.timeout(Duration.ofSeconds(60))
.header("Authorization", "Bearer " + token)
.header("Accept", "application/json");
if(body != null)
{
builder.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(body.toString()));
}
else
{
builder.method(method, HttpRequest.BodyPublishers.noBody());
}
HttpResponse<String> response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString());
return (new Response(response.statusCode(), response.body()));
}
catch(Exception e)
{
throw (new IllegalStateException(method + " " + path + " failed: " + e.getMessage(), e));
}
}
}
@@ -1,189 +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.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureRouter;
import com.kingsrook.qbits.authentik.metadata.AuthentikDriftReportWidgetRenderer;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
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.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Drift report on the recorded 2026.8.3 contract-test instance, and the
** scenarios it must catch.
*******************************************************************************/
class AuthentikDriftReportTest
{
private QInstance qInstance;
private AuthentikQBitConfig config;
private FixtureRouter router;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
FixtureActionUtil.reset();
config = AuthentikTestSupport.unitConfig().withApplicationsInScope(List.of("kof22-website-prod")).withGroupCacheTtlSeconds(0);
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
router = new FixtureRouter().install();
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
**
*******************************************************************************/
private List<DriftFinding> run() throws QException
{
router.refreshGraph();
return (new AuthentikDriftReport(AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run());
}
/*******************************************************************************
**
*******************************************************************************/
private static DriftFinding find(List<DriftFinding> findings, String check, DriftFinding.Severity severity)
{
return (findings.stream().filter(f -> f.check().equals(check) && f.severity() == severity).findFirst().orElse(null));
}
/*******************************************************************************
** The recorded instance, as the contract test saw it.
*******************************************************************************/
@Test
void recordedInstance() throws QException
{
List<DriftFinding> findings = run();
assertThat(find(findings, "version", DriftFinding.Severity.OK)).isNotNull();
assertThat(find(findings, "blueprints", DriftFinding.Severity.OK)).isNotNull();
assertThat(find(findings, "service account", DriftFinding.Severity.OK)).isNotNull();
assertThat(find(findings, "break-glass", DriftFinding.Severity.OK)).isNotNull();
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.message().contains("no.such.permission"));
assertThat(find(findings, "service-account activity", DriftFinding.Severity.ERROR)).as("the lockout probes on break-glass").isNotNull();
assertThat(findings.get(0).severity()).isEqualTo(DriftFinding.Severity.ERROR);
}
/*******************************************************************************
** Unsafe states.
*******************************************************************************/
@Test
void unsafeStates() throws QException
{
JSONObject serviceAccount = router.users.stream().filter(u -> u.getString("username").equals("qqq-kof22-admin-idadmin")).findFirst().orElseThrow();
serviceAccount.put("is_superuser", true).put("type", "internal").put("is_active", false).put("attributes", new JSONObject())
.put("groups_obj", new JSONArray().put(new JSONObject().put("pk", "x").put("name", "authentik Admins")));
router.tokens.add(new JSONObject(router.tokens.get(0).toString()).put("identifier", "second"));
router.blueprints.get(0).put("status", "error");
router.users.forEach(u -> u.put("attributes", new JSONObject()));
router.groups.forEach(g -> g.put("users_obj", new JSONArray()).put("users", new JSONArray()));
router.events.clear();
List<DriftFinding> findings = run();
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.severity() == DriftFinding.Severity.ERROR && f.message().contains("SUPERUSER"));
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("type is internal"));
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("inactive"));
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("qqq.io/protected"));
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("2 API tokens"));
assertThat(find(findings, "blueprints", DriftFinding.Severity.ERROR)).isNotNull();
assertThat(find(findings, "break-glass", DriftFinding.Severity.ERROR)).isNotNull();
assertThat(find(findings, "identity admins", DriftFinding.Severity.ERROR)).isNotNull();
assertThat(find(findings, "service-account activity", DriftFinding.Severity.OK)).isNotNull();
}
/*******************************************************************************
** Missing service account, version mismatch, a check that cannot run.
*******************************************************************************/
@Test
void missingAndUnreachable() throws QException
{
router.users.removeIf(u -> u.getString("username").equals("qqq-kof22-admin-idadmin"));
config.withExpectedServerVersion("2099.1");
router.blueprints.clear();
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/managed/blueprints/")
? FixtureActionUtil.response(403, "{\"detail\":\"You do not have permission to perform this action.\"}")
: router.apply(request);
List<DriftFinding> findings = run();
assertThat(find(findings, "service account", DriftFinding.Severity.ERROR)).isNotNull();
assertThat(find(findings, "version", DriftFinding.Severity.WARNING)).isNotNull();
assertThat(findings).anyMatch(f -> f.check().equals("blueprints") && f.message().startsWith("Check could not run"));
}
/*******************************************************************************
** The widget renders the findings, and needs authentik.admin.
*******************************************************************************/
@Test
void widgetRendersAndChecksPermission() throws QException
{
RenderWidgetInput input = new RenderWidgetInput();
input.setWidgetMetaData(qInstance.getWidget(config.applyPrefix("driftReport")));
TableData table = (TableData) new AuthentikDriftReportWidgetRenderer().render(input).getWidgetData();
assertThat(table.getColumns()).hasSize(4);
assertThat(table.getRows()).isNotEmpty();
assertThat(table.getRows().get(0).get("severity")).isEqualTo("ERROR");
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
assertThatThrownBy(() -> new AuthentikDriftReportWidgetRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
}
}
@@ -1,157 +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.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureRouter;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Tests for AuthentikGroupPermissionMapper over the recorded group graph.
*******************************************************************************/
class AuthentikGroupPermissionMapperTest
{
private QInstance qInstance;
private AuthentikQBitConfig config;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
FixtureActionUtil.reset();
config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0).withBootstrapOperators("Boot@Example.Test");
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
QContext.init(qInstance, new QSession());
new FixtureRouter().install();
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
**
*******************************************************************************/
private static Map<String, Object> context(String email, Object groups)
{
Map<String, Object> context = new HashMap<>();
context.put(AuthentikGroupPermissionMapper.CLAIMS_CONTEXT_KEY, new JSONObject().put("email", email).put("groups", groups));
return (context);
}
/*******************************************************************************
** Inherited authentik.* through a child group; existing permissions kept.
*******************************************************************************/
@Test
void groupsBecomePermissions()
{
QSession session = new QSession();
session.setPermissions(Set.of("already.there"));
new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins").put("no-such-group")));
for(IdentityPermission permission : IdentityPermission.values())
{
assertThat(session.getPermissions()).contains(permission.getSessionPermission());
}
assertThat(session.getPermissions()).contains("already.there");
}
/*******************************************************************************
** Static fallbacks, bootstrap operators (case-insensitive), single-string
** groups claim; no claims means no change.
*******************************************************************************/
@Test
void fallbacksAndBootstrap()
{
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper().withGroupPermissions("kof22-admins", List.of("adminEsb.hasAccess"));
QSession admin = new QSession();
mapper.customizeSession(qInstance, admin, context("someone@example.test", "kof22-admins"));
assertThat(admin.getPermissions()).contains("adminEsb.hasAccess").doesNotContain(IdentityPermission.USERS_READ.getSessionPermission());
QSession boot = new QSession();
mapper.customizeSession(qInstance, boot, context("boot@example.test", new JSONArray()));
assertThat(boot.getPermissions()).contains(IdentityPermission.MEMBERSHIPS_MANAGE.getSessionPermission());
QSession none = new QSession();
mapper.customizeSession(qInstance, none, Map.of());
mapper.customizeSession(qInstance, none, null);
assertThat(none.getPermissions()).isNullOrEmpty();
}
/*******************************************************************************
** Authentik unreachable: only the static fallbacks, never everything.
*******************************************************************************/
@Test
void authentikDownGrantsOnlyFallbacks()
{
FixtureActionUtil.groupGraphBody = "not json";
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("cms.publish"));
QSession session = new QSession();
mapper.customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins")));
assertThat(session.getPermissions()).containsExactly("cms.publish");
AuthentikGroupPermissionMapper wrongBackend = new AuthentikGroupPermissionMapper()
{
@Override
protected String getApiBackendName()
{
return ("nope");
}
};
assertThat(wrongBackend.resolve(qInstance, List.of("phase1-child-of-identity-admins"), "x@example.test")).isEmpty();
}
}
@@ -1,176 +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.List;
import java.util.Set;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Tests for GroupGraph and EffectivePermissionResolver.
*******************************************************************************/
class EffectivePermissionResolverTest
{
/*******************************************************************************
** admins (superuser) <- under-admins; identity-admins <- editors <- interns;
** publishers (string attribute); a cycle a <-> b.
*******************************************************************************/
private static GroupGraph graph()
{
return (GroupGraph.fromGroups(List.of(
group("admins", "authentik Admins", true, List.of(), null, null, List.of(1)),
group("under", "under-admins", false, List.of("admins"), List.of("cms.publish"), null, List.of(2)),
group("ida", "identity-admins", false, List.of(), List.of("authentik.*"), List.of("kof22-website-prod"), List.of(3)),
group("editors", "editors", false, List.of("ida"), List.of("cms.edit", "cms.*"), null, List.of(4)),
group("interns", "interns", false, List.of("editors"), List.of("authentik.users.read", "nope.unknown"), null, List.of(5)),
new JSONObject().put("pk", "pub").put("name", "publishers").put("is_superuser", false).put("parents", new JSONArray())
.put("attributes", new JSONObject().put("qqq.io/permissions", "cms.publish").put("qqq.io/app-access", "kof22-website-prod")).put("users", new JSONArray().put(6)),
group("a", "cycle-a", false, List.of("b"), List.of("cms.edit"), null, List.of()),
group("b", "cycle-b", false, List.of("a"), List.of(), null, List.of(7)))));
}
/*******************************************************************************
**
*******************************************************************************/
private static JSONObject group(String pk, String name, boolean superuser, List<String> parents, List<String> permissions, List<String> appAccess, List<Integer> users)
{
JSONObject attributes = new JSONObject();
if(permissions != null)
{
attributes.put("qqq.io/permissions", new JSONArray(permissions));
}
if(appAccess != null)
{
attributes.put("qqq.io/app-access", new JSONArray(appAccess));
}
return (new JSONObject().put("pk", pk).put("name", name).put("is_superuser", superuser).put("parents", new JSONArray(parents))
.put("attributes", attributes).put("users", new JSONArray(users)));
}
/*******************************************************************************
**
*******************************************************************************/
private static final Set<String> CATALOG = Set.of("cms.publish", "cms.edit", "cms.delete", "adminEsb.hasAccess");
/*******************************************************************************
**
*******************************************************************************/
@Test
void ancestryAndInheritedSuperuser()
{
GroupGraph graph = graph();
assertThat(graph.ancestorPks("interns")).containsExactly("editors", "ida");
assertThat(graph.isSuperuserInherited("under")).isTrue();
assertThat(graph.isSuperuserInherited("admins")).isTrue();
assertThat(graph.isSuperuserInherited("interns")).isFalse();
assertThat(graph.ancestorPks("a")).containsExactly("b");
assertThat(graph.ancestorPks("unknown")).isEmpty();
assertThat(graph.byName("editors").pk()).isEqualTo("editors");
assertThat(graph.groupsOfUser(4)).containsExactly("editors");
}
/*******************************************************************************
** Inheritance, wildcards, identity names, unknown names, the catalog.
*******************************************************************************/
@Test
void resolvesWithInheritanceAndWildcards()
{
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), CATALOG);
EffectivePermissionResolver.Resolution interns = resolver.resolveUser(5);
for(IdentityPermission permission : IdentityPermission.values())
{
assertThat(interns.sessionPermissions()).contains(permission.getSessionPermission());
}
assertThat(interns.sessionPermissions()).contains("cms.edit", "cms.publish", "cms.delete").doesNotContain("adminEsb.hasAccess", "nope.unknown");
assertThat(interns.unknown()).containsExactly("nope.unknown");
assertThat(interns.grantedBy().get("cms.delete")).containsExactly("editors");
assertThat(interns.grantedBy().get(IdentityPermission.USERS_READ.getSessionPermission())).containsExactlyInAnyOrder("identity-admins", "interns");
assertThat(interns.appAccess()).containsExactly("kof22-website-prod");
assertThat(EffectivePermissionResolver.holdsAll(interns, List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE))).isTrue();
assertThat(EffectivePermissionResolver.holdsAll(resolver.resolveUser(6), List.of(IdentityPermission.USERS_READ))).isFalse();
}
/*******************************************************************************
** Pre-rename identity.* grants (owner naming rule, 2026-10-12) are reported
** as unknown and grant nothing until the group attribute is updated.
*******************************************************************************/
@Test
@SuppressWarnings("deprecation")
void legacyIdentityNamesAreUnknown()
{
GroupGraph graph = GroupGraph.fromGroups(List.of(
group("old", "identity-admins", false, List.of(), List.of(IdentityPermission.LEGACY_WILDCARD, IdentityPermission.USERS_READ.getLegacyShortName()), null, List.of(9))));
EffectivePermissionResolver.Resolution old = new EffectivePermissionResolver(graph, CATALOG).resolveUser(9);
assertThat(old.sessionPermissions()).isEmpty();
assertThat(old.unknown()).containsExactlyInAnyOrder("identity.*", "identity.users.read");
assertThat(EffectivePermissionResolver.holdsAll(old, List.of(IdentityPermission.USERS_READ))).isFalse();
}
/*******************************************************************************
** A single string attribute is a list of one; cycles terminate.
*******************************************************************************/
@Test
void stringAttributesAndCycles()
{
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), CATALOG);
assertThat(resolver.resolveUser(6).sessionPermissions()).containsExactly("cms.publish");
assertThat(resolver.resolveUser(6).appAccess()).containsExactly("kof22-website-prod");
assertThat(resolver.resolveUser(7).sessionPermissions()).containsExactly("cms.edit");
assertThat(resolver.resolveUser(99).sessionPermissions()).isEmpty();
}
/*******************************************************************************
** Without a catalog: names pass through, but wildcards are never granted
** literally.
*******************************************************************************/
@Test
void withoutCatalog()
{
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), null);
assertThat(resolver.expand("anything.goes")).containsExactly("anything.goes");
assertThat(resolver.expand("cms.*")).isEmpty();
assertThat(resolver.expand(" authentik.audit.read ")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission());
assertThat(resolver.expand("authentik.audit.read.hasAccess")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission());
assertThat(resolver.expand("")).isEmpty();
assertThat(resolver.expand(null)).isEmpty();
}
}
@@ -1,68 +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 com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Unit test for IdentityPermission: the authentik.* names (owner naming rule,
** 2026-10-12) and the legacy identity.* names.
*******************************************************************************/
class IdentityPermissionTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void namesUseTheQbitPrefix()
{
assertThat(IdentityPermission.WILDCARD).isEqualTo("authentik.*");
for(IdentityPermission permission : IdentityPermission.values())
{
assertThat(permission.getShortName()).startsWith("authentik.");
assertThat(permission.getSessionPermission()).isEqualTo(permission.getShortName() + ".hasAccess");
}
assertThat(IdentityPermission.USERS_READ.getShortName()).isEqualTo("authentik.users.read");
assertThat(IdentityPermission.ADMIN.getShortName()).isEqualTo("authentik.admin");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
@SuppressWarnings("deprecation")
void legacyNamesAreKnownButNeverGranted()
{
assertThat(IdentityPermission.LEGACY_WILDCARD).isEqualTo("identity.*");
assertThat(IdentityPermission.USERS_READ.getLegacyShortName()).isEqualTo("identity.users.read");
assertThat(IdentityPermission.MEMBERSHIPS_MANAGE.getLegacyShortName()).isEqualTo("identity.memberships.manage");
assertThat(IdentityPermission.ADMIN.getLegacyShortName()).isEqualTo("identity.admin");
assertThat(IdentityPermission.fromName("authentik.users.read")).contains(IdentityPermission.USERS_READ);
assertThat(IdentityPermission.fromName("authentik.users.read.hasAccess")).contains(IdentityPermission.USERS_READ);
assertThat(IdentityPermission.fromName("identity.users.read")).isEmpty();
assertThat(IdentityPermission.fromName("identity.users.read.hasAccess")).isEmpty();
}
}
File diff suppressed because it is too large. Load diff
@@ -1,107 +0,0 @@
# Test fixture: the kof22 identity bootstrap blueprint (DESIGN.md section 4.3),
# in the shape proposed for cluster-gitops. Identity objects are
# `state: created`: created when missing, never updated, never given members.
version: 1
metadata:
name: kof22-identity-bootstrap
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# Apply the infrastructure blueprint first (it owns the recovery email stage
# referenced below). On an empty instance this is what makes the bootstrap
# apply cleanly (contract test h).
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: kof22-oauth2-apps
required: true
# The role the qbit service account runs as. It is the qbit's own
# infrastructure, not identity data, so it is `present`: attrs carry ONLY
# `permissions` (global permissions; a blueprint-only RoleSerializer field),
# which are assigned additively on every apply, so a permission removed in
# the UI comes back within the hour and nothing granted elsewhere is taken
# away. No users, no groups. Phase 0/1 = read-only; later phases append here.
# Note: matching is by codename, so view_group also grants Django's unused
# auth.view_group (harmless).
- model: authentik_rbac.role
state: present
id: qqq-role
identifiers:
name: qqq-kof22-admin-idadmin
attrs:
permissions:
- authentik_core.view_user
- authentik_core.view_group
- authentik_core.view_application
- authentik_core.view_authenticatedsession
- authentik_rbac.view_role
- authentik_policies.view_policybinding
- authentik_events.view_event
- authentik_events.add_event
- authentik_blueprints.view_blueprintinstance
# The qbit service account. Its API token is minted once by hand (never here).
- model: authentik_core.user
state: created
id: qqq-sa
identifiers:
username: qqq-kof22-admin-idadmin
attrs:
name: QQQ kof22 Admin identity service
type: service_account
path: service-accounts/qqq
roles:
- !KeyOf qqq-role
attributes:
qqq.io/protected: true
# Object permission on a stage the infrastructure blueprint owns: the bare
# `created` entry is skipped (the stage exists), but its permissions are
# still applied, additively, on every run.
- model: authentik_stages_email.emailstage
state: created
identifiers:
name: kof22-recovery-email
permissions:
- role: !KeyOf qqq-role
permission: authentik_stages_email.view_emailstage
# Seed groups: created once, then plain data. No users, no member lists.
- model: authentik_core.group
state: created
identifiers:
name: identity-admins
attrs:
attributes:
qqq.io/permissions: ["authentik.*"]
qqq.io/app-access: ["kof22-website-prod"]
- model: authentik_core.group
state: created
identifiers:
name: kof22-admins
- model: authentik_core.group
state: created
identifiers:
name: kof-website-publishers
# No qqq.io/app-access yet: drafters sign in to the admin only once the owner
# decides to set that one attribute (DESIGN.md section 11.2 item 6).
- model: authentik_core.group
state: created
identifiers:
name: kof-website-drafters
# Break-glass admin: a superuser the qbit cannot touch. Password set once by
# hand (1Password); recovery through `ak create_recovery_key`.
- model: authentik_core.user
state: created
identifiers:
username: kof22-breakglass
attrs:
name: Break-glass admin
type: internal
path: users/breakglass
groups:
- !Find [authentik_core.group, [name, "authentik Admins"]]
attributes:
qqq.io/protected: true
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more