Phase 0: foundations (read-only client, Authentik 2026.8.3 contract tests) (#1)

* feat: phase 0 foundations (read-only client, contract tests on Authentik 2026.8.3)

- AuthentikQBitConfig (validated, secrets resolved by the qbit, never
  serialized or printed) and AuthentikQBitProducer (API backend, read-only
  authentikUser / authentikGroup tables, Identity app).
- AuthentikActionUtil on qqq-backend-module-api: bearer auth, page/page_size
  paging with pagination.count, key lookups by GET, server-side user scope,
  AuthentikFilterMapper (rejects whatever Authentik cannot evaluate),
  10 rps token-bucket throttle, 429/5xx retries, error mapping, and logging
  that never carries the token, query strings, successful GET bodies or
  sensitive bodies (AuthentikRedactor).
- AuthentikGuard: identity.* read checks, sensitive fields behind
  identity.audit.read, all writes refused in this release, protected-account
  detection.
- Pinned Authentik 2026.8.3 schema, SchemaDriftTest / SchemaDriftIT over
  the API surface used, and a weekly drift workflow against new releases.
- Testcontainers harness (Authentik server + worker, Postgres, Mailpit) with
  the kof22-shaped blueprints, fixture recorder, and contract tests (b), (c),
  (d), (h) plus the access-check, sign-in policy, webhook and token findings.
- Owner checklist, rehearsal script and blueprint lint for the cluster-gitops
  migration (KofTwentyTwo/cluster-gitops#72).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop committed Python bytecode and ignore it

The lint script's __pycache__/*.pyc was committed by accident: it is
generated output and an unreviewable binary (K22-REPO-05).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #1.
This commit is contained in:
james.maesandClaude Opus 5.5 authored and GitHub committed 2026-10-12 06:03:21 -05:00
1 parent 6afe1b045a
commit 037d736bfd
60 files changed
+71528

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
# 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"
+2
View File
@@ -19,3 +19,5 @@ dependency-reduced-pom.xml
/.env.local
/.cache/
.claude/settings.local.json
__pycache__/
*.py[cod]
+10
View File
@@ -0,0 +1,10 @@
# Changelog
All notable changes to this project are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- 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.
+103
View File
@@ -0,0 +1,103 @@
# 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`.
- 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.
+23
View File
@@ -0,0 +1,23 @@
# 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` |
## Contract test results (Authentik 2026.8.3)
`CI=true ./mvnw verify`: 48 unit tests and 27 contract tests, all green; JaCoCo gate met.
+21
View File
@@ -0,0 +1,21 @@
# 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
```
@@ -0,0 +1,108 @@
#!/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
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"
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" <<'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' | sort
}
wait_blueprints() {
for _ in $(seq 1 90); do
states=$(api "/managed/blueprints/?page_size=100" | jq -r '[.results[] | select(.name|startswith("kof22")) | .status] | join(" ")' || true)
if [ -n "$states" ] && ! 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 || true
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:/blueprints/custom" "$IMAGE" worker >/dev/null
docker run -d --name akr-server --network $NET -p "$PORT:9000" "${ENV[@]}" -v "$BP:/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 = User.objects.filter(is_superuser=True, is_active=True).exclude(username__startswith='ak-').order_by('pk').first()
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>/dev/null | grep rehearsal
wait_blueprints
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
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
echo "== 4. checks"
snapshot > "$WORK/after.tsv"
fail=0
if ! diff <(grep -v -E '^(qqq-kof22-admin-idadmin|kof22-breakglass)\b' "$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
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; }
@@ -0,0 +1,225 @@
#!/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:]))
@@ -0,0 +1,58 @@
/*
* 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
}
@@ -0,0 +1,925 @@
/*
* 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.net.URI;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Host-facing configuration of the Authentik QBit (DESIGN.md section 3.2).
**
** Secrets (apiToken, webhookSecret) may be given as ${env.NAME} references;
** the qbit resolves them itself (APIBackendMetaData.enrich() is disabled in
** QQQ), never serializes them, and redacts them in toString().
**
** validate() is list-based and never throws; the producer fails production
** with all collected errors.
*******************************************************************************/
public class AuthentikQBitConfig implements QBitConfig
{
/** Features implemented in this release. Others are refused by validate(). */
public static final Set<AuthentikFeature> AVAILABLE_FEATURES = EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MIRROR);
private static final Pattern APP_KEY_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,48}[a-z0-9]");
private static final Pattern PREFIX_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9]{0,30}");
private String baseUrl;
private String apiToken;
private String appKey;
private String tableNamePrefix = "authentik";
private Set<AuthentikFeature> features = EnumSet.of(AuthentikFeature.READ);
private Boolean readOnly = false;
private String serviceAccountUsername;
private String userScopePathStartsWith;
private List<String> userScopeGroupNames = new ArrayList<>();
private String groupScopeNamePrefix;
private List<String> protectedUsernames = new ArrayList<>();
private String bootstrapOperators = "${env.AUTHENTIK_QBIT_BOOTSTRAP_OPERATORS}";
private List<String> applicationsInScope = new ArrayList<>();
private String mirrorBackendName;
private String recoveryEmailStage;
private String invitationFlow;
private String newUserPath = "users";
private String webhookSecret = "${env.AUTHENTIK_WEBHOOK_SECRET}";
private Integer throttleRequestsPerSecond = 10;
private Integer throttleBurst = 20;
private Integer bulkPacingMillis = 100;
private Integer listCacheTtlSeconds = 30;
private Integer groupCacheTtlSeconds = 60;
private Integer connectTimeoutMillis = 5_000;
private Integer socketTimeoutMillis = 30_000;
private String expectedServerVersion = "2026.8";
/*******************************************************************************
** Collect configuration errors. Never throws; null-safe for a partially
** built QInstance.
*******************************************************************************/
@Override
public void validate(QInstance qInstance, List<String> errors)
{
if(!StringUtils.hasContent(baseUrl))
{
errors.add("baseUrl is required (the Authentik host root, e.g. https://auth.example.com)");
}
else
{
try
{
URI uri = URI.create(baseUrl);
if(uri.getScheme() == null || !(uri.getScheme().equals("https") || uri.getScheme().equals("http")) || uri.getHost() == null)
{
errors.add("baseUrl must be an absolute http(s) URL: " + baseUrl);
}
else if(uri.getPath() != null && uri.getPath().contains("/api/v3"))
{
errors.add("baseUrl must be the host root; /api/v3 is appended by the qbit: " + baseUrl);
}
}
catch(IllegalArgumentException e)
{
errors.add("baseUrl is not a valid URL: " + baseUrl);
}
}
if(!StringUtils.hasContent(getResolvedApiToken()))
{
errors.add("apiToken is required (usually ${env.AUTHENTIK_API_TOKEN}) and resolved to an empty value");
}
if(!StringUtils.hasContent(appKey) || !APP_KEY_PATTERN.matcher(appKey).matches())
{
errors.add("appKey is required: lower-case letters, digits and dashes, e.g. kof22-admin");
}
if(tableNamePrefix != null && !tableNamePrefix.isEmpty() && !PREFIX_PATTERN.matcher(tableNamePrefix).matches())
{
errors.add("tableNamePrefix must start with a letter and contain only letters and digits: " + tableNamePrefix);
}
Set<AuthentikFeature> requested = features == null ? EnumSet.noneOf(AuthentikFeature.class) : features;
if(!requested.contains(AuthentikFeature.READ))
{
errors.add("feature READ is required");
}
for(AuthentikFeature feature : requested)
{
if(!AVAILABLE_FEATURES.contains(feature))
{
errors.add("feature " + feature + " is not available in this release of qbit-authentik (available: " + AVAILABLE_FEATURES + ")");
}
}
if(requested.contains(AuthentikFeature.MIRROR))
{
if(!StringUtils.hasContent(mirrorBackendName))
{
errors.add("feature MIRROR needs mirrorBackendName (an RDBMS backend of the instance)");
}
else if(qInstance != null && qInstance.getBackends() != null && qInstance.getBackend(mirrorBackendName) == null)
{
errors.add("mirrorBackendName: backend not found: " + mirrorBackendName);
}
}
if((requested.contains(AuthentikFeature.USERS) || requested.contains(AuthentikFeature.CREDENTIALS)) && !StringUtils.hasContent(recoveryEmailStage))
{
errors.add("features USERS and CREDENTIALS need recoveryEmailStage");
}
if(requested.contains(AuthentikFeature.WEBHOOK) && !StringUtils.hasContent(getResolvedWebhookSecret()))
{
errors.add("feature WEBHOOK needs webhookSecret");
}
for(Integer value : new Integer[] { throttleRequestsPerSecond, throttleBurst, listCacheTtlSeconds, groupCacheTtlSeconds, connectTimeoutMillis, socketTimeoutMillis })
{
if(value == null || value < 0)
{
errors.add("throttle, cache and timeout settings must be non-negative numbers");
break;
}
}
if(throttleRequestsPerSecond != null && throttleRequestsPerSecond == 0)
{
errors.add("throttleRequestsPerSecond must be at least 1");
}
}
/*******************************************************************************
** Prefix a component name with the tableNamePrefix (camel-cased join).
*******************************************************************************/
public String applyPrefix(String name)
{
if(!StringUtils.hasContent(tableNamePrefix))
{
return (name);
}
return (tableNamePrefix + Character.toUpperCase(name.charAt(0)) + name.substring(1));
}
/*******************************************************************************
** Name of the API backend the qbit produces.
*******************************************************************************/
@JsonIgnore
public String getApiBackendName()
{
return (applyPrefix("api"));
}
/*******************************************************************************
** The API root: baseUrl without a trailing slash, plus /api/v3.
*******************************************************************************/
@JsonIgnore
public String getApiRoot()
{
String root = baseUrl == null ? "" : baseUrl.trim();
while(root.endsWith("/"))
{
root = root.substring(0, root.length() - 1);
}
return (root + "/api/v3");
}
/*******************************************************************************
** The service account's username: explicit, or qqq-{appKey}-idadmin.
*******************************************************************************/
@JsonIgnore
public String getEffectiveServiceAccountUsername()
{
return (StringUtils.hasContent(serviceAccountUsername) ? serviceAccountUsername : "qqq-" + appKey + "-idadmin");
}
/*******************************************************************************
** The API token with ${env.X} / ${prop.X} references resolved.
*******************************************************************************/
@JsonIgnore
public String getResolvedApiToken()
{
return (resolve(apiToken));
}
/*******************************************************************************
** The webhook secret with references resolved.
*******************************************************************************/
@JsonIgnore
public String getResolvedWebhookSecret()
{
return (resolve(webhookSecret));
}
/*******************************************************************************
** Bootstrap operator emails, resolved and split on commas, lower-cased.
*******************************************************************************/
@JsonIgnore
public Set<String> getResolvedBootstrapOperators()
{
Set<String> rs = new LinkedHashSet<>();
String resolved = resolve(bootstrapOperators);
if(StringUtils.hasContent(resolved))
{
for(String part : resolved.split(","))
{
if(StringUtils.hasContent(part))
{
rs.add(part.trim().toLowerCase());
}
}
}
return (rs);
}
/*******************************************************************************
** Whether a feature is enabled.
*******************************************************************************/
public boolean hasFeature(AuthentikFeature feature)
{
return (features != null && features.contains(feature));
}
/*******************************************************************************
** Resolve ${env.X} style references; a reference to an unset variable
** resolves to null rather than to its literal text.
*******************************************************************************/
static String resolve(String value)
{
if(value == null)
{
return (null);
}
String resolved = new QMetaDataVariableInterpreter().interpret(value);
if(resolved != null && resolved.startsWith("${") && resolved.endsWith("}"))
{
return (null);
}
return (resolved);
}
/*******************************************************************************
** Never print secrets.
*******************************************************************************/
@Override
public String toString()
{
return ("AuthentikQBitConfig{baseUrl=" + baseUrl + ", appKey=" + appKey + ", tableNamePrefix=" + tableNamePrefix
+ ", features=" + features + ", readOnly=" + readOnly + ", apiToken=" + (StringUtils.hasContent(apiToken) ? "******" : "(unset)") + "}");
}
/*******************************************************************************
** Getter for baseUrl
*******************************************************************************/
public String getBaseUrl()
{
return (this.baseUrl);
}
/*******************************************************************************
** Setter for baseUrl
*******************************************************************************/
public void setBaseUrl(String baseUrl)
{
this.baseUrl = baseUrl;
}
/*******************************************************************************
** Fluent setter for baseUrl
*******************************************************************************/
public AuthentikQBitConfig withBaseUrl(String baseUrl)
{
this.baseUrl = baseUrl;
return (this);
}
/*******************************************************************************
** Getter for apiToken (the raw, possibly ${env.X}, value). Never serialized.
*******************************************************************************/
@JsonIgnore
public String getApiToken()
{
return (this.apiToken);
}
/*******************************************************************************
** Setter for apiToken
*******************************************************************************/
public void setApiToken(String apiToken)
{
this.apiToken = apiToken;
}
/*******************************************************************************
** Fluent setter for apiToken
*******************************************************************************/
public AuthentikQBitConfig withApiToken(String apiToken)
{
this.apiToken = apiToken;
return (this);
}
/*******************************************************************************
** Getter for appKey
*******************************************************************************/
public String getAppKey()
{
return (this.appKey);
}
/*******************************************************************************
** Setter for appKey
*******************************************************************************/
public void setAppKey(String appKey)
{
this.appKey = appKey;
}
/*******************************************************************************
** Fluent setter for appKey
*******************************************************************************/
public AuthentikQBitConfig withAppKey(String appKey)
{
this.appKey = appKey;
return (this);
}
/*******************************************************************************
** Getter for tableNamePrefix
*******************************************************************************/
public String getTableNamePrefix()
{
return (this.tableNamePrefix);
}
/*******************************************************************************
** Setter for tableNamePrefix
*******************************************************************************/
public void setTableNamePrefix(String tableNamePrefix)
{
this.tableNamePrefix = tableNamePrefix;
}
/*******************************************************************************
** Fluent setter for tableNamePrefix
*******************************************************************************/
public AuthentikQBitConfig withTableNamePrefix(String tableNamePrefix)
{
this.tableNamePrefix = tableNamePrefix;
return (this);
}
/*******************************************************************************
** Getter for features
*******************************************************************************/
public Set<AuthentikFeature> getFeatures()
{
return (this.features);
}
/*******************************************************************************
** Setter for features
*******************************************************************************/
public void setFeatures(Set<AuthentikFeature> features)
{
this.features = features;
}
/*******************************************************************************
** Fluent setter for features
*******************************************************************************/
public AuthentikQBitConfig withFeatures(Set<AuthentikFeature> features)
{
this.features = features;
return (this);
}
/*******************************************************************************
** Getter for readOnly
*******************************************************************************/
public Boolean getReadOnly()
{
return (this.readOnly);
}
/*******************************************************************************
** Setter for readOnly
*******************************************************************************/
public void setReadOnly(Boolean readOnly)
{
this.readOnly = readOnly;
}
/*******************************************************************************
** Fluent setter for readOnly
*******************************************************************************/
public AuthentikQBitConfig withReadOnly(Boolean readOnly)
{
this.readOnly = readOnly;
return (this);
}
/*******************************************************************************
** Getter for serviceAccountUsername
*******************************************************************************/
public String getServiceAccountUsername()
{
return (this.serviceAccountUsername);
}
/*******************************************************************************
** Fluent setter for serviceAccountUsername
*******************************************************************************/
public AuthentikQBitConfig withServiceAccountUsername(String serviceAccountUsername)
{
this.serviceAccountUsername = serviceAccountUsername;
return (this);
}
/*******************************************************************************
** Getter for userScopePathStartsWith
*******************************************************************************/
public String getUserScopePathStartsWith()
{
return (this.userScopePathStartsWith);
}
/*******************************************************************************
** Fluent setter for userScopePathStartsWith
*******************************************************************************/
public AuthentikQBitConfig withUserScopePathStartsWith(String userScopePathStartsWith)
{
this.userScopePathStartsWith = userScopePathStartsWith;
return (this);
}
/*******************************************************************************
** Getter for userScopeGroupNames
*******************************************************************************/
public List<String> getUserScopeGroupNames()
{
return (this.userScopeGroupNames);
}
/*******************************************************************************
** Fluent setter for userScopeGroupNames
*******************************************************************************/
public AuthentikQBitConfig withUserScopeGroupNames(List<String> userScopeGroupNames)
{
this.userScopeGroupNames = userScopeGroupNames;
return (this);
}
/*******************************************************************************
** Getter for groupScopeNamePrefix
*******************************************************************************/
public String getGroupScopeNamePrefix()
{
return (this.groupScopeNamePrefix);
}
/*******************************************************************************
** Fluent setter for groupScopeNamePrefix
*******************************************************************************/
public AuthentikQBitConfig withGroupScopeNamePrefix(String groupScopeNamePrefix)
{
this.groupScopeNamePrefix = groupScopeNamePrefix;
return (this);
}
/*******************************************************************************
** Getter for protectedUsernames
*******************************************************************************/
public List<String> getProtectedUsernames()
{
return (this.protectedUsernames);
}
/*******************************************************************************
** Fluent setter for protectedUsernames
*******************************************************************************/
public AuthentikQBitConfig withProtectedUsernames(List<String> protectedUsernames)
{
this.protectedUsernames = protectedUsernames;
return (this);
}
/*******************************************************************************
** Getter for bootstrapOperators (raw value)
*******************************************************************************/
public String getBootstrapOperators()
{
return (this.bootstrapOperators);
}
/*******************************************************************************
** Fluent setter for bootstrapOperators (comma-separated emails or ${env.X})
*******************************************************************************/
public AuthentikQBitConfig withBootstrapOperators(String bootstrapOperators)
{
this.bootstrapOperators = bootstrapOperators;
return (this);
}
/*******************************************************************************
** Getter for applicationsInScope
*******************************************************************************/
public List<String> getApplicationsInScope()
{
return (this.applicationsInScope);
}
/*******************************************************************************
** Fluent setter for applicationsInScope. Explicit slugs are required:
** Authentik lists to a non-superuser only the applications that user can
** itself access, so the qbit cannot discover them (phase 0 finding).
*******************************************************************************/
public AuthentikQBitConfig withApplicationsInScope(List<String> applicationsInScope)
{
this.applicationsInScope = applicationsInScope;
return (this);
}
/*******************************************************************************
** Getter for mirrorBackendName
*******************************************************************************/
public String getMirrorBackendName()
{
return (this.mirrorBackendName);
}
/*******************************************************************************
** Fluent setter for mirrorBackendName
*******************************************************************************/
public AuthentikQBitConfig withMirrorBackendName(String mirrorBackendName)
{
this.mirrorBackendName = mirrorBackendName;
return (this);
}
/*******************************************************************************
** Getter for recoveryEmailStage
*******************************************************************************/
public String getRecoveryEmailStage()
{
return (this.recoveryEmailStage);
}
/*******************************************************************************
** Fluent setter for recoveryEmailStage
*******************************************************************************/
public AuthentikQBitConfig withRecoveryEmailStage(String recoveryEmailStage)
{
this.recoveryEmailStage = recoveryEmailStage;
return (this);
}
/*******************************************************************************
** Getter for invitationFlow
*******************************************************************************/
public String getInvitationFlow()
{
return (this.invitationFlow);
}
/*******************************************************************************
** Fluent setter for invitationFlow
*******************************************************************************/
public AuthentikQBitConfig withInvitationFlow(String invitationFlow)
{
this.invitationFlow = invitationFlow;
return (this);
}
/*******************************************************************************
** Getter for newUserPath
*******************************************************************************/
public String getNewUserPath()
{
return (this.newUserPath);
}
/*******************************************************************************
** Fluent setter for newUserPath
*******************************************************************************/
public AuthentikQBitConfig withNewUserPath(String newUserPath)
{
this.newUserPath = newUserPath;
return (this);
}
/*******************************************************************************
** Getter for webhookSecret (raw value). Never serialized.
*******************************************************************************/
@JsonIgnore
public String getWebhookSecret()
{
return (this.webhookSecret);
}
/*******************************************************************************
** Fluent setter for webhookSecret
*******************************************************************************/
public AuthentikQBitConfig withWebhookSecret(String webhookSecret)
{
this.webhookSecret = webhookSecret;
return (this);
}
/*******************************************************************************
** Getter for throttleRequestsPerSecond
*******************************************************************************/
public Integer getThrottleRequestsPerSecond()
{
return (this.throttleRequestsPerSecond);
}
/*******************************************************************************
** Fluent setter for throttleRequestsPerSecond
*******************************************************************************/
public AuthentikQBitConfig withThrottleRequestsPerSecond(Integer throttleRequestsPerSecond)
{
this.throttleRequestsPerSecond = throttleRequestsPerSecond;
return (this);
}
/*******************************************************************************
** Getter for throttleBurst
*******************************************************************************/
public Integer getThrottleBurst()
{
return (this.throttleBurst);
}
/*******************************************************************************
** Fluent setter for throttleBurst
*******************************************************************************/
public AuthentikQBitConfig withThrottleBurst(Integer throttleBurst)
{
this.throttleBurst = throttleBurst;
return (this);
}
/*******************************************************************************
** Getter for bulkPacingMillis
*******************************************************************************/
public Integer getBulkPacingMillis()
{
return (this.bulkPacingMillis);
}
/*******************************************************************************
** Fluent setter for bulkPacingMillis
*******************************************************************************/
public AuthentikQBitConfig withBulkPacingMillis(Integer bulkPacingMillis)
{
this.bulkPacingMillis = bulkPacingMillis;
return (this);
}
/*******************************************************************************
** Getter for listCacheTtlSeconds
*******************************************************************************/
public Integer getListCacheTtlSeconds()
{
return (this.listCacheTtlSeconds);
}
/*******************************************************************************
** Fluent setter for listCacheTtlSeconds (0 disables the list cache)
*******************************************************************************/
public AuthentikQBitConfig withListCacheTtlSeconds(Integer listCacheTtlSeconds)
{
this.listCacheTtlSeconds = listCacheTtlSeconds;
return (this);
}
/*******************************************************************************
** Getter for groupCacheTtlSeconds
*******************************************************************************/
public Integer getGroupCacheTtlSeconds()
{
return (this.groupCacheTtlSeconds);
}
/*******************************************************************************
** Fluent setter for groupCacheTtlSeconds (0 disables the group cache)
*******************************************************************************/
public AuthentikQBitConfig withGroupCacheTtlSeconds(Integer groupCacheTtlSeconds)
{
this.groupCacheTtlSeconds = groupCacheTtlSeconds;
return (this);
}
/*******************************************************************************
** Getter for connectTimeoutMillis
*******************************************************************************/
public Integer getConnectTimeoutMillis()
{
return (this.connectTimeoutMillis);
}
/*******************************************************************************
** Fluent setter for connectTimeoutMillis
*******************************************************************************/
public AuthentikQBitConfig withConnectTimeoutMillis(Integer connectTimeoutMillis)
{
this.connectTimeoutMillis = connectTimeoutMillis;
return (this);
}
/*******************************************************************************
** Getter for socketTimeoutMillis
*******************************************************************************/
public Integer getSocketTimeoutMillis()
{
return (this.socketTimeoutMillis);
}
/*******************************************************************************
** Fluent setter for socketTimeoutMillis
*******************************************************************************/
public AuthentikQBitConfig withSocketTimeoutMillis(Integer socketTimeoutMillis)
{
this.socketTimeoutMillis = socketTimeoutMillis;
return (this);
}
/*******************************************************************************
** Getter for expectedServerVersion
*******************************************************************************/
public String getExpectedServerVersion()
{
return (this.expectedServerVersion);
}
/*******************************************************************************
** Fluent setter for expectedServerVersion (major.minor, e.g. 2026.8)
*******************************************************************************/
public AuthentikQBitConfig withExpectedServerVersion(String expectedServerVersion)
{
this.expectedServerVersion = expectedServerVersion;
return (this);
}
}
@@ -0,0 +1,223 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik;
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);
}
}
@@ -0,0 +1,177 @@
/*
* 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.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 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);
}
/*******************************************************************************
** 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();
}
/*******************************************************************************
** 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);
}
}
@@ -0,0 +1,918 @@
/*
* 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.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
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.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.SleepUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
import com.kingsrook.qqq.backend.module.api.model.OutboundAPILog;
import org.apache.http.Header;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.NameValuePair;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.util.EntityUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The qbit's Authentik API client, as a qqq-backend-module-api action util
** (DESIGN.md sections 2.4 and 6.1).
**
** - Bearer authentication with the service account's API token.
** - Page-based paging (page / page_size, max 100), count from
** pagination.count with page_size=1.
** - QQQ filters mapped by AuthentikFilterMapper; unsupported ones rejected.
** - Every call passes AuthentikGuard first, then the shared token-bucket
** throttle (Authentik does not throttle authenticated calls).
** - Logs carry method, path and status only. Request query strings (search
** terms are PII), bodies of sensitive endpoints and successful GET bodies
** are never logged or stored; anything stored is passed through
** AuthentikRedactor.
*******************************************************************************/
public class AuthentikActionUtil extends BaseAPIActionUtil
{
private static final QLogger LOG = QLogger.getLogger(AuthentikActionUtil.class);
public static final int MAX_PAGE_SIZE = 100;
private static final int MAX_RETRIES = 3;
private static final int INITIAL_BACKOFF_MS = 250;
private static final int MAX_RETRY_AFTER_SECS = 30;
private static final AtomicBoolean VERSION_CHECKED = new AtomicBoolean(false);
/*******************************************************************************
** The runtime for the backend this util serves.
*******************************************************************************/
protected AuthentikRuntime runtime()
{
return (AuthentikRuntime.forBackend(backendMetaData.getName()));
}
/*******************************************************************************
** The spec for a table, or an exception for a table the qbit didn't produce.
*******************************************************************************/
protected AuthentikTableSpec spec(QTableMetaData table) throws QException
{
AuthentikTableSpec spec = runtime().getSpec(table.getName());
if(spec == null)
{
throw (new QException("Table " + table.getName() + " is not an Authentik qbit table"));
}
return (spec);
}
/*******************************************************************************
** Count: one request with page_size=1, reading pagination.count.
*******************************************************************************/
@Override
public CountOutput doCount(QTableMetaData table, CountInput countInput) throws QException
{
AuthentikTableSpec spec = spec(table);
runtime().getGuard().checkRead(spec);
AuthentikFilterMapper.MappedFilter mapped = map(spec, countInput.getFilter());
CountOutput output = new CountOutput();
if(mapped.isKeyLookup())
{
output.setCount(fetchByKeys(table, spec, mapped.keyLookup(), null).size());
return (output);
}
JSONObject page = getJson(listUrl(spec, mapped, 1, 1));
output.setCount(page.optJSONObject("pagination") == null ? 0 : page.getJSONObject("pagination").optInt("count", 0));
return (output);
}
/*******************************************************************************
** Get one record by key. Out-of-scope or missing records return no record.
*******************************************************************************/
@Override
public GetOutput doGet(QTableMetaData table, GetInput getInput) throws QException
{
AuthentikTableSpec spec = spec(table);
runtime().getGuard().checkRead(spec);
GetOutput output = new GetOutput();
if(getInput.getPrimaryKey() == null)
{
throw (new QUserFacingException(spec.getLabel() + " can only be fetched by id."));
}
JSONObject object = getObject(spec, getInput.getPrimaryKey());
if(object != null && isVisible(spec, object))
{
output.setRecord(toRecord(spec, object, runtime().getGuard().maySeeSensitive()));
}
return (output);
}
/*******************************************************************************
** Query with page-based paging.
*******************************************************************************/
@Override
public QueryOutput doQuery(QTableMetaData table, QueryInput queryInput) throws QException
{
AuthentikTableSpec spec = spec(table);
runtime().getGuard().checkRead(spec);
QueryOutput output = new QueryOutput(queryInput);
QQueryFilter filter = queryInput.getFilter();
AuthentikFilterMapper.MappedFilter mapped = map(spec, filter);
boolean sensitive = runtime().getGuard().maySeeSensitive();
Integer limit = filter == null ? null : filter.getLimit();
int skip = filter == null || filter.getSkip() == null ? 0 : filter.getSkip();
if(mapped.isKeyLookup())
{
List<JSONObject> objects = fetchByKeys(table, spec, mapped.keyLookup(), limit);
int added = 0;
for(int i = skip; i < objects.size() && (limit == null || added < limit); i++)
{
output.addRecord(toRecord(spec, objects.get(i), sensitive));
added++;
}
return (output);
}
int pageSize;
int page;
int offset;
if(limit != null && limit > 0 && limit <= MAX_PAGE_SIZE && skip % limit == 0)
{
pageSize = limit;
page = skip / limit + 1;
offset = 0;
}
else
{
pageSize = MAX_PAGE_SIZE;
page = skip / MAX_PAGE_SIZE + 1;
offset = skip % MAX_PAGE_SIZE;
}
int collected = 0;
while(true)
{
JSONObject response = getJson(listUrl(spec, mapped, page, pageSize));
JSONArray results = response.optJSONArray("results");
if(results == null || results.isEmpty())
{
return (output);
}
for(int i = offset; i < results.length(); i++)
{
JSONObject object = results.getJSONObject(i);
if(!isVisible(spec, object))
{
continue;
}
output.addRecord(toRecord(spec, object, sensitive));
collected++;
if(limit != null && collected >= limit)
{
return (output);
}
}
offset = 0;
JSONObject pagination = response.optJSONObject("pagination");
int totalPages = pagination == null ? page : pagination.optInt("total_pages", page);
if(page >= totalPages || queryInput.getAsyncJobCallback().wasCancelRequested())
{
return (output);
}
page++;
}
}
/*******************************************************************************
** Writes go through the guard, which refuses them in this release.
*******************************************************************************/
@Override
public InsertOutput doInsert(QTableMetaData table, InsertInput insertInput) throws QException
{
runtime().getGuard().checkWrite(spec(table), "insert");
throw (new QPermissionDeniedException("Not available"));
}
/*******************************************************************************
** Writes go through the guard, which refuses them in this release.
*******************************************************************************/
@Override
public UpdateOutput doUpdate(QTableMetaData table, UpdateInput updateInput) throws QException
{
runtime().getGuard().checkWrite(spec(table), "update");
throw (new QPermissionDeniedException("Not available"));
}
/*******************************************************************************
** Writes go through the guard, which refuses them in this release.
*******************************************************************************/
@Override
public DeleteOutput doDelete(QTableMetaData table, DeleteInput deleteInput) throws QException
{
runtime().getGuard().checkWrite(spec(table), "delete");
throw (new QPermissionDeniedException("Not available"));
}
/*******************************************************************************
** Fetch a single Authentik object by key (null when it doesn't exist).
** No permission check: callers check first.
*******************************************************************************/
public JSONObject getObject(AuthentikTableSpec spec, Serializable key) throws QException
{
String keyText = String.valueOf(key).trim();
if(keyText.isEmpty() || !keyText.matches("[A-Za-z0-9._:-]+"))
{
return (null);
}
try
{
URIBuilder builder = new URIBuilder(runtime().getConfig().getApiRoot() + spec.getPath() + keyText + "/");
for(NameValuePair param : spec.getDefaultGetParams())
{
builder.addParameter(param.getName(), param.getValue());
}
return (getJsonOrNull(builder.build().toString()));
}
catch(java.net.URISyntaxException e)
{
throw (new QException("Bad Authentik URL", e));
}
}
/*******************************************************************************
** GET an API path (relative to /api/v3, may include a query string) and
** parse it as a JSON object. Used by the resolver and health checks.
*******************************************************************************/
public JSONObject getApiJson(String pathAndQuery) throws QException
{
return (getJson(runtime().getConfig().getApiRoot() + pathAndQuery));
}
/*******************************************************************************
** Fetch every page of a list endpoint (relative path with query string,
** without page parameters). Bounded by maxObjects.
*******************************************************************************/
public List<JSONObject> getAllPages(String pathAndQuery, int maxObjects) throws QException
{
List<JSONObject> rs = new ArrayList<>();
int page = 1;
while(true)
{
String separator = pathAndQuery.contains("?") ? "&" : "?";
JSONObject response = getApiJson(pathAndQuery + separator + "page=" + page + "&page_size=" + MAX_PAGE_SIZE);
JSONArray results = response.optJSONArray("results");
if(results == null || results.isEmpty())
{
return (rs);
}
for(int i = 0; i < results.length() && rs.size() < maxObjects; i++)
{
rs.add(results.getJSONObject(i));
}
JSONObject pagination = response.optJSONObject("pagination");
if(rs.size() >= maxObjects || pagination == null || page >= pagination.optInt("total_pages", page))
{
return (rs);
}
page++;
}
}
/*******************************************************************************
** The server's version (GET /admin/version/), or empty when unavailable.
*******************************************************************************/
public Optional<String> fetchServerVersion()
{
try
{
JSONObject version = getApiJson("/admin/version/");
return (Optional.ofNullable(version.optString("version_current", null)));
}
catch(Exception e)
{
LOG.info("Could not read the Authentik server version", logPair("error", AuthentikRedactor.redactText(e.getMessage(), token())));
return (Optional.empty());
}
}
/*******************************************************************************
**
*******************************************************************************/
private AuthentikFilterMapper.MappedFilter map(AuthentikTableSpec spec, QQueryFilter filter) throws QException
{
AuthentikFilterMapper.MappedFilter mapped = runtime().getFilterMapper().map(spec, filter, runtime().getGuard().maySeeSensitive());
if(spec.getRequiredFilterField() != null && !mapped.filteredFields().contains(spec.getRequiredFilterField()))
{
throw (new QUserFacingException(spec.getLabel() + " can only be listed with a filter on " + spec.getField(spec.getRequiredFilterField()).getLabel() + "."));
}
return (mapped);
}
/*******************************************************************************
** Build a list URL: mapped params, defaults, scope params, paging.
*******************************************************************************/
private String listUrl(AuthentikTableSpec spec, AuthentikFilterMapper.MappedFilter mapped, int page, int pageSize) throws QException
{
try
{
URIBuilder builder = new URIBuilder(runtime().getConfig().getApiRoot() + spec.getPath());
List<String> usedNames = new ArrayList<>();
for(NameValuePair param : mapped.params())
{
builder.addParameter(param.getName(), param.getValue());
usedNames.add(param.getName());
}
for(NameValuePair param : spec.getDefaultListParams())
{
if(!usedNames.contains(param.getName()))
{
builder.addParameter(param.getName(), param.getValue());
}
}
if(AuthentikTables.USER.equals(spec.getName()))
{
addUserScope(builder, usedNames);
}
builder.addParameter("page", String.valueOf(page));
builder.addParameter("page_size", String.valueOf(pageSize));
return (builder.build().toString());
}
catch(java.net.URISyntaxException e)
{
throw (new QException("Bad Authentik URL", e));
}
}
/*******************************************************************************
** Server-side user scope (records are also checked one by one). A filter
** the operator set on the same parameter wins server-side; the per-record
** check still enforces the scope.
*******************************************************************************/
private void addUserScope(URIBuilder builder, List<String> usedNames)
{
if(!usedNames.contains("type"))
{
for(String type : AuthentikTables.DEFAULT_USER_TYPES)
{
builder.addParameter("type", type);
}
}
String pathPrefix = runtime().getConfig().getUserScopePathStartsWith();
if(StringUtils.hasContent(pathPrefix) && !usedNames.contains("path_startswith"))
{
builder.addParameter("path_startswith", pathPrefix);
}
List<String> groups = runtime().getConfig().getUserScopeGroupNames();
if(groups != null && !groups.isEmpty() && !usedNames.contains("groups_by_name"))
{
groups.forEach(g -> builder.addParameter("groups_by_name", g));
}
}
/*******************************************************************************
**
*******************************************************************************/
private List<JSONObject> fetchByKeys(QTableMetaData table, AuthentikTableSpec spec, List<Serializable> keys, Integer limit) throws QException
{
List<JSONObject> rs = new ArrayList<>();
for(Serializable key : keys)
{
if(key == null)
{
continue;
}
JSONObject object = getObject(spec, key);
if(object != null && isVisible(spec, object))
{
rs.add(object);
}
}
return (rs);
}
/*******************************************************************************
** Per-record visibility (scope).
*******************************************************************************/
private boolean isVisible(AuthentikTableSpec spec, JSONObject object)
{
if(AuthentikTables.USER.equals(spec.getName()))
{
return (runtime().getGuard().isUserInScope(object));
}
return (true);
}
/*******************************************************************************
** Convert an Authentik object to a QRecord, dropping sensitive fields the
** session may not see and computing derived fields.
*******************************************************************************/
public QRecord toRecord(AuthentikTableSpec spec, JSONObject object, boolean maySeeSensitive) throws QException
{
try
{
QRecord record = new QRecord();
for(AuthentikFieldSpec field : spec.getFields())
{
if(field.isSensitive() && !maySeeSensitive)
{
continue;
}
record.setValue(field.getName(), field.extract(object));
}
if(AuthentikTables.USER.equals(spec.getName()))
{
AuthentikGuard.ProtectionResult protection = runtime().getGuard().protection(object);
record.setValue("isProtected", protection.isProtected());
if(protection.isProtected())
{
record.setDisplayValue("isProtected", "Protected: " + protection.reason());
}
}
return (record);
}
catch(JSONException | IllegalArgumentException e)
{
throw (new QException("Unexpected Authentik response for " + spec.getLabel() + ": " + e.getMessage(), e));
}
}
/*******************************************************************************
** GET JSON through the response cache.
*******************************************************************************/
private JSONObject getJson(String url) throws QException
{
String body = getBody(url, false);
return (parse(url, body));
}
/*******************************************************************************
** GET JSON, or null on 404.
*******************************************************************************/
private JSONObject getJsonOrNull(String url) throws QException
{
String body = getBody(url, true);
return (body == null ? null : parse(url, body));
}
/*******************************************************************************
**
*******************************************************************************/
private String getBody(String url, boolean nullOn404) throws QException
{
AuthentikRuntime runtime = runtime();
if(runtime.isCacheEnabled())
{
Optional<String> cached = runtime.getResponseCache().getMemoizedResult(url).map(m -> m.getResult());
if(cached.isPresent())
{
return (cached.get());
}
}
QHttpResponse response = execute(new HttpGet(url));
if(response.getStatusCode() == 404 && nullOn404)
{
return (null);
}
if(response.getStatusCode() == 404)
{
throw (new QException("Authentik endpoint not found (404): " + pathOf(url)));
}
String body = response.getContent();
if(runtime.isCacheEnabled() && body != null)
{
runtime.getResponseCache().storeResult(url, body);
}
return (body);
}
/*******************************************************************************
**
*******************************************************************************/
private JSONObject parse(String url, String body) throws QException
{
try
{
return (new JSONObject(body));
}
catch(JSONException e)
{
throw (new QException("Authentik returned a non-JSON response for " + pathOf(url)));
}
}
/*******************************************************************************
** Execute a request: throttle, auth, retries for 429 and (GET-only) 5xx,
** error mapping, logging. Returns 2xx and 404 responses.
*******************************************************************************/
public QHttpResponse execute(HttpRequestBase request) throws QException
{
String path = pathOf(request.getURI().toString());
int attempts = 0;
int backoffMs = INITIAL_BACKOFF_MS;
String token = token();
if(!StringUtils.hasContent(token))
{
throw (new QException("The Authentik API token is not configured (apiToken resolved to an empty value)."));
}
while(true)
{
attempts++;
runtime().getThrottle().acquire();
request.setHeader("Authorization", "Bearer " + token);
request.setHeader("Accept", "application/json");
if(request instanceof HttpEntityEnclosingRequest)
{
request.setHeader("Content-Type", "application/json");
}
long start = System.currentTimeMillis();
QHttpResponse response;
try
{
response = send(request);
}
catch(Exception e)
{
String message = AuthentikRedactor.redactText(e.getMessage(), token);
LOG.warn("Authentik API call failed", logPair("method", request.getMethod()), logPair("path", path), logPair("error", message));
if(attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
backoffMs *= 2;
continue;
}
throw (new QException("Could not reach Authentik (" + request.getMethod() + " " + path + "): " + message));
}
int status = response.getStatusCode() == null ? 0 : response.getStatusCode();
LOG.info("Authentik API call", logPair("method", request.getMethod()), logPair("path", path), logPair("status", status), logPair("millis", System.currentTimeMillis() - start));
logOutboundCall(request, path, response, token);
checkVersionOnce(status);
if(status == 429 && attempts <= MAX_RETRIES)
{
SleepUtils.sleep(retryAfterMillis(response, backoffMs), java.util.concurrent.TimeUnit.MILLISECONDS);
backoffMs *= 2;
continue;
}
if(status >= 500 && attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
backoffMs *= 2;
continue;
}
if((status >= 200 && status < 300) || status == 404)
{
return (response);
}
throw (mapError(request, path, status, response.getContent(), token));
}
}
/*******************************************************************************
** Send one request. The single seam tests override.
*******************************************************************************/
protected QHttpResponse send(HttpRequestBase request) throws Exception
{
try(CloseableHttpClient client = buildHttpClient(); CloseableHttpResponse response = executeHttpRequest(request, client))
{
QHttpResponse qResponse = new QHttpResponse();
qResponse.setStatusCode(response.getStatusLine().getStatusCode());
qResponse.setHeaderList(List.of(response.getAllHeaders()));
qResponse.setContent(response.getEntity() == null ? null : EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
return (qResponse);
}
}
/*******************************************************************************
** HTTP client with the configured timeouts.
*******************************************************************************/
@Override
protected CloseableHttpClient buildHttpClient()
{
return (HttpClientBuilder.create()
.disableRedirectHandling()
.setDefaultRequestConfig(RequestConfig.custom()
.setConnectTimeout(runtime().getConfig().getConnectTimeoutMillis())
.setConnectionRequestTimeout(runtime().getConfig().getConnectTimeoutMillis())
.setSocketTimeout(runtime().getConfig().getSocketTimeoutMillis()).build())
.build());
}
/*******************************************************************************
** Map an Authentik error response to an exception, with a redacted message.
*******************************************************************************/
static QException mapError(HttpRequestBase request, String path, int status, String body, String token)
{
String detail = AuthentikRedactor.redactText(errorDetail(body), token);
if(AuthentikRedactor.isSensitivePath(path))
{
detail = "(details omitted for a sensitive endpoint)";
}
return switch(status)
{
case 400 -> new QUserFacingException("Authentik rejected the request: " + detail);
case 401 -> new QException("Authentik rejected the qbit's API token (401). The service-account token may be expired or revoked; rotate it (see the phase 0 owner checklist).");
case 403 -> new QPermissionDeniedException("Authentik refused " + request.getMethod() + " " + path + " (403): " + detail);
default -> new QException("Authentik error " + status + " on " + request.getMethod() + " " + path + ": " + detail);
};
}
/*******************************************************************************
** Flatten Authentik's error JSON ({"detail": ...} or {"field": ["msg"]}).
*******************************************************************************/
static String errorDetail(String body)
{
if(body == null || body.isBlank())
{
return ("(no details)");
}
try
{
JSONObject object = new JSONObject(body);
List<String> parts = new ArrayList<>();
Iterator<String> keys = object.keys();
while(keys.hasNext())
{
String key = keys.next();
Object value = object.get(key);
String text = value instanceof JSONArray array ? joinArray(array) : String.valueOf(value);
parts.add(key.equals("detail") || key.equals("non_field_errors") ? text : key + ": " + text);
}
return (String.join("; ", parts));
}
catch(JSONException e)
{
return (body.length() > 200 ? body.substring(0, 200) + "..." : body);
}
}
/*******************************************************************************
**
*******************************************************************************/
private static String joinArray(JSONArray array)
{
List<String> values = new ArrayList<>();
for(int i = 0; i < array.length(); i++)
{
values.add(String.valueOf(array.get(i)));
}
return (String.join(" ", values));
}
/*******************************************************************************
**
*******************************************************************************/
private static long retryAfterMillis(QHttpResponse response, int fallbackMillis)
{
for(Header header : response.getHeaderList() == null ? List.<Header>of() : response.getHeaderList())
{
if(header.getName().equalsIgnoreCase("Retry-After"))
{
try
{
return (Math.min(MAX_RETRY_AFTER_SECS, Long.parseLong(header.getValue().trim())) * 1000L);
}
catch(NumberFormatException e)
{
break;
}
}
}
return (fallbackMillis);
}
/*******************************************************************************
** Write an OutboundAPILog row (if the host has that table), keeping PII
** out: no query string, no successful GET bodies, sensitive bodies
** dropped, everything else redacted.
*******************************************************************************/
protected void logOutboundCall(HttpRequestBase request, String path, QHttpResponse response, String token)
{
try
{
if(QContext.getQInstance() == null || QContext.getQInstance().getTable(OutboundAPILog.TABLE_NAME) == null)
{
return;
}
String requestBody = null;
if(request instanceof HttpEntityEnclosingRequest entityRequest && entityRequest.getEntity() != null)
{
requestBody = EntityUtils.toString(entityRequest.getEntity(), StandardCharsets.UTF_8);
}
int status = response.getStatusCode() == null ? 0 : response.getStatusCode();
String responseBody = "GET".equals(request.getMethod()) && status < 400
? "[" + (response.getContent() == null ? 0 : response.getContent().length()) + " characters, not stored]"
: AuthentikRedactor.redactBody(path, response.getContent(), token);
OutboundAPILog log = new OutboundAPILog()
.withMethod(request.getMethod())
.withUrl(path)
.withTimestamp(Instant.now())
.withRequestBody(AuthentikRedactor.redactBody(path, requestBody, token))
.withStatusCode(status)
.withResponseBody(responseBody);
InsertInput insertInput = new InsertInput();
insertInput.setTableName(OutboundAPILog.TABLE_NAME);
insertInput.setRecords(List.of(log.toQRecord()));
new InsertAction().executeAsync(insertInput);
}
catch(Exception e)
{
LOG.warn("Error logging an outbound Authentik call", logPair("path", path));
}
}
/*******************************************************************************
** Once per JVM, after the first successful call, compare the server's
** major.minor to the configured expectation and warn on a mismatch.
*******************************************************************************/
private void checkVersionOnce(int status)
{
if(status < 200 || status >= 300 || !VERSION_CHECKED.compareAndSet(false, true))
{
return;
}
String expected = runtime().getConfig().getExpectedServerVersion();
fetchServerVersion().ifPresent(actual ->
{
if(StringUtils.hasContent(expected) && !actual.startsWith(expected + ".") && !actual.equals(expected))
{
LOG.warn("Authentik server version differs from the version qbit-authentik was tested against", logPair("expected", expected), logPair("actual", actual));
}
});
}
/*******************************************************************************
** Reset the once-per-JVM version check (tests).
*******************************************************************************/
static void resetVersionCheck()
{
VERSION_CHECKED.set(false);
}
/*******************************************************************************
**
*******************************************************************************/
private String token()
{
return (runtime().getConfig().getResolvedApiToken());
}
/*******************************************************************************
** The path of a URL without its query string (query strings may carry PII).
*******************************************************************************/
static String pathOf(String url)
{
try
{
return (URI.create(url).getPath());
}
catch(IllegalArgumentException e)
{
int q = url.indexOf('?');
return (q < 0 ? url : url.substring(0, q));
}
}
}
@@ -0,0 +1,98 @@
/*
* 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()));
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())
{
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);
}
}
@@ -0,0 +1,204 @@
/*
* 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 identity.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 identity.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);
}
}
@@ -0,0 +1,446 @@
/*
* 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 || criterion.getFieldName() == null)
{
continue;
}
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", 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)
{
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);
}
/*******************************************************************************
** 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();
return (text.equals("true") || text.equals("1") || text.equals("yes") ? "true" : "false");
}
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());
}
}
@@ -0,0 +1,234 @@
/*
* 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);
}
/*******************************************************************************
** 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);
}
/*******************************************************************************
** 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());
}
}
@@ -0,0 +1,129 @@
/*
* 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());
}
}
@@ -0,0 +1,277 @@
/*
* 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;
/*******************************************************************************
** 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);
}
/*******************************************************************************
** 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);
}
}
@@ -0,0 +1,187 @@
/*
* 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.qqq.backend.core.model.metadata.fields.QFieldType;
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.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";
/** 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());
}
/*******************************************************************************
** 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("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("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);
}
}
@@ -0,0 +1,135 @@
/*
* 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 kof22's Authentik shares the
** website's Postgres, 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();
}
}
}
@@ -0,0 +1,121 @@
/*
* 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.applyPrefix(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);
}
}
@@ -0,0 +1,85 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.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 identity.users.read. Hosts re-parent it, e.g.
** kof22 nests it under Admin; 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.applyPrefix(specName));
if(table != null)
{
app.withChild(table);
}
}
}
return (app);
}
/*******************************************************************************
** Last, so the tables exist in the output.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (900);
}
}
@@ -0,0 +1,65 @@
/*
* 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);
}
}
@@ -0,0 +1,38 @@
/*
* 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;
/*******************************************************************************
** authentikGroup: /core/groups/.
*******************************************************************************/
public class AuthentikGroupTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.GROUP);
}
}
@@ -0,0 +1,38 @@
/*
* 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;
/*******************************************************************************
** authentikUser: /core/users/.
*******************************************************************************/
public class AuthentikUserTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.USER);
}
}
@@ -0,0 +1,255 @@
/*
* 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 identity.* permission (system sessions may read,
** for the mirror sync);
** - sensitive fields need identity.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"));
}
JSONObject attributes = user.optJSONObject("attributes");
if(attributes != null && isTrue(attributes.opt(AuthentikJson.ATTR_PROTECTED)))
{
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 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));
}
}
}
@@ -0,0 +1,118 @@
/*
* 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. "identity.users.read") into a group's
** qqq.io/permissions attribute; "identity.*" 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("identity.users.read"),
USERS_MANAGE("identity.users.manage"),
GROUPS_MANAGE("identity.groups.manage"),
MEMBERSHIPS_MANAGE("identity.memberships.manage"),
PERMISSIONS_MANAGE("identity.permissions.manage"),
ROLES_MANAGE("identity.roles.manage"),
CREDENTIALS_RESET("identity.credentials.reset"),
SESSIONS_REVOKE("identity.sessions.revoke"),
AUDIT_READ("identity.audit.read"),
APPROVE("identity.approve"),
ADMIN("identity.admin");
/** The wildcard operators write for "all identity permissions". */
public static final String WILDCARD = "identity.*";
/** Suffix QQQ uses for HAS_ACCESS permissions. */
public static final String HAS_ACCESS_SUFFIX = ".hasAccess";
private final String shortName;
/*******************************************************************************
** Constructor.
*******************************************************************************/
IdentityPermission(String shortName)
{
this.shortName = shortName;
}
/*******************************************************************************
** The name written in qqq.io/permissions, e.g. "identity.users.read".
*******************************************************************************/
public String getShortName()
{
return (shortName);
}
/*******************************************************************************
** The QQQ session permission, e.g. "identity.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,153 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik;
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("user")).isEqualTo("authentikUser");
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("").applyPrefix("user")).isEqualTo("user");
assertThat(config.withBootstrapOperators(" A@x.test, ,b@x.test ").getResolvedBootstrapOperators()).containsExactly("a@x.test", "b@x.test");
}
}
@@ -0,0 +1,121 @@
/*
* 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("authentikUser");
assertThat(users.getBackendName()).isEqualTo("authentikApi");
assertThat(users.getPrimaryKeyField()).isEqualTo("pk");
assertThat(users.getPermissionRules().getPermissionBaseName()).isEqualTo("identity.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("authentikGroup")).isNotNull();
QAppMetaData app = qInstance.getApp("authentikAdmin");
assertThat(app.getLabel()).isEqualTo("Identity");
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("authentikUser", "authentikGroup");
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("idpUser")).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("identity.audit.read")).contains(IdentityPermission.AUDIT_READ);
assertThat(IdentityPermission.fromName("identity.audit.read.hasAccess")).contains(IdentityPermission.AUDIT_READ);
assertThat(IdentityPermission.fromName("nope")).isEmpty();
assertThat(IdentityPermission.fromName(null)).isEmpty();
}
}
@@ -0,0 +1,142 @@
/*
* 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));
}
}
}
@@ -0,0 +1,72 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik;
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)"));
}
}
@@ -0,0 +1,75 @@
/*
* 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);
}
}
@@ -0,0 +1,177 @@
/*
* 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);
}
}
@@ -0,0 +1,482 @@
/*
* 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.applyPrefix("user");
groupTable = config.applyPrefix("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("identity.*");
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 ");
}
}
@@ -0,0 +1,225 @@
/*
* 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 identity.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");
}
}
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.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();
}
}
@@ -0,0 +1,98 @@
/*
* 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 List<HttpRequestBase> REQUESTS = new ArrayList<>();
public static Function<HttpRequestBase, QHttpResponse> handler = r -> response(404, "{\"detail\":\"Not found.\"}");
/*******************************************************************************
** Reset captured requests and the handler.
*******************************************************************************/
public static void reset()
{
REQUESTS.clear();
handler = r -> response(404, "{\"detail\":\"Not found.\"}");
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")));
}
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"));
}
}
@@ -0,0 +1,114 @@
/*
* 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);
}
}
@@ -0,0 +1,126 @@
/*
* 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");
}
}
@@ -0,0 +1,349 @@
/*
* 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 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", "contract-test-password-123");
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));
}
}
@@ -0,0 +1,197 @@
/*
* 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("identity.*").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("identity.*", "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("identity.*")).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);
}
}
@@ -0,0 +1,175 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.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("identity.*");
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);
}
}
@@ -0,0 +1,128 @@
/*
* 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));
}
}
}
@@ -0,0 +1,319 @@
/*
* 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.applyPrefix("user");
groupTable = config.applyPrefix("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 identity.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("identity.*");
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);
}
}
@@ -0,0 +1,100 @@
/*
* 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());
}
}
@@ -0,0 +1,148 @@
/*
* 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);
}
}
@@ -0,0 +1,266 @@
/*
* 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();
}
}
@@ -0,0 +1,219 @@
/*
* 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));
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,107 @@
# 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: ["identity.*"]
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
@@ -0,0 +1,372 @@
# Test fixture: an infrastructure blueprint shaped like kof22's production
# kof22-oauth2-apps.yaml (cluster-gitops tenants/kof22/website-authentik),
# trimmed to what the contract tests need. Branding and the authorization
# flow's consent stage are left out. The provider secret comes from !Env, as in
# production; the test container sets KOF22_OAUTH2_CLIENT_SECRET to a dummy.
#
# This is the AFTER-migration shape (DESIGN.md section 4.4): the present
# james.maes user and kof22-admins group entries have been removed, and the
# sign-in policy is attribute-driven with the group-name fallback.
version: 1
metadata:
name: kof22-oauth2-apps
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_flows.flow
id: kof22-recovery-flow
state: present
identifiers:
slug: kof22-recovery
attrs:
name: "Kof22 Recovery"
title: "Reset your password"
designation: recovery
authentication: require_unauthenticated
- model: authentik_stages_identification.identificationstage
id: kof22-recovery-identification-stage
state: present
identifiers:
name: kof22-recovery-identification
attrs:
user_fields: [username, email]
pretend_user_exists: true
show_matched_user: false
- model: authentik_stages_email.emailstage
id: kof22-recovery-email-stage
state: present
identifiers:
name: kof22-recovery-email
attrs:
use_global_settings: true
template: email/password_reset.html
subject: "Kof Twenty Two: reset your password"
token_expiry: minutes=30
activate_user_on_success: true
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf kof22-recovery-flow
stage: !KeyOf kof22-recovery-identification-stage
order: 10
attrs:
target: !KeyOf kof22-recovery-flow
stage: !KeyOf kof22-recovery-identification-stage
order: 10
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf kof22-recovery-flow
stage: !KeyOf kof22-recovery-email-stage
order: 20
attrs:
target: !KeyOf kof22-recovery-flow
stage: !KeyOf kof22-recovery-email-stage
order: 20
- model: authentik_flows.flow
id: kof22-authorization-flow
state: present
identifiers:
slug: kof22-authorization
attrs:
name: "Kof22 Authorization"
title: "Authorize Kof Twenty Two"
designation: authorization
authentication: require_authenticated
- model: authentik_flows.flow
id: kof22-invalidation-prod-flow
state: present
identifiers:
slug: kof22-invalidation-prod
attrs:
name: "Kof22 Invalidation (Production)"
title: "Signing out..."
designation: invalidation
authentication: none
- model: authentik_providers_oauth2.oauth2provider
state: present
identifiers:
name: kof22-website-prod
attrs:
name: kof22-website-prod
authorization_flow: !KeyOf kof22-authorization-flow
invalidation_flow: !KeyOf kof22-invalidation-prod-flow
client_type: confidential
client_id: kof22-website-prod
client_secret: !Env KOF22_OAUTH2_CLIENT_SECRET
redirect_uris:
- matching_mode: strict
url: "https://admin.kof22.test/token"
sub_mode: user_email
issuer_mode: per_provider
- model: authentik_core.application
state: present
identifiers:
slug: kof22-website-prod
attrs:
name: "Kof22 Website (Production)"
slug: kof22-website-prod
provider: !Find [authentik_providers_oauth2.oauth2provider, [name, kof22-website-prod]]
policy_engine_mode: any
- model: authentik_policies_expression.expressionpolicy
id: kof22-require-admins-policy
state: present
identifiers:
name: kof22-require-admins-group
attrs:
name: kof22-require-admins-group
# Attribute-driven app access (DESIGN.md section 4.4): any group of the
# user, including inherited parents, whose qqq.io/app-access lists this
# app. A plain string is treated as a list of one (never a substring).
expression: |
appkey = "kof22-website-prod"
for group in request.user.all_groups():
access = (group.attributes or {}).get("qqq.io/app-access", [])
if isinstance(access, str):
access = [access]
if isinstance(access, list) and appkey in access:
return True
# Transition fallback (DESIGN.md section 4.4 step 2); remove after the backend cut-over.
return ak_is_group_member(request.user, name__in=["kof22-admins", "kof-website-publishers"])
- model: authentik_policies.policybinding
state: present
identifiers:
target: !Find [authentik_core.application, [slug, kof22-website-prod]]
policy: !KeyOf kof22-require-admins-policy
order: 0
attrs:
target: !Find [authentik_core.application, [slug, kof22-website-prod]]
policy: !KeyOf kof22-require-admins-policy
order: 0
enabled: true
negate: false
timeout: 30
failure_result: false
# ===== qbit-authentik webhook plumbing (DESIGN.md section 7.1) =====
# Infrastructure, `present`. INERT until phase 2: the rule has no destination
# (destination_event_user false, no destination_group), so Authentik creates
# no notifications and sends nothing. Phase 2 flips destination_event_user to
# true once the QQQ receiver (/qqq/v1/authentik/webhook) exists.
- model: authentik_events.notificationwebhookmapping
id: qqq-kof22-admin-webhook-body
state: present
identifiers:
name: qqq-kof22-admin-webhook-body
attrs:
name: qqq-kof22-admin-webhook-body
expression: |
event = notification.event
if event is None:
return {"eventPk": None, "action": None}
return {
"eventPk": str(event.pk),
"action": event.action,
"created": event.created.isoformat(),
"username": (event.user or {}).get("username"),
"model": (event.context or {}).get("model"),
"app": event.app,
}
- model: authentik_events.notificationwebhookmapping
id: qqq-kof22-admin-webhook-headers
state: present
identifiers:
name: qqq-kof22-admin-webhook-headers
attrs:
name: qqq-kof22-admin-webhook-headers
# Shared secret from the worker environment (Infisical, never in git).
expression: |
import os
return {"X-QQQ-Webhook-Secret": os.environ.get("QQQ_KOF22_ADMIN_WEBHOOK_SECRET", "")}
- model: authentik_events.notificationtransport
id: qqq-kof22-admin-transport
state: present
identifiers:
name: qqq-kof22-admin
attrs:
name: qqq-kof22-admin
mode: webhook
webhook_url: "https://admin.kof22.com/qqq/v1/authentik/webhook"
webhook_mapping_body: !KeyOf qqq-kof22-admin-webhook-body
webhook_mapping_headers: !KeyOf qqq-kof22-admin-webhook-headers
send_once: true
- model: authentik_events.notificationrule
id: qqq-kof22-admin-rule
state: present
identifiers:
name: qqq-kof22-admin-sync
attrs:
name: qqq-kof22-admin-sync
severity: notice
transports:
- !KeyOf qqq-kof22-admin-transport
destination_event_user: false
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-user-created
state: present
identifiers: { name: qqq-kof22-admin-sync-user-created }
attrs: { action: model_created, model: authentik_core.user }
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-user-updated
state: present
identifiers: { name: qqq-kof22-admin-sync-user-updated }
attrs: { action: model_updated, model: authentik_core.user }
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-user-deleted
state: present
identifiers: { name: qqq-kof22-admin-sync-user-deleted }
attrs: { action: model_deleted, model: authentik_core.user }
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-group-created
state: present
identifiers: { name: qqq-kof22-admin-sync-group-created }
attrs: { action: model_created, model: authentik_core.group }
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-group-updated
state: present
identifiers: { name: qqq-kof22-admin-sync-group-updated }
attrs: { action: model_updated, model: authentik_core.group }
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-group-deleted
state: present
identifiers: { name: qqq-kof22-admin-sync-group-deleted }
attrs: { action: model_deleted, model: authentik_core.group }
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-login
state: present
identifiers: { name: qqq-kof22-admin-sync-login }
attrs: { action: login }
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-login-failed
state: present
identifiers: { name: qqq-kof22-admin-sync-login-failed }
attrs: { action: login_failed }
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-password-set
state: present
identifiers: { name: qqq-kof22-admin-sync-password-set }
attrs: { action: password_set }
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-invitation-used
state: present
identifiers: { name: qqq-kof22-admin-sync-invitation-used }
attrs: { action: invitation_used }
- model: authentik_policies_event_matcher.eventmatcherpolicy
id: qqq-sync-user-write
state: present
identifiers: { name: qqq-kof22-admin-sync-user-write }
attrs: { action: user_write }
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-user-created
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-user-created
order: 0
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-user-updated
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-user-updated
order: 1
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-user-deleted
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-user-deleted
order: 2
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-group-created
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-group-created
order: 3
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-group-updated
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-group-updated
order: 4
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-group-deleted
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-group-deleted
order: 5
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-login
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-login
order: 6
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-login-failed
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-login-failed
order: 7
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-password-set
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-password-set
order: 8
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-invitation-used
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-invitation-used
order: 9
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-user-write
attrs:
target: !KeyOf qqq-kof22-admin-rule
policy: !KeyOf qqq-sync-user-write
order: 10
@@ -0,0 +1,8 @@
{
"version_current": "2026.8.3",
"outdated": false,
"version_latest_valid": false,
"version_latest": "0.0.0",
"outpost_outdated": false,
"build_hash": ""
}
@@ -0,0 +1 @@
{"last_login__lt": ["Enter a valid date/time."]}
@@ -0,0 +1 @@
{"detail": "You do not have permission to perform this action."}
@@ -0,0 +1,54 @@
{
"pagination": {
"next": 0,
"current": 1,
"previous": 0,
"start_index": 1,
"count": 1,
"end_index": 1,
"total_pages": 1
},
"autocomplete": {
"models": {
"authentik_core.group": {
"is_superuser": {
"nullable": true,
"options": null,
"type": "bool"
},
"name": {
"nullable": false,
"options": null,
"type": "str"
},
"attributes": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_core.group_attributes"
}
},
"authentik_core.group_attributes": {}
},
"current_model": "authentik_core.group"
},
"results": [{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 97374,
"users_obj": null,
"users": [],
"parents_obj": [],
"children": [],
"name": "identity-admins",
"attributes": {
"qqq.io/app-access": ["kof22-website-prod"],
"qqq.io/permissions": ["identity.*"]
},
"pk": "4941bc80-3f27-446f-b4d3-ba023f044b65",
"children_obj": [],
"parents": []
}]
}
@@ -0,0 +1,22 @@
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:44.589851Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:44.548432Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "b99e966f-61bb-4f9f-b136-b747d5fb68f7",
"uid": "77947ea5357373462f042427a96e83bb14ea799dfae76daf6a014d01fc4580ce",
"path": "users",
"name": "Read Probe 2",
"attributes": {},
"pk": 17,
"date_joined": "2026-10-12T00:42:44.548001Z",
"email": "read.probe2@example.test",
"username": "read.probe2"
}
@@ -0,0 +1,380 @@
{
"pagination": {
"next": 0,
"current": 1,
"previous": 0,
"start_index": 1,
"count": 12,
"end_index": 12,
"total_pages": 1
},
"autocomplete": {
"models": {
"authentik_core.user_attributes": {},
"authentik_core.user": {
"path": {
"nullable": false,
"options": null,
"type": "str"
},
"is_active": {
"nullable": true,
"options": null,
"type": "bool"
},
"name": {
"nullable": false,
"options": null,
"type": "str"
},
"attributes": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_core.user_attributes"
},
"type": {
"nullable": false,
"options": [
"internal",
"external",
"service_account",
"internal_service_account"
],
"type": "str"
},
"email": {
"nullable": false,
"options": null,
"type": "str"
},
"username": {
"nullable": false,
"options": null,
"type": "str"
}
}
},
"current_model": "authentik_core.user"
},
"results": [
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:38.179289Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:38.131278Z",
"roles": [],
"groups": ["c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd"],
"avatar": "",
"type": "internal",
"groups_obj": [{
"is_superuser": false,
"name": "kof-website-publishers",
"attributes": {},
"pk": "c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd",
"num_pk": 26548
}],
"uuid": "6656101d-7d44-4b7f-aeb5-257b13a4447f",
"uid": "41758f45cd5b97f58994f51764097ce15a3b6daaad23a2aaadc3a47325370edb",
"path": "users",
"name": "Access Publisher",
"attributes": {},
"pk": 14,
"date_joined": "2026-10-12T00:42:38.130713Z",
"email": "",
"username": "access.publisher"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:41:39.098819Z",
"is_active": true,
"is_superuser": true,
"last_login": null,
"password_change_date": "2026-10-12T00:41:38.964003Z",
"roles": [],
"groups": [
"99da4fa1-41b7-41ed-9a09-536682c4589b",
"6b62508d-14e8-4cf6-9783-682d7c94af12"
],
"avatar": "",
"type": "internal",
"groups_obj": [
{
"is_superuser": true,
"name": "authentik Admins",
"attributes": {},
"pk": "99da4fa1-41b7-41ed-9a09-536682c4589b",
"num_pk": 20450
},
{
"is_superuser": false,
"name": "lint-demo-group",
"attributes": {"note": "declared in git"},
"pk": "6b62508d-14e8-4cf6-9783-682d7c94af12",
"num_pk": 14273
}
],
"uuid": "96235ac9-286c-4a6a-ae21-4df0dab33315",
"uid": "dee4b27045f6d9ad52b880c654fbdafc4c3ce86c45d3f045e5a638ea3c4b0065",
"path": "users",
"name": "authentik Default Admin",
"attributes": {},
"pk": 4,
"date_joined": "2026-10-12T00:41:38.934836Z",
"email": "akadmin@example.test",
"username": "akadmin"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:40.507175Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:40.455393Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "e933b7a1-db0d-4d7f-af92-ebca960e04ce",
"uid": "5f82699a0cd33c6c02a73fcd8cc3a995302a22e28881733fa3dca7ef2dfff0ac",
"path": "users",
"name": "Floor Probe",
"attributes": {},
"pk": 15,
"date_joined": "2026-10-12T00:42:40.454755Z",
"email": "",
"username": "floor.probe"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:41.985973Z",
"is_active": true,
"is_superuser": true,
"last_login": null,
"password_change_date": "2026-10-12T00:41:47.932445Z",
"roles": [],
"groups": ["99da4fa1-41b7-41ed-9a09-536682c4589b"],
"avatar": "",
"type": "internal",
"groups_obj": [{
"is_superuser": true,
"name": "authentik Admins",
"attributes": {},
"pk": "99da4fa1-41b7-41ed-9a09-536682c4589b",
"num_pk": 20450
}],
"uuid": "f72814ed-7574-4d20-bbcb-56545e39adab",
"uid": "67d171fe0aa7735f081344377ef53325b135026db1544efcb8e1a3c7d236cdf2",
"path": "users/breakglass",
"name": "Break-glass admin",
"attributes": {"qqq.io/protected": true},
"pk": 8,
"date_joined": "2026-10-12T00:41:47.930197Z",
"email": "",
"username": "kof22-breakglass"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:19.183065Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:19.139382Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "1cca8899-bf67-4acb-8231-b82f48b61549",
"uid": "84030aa88ef5db17880ead3e60f4830246bdf0a7e40c71599a047ddf3cbac5ea",
"path": "users",
"name": "Added Via API",
"attributes": {},
"pk": 12,
"date_joined": "2026-10-12T00:42:19.138961Z",
"email": "",
"username": "lint.added"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:20.680396Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:18.514303Z",
"roles": [],
"groups": ["6b62508d-14e8-4cf6-9783-682d7c94af12"],
"avatar": "",
"type": "internal",
"groups_obj": [{
"is_superuser": false,
"name": "lint-demo-group",
"attributes": {"note": "declared in git"},
"pk": "6b62508d-14e8-4cf6-9783-682d7c94af12",
"num_pk": 14273
}],
"uuid": "db22814d-9610-429b-ab01-9ca7fdd6a3dd",
"uid": "bb030a055fdd722735c72012ae24e9142e3626875be2ed6e82994079674b009f",
"path": "users",
"name": "Lint Demo User",
"attributes": {},
"pk": 11,
"date_joined": "2026-10-12T00:42:18.512951Z",
"email": "",
"username": "lint.demo.user"
},
{
"roles_obj": [
{
"name": "qqq-kof22-admin-idadmin",
"pk": "2de63138-3566-48e7-9cf2-913a70b2c06f"
},
{
"name": "ak-managed-role--user-7",
"pk": "ea22584a-d801-4ebc-aa2f-6cbb1f0ba9f7"
}
],
"last_updated": "2026-10-12T00:41:47.841974Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:41:47.831570Z",
"roles": [
"2de63138-3566-48e7-9cf2-913a70b2c06f",
"ea22584a-d801-4ebc-aa2f-6cbb1f0ba9f7"
],
"groups": [],
"avatar": "",
"type": "service_account",
"groups_obj": [],
"uuid": "63ac79e2-ce5c-46d1-9209-8210ad2b4b9e",
"uid": "f15497ad81cf34b68a067048a07bf0ca199d9d1b8a4afa24c26b8d7211bac66d",
"path": "service-accounts/qqq",
"name": "QQQ kof22 Admin identity service",
"attributes": {"qqq.io/protected": true},
"pk": 7,
"date_joined": "2026-10-12T00:41:47.829884Z",
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:44.388384Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:44.335235Z",
"roles": [],
"groups": ["c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd"],
"avatar": "",
"type": "internal",
"groups_obj": [{
"is_superuser": false,
"name": "kof-website-publishers",
"attributes": {},
"pk": "c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd",
"num_pk": 26548
}],
"uuid": "7f8691f0-1ec0-42da-a24f-cb71bcb64c17",
"uid": "07df95d4db5f9cd781eac774b3b8129b7bf9ee3106a4c9a2f830d5fbdb85c583",
"path": "users",
"name": "Read Probe 1",
"attributes": {},
"pk": 16,
"date_joined": "2026-10-12T00:42:44.334765Z",
"email": "read.probe1@example.test",
"username": "read.probe1"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:44.589851Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:44.548432Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "b99e966f-61bb-4f9f-b136-b747d5fb68f7",
"uid": "77947ea5357373462f042427a96e83bb14ea799dfae76daf6a014d01fc4580ce",
"path": "users",
"name": "Read Probe 2",
"attributes": {},
"pk": 17,
"date_joined": "2026-10-12T00:42:44.548001Z",
"email": "read.probe2@example.test",
"username": "read.probe2"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:44.790626Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:44.740536Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "9cb596e1-ac25-4c9c-aa49-e9a66e07a5fd",
"uid": "961c6ec5f4e73114ab10c834f18cf279fb6a61b53144302674144c73f7194932",
"path": "users",
"name": "Read Probe 3",
"attributes": {},
"pk": 18,
"date_joined": "2026-10-12T00:42:44.740046Z",
"email": "read.probe3@example.test",
"username": "read.probe3"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:45.576270Z",
"is_active": false,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:44.997114Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "9c523de5-9a37-4510-85d3-4cb23a8ba6df",
"uid": "41ae72db125e854553e33f6ca481dc2dc2194fe5b0154a44a2f3cd0482e281b9",
"path": "users",
"name": "Read Probe 4",
"attributes": {},
"pk": 19,
"date_joined": "2026-10-12T00:42:44.996692Z",
"email": "read.probe4@example.test",
"username": "read.probe4"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:22.763043Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:22.714165Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "b6ec0c40-5bff-4cb3-8434-7fe74c55c879",
"uid": "e3bf082dd81daa0f56be03530c403e048dce22e7a522e02594897674fdf3b51f",
"path": "users",
"name": "Reapply Member",
"attributes": {},
"pk": 13,
"date_joined": "2026-10-12T00:42:22.713832Z",
"email": "",
"username": "reapply.member"
}
]
}
@@ -0,0 +1 @@
junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$OrderAnnotation
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Test logging: INFO, with Docker / HTTP wire logging kept quiet (the contract -->
<!-- tests would otherwise log every Docker API exchange at DEBUG). -->
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} | %-5level | %c{1} | %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Logger name="org.testcontainers" level="WARN"/>
<Logger name="com.github.dockerjava" level="WARN"/>
<Logger name="tc" level="WARN"/>
<Logger name="org.apache.http" level="WARN"/>
<Logger name="com.kingsrook.qbits.authentik" level="INFO"/>
<Root level="INFO">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>