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:
1 parent
6afe1b045a
commit
037d736bfd
60 files changed
+71528
No files matched your search
@@ -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"
|
||||
@@ -19,3 +19,5 @@ dependency-reduced-pom.xml
|
||||
/.env.local
|
||||
/.cache/
|
||||
.claude/settings.local.json
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+121
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+65
@@ -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
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user