Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ba505aa0f
|
||
|
|
fcea48de32
|
||
|
|
916bb012dc
|
||
|
|
77c39f7f22 | ||
|
|
f99988fc27 |
No files matched your search
+11
-68
@@ -1,71 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*|hotfix\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only: [/hotfix\/.*/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
ignore: /.*/
|
||||
@@ -1,20 +0,0 @@
|
||||
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 3
|
||||
tab_width = 3
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{cmd,bat}]
|
||||
end_of_line = crlf
|
||||
@@ -1,3 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
*.cmd text eol=crlf
|
||||
mvnw text eol=lf
|
||||
@@ -0,0 +1,25 @@
|
||||
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
|
||||
name: qrun-release
|
||||
on:
|
||||
push: {}
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
jobs:
|
||||
release:
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
docker-runner: container-build
|
||||
browsers: true
|
||||
browser-runner: browser-build
|
||||
postpublish:
|
||||
if: ${{ needs.release.outputs.capture == 'true' }}
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
@@ -1,25 +0,0 @@
|
||||
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
|
||||
# major updates stay separate so each gets its own review. At most three version-update PRs per
|
||||
# ecosystem are open at once. Security updates are enabled in the repository settings and are
|
||||
# not limited by this schedule.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: maven
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
maven-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
github-actions:
|
||||
patterns: ['*']
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,42 +0,0 @@
|
||||
# Weekly: does the newest Authentik release still have every operation and
|
||||
# query parameter qbit-authentik uses? Downloads the release's schema.yml from
|
||||
# goauthentik/authentik and runs SchemaDriftTest against it (no Docker, no
|
||||
# credentials). A failure means: review the release notes before upgrading.
|
||||
name: authentik-schema-drift
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 6 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Authentik version (e.g. 2026.10.0); empty = latest release"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
drift:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: maven
|
||||
- name: Resolve version and fetch its schema
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
version="${INPUT_VERSION}"
|
||||
if [ -z "$version" ]; then
|
||||
version=$(gh release view --repo goauthentik/authentik --json tagName -q .tagName | sed 's#^version/##')
|
||||
fi
|
||||
echo "Authentik $version"
|
||||
curl -fsSL "https://raw.githubusercontent.com/goauthentik/authentik/version/${version}/schema.yml" -o "$RUNNER_TEMP/schema.yml"
|
||||
echo "SCHEMA=$RUNNER_TEMP/schema.yml" >> "$GITHUB_ENV"
|
||||
- name: Drift test
|
||||
run: ./mvnw -B -q test -Dtest=SchemaDriftTest -Dsurefire.failIfNoSpecifiedTests=false -Dauthentik.schema.file="$SCHEMA"
|
||||
+15
-18
@@ -1,23 +1,20 @@
|
||||
# Maven
|
||||
target/
|
||||
.env
|
||||
.idea
|
||||
*.iml
|
||||
*.class
|
||||
*.log
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
hs_err_pid*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
*.sw*
|
||||
.flattened-pom.xml
|
||||
dependency-reduced-pom.xml
|
||||
/.env.local
|
||||
/.cache/
|
||||
.claude/settings.local.json
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: none
|
||||
test_branch_policy: all
|
||||
publish:
|
||||
snapshot: false
|
||||
release_candidate: false
|
||||
release: false
|
||||
asciidoc: false
|
||||
@@ -1,66 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex and other agents when working with code in this repository. It mirrors CLAUDE.md; keep them in sync.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and (from phase 2) writes through the Authentik API. Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = read-only (phases 0-1).
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
|
||||
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
|
||||
./mvnw test -Dtest=ClassName
|
||||
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
|
||||
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
|
||||
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
|
||||
./mvnw test -Dtest=SchemaDriftTest -Dauthentik.schema.file=/path/to/schema.yml # drift vs another release
|
||||
```
|
||||
|
||||
- **Contract ITs** (`*IT`, package `contract`) share one `AuthentikTestStack` per JVM; first boot takes ~1.5-3 minutes. `@ExtendWith(RequiresDockerCondition.class)`: skipped without Docker locally, failed with `CI=true`.
|
||||
- The stack boots with `src/test/resources/blueprints/*.yaml` already in `/blueprints/custom`: that IS contract test (h) (bootstrap on an empty instance).
|
||||
- `TestApi` uses HTTP/1.1 on purpose: Java's HttpClient tries an h2c upgrade on plain-http POSTs, which Authentik answers with "400 Invalid HTTP request received."
|
||||
- Tests that grant extra permissions to the service-account role must remove them again (they are additive and shared).
|
||||
|
||||
## Architecture
|
||||
|
||||
- `AuthentikQBitProducer` validates the config, registers an `AuthentikRuntime` for the API backend, then runs the component producers in `metadata/`.
|
||||
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
|
||||
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
|
||||
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
|
||||
- Writes go to the guard, which refuses them in 0.1.
|
||||
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
|
||||
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
|
||||
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
|
||||
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
|
||||
- `security/AuthentikGuard` handles read and write permission checks, the user scope, and protected-account detection. `security/IdentityPermission` is the `identity.*` catalog (session form `<name>.hasAccess`).
|
||||
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
|
||||
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
|
||||
- Never filter client-side; reject what Authentik cannot evaluate.
|
||||
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
|
||||
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
|
||||
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
|
||||
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions.
|
||||
|
||||
## Code Style
|
||||
|
||||
3-space indentation, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters, wrapper types in config.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
|
||||
## Release
|
||||
|
||||
CircleCI qqq-orb 0.6.8:
|
||||
- `develop` publishes SNAPSHOTs
|
||||
- `release/*` publishes RCs
|
||||
- `v*` tags publish releases
|
||||
|
||||
Cut `release/0.1` only after phase 1 is green and reviewed.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
|
||||
- **Table names are lowercase snake_case with the `authentik_` prefix** (owner naming rule, 2026-10-12): `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event` (were `authentikUser`, ...). New `AuthentikQBitConfig.applyTablePrefix` and `toSnakeCase`; apps, widgets, the backend and processes keep camelCase names (`authentikAdmin`, `authentikApi`). The old names are kept as `@Deprecated` `AuthentikTables.LEGACY_*_TABLE_NAME` constants. See [UPGRADE.md](UPGRADE.md).
|
||||
|
||||
### Documentation
|
||||
- README: "Adopting in another app" section (coordinates, configuration, secrets, permissions, limitations) linking the QQQ adoption checklist; examples use neutral names instead of one host's (#3).
|
||||
|
||||
### Added (phase 1, read-only MVP)
|
||||
- Tables: `authentik_group_member` (virtual membership table: by group, by user, by key, or a bounded sweep), `authentik_session` and `authentik_event` (both need `authentik.audit.read`).
|
||||
- `GroupGraph` and `EffectivePermissionResolver`: groups, including inherited parents, become QQQ permissions. `authentik.*` and catalog wildcards are expanded; unknown names are reported, never granted. Groups show inherited superuser status and effective permissions; users show effective permissions.
|
||||
- `AuthentikGroupPermissionMapper`: an authentication customizer that turns the token's `groups` claim into QQQ session permissions through the resolver. It supports static fallbacks and `bootstrapOperators`; if Authentik cannot be read it grants only the static fallbacks, never everything.
|
||||
- Review hardening:
|
||||
- `search` accepts plain text only; Authentik's query language could reach sensitive fields.
|
||||
- The group graph is thread-safe.
|
||||
- A failed group-graph read shows "Unavailable", never empty permissions.
|
||||
- Malformed `qqq.io/*` attributes are reported.
|
||||
- Operator filters can't widen the user scope, so count and query agree.
|
||||
- Membership rows are always scope-checked.
|
||||
- Missing pagination is an error.
|
||||
- Sorting by the key falls back to the default order.
|
||||
- Booleans are strict.
|
||||
- `..` keys are refused.
|
||||
- https is required (except loopback or `allowInsecureHttp`).
|
||||
- `groupScopeNamePrefix` is refused until implemented.
|
||||
- Drift checks that cannot run report ERROR ("result unknown"); service-account tokens are filtered server-side; deleted targets are flagged; only human users count as identity admins.
|
||||
- Identity drift report widget (`authentik.admin`). It checks the server version, blueprint status, the service account (type, groups, superuser, protection, extra self-minted tokens), break-glass presence, the last identity administrator, `qqq.io/*` attribute validity, and service-account changes to protected or superuser objects (lockout detection).
|
||||
|
||||
### Added (phase 0)
|
||||
- Phase 0 foundations: `AuthentikQBitConfig`, `AuthentikQBitProducer`, `AuthentikActionUtil` (bearer auth, page/page_size paging, filter mapping that rejects unsupported filters, 10 rps token-bucket throttle, log redaction), read-only Users and Groups tables, Identity app.
|
||||
- Pinned Authentik 2026.8.3 OpenAPI schema and schema drift tests.
|
||||
- Testcontainers contract tests against Authentik 2026.8.3 (bootstrap on an empty instance, superuser floor, blueprint re-apply safety, access-check behaviour) and a fixture recorder.
|
||||
@@ -1,66 +1,17 @@
|
||||
# CLAUDE.md
|
||||
# qbit-template-extension
|
||||
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
GitHub template repository for scaffolding QQQ "Extension QBits" (infrastructure-level plugins: table customizers, auth, audit, action wrappers). Not a shipping library — consumers click "Use this template" and rename the `com.kingsrook.qbits.example` package.
|
||||
|
||||
## Project Overview
|
||||
## QQQ 4.0 baseline — 2026-09-24
|
||||
|
||||
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and (from phase 2) writes through the Authentik API. Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = read-only (phases 0-1).
|
||||
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
|
||||
|
||||
## Build Commands
|
||||
## Knowledge base
|
||||
|
||||
```bash
|
||||
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
|
||||
./mvnw test # unit tests, offline, on recorded fixtures (src/test/resources/fixtures/2026.8.3)
|
||||
./mvnw test -Dtest=ClassName
|
||||
CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Postgres, Mailpit (Testcontainers), JaCoCo gate
|
||||
./mvnw verify -Dit.test=QbitReadContractIT -Dtest=none -Dsurefire.failIfNoSpecifiedTests=false
|
||||
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures from the contract ITs
|
||||
./mvnw test -Dtest=SchemaDriftTest -Dauthentik.schema.file=/path/to/schema.yml # drift vs another release
|
||||
```
|
||||
Reviewed dossiers for this repo and the wider QQQ platform live in the second-brain vault:
|
||||
|
||||
- **Contract ITs** (`*IT`, package `contract`) share one `AuthentikTestStack` per JVM; first boot takes ~1.5-3 minutes. `@ExtendWith(RequiresDockerCondition.class)`: skipped without Docker locally, failed with `CI=true`.
|
||||
- The stack boots with `src/test/resources/blueprints/*.yaml` already in `/blueprints/custom`: that IS contract test (h) (bootstrap on an empty instance).
|
||||
- `TestApi` uses HTTP/1.1 on purpose: Java's HttpClient tries an h2c upgrade on plain-http POSTs, which Authentik answers with "400 Invalid HTTP request received."
|
||||
- Tests that grant extra permissions to the service-account role must remove them again (they are additive and shared).
|
||||
- Hub / entry point: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
|
||||
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-extension.md`
|
||||
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
|
||||
|
||||
## Architecture
|
||||
|
||||
- `AuthentikQBitProducer` validates the config, registers an `AuthentikRuntime` for the API backend, then runs the component producers in `metadata/`.
|
||||
- `AuthentikRuntime` (registry by API backend name): config, `TokenBucketThrottle` (10 rps, burst 20 by default), `AuthentikGuard`, `AuthentikFilterMapper`, response cache, and the table-name to `AuthentikTableSpec` map.
|
||||
- `client/AuthentikActionUtil extends BaseAPIActionUtil` is the only code that talks to Authentik.
|
||||
- `doQuery`/`doCount`/`doGet` use page/page_size paging (max 100). Key-only filters become per-id GETs. The user scope and type filters are applied server-side and re-checked per record.
|
||||
- Writes go to the guard, which refuses them in 0.1.
|
||||
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
|
||||
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
|
||||
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
|
||||
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
|
||||
- `security/AuthentikGuard` handles read and write permission checks, the user scope, and protected-account detection. `security/IdentityPermission` is the `identity.*` catalog (session form `<name>.hasAccess`).
|
||||
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
|
||||
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
|
||||
- Never filter client-side; reject what Authentik cannot evaluate.
|
||||
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
|
||||
- The user detail GET takes no `include_*` params. Groups have no `last_updated` in 2026.8.
|
||||
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
|
||||
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions.
|
||||
|
||||
## Code Style
|
||||
|
||||
3-space indentation, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters, wrapper types in config.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
|
||||
## Release
|
||||
|
||||
CircleCI qqq-orb 0.6.8:
|
||||
- `develop` publishes SNAPSHOTs
|
||||
- `release/*` publishes RCs
|
||||
- `v*` tags publish releases
|
||||
|
||||
Cut `release/0.1` only after phase 1 is green and reviewed.
|
||||
Reviewed at commit `e7d4b4d672cc` (branch `main`, 2026-05-21). Known drift as of that review: no `qbit-build-parent` parent pom (org convention), qqq pinned at 0.35.0 on main (0.40.0-SNAPSHOT on develop), README still claims AGPL-3.0 while LICENSE/NOTICE are Apache-2.0, and README references `scripts/customize_template.py` / `actions/ExampleActionCustomizer.java` which do not exist. See the dossier's "Maturity & risks" and "v4.0 impact" sections before modifying the scaffold.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Code of Conduct
|
||||
|
||||
See Project level [Code of Conduct](https://github.com/Kingsrook/qqq/blob/develop/CODE_OF_CONDUCT.md)
|
||||
@@ -1,3 +0,0 @@
|
||||
# Code Style Guide
|
||||
|
||||
* See Project [Code Style Guide](https://github.com/Kingsrook/qqq/blob/develop/CODE_STYLE.md)
|
||||
@@ -1,3 +0,0 @@
|
||||
# Contributing to QBit Authentik
|
||||
|
||||
* See Project [Contributing](https://github.com/Kingsrook/qqq/blob/develop/CONTRIBUTING.md)
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024 Kingsrook, LLC
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
QBit Authentik
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
qBit Template - Extension
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
|
||||
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
|
||||
This product includes software developed at QRunIO, LLC (https://qrun.io/).
|
||||
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
@@ -1,80 +1,42 @@
|
||||
# QBit Authentik
|
||||
# QBit Template: Extension
|
||||
|
||||
A QQQ QBit for managing [Authentik](https://goauthentik.io) identities (users, groups, memberships, and the QQQ permissions a group grants) from a QQQ app. **Authentik stays the source of truth.** The qbit reads Authentik live and will write through the Authentik API. Changes made in the Authentik admin UI are equally valid.
|
||||
Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers.
|
||||
|
||||
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (copy of the approved spec, QRun-IO/qqq#1065). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Status and linked PRs: [`docs/STATUS.md`](docs/STATUS.md).
|
||||
## What is an Extension QBit?
|
||||
|
||||
## Status
|
||||
Extension QBits extend or enhance how QQQ operates at the infrastructure/framework level. They provide:
|
||||
- New backend implementations
|
||||
- Authentication mechanisms
|
||||
- Audit capabilities
|
||||
- Table customizers
|
||||
- Action handlers
|
||||
|
||||
`0.1.0-SNAPSHOT`, the **read-only** release line (phases 0 and 1 of DESIGN.md section 11):
|
||||
## Quick Start
|
||||
|
||||
| Available | Not yet (refused by config validation) |
|
||||
|---|---|
|
||||
| `READ`: Authentik tables over the API | `MEMBERSHIP`, `GROUPS`, `USERS`, `CREDENTIALS`, `SESSIONS` writes (phases 2-3) |
|
||||
| `MIRROR` (phase 1) | `RBAC` (phase 4), `WEBHOOK`, `SNAPSHOT_EXPORT` |
|
||||
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create your repository, customize its Maven coordinates and Java package/classes, implement the table customizer, then run `mvn clean verify`. See [Getting Started](docs/00-getting-started.md).
|
||||
|
||||
Every write is refused by `AuthentikGuard` in this release.
|
||||
## Structure
|
||||
|
||||
## Usage
|
||||
|
||||
```java
|
||||
AuthentikQBitConfig config = new AuthentikQBitConfig()
|
||||
.withBaseUrl("https://auth.example.com") // host root; /api/v3 is appended
|
||||
.withApiToken("${env.AUTHENTIK_API_TOKEN}") // service-account token, resolved by the qbit
|
||||
.withAppKey("my-app-admin") // service account: qqq-my-app-admin-idadmin
|
||||
.withApplicationsInScope(List.of("my-app-prod"));
|
||||
|
||||
new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
```
|
||||
src/main/java/com/kingsrook/qbits/example/
|
||||
├── ExampleExtensionQBitConfig.java # Configuration
|
||||
├── ExampleExtensionQBitProducer.java # Entry point
|
||||
└── customizers/
|
||||
└── ExampleTableCustomizer.java # Table customization
|
||||
```
|
||||
|
||||
This produces:
|
||||
- an API backend `authentikApi`
|
||||
- read-only tables `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session` and `authentik_event`
|
||||
- the Identity Drift Report widget
|
||||
- an app `authentikAdmin`, labelled "Identity", that hosts can nest, for example under Admin
|
||||
## Key Characteristics
|
||||
|
||||
Access is controlled by QQQ permissions:
|
||||
- **No QAppSection** - Pure infrastructure
|
||||
- **No user-facing tables** (or minimal operational)
|
||||
- **Wraps existing tables/processes** with new behavior
|
||||
- **Validates dependencies** on other QQQ components
|
||||
|
||||
| Permission (held as `<name>.hasAccess`) | Grants |
|
||||
|---|---|
|
||||
| `authentik.users.read` | the Identity app and its tables |
|
||||
| `authentik.audit.read` | sensitive fields and tables |
|
||||
## Documentation
|
||||
|
||||
The full `authentik.*` set is in DESIGN.md section 5.2.
|
||||
|
||||
### Authentik side
|
||||
|
||||
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the read permissions it needs. The bootstrap blueprint that creates it lives in the host's GitOps repo. One host's version, which the contract tests exercise, is [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml); treat it as an example, not something to apply as is.
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/docs/migration/adoption-checklist.adoc) (section 5 covers this QBit).
|
||||
|
||||
| Item | What to do |
|
||||
|---|---|
|
||||
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release candidate or release is on Maven Central yet; CircleCI publishes snapshots from `develop`. Build from source or use a snapshot until `0.1.0` ships. |
|
||||
| QQQ version | QQQ 4.1 (see `pom.xml` for the exact RC). Java 21. |
|
||||
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl` (`/api/v3` is appended). |
|
||||
| Authentik setup | A dedicated service account (default username `qqq-<appKey>-idadmin`) whose role holds only the read permissions the QBit needs, created by a bootstrap blueprint in the host's GitOps repository. `src/test/resources/blueprints/kof22-identity-bootstrap.yaml` is a worked example from one host, not something to apply as is. |
|
||||
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. Produces the `authentikApi` API backend, read-only tables (`authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event`), the drift report widget, and the `authentikAdmin` ("Identity") app, which the host may nest. |
|
||||
| Required config | `baseUrl`, `apiToken`, `appKey` (lower-case letters, digits, dashes; chooses the service-account name), `applicationsInScope` (Authentik application slugs). Optional: `userScopePathStartsWith`, `groupScopeNamePrefix`, `mirrorBackendName` (phase 1 `MIRROR`), `throttleRequestsPerSecond` (default 10). |
|
||||
| Secrets / env | `apiToken` as a `${env.X}` reference (the example uses `AUTHENTIK_API_TOKEN`); `bootstrapOperators` defaults to `${env.AUTHENTIK_QBIT_BOOTSTRAP_OPERATORS}` and `webhookSecret` to `${env.AUTHENTIK_WEBHOOK_SECRET}`. Keep them in the host's secret store, never in config files. |
|
||||
| Permissions | `authentik.users.read` (Identity app and tables), `authentik.audit.read` (sensitive fields and tables); the full `authentik.*` set is in DESIGN.md section 5.2. Granted through the Authentik group attribute `qqq.io/permissions`, read by `AuthentikGroupPermissionMapper`. |
|
||||
| Storage | None for the read-only line, except the mirror tables when `MIRROR` is enabled on `mirrorBackendName`. |
|
||||
| Known limitations | Read-only: every write is refused by `AuthentikGuard` until phases 2-3. `RBAC`, `WEBHOOK` and `SNAPSHOT_EXPORT` are refused by config validation. |
|
||||
| Upgrading | First release line; nothing to migrate. |
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./mvnw test # unit tests (recorded 2026.8.3 fixtures, no network)
|
||||
CI=true ./mvnw verify # + contract tests against Authentik 2026.8.3 in Docker (Testcontainers)
|
||||
./mvnw verify -Dauthentik.test.image=ghcr.io/goauthentik/server:2026.10.0 # rehearse an upgrade
|
||||
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures
|
||||
```
|
||||
|
||||
Contract tests start Authentik (server and worker), Postgres and Mailpit, and take about 10 minutes, mostly first boot and blueprint applies. Without Docker they are skipped locally. With `CI=true` they fail instead.
|
||||
- [Getting Started](docs/00-getting-started.md)
|
||||
- [Extension Patterns](docs/01-extension-patterns.md)
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
@@ -1,3 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
* See Project [Security Policy](https://github.com/Kingsrook/qqq/blob/develop/SECURITY.md)
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
# Upgrading qbit-authentik
|
||||
|
||||
## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release)
|
||||
|
||||
The qbit's permissions follow the same owner naming rule (2026-10-12) and now
|
||||
carry the qbit's name. The suffixes are unchanged:
|
||||
|
||||
| Before | After |
|
||||
|---|---|
|
||||
| `identity.*` | `authentik.*` |
|
||||
| `identity.users.read` | `authentik.users.read` |
|
||||
| `identity.users.manage` | `authentik.users.manage` |
|
||||
| `identity.groups.manage` | `authentik.groups.manage` |
|
||||
| `identity.memberships.manage` | `authentik.memberships.manage` |
|
||||
| `identity.permissions.manage` | `authentik.permissions.manage` |
|
||||
| `identity.roles.manage` | `authentik.roles.manage` |
|
||||
| `identity.credentials.reset` | `authentik.credentials.reset` |
|
||||
| `identity.sessions.revoke` | `authentik.sessions.revoke` |
|
||||
| `identity.audit.read` | `authentik.audit.read` |
|
||||
| `identity.approve` | `authentik.approve` |
|
||||
| `identity.admin` | `authentik.admin` |
|
||||
|
||||
The QQQ session form is the new name plus `.hasAccess`
|
||||
(`authentik.users.read.hasAccess`).
|
||||
|
||||
**Legacy names are not granted.** The resolver lists an old `identity.*` name
|
||||
among a group's *unknown* permissions, so it grants nothing and fails closed.
|
||||
Update every stored grant **before** you deploy, or the admins lose the
|
||||
Identity app until you do:
|
||||
|
||||
- **Authentik group attributes:** edit `qqq.io/permissions` on each group, for
|
||||
example `identity-admins`, in the Authentik UI (Directory → Groups →
|
||||
Attributes) or in its blueprint. Replace `identity.` with `authentik.`:
|
||||
|
||||
```yaml
|
||||
attributes:
|
||||
qqq.io/permissions:
|
||||
- authentik.* # was identity.*
|
||||
```
|
||||
|
||||
To find the groups that still carry old names, run the drift report. Its
|
||||
"group attributes" check warns about each `identity.*` entry ("grants
|
||||
nothing in this app").
|
||||
- **QQQ-side grants:** these are static fallbacks in
|
||||
`AuthentikGroupPermissionMapper`, host permission lists and role tables. If
|
||||
a host stores permission names in a table:
|
||||
|
||||
```sql
|
||||
-- example only: use your host's table and column names
|
||||
UPDATE role_permission
|
||||
SET permission_name = REPLACE(permission_name, 'identity.', 'authentik.')
|
||||
WHERE permission_name LIKE 'identity.%';
|
||||
```
|
||||
|
||||
The qbit itself stores no grants in a database. `qqq_user_session` rows hold
|
||||
resolved permissions, so sign users out (or let their sessions expire)
|
||||
after the change.
|
||||
- **Code** that names `identity.*` strings. Use `IdentityPermission`
|
||||
instead. `IdentityPermission.LEGACY_PREFIX`, `LEGACY_WILDCARD` and
|
||||
`getLegacyShortName()` (all deprecated) give the old names for upgrade
|
||||
scripts.
|
||||
|
||||
The `identity-admins` group name and the "Identity" app label are unchanged.
|
||||
|
||||
## Snake_case table names (unreleased → first release)
|
||||
|
||||
Table names now follow the owner naming rule (2026-10-12): lowercase snake_case,
|
||||
prefixed with the qbit's name and `_`.
|
||||
|
||||
| Before | After |
|
||||
|---|---|
|
||||
| `authentikUser` | `authentik_user` |
|
||||
| `authentikGroup` | `authentik_group` |
|
||||
| `authentikGroupMember` | `authentik_group_member` |
|
||||
| `authentikSession` | `authentik_session` |
|
||||
| `authentikEvent` | `authentik_event` |
|
||||
|
||||
With a custom `tableNamePrefix` the prefix is snake-cased too: `myAuth` gives
|
||||
`my_auth_user`. App, widget, backend and process names keep camelCase
|
||||
(`authentikAdmin`, `authentikDriftReport`, `authentikApi`).
|
||||
|
||||
The tables are API-backed, so there is no database DDL. Hosts that ran a
|
||||
pre-release snapshot should update anything that stores a table name:
|
||||
|
||||
- **Permissions:** grants or role definitions that name a table (for example
|
||||
table-level permission names derived from the table name) and any host code
|
||||
calling `qInstance.getTable("authentikUser")`.
|
||||
- **Saved views and reports:** rows whose table name is an old name, for
|
||||
example `UPDATE saved_view SET table_name = 'authentik_user' WHERE table_name = 'authentikUser';`
|
||||
(and the same for saved reports and any shared/favorite records).
|
||||
- **Audit:** `audit_table.name` rows for the old names, if the host audits
|
||||
these tables (rename them, or leave history under the old name).
|
||||
- **Frontend links and bookmarks** of the form `/…/authentikUser/…`.
|
||||
|
||||
`AuthentikTables.LEGACY_*_TABLE_NAME` hold the old names (deprecated) so
|
||||
upgrade scripts can find them.
|
||||
@@ -1,246 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
~ QQQ - Low-code Application Framework for Engineers.
|
||||
~ Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
~ contact@kingsrook.com
|
||||
~ https://github.com/Kingsrook/
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ https://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
|
||||
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
|
||||
<!--
|
||||
See reference at https://checkstyle.sourceforge.io/checks.html
|
||||
-->
|
||||
<module name = "Checker">
|
||||
<property name="charset" value="UTF-8"/>
|
||||
|
||||
<property name="severity" value="warning"/>
|
||||
|
||||
<property name="fileExtensions" value="java, properties, xml"/>
|
||||
<!-- Checks for whitespace -->
|
||||
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
|
||||
<module name="FileTabCharacter">
|
||||
<property name="eachLine" value="true"/>
|
||||
</module>
|
||||
|
||||
<!--
|
||||
<module name="LineLength">
|
||||
<property name="max" value="100"/>
|
||||
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
|
||||
</module>
|
||||
-->
|
||||
|
||||
<module name="TreeWalker">
|
||||
<module name="SuppressWarningsHolder"/>
|
||||
<module name="OuterTypeFilename"/>
|
||||
<module name="IllegalTokenText">
|
||||
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
|
||||
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
|
||||
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
|
||||
</module>
|
||||
<module name="AvoidEscapedUnicodeCharacters">
|
||||
<property name="allowEscapesForControlCharacters" value="true"/>
|
||||
<property name="allowByTailComment" value="true"/>
|
||||
<property name="allowNonPrintableEscapes" value="true"/>
|
||||
</module>
|
||||
<module name="AvoidStarImport"/>
|
||||
<module name="OneTopLevelClass"/>
|
||||
<module name="NoLineWrap"/>
|
||||
<module name="EmptyBlock">
|
||||
<property name="option" value="TEXT"/>
|
||||
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
|
||||
</module>
|
||||
<module name="NeedBraces"/>
|
||||
<module name="LeftCurly">
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="RightCurly">
|
||||
<property name="option" value="alone"/>
|
||||
</module>
|
||||
<!--
|
||||
<module name="WhitespaceAround">
|
||||
<property name="allowEmptyConstructors" value="true"/>
|
||||
<property name="allowEmptyMethods" value="true"/>
|
||||
<property name="allowEmptyTypes" value="true"/>
|
||||
<property name="allowEmptyLoops" value="true"/>
|
||||
<message key="ws.notFollowed"
|
||||
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
|
||||
<message key="ws.notPreceded"
|
||||
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
-->
|
||||
<module name="OneStatementPerLine"/>
|
||||
<module name="MultipleVariableDeclarations"/>
|
||||
<module name="ArrayTypeStyle"/>
|
||||
<module name="MissingSwitchDefault"/>
|
||||
<module name="FallThrough"/>
|
||||
<module name="UpperEll"/>
|
||||
<module name="ModifierOrder"/>
|
||||
<module name="EmptyLineSeparator">
|
||||
<property name="allowNoEmptyLineBetweenFields" value="true"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapDot"/>
|
||||
<property name="tokens" value="DOT"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapComma"/>
|
||||
<property name="tokens" value="COMMA"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
|
||||
<property name="id" value="SeparatorWrapEllipsis"/>
|
||||
<property name="tokens" value="ELLIPSIS"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
|
||||
<property name="id" value="SeparatorWrapArrayDeclarator"/>
|
||||
<property name="tokens" value="ARRAY_DECLARATOR"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapMethodRef"/>
|
||||
<property name="tokens" value="METHOD_REF"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="PackageName">
|
||||
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Package name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="TypeName">
|
||||
<message key="name.invalidPattern"
|
||||
value="Type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MemberName">
|
||||
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ParameterName">
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="CatchParameterName">
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="LocalVariableName">
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ClassTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MethodTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="InterfaceTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="NoFinalizer"/>
|
||||
<module name="GenericWhitespace">
|
||||
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
|
||||
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
|
||||
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
|
||||
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
<module name="Indentation">
|
||||
<property name="basicOffset" value="3"/>
|
||||
<property name="braceAdjustment" value="0"/>
|
||||
<property name="caseIndent" value="3"/>
|
||||
<property name="throwsIndent" value="6"/>
|
||||
<property name="lineWrappingIndentation" value="3"/>
|
||||
<property name="arrayInitIndent" value="6"/>
|
||||
</module>
|
||||
<!--
|
||||
<module name="AbbreviationAsWordInName">
|
||||
<property name="ignoreFinal" value="false"/>
|
||||
<property name="allowedAbbreviationLength" value="1"/>
|
||||
</module>
|
||||
<module name="OverloadMethodsDeclarationOrder"/>
|
||||
<module name="VariableDeclarationUsageDistance"/>
|
||||
<module name="CustomImportOrder">
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="true"/>
|
||||
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
|
||||
</module>
|
||||
-->
|
||||
<module name="CustomImportOrder">
|
||||
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
|
||||
<property name="specialImportsRegExp" value="^javax\."/>
|
||||
<property name="standardPackageRegExp" value="^java\."/>
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="false"/>
|
||||
</module>
|
||||
<module name="MethodParamPad"/>
|
||||
<module name="NoWhitespaceBefore">
|
||||
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
|
||||
<property name="allowLineBreaks" value="true"/>
|
||||
</module>
|
||||
<module name="ParenPad"/>
|
||||
<module name="OperatorWrap">
|
||||
<property name="option" value="NL"/>
|
||||
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationMostCases"/>
|
||||
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationVariables"/>
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="allowSamelineMultipleAnnotations" value="true"/>
|
||||
</module>
|
||||
<module name="JavadocMethod">
|
||||
<property name="allowMissingParamTags" value="true"/>
|
||||
<property name="allowMissingReturnTag" value="true"/>
|
||||
<property name="allowedAnnotations" value="Override, Test"/>
|
||||
</module>
|
||||
<module name="MissingJavadocMethod">
|
||||
<property name="scope" value="private"/>
|
||||
</module>
|
||||
<module name="MissingJavadocType">
|
||||
<property name="scope" value="private"/>
|
||||
</module>
|
||||
<module name="MethodName">
|
||||
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Method name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="EmptyCatchBlock">
|
||||
<property name="exceptionVariableName" value="expected"/>
|
||||
</module>
|
||||
<module name="CommentsIndentation"/>
|
||||
|
||||
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
|
||||
<module name="MissingOverride"/>
|
||||
|
||||
</module>
|
||||
<module name="Header">
|
||||
<property name="headerFile" value="checkstyle/license.txt"/>
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="ignoreLines" value="3"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
</module>
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
@@ -0,0 +1,28 @@
|
||||
# Getting Started
|
||||
|
||||
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
|
||||
|
||||
1. Choose **Use this template** on GitHub, then clone the repository you created.
|
||||
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
|
||||
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
|
||||
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
|
||||
5. Build the generated project:
|
||||
|
||||
```bash
|
||||
mvn clean verify
|
||||
```
|
||||
|
||||
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
|
||||
|
||||
## Register with a host application
|
||||
|
||||
The following uses the original example names; substitute the names chosen above:
|
||||
|
||||
```java
|
||||
new ExampleExtensionQBitProducer()
|
||||
.withConfig(new ExampleExtensionQBitConfig()
|
||||
.withTargetTableName("order"))
|
||||
.produce(qInstance, "my-extension");
|
||||
```
|
||||
|
||||
Register the target table before producing the extension, then implement its customizer. See [Extension Patterns](01-extension-patterns.md).
|
||||
@@ -0,0 +1,16 @@
|
||||
# Extension Patterns
|
||||
|
||||
[ExampleExtensionQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java) registers the QBit identity and calls `applyTableCustomizations` for the configured target. That method is the extension point: look up the table, validate that it exists, and call your customized [ExampleTableCustomizer](../src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java).
|
||||
|
||||
```java
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
if(table == null)
|
||||
{
|
||||
throw new QException("Target table not found: " + tableName);
|
||||
}
|
||||
new ExampleTableCustomizer().customize(table);
|
||||
```
|
||||
|
||||
When implementing this method, add `throws QException` to its declaration. Run the config's `validate(QInstance, List<String>)` before registering metadata, and report validation errors rather than silently ignoring a missing target. The uncustomized template contains placeholders for this behavior.
|
||||
|
||||
For record hooks, use QQQ's existing table customizer interfaces or `AbstractPreInsertCustomizer`. Its `apply(List<QRecord>)` method returns the records for the next stage; register the customizer through the table's `TableCustomizers.PRE_INSERT_RECORD` role. Check the actual QQQ interface before adding another hook. Add tests proving that the intended table changes and invalid configurations are handled.
|
||||
-1061
File diff suppressed because it is too large.
Load diff
@@ -1,103 +0,0 @@
|
||||
# Phase 0 owner checklist (kof22)
|
||||
|
||||
These are the steps only James can take: items 6, 7, 8 and 10 of DESIGN.md section 11.0, plus a few decisions the phase 0 work surfaced. Agents prepared everything else. Nothing below has been run against production.
|
||||
|
||||
Links:
|
||||
- cluster-gitops draft PR: KofTwentyTwo/cluster-gitops#72 (the identity bootstrap blueprint, the removal of the `present` entries, the attribute-driven sign-in policy, inert webhook plumbing, and the blueprint lint)
|
||||
- Website-Backend and cluster-gitops wiring draft PRs: see `docs/STATUS.md`
|
||||
- Spec: QRun-IO/qqq#1065 (revision 3 records the phase 0 findings)
|
||||
|
||||
## 0. Decisions surfaced by phase 0 (read first)
|
||||
|
||||
| # | Decision | Recommendation |
|
||||
|---|---|---|
|
||||
| D1 | **Repo visibility.** `QRun-IO/qbit-authentik` was created **private**, because creating a public repo was blocked for the agent. Other generic qbits (quick-search, sql-workbench) are public, and the spec is already public in qqq#1065. CI publishes `develop` snapshots and `release/*` RCs to Maven Central / Sonatype (with sources jars), which would make the code public anyway. | Make it public before the first publish: *Settings → General → Change visibility*. Then check that CircleCI follows the repo (it must, for PR CI). |
|
||||
| D2 | **Lockout risk.** Authentik stops the service account from *escalating*, but not from *locking out*. With the phase 2/3 permissions it can do all of the following (contract test `SuperuserFloorIT`): remove members from `authentik Admins`, deactivate `kof22-breakglass`, strip `qqq.io/protected`, and rename the superuser group. The qbit guard refuses all of these; Authentik does not. | Accept, with the mitigations in the spec (§5.1, revision 3). The real break-glass for a compromised QQQ is cluster access: `kubectl exec` into the Authentik worker and run `ak shell` to reactivate or re-add. Optionally, phase 2 grants `remove_user_from_group` per group (object permissions) instead of globally. |
|
||||
| D3 | **Self-minted tokens.** Any authenticated user, including the service account, can create API tokens **for itself** without `add_token`, and read their keys. A compromised QQQ could therefore keep a second token alive after you rotate the first. | Accept. The phase 1 drift report flags service-account tokens beyond the expected one. Check that list after every rotation (item 8). |
|
||||
| D4 | **Access matrix.** `check_access?for_user=` only works for superusers. For the service account it silently answers *for itself*. The policy-test endpoint returns HTTP 500 for non-superusers on 2026.8.3. | Accepted in the spec: the qbit computes app access itself from `qqq.io/app-access` and the app's bindings, and shows other expression policies as "not evaluated". |
|
||||
|
||||
## Item 6: staging rehearsal (needs your go-ahead)
|
||||
|
||||
The agent cannot read production data. The rehearsal runs entirely in local Docker, on a dump you take.
|
||||
|
||||
1. **Take a dump** of the Authentik database. This is a read-only `pg_dump` of the CNPG cluster that hosts it:
|
||||
```bash
|
||||
# CNPG cluster kof22-website-pg (namespace kof22-website-prod) hosts the database "authentik"
|
||||
P=$(kubectl --context=k8s-galaxy -n kof22-website-prod get pod -l cnpg.io/cluster=kof22-website-pg,cnpg.io/instanceRole=primary -o name)
|
||||
kubectl --context=k8s-galaxy -n kof22-website-prod exec "$P" -c postgres -- \
|
||||
pg_dump -Fc -d authentik > authentik-$(date +%F).dump
|
||||
```
|
||||
Keep the dump on an encrypted disk. It contains every identity and password hash. Delete it after the rehearsal.
|
||||
2. **Get both blueprint versions**:
|
||||
```bash
|
||||
git -C cluster-gitops show origin/main:tenants/kof22/website-authentik/blueprint-configmap.yaml > before.yaml
|
||||
git -C cluster-gitops show origin/feat/kof22-identity-bootstrap:tenants/kof22/website-authentik/blueprint-configmap.yaml > after.yaml
|
||||
```
|
||||
3. **Run** `docs/cluster-gitops-phase0/rehearse-phase0.sh authentik-<date>.dump before.yaml after.yaml` from macOS, Linux or Git Bash. The script has already passed on a synthetic production-like dump: today's `main` blueprint with James re-pinned by the `present` entry, plus UI-made publishers.
|
||||
- The script restores the dump into a throwaway Postgres and starts Authentik 2026.8.3 on the current blueprints. It then records every user's groups, switches to the phase 0 blueprints, re-applies both twice, and checks:
|
||||
- nobody's memberships changed
|
||||
- `james.maes` keeps `authentik Admins` and `kof22-admins`
|
||||
- the service account, break-glass and seed groups exist
|
||||
- It prints `REHEARSAL PASSED` or the differences.
|
||||
- Containers and the work directory are removed on exit. The script mints a rehearsal-only API token **inside the throwaway copy**, which is deleted with the container.
|
||||
4. Reply "rehearsal passed" (or paste the output) on cluster-gitops#72.
|
||||
|
||||
## Item 7: production apply
|
||||
|
||||
Do these in order, in one sitting.
|
||||
|
||||
1. **Freeze.** Record the current memberships: *Authentik admin → Directory → Users*, or export each admin group's member list. Note who is in `authentik Admins`, `kof22-admins` and `kof-website-publishers`.
|
||||
2. **Merge** cluster-gitops#72 (convert it from draft first). ArgoCD syncs the ConfigMap, and the worker sees the file change and applies both blueprints within about a minute.
|
||||
3. **Verify** in *Authentik admin → Customization → Blueprints*:
|
||||
- [ ] `kof22-oauth2-apps` and `kof22-identity-bootstrap` both show **successful**
|
||||
- [ ] `james.maes` still has `authentik Admins` and `kof22-admins`
|
||||
- [ ] user `qqq-kof22-admin-idadmin` exists: type *Service account*, no groups, role `qqq-kof22-admin-idadmin`
|
||||
- [ ] user `kof22-breakglass` exists and is a superuser
|
||||
- [ ] groups `identity-admins`, `kof22-admins`, `kof-website-publishers`, `kof-website-drafters` exist; only `identity-admins` has attributes
|
||||
- [ ] you can still sign in to `https://admin.kof22.com` (the name fallback in the sign-in policy keeps today's access)
|
||||
- [ ] *Events → Notification rules*: `qqq-kof22-admin-sync` has **no destination** (inert)
|
||||
4. **Prove it** (optional, recommended): add a test user to `kof-website-publishers` in the UI. Then *Blueprints → kof22-oauth2-apps → Apply* and the same for `kof22-identity-bootstrap`. Wait a minute and confirm the member is still there. Remove the test user afterwards.
|
||||
5. **Rollback** if anything is wrong: revert the PR. This re-adds the old `present` entries, which re-pin James's groups. Logins keep working throughout because of the fallback.
|
||||
|
||||
## Item 8: service-account token and secrets
|
||||
|
||||
1. **Mint the API token**:
|
||||
- In Authentik admin, go to *Directory → Tokens and App passwords → Create*.
|
||||
- Identifier: `qqq-kof22-admin-idadmin-api`. User: `qqq-kof22-admin-idadmin`. Intent: **API**. Expiring: **on**, with an expiry **90 days** out.
|
||||
- Copy the key once.
|
||||
2. **Store it** in Infisical at `/kof22-website-authentik-api/token`. Paste it straight from the Authentik dialog into Infisical; it must never go in chat, git or a ticket.
|
||||
3. **Webhook secret** (used in phase 2; create it now so the phase 2 PR can be merged directly): `openssl rand -base64 48` into Infisical at `/kof22-website-authentik/qqq-webhook-secret`.
|
||||
4. **Check** that *Directory → Tokens* shows **exactly one** token for `qqq-kof22-admin-idadmin` (see D3).
|
||||
5. **Calendar** a rotation reminder for day 76. The health widget will also warn 14 days before expiry.
|
||||
|
||||
## Item 10: seed data (Authentik UI)
|
||||
|
||||
1. Add **yourself** (`james.maes`) to **`identity-admins`**.
|
||||
2. On group **`kof22-admins`** → *Attributes*, set the following. This is today's `ADMIN_PERMISSIONS` from Website-Backend `AuthentikAuthenticationCustomizer`, resolved to literal strings on 2026-10-11 against `origin/main` f936cbd. Re-check if that list changed before you seed, because the phase 1 mapper will grant exactly this:
|
||||
```yaml
|
||||
qqq.io/app-access: ["kof22-website-prod"]
|
||||
qqq.io/permissions:
|
||||
- esbView.hasAccess
|
||||
- esbOperate.hasAccess
|
||||
- esbDelete.hasAccess
|
||||
- analytics.hasAccess
|
||||
- quickSearchAdmin.hasAccess
|
||||
- quickSearchIndex.read
|
||||
- quickSearchIndex.write
|
||||
- quickSearchIndexRun.read
|
||||
- quickSearchIndexRun.write
|
||||
- quickSearchFailedEvent.read
|
||||
- quickSearchFailedEvent.write
|
||||
- dataHealth.hasAccess
|
||||
```
|
||||
Keep the existing attributes of the group. Add only these two keys.
|
||||
3. On group **`kof-website-publishers`** → *Attributes*: `qqq.io/app-access: ["kof22-website-prod"]`. This keeps the `kof-publisher-prod` service account's client-credentials tokens working once the name fallback is removed.
|
||||
4. **`kof-website-drafters`**: leave it without `qqq.io/app-access` until you decide that drafters should sign in to the admin. Granting it later is that one attribute.
|
||||
5. **Break-glass password**:
|
||||
- Set a long random password on `kof22-breakglass` (*Directory → Users → kof22-breakglass → Set password*) and store it in 1Password.
|
||||
- Sign in with it once in a private window to prove it works, then sign out.
|
||||
- Its recovery path is `ak create_recovery_key 10 kof22-breakglass` on the worker pod.
|
||||
|
||||
## After items 6-10
|
||||
|
||||
Tell the agent "phase 0 owner steps done". Next come item 11 (the qbit in read-only mode on staging, behind `AUTHENTIK_QBIT_ENABLED`), then the phase 1 mapper cut-over, then removal of the sign-in name fallback.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Status
|
||||
|
||||
The Authentik qbit's progress through DESIGN.md section 11, with links. Updated with each PR.
|
||||
|
||||
## Phase 0: foundations (agent items done; owner items open)
|
||||
|
||||
| # | Item | State | Where |
|
||||
|---|---|---|---|
|
||||
| 1 | Repo, DESIGN.md, CI, README, CLAUDE.md | done | this repo (`develop`); private for now, see owner checklist D1 |
|
||||
| 2 | Config, producer, `AuthentikActionUtil`, pinned schema + drift test | done | phase 0 PR |
|
||||
| 3 | Testcontainers harness, fixture recorder, contract tests (b), (c), (d), (h) | done | phase 0 PR, `src/test/java/.../contract` |
|
||||
| 4 | Open points (i)-(iv) resolved | done | DESIGN.md section 0.2; QRun-IO/qqq#1065 revision 3 |
|
||||
| 5 | cluster-gitops PR (bootstrap, removals, sign-in policy, webhook, lint) | draft | KofTwentyTwo/cluster-gitops#72 |
|
||||
| 6 | Staging rehearsal | **owner** | `docs/PHASE0-OWNER-CHECKLIST.md`, `docs/cluster-gitops-phase0/rehearse-phase0.sh` |
|
||||
| 7 | Production apply | **owner** | checklist |
|
||||
| 8 | Service-account token, Infisical, webhook secret | **owner** | checklist |
|
||||
| 9 | Website-Backend wiring (ExternalSecret + env, qbit behind a disabled flag) | draft | KofTwentyTwo/cluster-gitops#73, Website-Backend draft PR |
|
||||
| 10 | Seed attributes, break-glass password | **owner** | checklist |
|
||||
| 11 | Read-only on staging | after 6-10 | flip `AUTHENTIK_QBIT_ENABLED` |
|
||||
|
||||
## Phase 1: read-only MVP
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Users, groups, memberships, sessions, events tables | done (phase 1 PR) |
|
||||
| Effective permissions (resolver), inherited superuser | done |
|
||||
| Identity drift report (widget) | done |
|
||||
| Identity app under Admin, after Web Users (kof22) | Website-Backend draft PR #94 |
|
||||
| `AuthentikGroupPermissionMapper` (sign-in permissions from groups) | done in the qbit; the kof22 cut-over (replacing `AuthentikAuthenticationCustomizer`) waits for owner item 10 |
|
||||
| Code review (general + silent-failure) | done; all high and most medium findings fixed, with regression tests |
|
||||
| Mirror tables, group tree, access-matrix widgets | next PRs |
|
||||
|
||||
## Contract test results (Authentik 2026.8.3)
|
||||
|
||||
`CI=true ./mvnw verify`:
|
||||
|
||||
| Phase | Unit tests | Contract tests |
|
||||
|---|---|---|
|
||||
| 0 | 48 | 27 |
|
||||
| 1 | 68 | 31 |
|
||||
|
||||
All green, JaCoCo gate met. CircleCI is not enabled for this repo yet (owner checklist D1).
|
||||
@@ -1,21 +0,0 @@
|
||||
# cluster-gitops phase 0 support files
|
||||
|
||||
The cluster-gitops changes themselves are in **KofTwentyTwo/cluster-gitops#72** (draft). That PR adds:
|
||||
- the identity bootstrap blueprint
|
||||
- the removal of the `present` `james.maes` and `kof22-admins` entries
|
||||
- the attribute-driven sign-in policy
|
||||
- the inert webhook plumbing
|
||||
- the lint
|
||||
|
||||
The blueprint YAML in that PR is generated from this repo's tested fixtures, `src/test/resources/blueprints/*.yaml`. That way, what goes to cluster-gitops is what the contract tests applied to Authentik 2026.8.3.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `scripts/lint-authentik-blueprints.py` | The blueprint lint (also in cluster-gitops#72 under `scripts/`). It forbids `present` user/group entries, member lists, roles beyond `permissions`, tokens and literal secrets. `--self-test` runs the built-in cases. |
|
||||
| `rehearse-phase0.sh` | Owner checklist item 6: replays the migration on a **local, disposable** Authentik restored from a dump you take, and checks that no membership moves. |
|
||||
|
||||
To re-check the fixtures with the lint:
|
||||
|
||||
```bash
|
||||
python3 docs/cluster-gitops-phase0/scripts/lint-authentik-blueprints.py src/test/resources/blueprints
|
||||
```
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rehearse the phase 0 blueprint migration (DESIGN.md section 4.4, owner
|
||||
# checklist item 6) on a DISPOSABLE local Authentik restored from a dump of the
|
||||
# production Authentik database. Nothing here talks to production: you supply
|
||||
# the dump file; everything runs in local Docker and is removed at the end.
|
||||
#
|
||||
# Usage:
|
||||
# rehearse-phase0.sh <authentik-db.dump> <configmap-before.yaml> <configmap-after.yaml>
|
||||
#
|
||||
# authentik-db.dump pg_dump -Fc of the production Authentik database
|
||||
# configmap-before.yaml tenants/kof22/website-authentik/blueprint-configmap.yaml at main
|
||||
# configmap-after.yaml the same file on branch feat/kof22-identity-bootstrap
|
||||
#
|
||||
# Needs: docker, jq, python3 with PyYAML. Takes ~5 minutes.
|
||||
set -euo pipefail
|
||||
export MSYS_NO_PATHCONV=1 # Git Bash on Windows: keep container paths as-is
|
||||
|
||||
DUMP=${1:?dump file}; BEFORE=${2:?configmap before}; AFTER=${3:?configmap after}
|
||||
IMAGE=${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server:2026.8.3}
|
||||
NET=ak-rehearsal; PORT=${PORT:-19900}
|
||||
WORK=$(mktemp -d); BP="$WORK/blueprints"; mkdir -p "$BP"
|
||||
BP_MOUNT="$BP"; command -v cygpath >/dev/null 2>&1 && BP_MOUNT=$(cygpath -w "$BP")
|
||||
ADMIN_TOKEN="rehearsal-$(openssl rand -hex 16)"
|
||||
API="http://localhost:$PORT/api/v3"
|
||||
|
||||
cleanup() { docker rm -f akr-server akr-worker akr-pg >/dev/null 2>&1 || true; docker network rm $NET >/dev/null 2>&1 || true; rm -rf "$WORK"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
extract() { # write every blueprint key of a ConfigMap into $BP
|
||||
rm -f "$BP"/*.yaml
|
||||
python3 - "$1" "$BP_MOUNT" <<'PY'
|
||||
import sys, yaml
|
||||
doc = yaml.safe_load(open(sys.argv[1], encoding="utf-8"))
|
||||
for key, value in doc["data"].items():
|
||||
open(f"{sys.argv[2]}/{key}", "w", encoding="utf-8").write(value)
|
||||
print(" blueprint file:", key)
|
||||
PY
|
||||
}
|
||||
|
||||
api() { curl -sf -H "Authorization: Bearer $ADMIN_TOKEN" "$API$1"; }
|
||||
snapshot() { # users -> sorted groups, as TSV
|
||||
api "/core/users/?page_size=100&include_groups=true&type=internal&type=external&type=service_account" \
|
||||
| jq -r '.results[] | [.username, ([.groups_obj[].name] | sort | join(","))] | @tsv' | tr -d '\r' | sort
|
||||
}
|
||||
wait_blueprints() { # $1 = how many kof22* blueprint instances must exist
|
||||
local want=${1:-1}
|
||||
for _ in $(seq 1 90); do
|
||||
states=$(api "/managed/blueprints/?page_size=100" | jq -r '[.results[] | select(.name|startswith("kof22")) | .status] | join(" ")' || true)
|
||||
count=$(wc -w <<<"$states")
|
||||
if [ "$count" -ge "$want" ] && ! grep -qv successful <<<"$(tr ' ' '\n' <<<"$states")"; then echo " kof22 blueprints: $states"; return 0; fi
|
||||
sleep 5
|
||||
done
|
||||
echo " blueprints did not all reach successful: $states"; api "/managed/blueprints/?page_size=100" | jq -r '.results[] | "\(.name) \(.status)"'; return 1
|
||||
}
|
||||
|
||||
echo "== 1. restore the dump into a disposable Postgres"
|
||||
docker network create $NET >/dev/null
|
||||
docker run -d --name akr-pg --network $NET -e POSTGRES_USER=authentik -e POSTGRES_PASSWORD=rehearsal -e POSTGRES_DB=authentik postgres:16-alpine >/dev/null
|
||||
until docker exec akr-pg pg_isready -U authentik >/dev/null 2>&1; do sleep 1; done
|
||||
docker cp "$DUMP" akr-pg:/tmp/authentik.dump
|
||||
docker exec akr-pg pg_restore -U authentik -d authentik --no-owner --no-privileges /tmp/authentik.dump || echo " (pg_restore reported warnings; usually role/ownership notices)"
|
||||
docker exec akr-pg psql -U authentik -d authentik -tAc "select count(*) from authentik_core_user" | sed 's/^/ users restored: /'
|
||||
|
||||
echo "== 2. start Authentik with the CURRENT (main) blueprints"
|
||||
extract "$BEFORE"
|
||||
ENV=(-e AUTHENTIK_SECRET_KEY="$(openssl rand -hex 32)" -e AUTHENTIK_POSTGRESQL__HOST=akr-pg -e AUTHENTIK_POSTGRESQL__USER=authentik
|
||||
-e AUTHENTIK_POSTGRESQL__PASSWORD=rehearsal -e AUTHENTIK_POSTGRESQL__NAME=authentik -e AUTHENTIK_BOOTSTRAP_TOKEN="$ADMIN_TOKEN"
|
||||
-e KOF22_OAUTH2_CLIENT_SECRET=rehearsal-not-the-real-secret -e AUTHENTIK_ERROR_REPORTING__ENABLED=false
|
||||
-e AUTHENTIK_DISABLE_UPDATE_CHECK=true -e AUTHENTIK_EMAIL__HOST=localhost -e AUTHENTIK_EMAIL__PORT=1)
|
||||
docker run -d --name akr-worker --network $NET "${ENV[@]}" -v "$BP_MOUNT:/blueprints/custom" "$IMAGE" worker >/dev/null
|
||||
docker run -d --name akr-server --network $NET -p "$PORT:9000" "${ENV[@]}" -v "$BP_MOUNT:/blueprints/custom" "$IMAGE" server >/dev/null
|
||||
until curl -sf "http://localhost:$PORT/-/health/ready/" >/dev/null; do sleep 3; done
|
||||
# The bootstrap token only applies to an EMPTY instance; on a restored database
|
||||
# create a rehearsal token for akadmin-equivalent access through the shell.
|
||||
docker exec akr-worker ak shell -c "
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
u = next(x for x in User.objects.filter(is_active=True).exclude(username__startswith='ak-').order_by('pk') if x.is_superuser) # is_superuser is derived from groups
|
||||
Token.objects.update_or_create(identifier='phase0-rehearsal', defaults={'user': u, 'intent': TokenIntents.INTENT_API, 'key': '$ADMIN_TOKEN', 'expiring': False})
|
||||
print('rehearsal token for', u.username)" 2>&1 | grep -E 'rehearsal token|Error|error' | tail -3 | grep 'rehearsal token' || { echo "could not create the rehearsal API token in the restored copy"; exit 1; }
|
||||
wait_blueprints 1
|
||||
snapshot > "$WORK/before.tsv"; echo " $(wc -l < "$WORK/before.tsv") users recorded (step 1, freeze)"
|
||||
|
||||
echo "== 3. switch to the phase 0 blueprints (as an ArgoCD sync would)"
|
||||
extract "$AFTER"
|
||||
docker restart akr-worker >/dev/null # a restart triggers discovery like a ConfigMap change
|
||||
wait_blueprints 2
|
||||
for name in kof22-oauth2-apps kof22-identity-bootstrap; do
|
||||
pk=$(api "/managed/blueprints/?name=$name" | jq -r '.results[0].pk')
|
||||
curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$API/managed/blueprints/$pk/apply/" >/dev/null
|
||||
done
|
||||
sleep 30; wait_blueprints 2
|
||||
|
||||
echo "== 4. checks"
|
||||
snapshot > "$WORK/after.tsv"
|
||||
fail=0
|
||||
if ! diff <(awk -F'\t' '$1 != "qqq-kof22-admin-idadmin" && $1 != "kof22-breakglass"' "$WORK/after.tsv") "$WORK/before.tsv" >/dev/null; then
|
||||
echo " FAIL memberships changed:"; diff "$WORK/before.tsv" "$WORK/after.tsv" || true; fail=1
|
||||
else echo " ok existing users' memberships unchanged"; fi
|
||||
grep -q $'^james.maes\t.*authentik Admins.*kof22-admins' "$WORK/after.tsv" && echo " ok james.maes keeps authentik Admins + kof22-admins" || { echo " FAIL james.maes groups"; fail=1; }
|
||||
api "/core/users/?username=qqq-kof22-admin-idadmin&include_roles=true" | jq -e '.results[0] | .type=="service_account" and (.groups|length)==0 and ([.roles_obj[].name]==["qqq-kof22-admin-idadmin"])' >/dev/null \
|
||||
&& echo " ok service account: type, no groups, role" || { echo " FAIL service account"; fail=1; }
|
||||
api "/core/users/?username=kof22-breakglass" | jq -e '.results[0].is_superuser' >/dev/null && echo " ok break-glass superuser" || { echo " FAIL break-glass"; fail=1; }
|
||||
for g in identity-admins kof22-admins kof-website-publishers kof-website-drafters; do
|
||||
api "/core/groups/?name=$g" | jq -e '.pagination.count==1' >/dev/null && echo " ok group $g" || { echo " FAIL group $g"; fail=1; }
|
||||
done
|
||||
echo "== 5. re-apply both again (an hourly run): memberships must not move"
|
||||
for name in kof22-oauth2-apps kof22-identity-bootstrap; do
|
||||
pk=$(api "/managed/blueprints/?name=$name" | jq -r '.results[0].pk')
|
||||
curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$API/managed/blueprints/$pk/apply/" >/dev/null
|
||||
done
|
||||
sleep 30; wait_blueprints 2
|
||||
snapshot | diff - "$WORK/after.tsv" >/dev/null && echo " ok stable across re-apply" || { echo " FAIL changed on re-apply"; fail=1; }
|
||||
[ $fail = 0 ] && echo "REHEARSAL PASSED" || { echo "REHEARSAL FAILED"; exit 1; }
|
||||
@@ -1,225 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint Authentik blueprints so a re-apply can never revert identity data.
|
||||
|
||||
Authentik's blueprint importer re-saves `state: present` entries on every apply
|
||||
(hourly, and on every file change), and many-to-many attrs (users, groups,
|
||||
parents, roles) are REPLACED, not merged. A present user or group entry, or a
|
||||
member list, therefore silently undoes edits made in the Authentik UI or in QQQ
|
||||
(qbit-authentik DESIGN.md sections 2.2, 4.2 and 4.5; contract test (d)).
|
||||
|
||||
Rules (each violation is printed as file:entry: message):
|
||||
R1 authentik_core.user / authentik_core.group entries must be
|
||||
`state: created`, `must_created` or `absent` (no state means present).
|
||||
R2 no member lists: a group entry must not carry `users`.
|
||||
R3 an `authentik_rbac.role` entry may be `present` only if its attrs are
|
||||
limited to `permissions` (and `name`): the qbit role pattern, whose
|
||||
global permissions are re-asserted additively.
|
||||
R4 no literal secrets: password, password_hash, key, token, client_secret
|
||||
must be a YAML tag (e.g. !Env) or absent; authentik_core.token entries are
|
||||
not allowed at all.
|
||||
|
||||
Input: Kubernetes ConfigMap manifests (every data value that looks like a
|
||||
blueprint is checked) and plain blueprint files. Usage:
|
||||
|
||||
lint-authentik-blueprints.py FILE_OR_DIR... # exit 1 on violations
|
||||
lint-authentik-blueprints.py --self-test # run the built-in cases
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
import yaml
|
||||
|
||||
IDENTITY_MODELS = {"authentik_core.user", "authentik_core.group"}
|
||||
SAFE_IDENTITY_STATES = {"created", "must_created", "absent"}
|
||||
SECRET_KEYS = {"password", "password_hash", "key", "token", "client_secret"}
|
||||
ROLE_PRESENT_ALLOWED_ATTRS = {"permissions", "name"}
|
||||
|
||||
|
||||
class Tag:
|
||||
"""Placeholder for an Authentik YAML tag such as !KeyOf, !Find or !Env."""
|
||||
|
||||
def __init__(self, tag: str, value):
|
||||
self.tag = tag
|
||||
self.value = value
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.tag} {self.value!r}"
|
||||
|
||||
|
||||
class BlueprintLoader(yaml.SafeLoader):
|
||||
"""SafeLoader that accepts Authentik's custom tags."""
|
||||
|
||||
|
||||
def _construct_tag(loader: yaml.SafeLoader, tag_suffix: str, node: yaml.Node):
|
||||
if isinstance(node, yaml.ScalarNode):
|
||||
value = loader.construct_scalar(node)
|
||||
elif isinstance(node, yaml.SequenceNode):
|
||||
value = loader.construct_sequence(node, deep=True)
|
||||
else:
|
||||
value = loader.construct_mapping(node, deep=True)
|
||||
return Tag("!" + tag_suffix, value)
|
||||
|
||||
|
||||
BlueprintLoader.add_multi_constructor("!", _construct_tag)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Violation:
|
||||
source: str
|
||||
entry: str
|
||||
message: str
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.source}: {self.entry}: {self.message}"
|
||||
|
||||
|
||||
def entry_label(index: int, entry: dict) -> str:
|
||||
identifiers = entry.get("identifiers") or {}
|
||||
ident = ", ".join(f"{k}={v}" for k, v in identifiers.items()) if isinstance(identifiers, dict) else str(identifiers)
|
||||
return f"entry {index} ({entry.get('model')} {ident})"
|
||||
|
||||
|
||||
def lint_blueprint(source: str, document) -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
if not isinstance(document, dict) or not isinstance(document.get("entries"), list):
|
||||
return violations
|
||||
|
||||
for index, entry in enumerate(document["entries"]):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
model = entry.get("model")
|
||||
if isinstance(model, Tag):
|
||||
violations.append(Violation(source, entry_label(index, entry), "model must be a literal so it can be linted"))
|
||||
continue
|
||||
state = entry.get("state", "present")
|
||||
if isinstance(state, Tag):
|
||||
violations.append(Violation(source, entry_label(index, entry), "state must be a literal so it can be linted"))
|
||||
continue
|
||||
attrs = entry.get("attrs") or {}
|
||||
label = entry_label(index, entry)
|
||||
|
||||
if model in IDENTITY_MODELS and state not in SAFE_IDENTITY_STATES:
|
||||
violations.append(Violation(source, label, f"R1 {model} must be `state: created` (got `{state}`); present entries revert UI/QQQ edits on every apply"))
|
||||
|
||||
if model == "authentik_core.group" and state != "absent" and "users" in attrs:
|
||||
violations.append(Violation(source, label, "R2 member list (`users:`) on a group; memberships are data, manage them in the Authentik UI or QQQ"))
|
||||
|
||||
if model == "authentik_rbac.role" and state not in SAFE_IDENTITY_STATES:
|
||||
extra = set(attrs) - ROLE_PRESENT_ALLOWED_ATTRS
|
||||
if extra:
|
||||
violations.append(Violation(source, label, f"R3 present role may only declare `permissions` (found {sorted(extra)})"))
|
||||
|
||||
if model == "authentik_core.token":
|
||||
violations.append(Violation(source, label, "R4 tokens must never be declared in blueprints"))
|
||||
|
||||
for key in SECRET_KEYS:
|
||||
if key in attrs and attrs[key] is not None and not isinstance(attrs[key], Tag):
|
||||
violations.append(Violation(source, label, f"R4 literal `{key}`; use !Env or set it outside git"))
|
||||
return violations
|
||||
|
||||
|
||||
def blueprints_in_manifest(source: str, text: str):
|
||||
"""Yield (source, parsed blueprint) for a file: a ConfigMap or a blueprint."""
|
||||
for document in yaml.load_all(text, Loader=BlueprintLoader):
|
||||
if not isinstance(document, dict):
|
||||
continue
|
||||
if document.get("kind") == "ConfigMap" and isinstance(document.get("data"), dict):
|
||||
for key, value in document["data"].items():
|
||||
if isinstance(value, str) and "entries:" in value and "version:" in value:
|
||||
yield f"{source}#{key}", yaml.load(value, Loader=BlueprintLoader)
|
||||
elif "entries" in document:
|
||||
yield source, document
|
||||
|
||||
|
||||
def lint_paths(paths: list[str]) -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
for path in paths:
|
||||
files = []
|
||||
if os.path.isdir(path):
|
||||
for root, _, names in os.walk(path):
|
||||
files.extend(os.path.join(root, n) for n in names if n.endswith((".yaml", ".yml")))
|
||||
else:
|
||||
files.append(path)
|
||||
for file in sorted(files):
|
||||
with open(file, encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
if "entries:" not in text:
|
||||
continue
|
||||
try:
|
||||
for source, blueprint in blueprints_in_manifest(file, text):
|
||||
violations.extend(lint_blueprint(source, blueprint))
|
||||
except yaml.YAMLError as error:
|
||||
violations.append(Violation(file, "-", f"YAML error: {error}"))
|
||||
return violations
|
||||
|
||||
|
||||
SELF_TEST_GOOD = """
|
||||
version: 1
|
||||
entries:
|
||||
- model: authentik_rbac.role
|
||||
state: present
|
||||
identifiers: {name: qqq-x}
|
||||
attrs: {permissions: [authentik_core.view_user]}
|
||||
- model: authentik_core.user
|
||||
state: created
|
||||
identifiers: {username: sa}
|
||||
attrs: {type: service_account, roles: [!KeyOf r], groups: [!Find [authentik_core.group, [name, x]]]}
|
||||
- model: authentik_core.group
|
||||
state: created
|
||||
identifiers: {name: g}
|
||||
attrs: {attributes: {qqq.io/permissions: [a]}}
|
||||
- model: authentik_providers_oauth2.oauth2provider
|
||||
state: present
|
||||
identifiers: {name: p}
|
||||
attrs: {client_secret: !Env SECRET}
|
||||
"""
|
||||
|
||||
SELF_TEST_BAD = {
|
||||
"present group": ("model: authentik_core.group\n identifiers: {name: g}", "R1"),
|
||||
"present user": ("model: authentik_core.user\n state: present\n identifiers: {username: u}\n attrs: {groups: [!KeyOf g]}", "R1"),
|
||||
"member list": ("model: authentik_core.group\n state: created\n identifiers: {name: g}\n attrs: {users: [1]}", "R2"),
|
||||
"role with users": ("model: authentik_rbac.role\n identifiers: {name: r}\n attrs: {permissions: [a], users: [1]}", "R3"),
|
||||
"token": ("model: authentik_core.token\n state: created\n identifiers: {identifier: t}", "R4"),
|
||||
"literal secret": ("model: authentik_providers_oauth2.oauth2provider\n identifiers: {name: p}\n attrs: {client_secret: hunter2}", "R4"),
|
||||
"literal password": ("model: authentik_core.user\n state: created\n identifiers: {username: u}\n attrs: {password: hunter2}", "R4"),
|
||||
}
|
||||
|
||||
|
||||
def self_test() -> int:
|
||||
failures = 0
|
||||
good = lint_blueprint("good", yaml.load(SELF_TEST_GOOD, Loader=BlueprintLoader))
|
||||
if good:
|
||||
failures += 1
|
||||
print("FAIL good blueprint flagged:", *good, sep="\n ")
|
||||
for name, (entry, rule) in SELF_TEST_BAD.items():
|
||||
found = lint_blueprint(name, yaml.load(f"version: 1\nentries:\n - {entry}\n", Loader=BlueprintLoader))
|
||||
if not any(v.message.startswith(rule) for v in found):
|
||||
failures += 1
|
||||
print(f"FAIL {name}: expected {rule}, got {[str(v) for v in found]}")
|
||||
configmap = "apiVersion: v1\nkind: ConfigMap\nmetadata: {name: x}\ndata:\n bad.yaml: |\n version: 1\n entries:\n - model: authentik_core.group\n identifiers: {name: g}\n"
|
||||
if not any("#bad.yaml" in v.source for v in [v for s, b in blueprints_in_manifest("cm.yaml", configmap) for v in lint_blueprint(s, b)]):
|
||||
failures += 1
|
||||
print("FAIL ConfigMap data values are not linted")
|
||||
print("self-test:", "OK" if failures == 0 else f"{failures} failure(s)")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if argv == ["--self-test"]:
|
||||
return self_test()
|
||||
if not argv:
|
||||
print(__doc__)
|
||||
return 2
|
||||
violations = lint_paths(argv)
|
||||
for violation in violations:
|
||||
print(violation)
|
||||
print(f"{len(violations)} violation(s)")
|
||||
return 1 if violations else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -1,295 +0,0 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
set -euf
|
||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||
|
||||
# OS specific support.
|
||||
native_path() { printf %s\\n "$1"; }
|
||||
case "$(uname)" in
|
||||
CYGWIN* | MINGW*)
|
||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||
native_path() { cygpath --path --windows "$1"; }
|
||||
;;
|
||||
esac
|
||||
|
||||
# set JAVACMD and JAVACCMD
|
||||
set_java_home() {
|
||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||
if [ -n "${JAVA_HOME-}" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||
|
||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
JAVACMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v java
|
||||
)" || :
|
||||
JAVACCMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v javac
|
||||
)" || :
|
||||
|
||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hash string like Java String::hashCode
|
||||
hash_string() {
|
||||
str="${1:-}" h=0
|
||||
while [ -n "$str" ]; do
|
||||
char="${str%"${str#?}"}"
|
||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||
str="${str#?}"
|
||||
done
|
||||
printf %x\\n $h
|
||||
}
|
||||
|
||||
verbose() { :; }
|
||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||
|
||||
die() {
|
||||
printf %s\\n "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
trim() {
|
||||
# MWRAPPER-139:
|
||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||
# Needed for removing poorly interpreted newline sequences when running in more
|
||||
# exotic environments such as mingw bash on Windows.
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||
*)
|
||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||
distributionPlatform=linux-amd64
|
||||
;;
|
||||
esac
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||
|
||||
exec_maven() {
|
||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||
}
|
||||
|
||||
if [ -d "$MAVEN_HOME" ]; then
|
||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
exec_maven "$@"
|
||||
fi
|
||||
|
||||
case "${distributionUrl-}" in
|
||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||
esac
|
||||
|
||||
# prepare tmp dir
|
||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||
trap clean HUP INT TERM EXIT
|
||||
else
|
||||
die "cannot create temp dir"
|
||||
fi
|
||||
|
||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||
|
||||
# Download and Install Apache Maven
|
||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
verbose "Downloading from: $distributionUrl"
|
||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
# select .zip or .tar.gz
|
||||
if ! command -v unzip >/dev/null; then
|
||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
fi
|
||||
|
||||
# verbose opt
|
||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||
|
||||
# normalize http auth
|
||||
case "${MVNW_PASSWORD:+has-password}" in
|
||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
esac
|
||||
|
||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||
verbose "Found wget ... using wget"
|
||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||
verbose "Found curl ... using curl"
|
||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||
elif set_java_home; then
|
||||
verbose "Falling back to use Java to download"
|
||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
cat >"$javaSource" <<-END
|
||||
public class Downloader extends java.net.Authenticator
|
||||
{
|
||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||
{
|
||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||
}
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
setDefault( new Downloader() );
|
||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||
}
|
||||
}
|
||||
END
|
||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||
verbose " - Compiling Downloader.java ..."
|
||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||
verbose " - Running Downloader.java ..."
|
||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||
fi
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
if [ -n "${distributionSha256Sum-}" ]; then
|
||||
distributionSha256Result=false
|
||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $distributionSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# unzip and move
|
||||
if command -v unzip >/dev/null; then
|
||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
@@ -1,189 +0,0 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
@@ -1,104 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Kingsrook, LLC
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<testcontainers.version>1.21.4</testcontainers.version>
|
||||
|
||||
<!-- Contract tests run against this exact Authentik release (DESIGN.md section 10). -->
|
||||
<!-- Override with -Dauthentik.test.image=... to rehearse an upgrade. -->
|
||||
<authentik.test.image>ghcr.io/goauthentik/server:2026.8.3</authentik.test.image>
|
||||
<postgres.test.image>postgres:16-alpine</postgres.test.image>
|
||||
<mailpit.test.image>axllent/mailpit:v1.27</mailpit.test.image>
|
||||
<!-- -Dauthentik.fixtures.record=true refreshes src/test/resources/fixtures from the contract tests -->
|
||||
<authentik.fixtures.record>false</authentik.fixtures.record>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-authentik</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Authentik</name>
|
||||
<description>QBit for managing Authentik users, groups, memberships and QQQ permissions from a QQQ app, with Authentik as the source of truth</description>
|
||||
<url>https://github.com/QRun-IO/qbit-authentik</url>
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-example-extension</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<id>kingsrook</id>
|
||||
<name>Kingsrook Team</name>
|
||||
<email>contact@kingsrook.com</email>
|
||||
<organization>Kingsrook, LLC</organization>
|
||||
<organizationUrl>https://kingsrook.com</organizationUrl>
|
||||
</developer>
|
||||
</developers>
|
||||
<name>QBit Example Extension</name>
|
||||
<description>Example Extension QBit - rename this for your QBit</description>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:git://github.com/QRun-IO/qbit-authentik.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-authentik.git</developerConnection>
|
||||
<url>https://github.com/QRun-IO/qbit-authentik</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<qqq.version>4.0.0</qqq.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-api</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.2.220</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -107,169 +39,20 @@
|
||||
<version>3.27.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.8.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>qbit-authentik.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>qbit-authentik.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>**/*IntegrationTest.java</exclude>
|
||||
<exclude>**/*IT.java</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<authentik.test.image>${authentik.test.image}</authentik.test.image>
|
||||
<postgres.test.image>${postgres.test.image}</postgres.test.image>
|
||||
<mailpit.test.image>${mailpit.test.image}</mailpit.test.image>
|
||||
<authentik.fixtures.record>${authentik.fixtures.record}</authentik.fixtures.record>
|
||||
</systemPropertyVariables>
|
||||
<includes>
|
||||
<include>**/*IntegrationTest.java</include>
|
||||
<include>**/*IT.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!-- Needs qqq 4.1 (QBit producer API). Until qbit-build-parent imports the -->
|
||||
<!-- released 4.1 BOM, this profile is active by default and imports -->
|
||||
<!-- qqq-bom-pom 4.1.0-RC.2 ahead of the parent 4.0.0 BOM, as in -->
|
||||
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.snapshot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>sonatype-snapshots</id>
|
||||
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
</project>
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Features a host can turn on (DESIGN.md section 3.2). A feature that is off
|
||||
** produces no metadata, and the guard refuses its operations.
|
||||
*******************************************************************************/
|
||||
public enum AuthentikFeature
|
||||
{
|
||||
/** Read-only tables: users, groups, memberships, roles, applications, bindings, blueprints, sessions, events. */
|
||||
READ,
|
||||
|
||||
/** Create, invite, edit, deactivate users (phase 3). */
|
||||
USERS,
|
||||
|
||||
/** Create and edit groups, permissions and app-access attributes (phase 2/3). */
|
||||
GROUPS,
|
||||
|
||||
/** Add and remove members (phase 2). */
|
||||
MEMBERSHIP,
|
||||
|
||||
/** Recovery email, MFA reset, app-password revoke (phase 3). */
|
||||
CREDENTIALS,
|
||||
|
||||
/** Revoke sessions (phase 3). */
|
||||
SESSIONS,
|
||||
|
||||
/** Authentik RBAC role management (phase 4). */
|
||||
RBAC,
|
||||
|
||||
/** Access matrix widget. */
|
||||
ACCESS_MATRIX,
|
||||
|
||||
/** Local mirror tables (phase 1+). */
|
||||
MIRROR,
|
||||
|
||||
/** Inbound Authentik webhook (phase 2). */
|
||||
WEBHOOK,
|
||||
|
||||
/** Nightly read-only snapshot export (phase 3). */
|
||||
SNAPSHOT_EXPORT
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,223 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entry point of the Authentik QBit (QQQ 4.1 QBitMetaDataProducer).
|
||||
**
|
||||
** produce() validates the config, registers the AuthentikRuntime for the API
|
||||
** backend, then lets QQQ run the component producers in the metadata package
|
||||
** (API backend, read-only tables, the Identity app).
|
||||
**
|
||||
** Host usage:
|
||||
** new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
*******************************************************************************/
|
||||
public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBitConfig>
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikQBitProducer.class);
|
||||
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "authentik";
|
||||
|
||||
private static final String VERSION = loadVersion();
|
||||
|
||||
private AuthentikQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitProducer withQBitConfig(AuthentikQBitConfig config)
|
||||
{
|
||||
this.qBitConfig = config;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for the config.
|
||||
*******************************************************************************/
|
||||
public void setQBitConfig(AuthentikQBitConfig config)
|
||||
{
|
||||
this.qBitConfig = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AuthentikQBitConfig getQBitConfig()
|
||||
{
|
||||
return (this.qBitConfig);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Namespace for this QBit instance; also the default tableNamePrefix.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitProducer withNamespace(String namespace)
|
||||
{
|
||||
this.namespace = namespace;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getNamespace()
|
||||
{
|
||||
return (this.namespace);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QBitMetaData getQBitMetaData()
|
||||
{
|
||||
return (new QBitMetaData()
|
||||
.withGroupId(GROUP_ID)
|
||||
.withArtifactId(ARTIFACT_ID)
|
||||
.withVersion(VERSION)
|
||||
.withNamespace(getNamespace())
|
||||
.withConfig(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPackageNameForFindingMetaDataProducers()
|
||||
{
|
||||
return (AuthentikAppMetaDataProducer.class.getPackageName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A producer found by package scanning has no config; skip it.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate, register the runtime, produce and add everything to the
|
||||
** instance. Returns an empty output so a host that also calls
|
||||
** addSelfToInstance on the result adds nothing twice.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
if(qBitConfig == null)
|
||||
{
|
||||
throw (new QException("AuthentikQBit configuration validation failed: no config was supplied (call withQBitConfig)"));
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(namespace) && !StringUtils.hasContent(qBitConfig.getTableNamePrefix()))
|
||||
{
|
||||
qBitConfig.setTableNamePrefix(namespace);
|
||||
}
|
||||
|
||||
List<String> errors = new ArrayList<>();
|
||||
qBitConfig.validate(qInstance, errors);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new QException("AuthentikQBit configuration validation failed: " + String.join("; ", errors)));
|
||||
}
|
||||
|
||||
AuthentikRuntime.register(new AuthentikRuntime(qBitConfig));
|
||||
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
|
||||
output.addSelfToInstance(qInstance);
|
||||
|
||||
LOG.info("Authentik QBit produced", logPair("version", VERSION), logPair("appKey", qBitConfig.getAppKey()),
|
||||
logPair("features", qBitConfig.getFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The artifact version, from the Maven-filtered qbit-authentik.properties.
|
||||
*******************************************************************************/
|
||||
static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = AuthentikQBitProducer.class.getClassLoader().getResourceAsStream("qbit-authentik.properties"))
|
||||
{
|
||||
if(inputStream != null)
|
||||
{
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(StringUtils.hasContent(version) && !version.contains("${"))
|
||||
{
|
||||
return (version);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.debug("Could not read qbit-authentik.properties", e);
|
||||
}
|
||||
return ("unknown");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the artifact version.
|
||||
*******************************************************************************/
|
||||
public static String getVersion()
|
||||
{
|
||||
return (VERSION);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
|
||||
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Live state of one qbit instance: config, throttle, guard, filter mapper,
|
||||
** response cache, and the table-name to spec map. Registered by API backend
|
||||
** name, because AuthentikActionUtil is instantiated by QQQ (QCodeLoader) with
|
||||
** no constructor arguments and finds its runtime from the backend it serves.
|
||||
*******************************************************************************/
|
||||
public class AuthentikRuntime
|
||||
{
|
||||
private static final Map<String, AuthentikRuntime> BY_BACKEND = new ConcurrentHashMap<>();
|
||||
|
||||
private final AuthentikQBitConfig config;
|
||||
private final TokenBucketThrottle throttle;
|
||||
private final AuthentikGuard guard;
|
||||
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
|
||||
private final Memoization<String, String> responseCache;
|
||||
private final Memoization<String, GroupGraph> groupGraphCache;
|
||||
private volatile java.util.Set<String> permissionCatalog;
|
||||
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public AuthentikRuntime(AuthentikQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
this.throttle = new TokenBucketThrottle(config.getThrottleRequestsPerSecond(), config.getThrottleBurst());
|
||||
this.guard = new AuthentikGuard(config);
|
||||
this.responseCache = new Memoization<String, String>(Duration.ofSeconds(config.getListCacheTtlSeconds())).withMaxSize(500);
|
||||
this.groupGraphCache = new Memoization<String, GroupGraph>(Duration.ofSeconds(config.getGroupCacheTtlSeconds())).withMaxSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register a runtime for its config's API backend (replacing any previous).
|
||||
*******************************************************************************/
|
||||
public static AuthentikRuntime register(AuthentikRuntime runtime)
|
||||
{
|
||||
BY_BACKEND.put(runtime.config.getApiBackendName(), runtime);
|
||||
return (runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find the runtime for an API backend name.
|
||||
*******************************************************************************/
|
||||
public static AuthentikRuntime forBackend(String backendName)
|
||||
{
|
||||
AuthentikRuntime runtime = BY_BACKEND.get(backendName);
|
||||
if(runtime == null)
|
||||
{
|
||||
throw (new IllegalStateException("No Authentik qbit runtime is registered for backend " + backendName + " (was the qbit produced?)"));
|
||||
}
|
||||
return (runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Associate a produced (prefixed) table name with its spec.
|
||||
*******************************************************************************/
|
||||
public void registerTable(String tableName, AuthentikTableSpec spec)
|
||||
{
|
||||
specsByTableName.put(tableName, spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The spec for a produced table name, or null.
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec getSpec(String tableName)
|
||||
{
|
||||
return (specsByTableName.get(tableName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether responses should be cached at all.
|
||||
*******************************************************************************/
|
||||
public boolean isCacheEnabled()
|
||||
{
|
||||
return (config.getListCacheTtlSeconds() != null && config.getListCacheTtlSeconds() > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop every cached response (after writes, or when a webhook arrives).
|
||||
*******************************************************************************/
|
||||
public void invalidateCache()
|
||||
{
|
||||
responseCache.clear();
|
||||
groupGraphCache.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cache for the group graph (groupCacheTtlSeconds; 0 disables).
|
||||
*******************************************************************************/
|
||||
public Memoization<String, GroupGraph> getGroupGraphCache()
|
||||
{
|
||||
return (groupGraphCache);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ permission names that qqq.io/permissions may grant: every permission
|
||||
** the instance's metadata defines (PermissionsHelper), computed once.
|
||||
*******************************************************************************/
|
||||
public java.util.Set<String> getPermissionCatalog()
|
||||
{
|
||||
if(permissionCatalog == null)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
|
||||
permissionCatalog = qInstance == null ? java.util.Set.of()
|
||||
: new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
|
||||
}
|
||||
return (permissionCatalog);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for config
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig getConfig()
|
||||
{
|
||||
return (config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for throttle
|
||||
*******************************************************************************/
|
||||
public TokenBucketThrottle getThrottle()
|
||||
{
|
||||
return (throttle);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for guard
|
||||
*******************************************************************************/
|
||||
public AuthentikGuard getGuard()
|
||||
{
|
||||
return (guard);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filterMapper
|
||||
*******************************************************************************/
|
||||
public AuthentikFilterMapper getFilterMapper()
|
||||
{
|
||||
return (filterMapper);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for responseCache (session-independent: the API always runs as
|
||||
** the service account; permission filtering happens after the cache).
|
||||
*******************************************************************************/
|
||||
public Memoization<String, String> getResponseCache()
|
||||
{
|
||||
return (responseCache);
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,113 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.apache.http.NameValuePair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every Authentik operation the qbit uses, with the query parameters it
|
||||
** sends. The schema drift tests check this surface against the pinned
|
||||
** schema (and, in CI, against new Authentik releases). Table operations are
|
||||
** derived from AuthentikTables; others are listed here.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikApiSurface
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** One operation: method, schema path template, and parameters used.
|
||||
*******************************************************************************/
|
||||
public record Operation(String method, String path, Set<String> params)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Not instantiable.
|
||||
*******************************************************************************/
|
||||
private AuthentikApiSurface()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Operations outside the table specs.
|
||||
*******************************************************************************/
|
||||
static List<Operation> extraOperations()
|
||||
{
|
||||
List<Operation> rs = new ArrayList<>();
|
||||
rs.add(new Operation("get", "/admin/version/", Set.of()));
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// virtual membership table, group graph, drift report //
|
||||
///////////////////////////////////////////////////////////
|
||||
rs.add(new Operation("get", "/core/groups/", Set.of("include_users", "include_parents", "include_children", "name", "ordering", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/core/groups/{key}/", Set.of("include_users")));
|
||||
rs.add(new Operation("get", "/core/users/", Set.of("username", "include_groups", "attributes", "type", "path_startswith", "groups_by_name", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/core/users/{key}/", Set.of()));
|
||||
rs.add(new Operation("get", "/managed/blueprints/", Set.of("page", "page_size")));
|
||||
rs.add(new Operation("get", "/core/tokens/", Set.of("user__username", "intent", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/events/events/", Set.of("username", "ordering", "page", "page_size")));
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All operations. Detail paths use "{key}" for the id segment.
|
||||
*******************************************************************************/
|
||||
public static List<Operation> operations()
|
||||
{
|
||||
List<Operation> rs = new ArrayList<>();
|
||||
for(AuthentikTableSpec spec : AuthentikTables.all().values())
|
||||
{
|
||||
if(spec.isVirtual())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Set<String> listParams = new LinkedHashSet<>(List.of("page", "page_size", "ordering"));
|
||||
for(AuthentikFieldSpec field : spec.getFields())
|
||||
{
|
||||
listParams.addAll(field.getFilterParams().values());
|
||||
if(field.isSearchable())
|
||||
{
|
||||
listParams.add("search");
|
||||
}
|
||||
}
|
||||
spec.getDefaultListParams().stream().map(NameValuePair::getName).forEach(listParams::add);
|
||||
if(AuthentikTables.USER.equals(spec.getName()))
|
||||
{
|
||||
listParams.addAll(List.of("type", "path_startswith", "groups_by_name"));
|
||||
}
|
||||
rs.add(new Operation("get", spec.getPath(), listParams));
|
||||
|
||||
Set<String> getParams = new LinkedHashSet<>();
|
||||
spec.getDefaultGetParams().stream().map(NameValuePair::getName).forEach(getParams::add);
|
||||
rs.add(new Operation("get", spec.getPath() + "{key}/", getParams));
|
||||
}
|
||||
rs.addAll(extraOperations());
|
||||
return (rs);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One field of an Authentik-backed table: how to read it from the Authentik
|
||||
** JSON, which QQQ criteria map to which Authentik query parameters, and
|
||||
** whether it is sensitive (stripped without authentik.audit.read).
|
||||
*******************************************************************************/
|
||||
public class AuthentikFieldSpec
|
||||
{
|
||||
private final String name;
|
||||
private final String label;
|
||||
private final QFieldType type;
|
||||
private final Function<JSONObject, Serializable> extractor;
|
||||
private final Map<QCriteriaOperator, String> filterParams = new EnumMap<>(QCriteriaOperator.class);
|
||||
private boolean sensitive;
|
||||
private boolean searchable;
|
||||
private String orderingName;
|
||||
private boolean hidden;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public AuthentikFieldSpec(String name, String label, QFieldType type, Function<JSONObject, Serializable> extractor)
|
||||
{
|
||||
this.name = name;
|
||||
this.label = label;
|
||||
this.type = type;
|
||||
this.extractor = extractor;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map a QQQ operator to an Authentik query parameter for this field.
|
||||
*******************************************************************************/
|
||||
public AuthentikFieldSpec withFilter(QCriteriaOperator operator, String param)
|
||||
{
|
||||
filterParams.put(operator, param);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark as sensitive (needs authentik.audit.read to see or filter on).
|
||||
*******************************************************************************/
|
||||
public AuthentikFieldSpec withSensitive()
|
||||
{
|
||||
this.sensitive = true;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CONTAINS / STARTS_WITH on this field map to Authentik's search param.
|
||||
*******************************************************************************/
|
||||
public AuthentikFieldSpec withSearchable()
|
||||
{
|
||||
this.searchable = true;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Allow ordering on this field, by the given Authentik ordering name.
|
||||
*******************************************************************************/
|
||||
public AuthentikFieldSpec withOrdering(String orderingName)
|
||||
{
|
||||
this.orderingName = orderingName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hide the field in the UI (it still exists on records).
|
||||
*******************************************************************************/
|
||||
public AuthentikFieldSpec withHidden()
|
||||
{
|
||||
this.hidden = true;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read this field's value from an Authentik object.
|
||||
*******************************************************************************/
|
||||
public Serializable extract(JSONObject object)
|
||||
{
|
||||
return (extractor.apply(object));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for type
|
||||
*******************************************************************************/
|
||||
public QFieldType getType()
|
||||
{
|
||||
return (type);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filterParams
|
||||
*******************************************************************************/
|
||||
public Map<QCriteriaOperator, String> getFilterParams()
|
||||
{
|
||||
return (filterParams);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sensitive
|
||||
*******************************************************************************/
|
||||
public boolean isSensitive()
|
||||
{
|
||||
return (sensitive);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for searchable
|
||||
*******************************************************************************/
|
||||
public boolean isSearchable()
|
||||
{
|
||||
return (searchable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for orderingName
|
||||
*******************************************************************************/
|
||||
public String getOrderingName()
|
||||
{
|
||||
return (orderingName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hidden
|
||||
*******************************************************************************/
|
||||
public boolean isHidden()
|
||||
{
|
||||
return (hidden);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.AbstractFilterExpression;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import org.apache.http.NameValuePair;
|
||||
import org.apache.http.message.BasicNameValuePair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Maps a QQQ filter to Authentik query parameters (DESIGN.md section 6.1).
|
||||
**
|
||||
** Exactly what Authentik can evaluate server-side is accepted. Everything
|
||||
** else (OR, sub-filters, NOT_* operators, unmapped fields or operators,
|
||||
** field-to-field criteria) is REJECTED with a user-facing message: filtering
|
||||
** client-side over 100-row pages would silently truncate results.
|
||||
**
|
||||
** Notes:
|
||||
** - Authentik date filters are strict (__gt / __lt). GREATER_THAN_OR_EQUALS
|
||||
** and LESS_THAN_OR_EQUALS are widened by one millisecond; BETWEEN uses both.
|
||||
** - CONTAINS / STARTS_WITH on a searchable field use Authentik's "search"
|
||||
** parameter, which matches the term across several fields (a superset of
|
||||
** the field criterion). Only one search term per query.
|
||||
** - A filter made only of key EQUALS / IN criteria is returned as a key
|
||||
** lookup (the caller fetches each record by id), since Authentik has no
|
||||
** list filter on every table's key.
|
||||
*******************************************************************************/
|
||||
public class AuthentikFilterMapper
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Result of mapping a filter.
|
||||
*******************************************************************************/
|
||||
public record MappedFilter(List<NameValuePair> params, List<Serializable> keyLookup, Set<String> filteredFields)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Whether this mapping is a key lookup (fetch-by-id) rather than a list query.
|
||||
*******************************************************************************/
|
||||
public boolean isKeyLookup()
|
||||
{
|
||||
return (keyLookup != null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map a filter for a table.
|
||||
**
|
||||
** @param spec the table
|
||||
** @param filter the QQQ filter (may be null)
|
||||
** @param mayUseSensitive whether the session may filter on sensitive fields
|
||||
*******************************************************************************/
|
||||
public MappedFilter map(AuthentikTableSpec spec, QQueryFilter filter, boolean mayUseSensitive) throws QException
|
||||
{
|
||||
List<NameValuePair> params = new ArrayList<>();
|
||||
Set<String> usedParams = new HashSet<>();
|
||||
Set<String> filteredFields = new HashSet<>();
|
||||
|
||||
if(filter == null)
|
||||
{
|
||||
addOrdering(spec, null, params);
|
||||
return (new MappedFilter(params, null, filteredFields));
|
||||
}
|
||||
|
||||
if(CollectionUtils.nullSafeHasContents(filter.getSubFilters()))
|
||||
{
|
||||
throw (new QUserFacingException("Nested filters (sub-filters) are not supported for " + spec.getLabel() + "."));
|
||||
}
|
||||
|
||||
List<QFilterCriteria> criteria = CollectionUtils.nonNullList(filter.getCriteria());
|
||||
if(criteria.size() > 1 && filter.getBooleanOperator() == QQueryFilter.BooleanOperator.OR)
|
||||
{
|
||||
throw (new QUserFacingException("\"Any of\" (OR) filters are not supported for " + spec.getLabel() + "; use \"all of\" (AND)."));
|
||||
}
|
||||
|
||||
List<Serializable> keyLookup = keyLookup(spec, criteria);
|
||||
if(keyLookup != null)
|
||||
{
|
||||
filteredFields.add(spec.getKeyFieldName());
|
||||
return (new MappedFilter(params, keyLookup, filteredFields));
|
||||
}
|
||||
|
||||
boolean usedSearch = false;
|
||||
for(QFilterCriteria criterion : criteria)
|
||||
{
|
||||
if(criterion == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(criterion.getFieldName() == null)
|
||||
{
|
||||
throw (new QUserFacingException("A filter criterion without a field is not supported for " + spec.getLabel() + "."));
|
||||
}
|
||||
|
||||
if(criterion.getOtherFieldName() != null || criterion.getFieldFunction() != null)
|
||||
{
|
||||
throw (new QUserFacingException("Field-to-field and function criteria are not supported for " + spec.getLabel() + "."));
|
||||
}
|
||||
|
||||
AuthentikFieldSpec field = spec.getField(criterion.getFieldName());
|
||||
if(field == null)
|
||||
{
|
||||
throw (new QUserFacingException("Unknown field for " + spec.getLabel() + ": " + criterion.getFieldName()));
|
||||
}
|
||||
if(field.isSensitive() && !mayUseSensitive)
|
||||
{
|
||||
throw (new QUserFacingException("You do not have permission to filter " + spec.getLabel() + " by " + field.getLabel() + "."));
|
||||
}
|
||||
|
||||
QCriteriaOperator operator = criterion.getOperator();
|
||||
List<Serializable> values = evaluateValues(field, criterion.getValues());
|
||||
filteredFields.add(field.getName());
|
||||
|
||||
if((operator == QCriteriaOperator.CONTAINS || operator == QCriteriaOperator.STARTS_WITH) && !field.getFilterParams().containsKey(operator) && field.isSearchable())
|
||||
{
|
||||
if(usedSearch)
|
||||
{
|
||||
throw (new QUserFacingException("Only one text-search criterion is supported for " + spec.getLabel() + "."));
|
||||
}
|
||||
usedSearch = true;
|
||||
add(params, usedParams, "search", plainSearchTerm(field, singleString(field, operator, values)));
|
||||
continue;
|
||||
}
|
||||
|
||||
switch(operator)
|
||||
{
|
||||
case EQUALS, STARTS_WITH -> add(params, usedParams, param(field, operator), formatValue(field, singleValue(field, operator, values)));
|
||||
case IN ->
|
||||
{
|
||||
String param = param(field, operator);
|
||||
if(values.isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("An empty \"is any of\" list is not supported for " + field.getLabel() + "."));
|
||||
}
|
||||
if(!usedParams.add(param))
|
||||
{
|
||||
throw (duplicate(field));
|
||||
}
|
||||
for(Serializable value : values)
|
||||
{
|
||||
params.add(new BasicNameValuePair(param, formatValue(field, value)));
|
||||
}
|
||||
}
|
||||
case GREATER_THAN, LESS_THAN -> add(params, usedParams, param(field, operator), formatValue(field, singleValue(field, operator, values)));
|
||||
case GREATER_THAN_OR_EQUALS -> add(params, usedParams, param(field, QCriteriaOperator.GREATER_THAN), formatInstant(shift(field, singleValue(field, operator, values), -1)));
|
||||
case LESS_THAN_OR_EQUALS -> add(params, usedParams, param(field, QCriteriaOperator.LESS_THAN), formatInstant(shift(field, singleValue(field, operator, values), 1)));
|
||||
case BETWEEN ->
|
||||
{
|
||||
if(values.size() != 2)
|
||||
{
|
||||
throw (new QUserFacingException("\"Between\" needs two values for " + field.getLabel() + "."));
|
||||
}
|
||||
add(params, usedParams, param(field, QCriteriaOperator.GREATER_THAN), formatInstant(shift(field, values.get(0), -1)));
|
||||
add(params, usedParams, param(field, QCriteriaOperator.LESS_THAN), formatInstant(shift(field, values.get(1), 1)));
|
||||
}
|
||||
case IS_BLANK -> add(params, usedParams, param(field, operator), "true");
|
||||
case IS_NOT_BLANK -> add(params, usedParams, param(field, operator), "false");
|
||||
default -> throw (unsupported(field, operator));
|
||||
}
|
||||
}
|
||||
|
||||
addOrdering(spec, filter.getOrderBys(), params);
|
||||
return (new MappedFilter(params, null, filteredFields));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** If every criterion is key EQUALS / IN, return the keys; else null.
|
||||
*******************************************************************************/
|
||||
private List<Serializable> keyLookup(AuthentikTableSpec spec, List<QFilterCriteria> criteria)
|
||||
{
|
||||
if(criteria.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
List<Serializable> keys = null;
|
||||
for(QFilterCriteria criterion : criteria)
|
||||
{
|
||||
if(criterion == null || !spec.getKeyFieldName().equals(criterion.getFieldName()) || criterion.getOtherFieldName() != null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(criterion.getOperator() != QCriteriaOperator.EQUALS && criterion.getOperator() != QCriteriaOperator.IN)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
List<Serializable> these = new ArrayList<>(CollectionUtils.nonNullList(criterion.getValues()));
|
||||
if(keys == null)
|
||||
{
|
||||
keys = these;
|
||||
}
|
||||
else
|
||||
{
|
||||
keys.retainAll(these);
|
||||
}
|
||||
}
|
||||
return (keys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void addOrdering(AuthentikTableSpec spec, List<QFilterOrderBy> orderBys, List<NameValuePair> params) throws QUserFacingException
|
||||
{
|
||||
List<String> orderings = new ArrayList<>();
|
||||
for(QFilterOrderBy orderBy : CollectionUtils.nonNullList(orderBys))
|
||||
{
|
||||
AuthentikFieldSpec field = spec.getField(orderBy.getFieldName());
|
||||
if(field != null && field.getOrderingName() == null && spec.getKeyFieldName().equals(field.getName()))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// UIs often sort by the key by default: use the table's default //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
if(field == null || field.getOrderingName() == null)
|
||||
{
|
||||
throw (new QUserFacingException(spec.getLabel() + " cannot be sorted by " + (field == null ? orderBy.getFieldName() : field.getLabel()) + "."));
|
||||
}
|
||||
orderings.add((orderBy.getIsAscending() ? "" : "-") + field.getOrderingName());
|
||||
}
|
||||
|
||||
if(orderings.isEmpty() && spec.getDefaultOrdering() != null)
|
||||
{
|
||||
orderings.add(spec.getDefaultOrdering());
|
||||
}
|
||||
|
||||
if(!orderings.isEmpty())
|
||||
{
|
||||
params.add(new BasicNameValuePair("ordering", String.join(",", orderings)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void add(List<NameValuePair> params, Set<String> usedParams, String param, String value) throws QUserFacingException
|
||||
{
|
||||
if(!usedParams.add(param))
|
||||
{
|
||||
throw (new QUserFacingException("Two criteria map to the same Authentik filter (" + param + "); combine them into one."));
|
||||
}
|
||||
params.add(new BasicNameValuePair(param, value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String param(AuthentikFieldSpec field, QCriteriaOperator operator) throws QUserFacingException
|
||||
{
|
||||
String param = field.getFilterParams().get(operator);
|
||||
if(param == null)
|
||||
{
|
||||
throw (unsupported(field, operator));
|
||||
}
|
||||
return (param);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QUserFacingException unsupported(AuthentikFieldSpec field, QCriteriaOperator operator)
|
||||
{
|
||||
return (new QUserFacingException("Filtering " + field.getLabel() + " with \"" + operator.name().toLowerCase().replace('_', ' ') + "\" is not supported by Authentik."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QUserFacingException duplicate(AuthentikFieldSpec field)
|
||||
{
|
||||
return (new QUserFacingException("Only one criterion per field is supported (" + field.getLabel() + ")."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Serializable singleValue(AuthentikFieldSpec field, QCriteriaOperator operator, List<Serializable> values) throws QUserFacingException
|
||||
{
|
||||
if(values.size() != 1 || values.get(0) == null)
|
||||
{
|
||||
throw (new QUserFacingException("\"" + operator.name().toLowerCase().replace('_', ' ') + "\" needs exactly one value for " + field.getLabel() + "."));
|
||||
}
|
||||
return (values.get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String singleString(AuthentikFieldSpec field, QCriteriaOperator operator, List<Serializable> values) throws QUserFacingException
|
||||
{
|
||||
String value = String.valueOf(singleValue(field, operator, values)).trim();
|
||||
if(value.isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("A search term is required for " + field.getLabel() + "."));
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentik's search parameter also accepts a query language (QLSearch),
|
||||
** which could reach fields the session may not filter on (attributes).
|
||||
** Only plain text is passed: QL operators, quotes and keywords are refused.
|
||||
*******************************************************************************/
|
||||
static String plainSearchTerm(AuthentikFieldSpec field, String term) throws QUserFacingException
|
||||
{
|
||||
if(term.matches(".*[=~\"'()<>!\\\\].*") || term.matches("(?i).*\\s(and|or|not|in)\\s.*"))
|
||||
{
|
||||
throw (new QUserFacingException("Search " + field.getLabel() + " with plain text only (no operators or quotes)."));
|
||||
}
|
||||
return (term);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Evaluate filter expressions (e.g. "now minus 90 days") into plain values.
|
||||
*******************************************************************************/
|
||||
private static List<Serializable> evaluateValues(AuthentikFieldSpec field, List<Serializable> values) throws QException
|
||||
{
|
||||
List<Serializable> rs = new ArrayList<>();
|
||||
for(Serializable value : CollectionUtils.nonNullList(values))
|
||||
{
|
||||
if(value instanceof AbstractFilterExpression<?> expression)
|
||||
{
|
||||
rs.add(expression.evaluate(new QFieldMetaData(field.getName(), field.getType())));
|
||||
}
|
||||
else
|
||||
{
|
||||
rs.add(value);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Format a value for a query string.
|
||||
*******************************************************************************/
|
||||
static String formatValue(AuthentikFieldSpec field, Serializable value) throws QUserFacingException
|
||||
{
|
||||
if(field.getType() == QFieldType.DATE_TIME || field.getType() == QFieldType.DATE)
|
||||
{
|
||||
return (formatInstant(toInstant(field, value)));
|
||||
}
|
||||
if(field.getType() == QFieldType.BOOLEAN)
|
||||
{
|
||||
String text = String.valueOf(value).trim().toLowerCase();
|
||||
if(text.equals("true") || text.equals("1") || text.equals("yes"))
|
||||
{
|
||||
return ("true");
|
||||
}
|
||||
if(text.equals("false") || text.equals("0") || text.equals("no"))
|
||||
{
|
||||
return ("false");
|
||||
}
|
||||
throw (new QUserFacingException("Not a valid yes/no value for " + field.getLabel() + ": " + value));
|
||||
}
|
||||
return (String.valueOf(value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Instant shift(AuthentikFieldSpec field, Serializable value, long millis) throws QUserFacingException
|
||||
{
|
||||
return (toInstant(field, value).plusMillis(millis));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert a date-ish value to an Instant (a date means its start, UTC).
|
||||
*******************************************************************************/
|
||||
static Instant toInstant(AuthentikFieldSpec field, Serializable value) throws QUserFacingException
|
||||
{
|
||||
if(value instanceof Instant instant)
|
||||
{
|
||||
return (instant);
|
||||
}
|
||||
if(value instanceof LocalDate localDate)
|
||||
{
|
||||
return (localDate.atStartOfDay(ZoneOffset.UTC).toInstant());
|
||||
}
|
||||
if(value instanceof java.util.Date date)
|
||||
{
|
||||
return (date.toInstant());
|
||||
}
|
||||
|
||||
String text = value == null ? "" : String.valueOf(value).trim();
|
||||
try
|
||||
{
|
||||
return (Instant.parse(text));
|
||||
}
|
||||
catch(DateTimeParseException e)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (AuthentikJson.parseInstant(text));
|
||||
}
|
||||
catch(DateTimeParseException e2)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (LocalDate.parse(text).atStartOfDay(ZoneOffset.UTC).toInstant());
|
||||
}
|
||||
catch(DateTimeParseException e3)
|
||||
{
|
||||
throw (new QUserFacingException("Not a valid date/time for " + field.getLabel() + ": " + text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String formatInstant(Instant instant)
|
||||
{
|
||||
return (instant.toString());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null-safe readers for Authentik JSON objects, used as field extractors.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikJson
|
||||
{
|
||||
/** Attribute-key prefix the qbit owns (DESIGN.md section 5.2). */
|
||||
public static final String QQQ_ATTRIBUTE_PREFIX = "qqq.io/";
|
||||
|
||||
public static final String ATTR_PERMISSIONS = "qqq.io/permissions";
|
||||
public static final String ATTR_APP_ACCESS = "qqq.io/app-access";
|
||||
public static final String ATTR_PROTECTED = "qqq.io/protected";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Not instantiable.
|
||||
*******************************************************************************/
|
||||
private AuthentikJson()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** String value, or null.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> string(String key)
|
||||
{
|
||||
return (o -> o.has(key) && !o.isNull(key) ? String.valueOf(o.get(key)) : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Integer value, or null.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> integer(String key)
|
||||
{
|
||||
return (o -> o.has(key) && !o.isNull(key) ? o.getInt(key) : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Boolean value, or null.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> bool(String key)
|
||||
{
|
||||
return (o -> o.has(key) && !o.isNull(key) ? o.getBoolean(key) : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ISO-8601 date-time as an Instant, or null.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> instant(String key)
|
||||
{
|
||||
return (o -> parseInstant(o.has(key) && !o.isNull(key) ? o.getString(key) : null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A nested object (or array) rendered as JSON text, or null.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> json(String key)
|
||||
{
|
||||
return (o -> o.has(key) && !o.isNull(key) ? o.get(key).toString() : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A value at a nested path (e.g. "user", "username"), as a string, or null.
|
||||
** Objects and arrays are rendered as JSON text.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> at(String... path)
|
||||
{
|
||||
return (o ->
|
||||
{
|
||||
Object current = o;
|
||||
for(String key : path)
|
||||
{
|
||||
if(!(current instanceof JSONObject object) || !object.has(key) || object.isNull(key))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
current = object.get(key);
|
||||
}
|
||||
return (String.valueOf(current));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An integer at a nested path, or null.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> intAt(String... path)
|
||||
{
|
||||
Function<JSONObject, Serializable> text = at(path);
|
||||
return (o ->
|
||||
{
|
||||
Serializable value = text.apply(o);
|
||||
try
|
||||
{
|
||||
return (value == null ? null : Integer.valueOf(String.valueOf(value)));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Length of an array value, or null.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> arrayLength(String key)
|
||||
{
|
||||
return (o -> o.optJSONArray(key) == null ? null : o.getJSONArray(key).length());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Comma-joined values of a property across an array of objects, e.g. the
|
||||
** names in groups_obj. Null when the array is absent.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> joined(String arrayKey, String property)
|
||||
{
|
||||
return (o ->
|
||||
{
|
||||
JSONArray array = o.optJSONArray(arrayKey);
|
||||
if(array == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
Object element = array.get(i);
|
||||
if(element instanceof JSONObject object && object.has(property) && !object.isNull(property))
|
||||
{
|
||||
values.add(String.valueOf(object.get(property)));
|
||||
}
|
||||
else if(!(element instanceof JSONObject) && property == null)
|
||||
{
|
||||
values.add(String.valueOf(element));
|
||||
}
|
||||
}
|
||||
return (String.join(", ", values));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Comma-joined values of a string array inside "attributes", e.g.
|
||||
** qqq.io/permissions. Null when absent.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> attributeList(String attributeKey)
|
||||
{
|
||||
return (o ->
|
||||
{
|
||||
List<String> values = stringList(o.optJSONObject("attributes"), attributeKey);
|
||||
return (values == null ? null : String.join(", ", values));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the qqq.io/* attribute keys, as JSON text (non-sensitive view).
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> qqqAttributes()
|
||||
{
|
||||
return (o ->
|
||||
{
|
||||
JSONObject attributes = o.optJSONObject("attributes");
|
||||
if(attributes == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
JSONObject rs = new JSONObject();
|
||||
Iterator<String> keys = attributes.keys();
|
||||
while(keys.hasNext())
|
||||
{
|
||||
String key = keys.next();
|
||||
if(key.startsWith(QQQ_ATTRIBUTE_PREFIX))
|
||||
{
|
||||
rs.put(key, attributes.get(key));
|
||||
}
|
||||
}
|
||||
return (rs.isEmpty() ? null : rs.toString());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a list of strings from an object property; null when absent, and
|
||||
** non-string elements are skipped. A single string is treated as a list of
|
||||
** one (Authentik attributes are free-form JSON).
|
||||
*******************************************************************************/
|
||||
public static List<String> stringList(JSONObject object, String key)
|
||||
{
|
||||
if(object == null || !object.has(key) || object.isNull(key))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
Object value = object.get(key);
|
||||
List<String> values = new ArrayList<>();
|
||||
if(value instanceof JSONArray array)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
if(array.get(i) instanceof String s && !s.isBlank())
|
||||
{
|
||||
values.add(s.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(value instanceof String s && !s.isBlank())
|
||||
{
|
||||
values.add(s.trim());
|
||||
}
|
||||
return (values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entries of a list attribute that stringList() drops: a value that is not a
|
||||
** string or array, or non-string / blank array elements. Empty when fine.
|
||||
*******************************************************************************/
|
||||
public static List<String> malformedEntries(JSONObject object, String key)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
if(object == null || !object.has(key) || object.isNull(key))
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
Object value = object.get(key);
|
||||
if(value instanceof JSONArray array)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
Object element = array.get(i);
|
||||
if(!(element instanceof String s) || s.isBlank())
|
||||
{
|
||||
rs.add(String.valueOf(element));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(!(value instanceof String))
|
||||
{
|
||||
rs.add(String.valueOf(value));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse an Authentik timestamp (ISO-8601 with offset or Z).
|
||||
*******************************************************************************/
|
||||
public static Instant parseInstant(String value)
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (OffsetDateTime.parse(value).toInstant());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Redaction for anything the qbit logs or stores about Authentik calls
|
||||
** (DESIGN.md section 9.1):
|
||||
** - the bearer token is never logged (it is replaced wherever it appears);
|
||||
** - bodies of sensitive endpoints (recovery, tokens, passwords, service-account
|
||||
** creation, user create) are dropped entirely;
|
||||
** - in any other body, values of JSON keys that look like secrets (key, token,
|
||||
** link, password, secret, client_secret) are masked.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikRedactor
|
||||
{
|
||||
public static final String MASK = "******";
|
||||
|
||||
/** Path fragments whose request and response bodies are never logged. */
|
||||
private static final List<String> SENSITIVE_PATH_FRAGMENTS = List.of(
|
||||
"/recovery", "/recovery_email", "/set_password", "/set_password_hash", "/core/tokens/", "/service_account", "/impersonate", "/view_key", "/set_key");
|
||||
|
||||
private static final Pattern SECRET_JSON_VALUE = Pattern.compile(
|
||||
"(\"(?:key|token|link|password|secret|client_secret|password_hash|recovery_link)\"\\s*:\\s*)\"((?:[^\"\\\\]|\\\\.)*)\"", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final Pattern BEARER = Pattern.compile("(?i)(bearer\\s+)[A-Za-z0-9._~+/=-]+");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Not instantiable.
|
||||
*******************************************************************************/
|
||||
private AuthentikRedactor()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether bodies for this path must never be logged.
|
||||
*******************************************************************************/
|
||||
public static boolean isSensitivePath(String path)
|
||||
{
|
||||
if(path == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
for(String fragment : SENSITIVE_PATH_FRAGMENTS)
|
||||
{
|
||||
if(path.contains(fragment))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Redact a request or response body for logging.
|
||||
**
|
||||
** @param path the request path (decides whether to drop the body)
|
||||
** @param body the body (may be null)
|
||||
** @param token the bearer token in use (may be null)
|
||||
*******************************************************************************/
|
||||
public static String redactBody(String path, String body, String token)
|
||||
{
|
||||
if(body == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(isSensitivePath(path))
|
||||
{
|
||||
return ("[body omitted: sensitive endpoint]");
|
||||
}
|
||||
return (redactText(body, token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Redact free text (exception messages, log lines): the token, bearer
|
||||
** values and secret-looking JSON values.
|
||||
*******************************************************************************/
|
||||
public static String redactText(String text, String token)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
String safe = text;
|
||||
if(token != null && !token.isBlank())
|
||||
{
|
||||
safe = safe.replace(token, MASK);
|
||||
}
|
||||
safe = BEARER.matcher(safe).replaceAll("$1" + MASK);
|
||||
|
||||
Matcher matcher = SECRET_JSON_VALUE.matcher(safe);
|
||||
StringBuilder rs = new StringBuilder();
|
||||
while(matcher.find())
|
||||
{
|
||||
matcher.appendReplacement(rs, Matcher.quoteReplacement(matcher.group(1) + "\"" + MASK + "\""));
|
||||
}
|
||||
matcher.appendTail(rs);
|
||||
return (rs.toString());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import org.apache.http.NameValuePair;
|
||||
import org.apache.http.message.BasicNameValuePair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Definition of one Authentik-backed QQQ table: its endpoint, key, fields,
|
||||
** default query parameters and the permission needed to read it.
|
||||
*******************************************************************************/
|
||||
public class AuthentikTableSpec
|
||||
{
|
||||
private final String name;
|
||||
private final String label;
|
||||
private final String icon;
|
||||
private final String path;
|
||||
private final String keyFieldName;
|
||||
private final Map<String, AuthentikFieldSpec> fields = new LinkedHashMap<>();
|
||||
private final List<NameValuePair> defaultListParams = new ArrayList<>();
|
||||
private final List<NameValuePair> defaultGetParams = new ArrayList<>();
|
||||
private IdentityPermission readPermission = IdentityPermission.USERS_READ;
|
||||
private String defaultOrdering;
|
||||
private List<String> recordLabelFields = new ArrayList<>();
|
||||
private String requiredFilterField;
|
||||
private boolean virtual;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
**
|
||||
** @param name unprefixed table name, e.g. "user"
|
||||
** @param label UI label
|
||||
** @param icon material icon name
|
||||
** @param path API path below /api/v3, with leading and trailing slash
|
||||
** @param keyFieldName name of the primary-key field
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec(String name, String label, String icon, String path, String keyFieldName)
|
||||
{
|
||||
this.name = name;
|
||||
this.label = label;
|
||||
this.icon = icon;
|
||||
this.path = path;
|
||||
this.keyFieldName = keyFieldName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a field.
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec withField(AuthentikFieldSpec field)
|
||||
{
|
||||
fields.put(field.getName(), field);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a query parameter sent with every list request.
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec withDefaultListParam(String param, String value)
|
||||
{
|
||||
defaultListParams.add(new BasicNameValuePair(param, value));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a query parameter sent with every single-record GET.
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec withDefaultGetParam(String param, String value)
|
||||
{
|
||||
defaultGetParams.add(new BasicNameValuePair(param, value));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the permission needed to read the table.
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec withReadPermission(IdentityPermission readPermission)
|
||||
{
|
||||
this.readPermission = readPermission;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the Authentik ordering used when the query has no orderBy.
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec withDefaultOrdering(String defaultOrdering)
|
||||
{
|
||||
this.defaultOrdering = defaultOrdering;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the record label fields.
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec withRecordLabelFields(String... recordLabelFields)
|
||||
{
|
||||
this.recordLabelFields = new ArrayList<>(List.of(recordLabelFields));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Require a criterion on this field for every query (e.g. MFA devices are
|
||||
** only listed per user).
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec withRequiredFilterField(String requiredFilterField)
|
||||
{
|
||||
this.requiredFilterField = requiredFilterField;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark as virtual: served by custom logic in AuthentikActionUtil rather than
|
||||
** by one list endpoint (its filter "params" are field names, not Authentik
|
||||
** query parameters).
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec withVirtual()
|
||||
{
|
||||
this.virtual = true;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for virtual
|
||||
*******************************************************************************/
|
||||
public boolean isVirtual()
|
||||
{
|
||||
return (virtual);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get a field spec, or null.
|
||||
*******************************************************************************/
|
||||
public AuthentikFieldSpec getField(String fieldName)
|
||||
{
|
||||
return (fields.get(fieldName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All field specs, in display order.
|
||||
*******************************************************************************/
|
||||
public Collection<AuthentikFieldSpec> getFields()
|
||||
{
|
||||
return (fields.values());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for icon
|
||||
*******************************************************************************/
|
||||
public String getIcon()
|
||||
{
|
||||
return (icon);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for path
|
||||
*******************************************************************************/
|
||||
public String getPath()
|
||||
{
|
||||
return (path);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for keyFieldName
|
||||
*******************************************************************************/
|
||||
public String getKeyFieldName()
|
||||
{
|
||||
return (keyFieldName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultListParams
|
||||
*******************************************************************************/
|
||||
public List<NameValuePair> getDefaultListParams()
|
||||
{
|
||||
return (defaultListParams);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultGetParams
|
||||
*******************************************************************************/
|
||||
public List<NameValuePair> getDefaultGetParams()
|
||||
{
|
||||
return (defaultGetParams);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for readPermission
|
||||
*******************************************************************************/
|
||||
public IdentityPermission getReadPermission()
|
||||
{
|
||||
return (readPermission);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultOrdering
|
||||
*******************************************************************************/
|
||||
public String getDefaultOrdering()
|
||||
{
|
||||
return (defaultOrdering);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordLabelFields
|
||||
*******************************************************************************/
|
||||
public List<String> getRecordLabelFields()
|
||||
{
|
||||
return (recordLabelFields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requiredFilterField
|
||||
*******************************************************************************/
|
||||
public String getRequiredFilterField()
|
||||
{
|
||||
return (requiredFilterField);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.at;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.attributeList;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.bool;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.instant;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.intAt;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.integer;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.joined;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.json;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.qqqAttributes;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.string;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Authentik-backed tables (DESIGN.md section 6.1), mapped against the
|
||||
** pinned 2026.8.3 schema (src/test/resources/authentik/schema-2026.8.3.yml).
|
||||
**
|
||||
** Multi-value query parameters are sent as repeated plain names
|
||||
** (type=internal&type=external). Authentik silently IGNORES the bracketed
|
||||
** form (type[]=internal), returning unfiltered results (phase 0 finding).
|
||||
*******************************************************************************/
|
||||
public final class AuthentikTables
|
||||
{
|
||||
public static final String USER = "user";
|
||||
public static final String GROUP = "group";
|
||||
public static final String GROUP_MEMBER = "groupMember";
|
||||
public static final String SESSION = "session";
|
||||
public static final String EVENT = "event";
|
||||
|
||||
/*******************************************************************************
|
||||
** Table names produced before the owner naming rule (2026-10-12), with the
|
||||
** default prefix. Kept only to find stored references (permissions, saved
|
||||
** views, audit table names) when upgrading; see UPGRADE.md.
|
||||
*******************************************************************************/
|
||||
@Deprecated
|
||||
public static final String LEGACY_USER_TABLE_NAME = "authentikUser";
|
||||
@Deprecated
|
||||
public static final String LEGACY_GROUP_TABLE_NAME = "authentikGroup";
|
||||
@Deprecated
|
||||
public static final String LEGACY_GROUP_MEMBER_TABLE_NAME = "authentikGroupMember";
|
||||
@Deprecated
|
||||
public static final String LEGACY_SESSION_TABLE_NAME = "authentikSession";
|
||||
@Deprecated
|
||||
public static final String LEGACY_EVENT_TABLE_NAME = "authentikEvent";
|
||||
|
||||
/** User types the qbit shows by default: everything but outposts' internal service accounts. */
|
||||
public static final String[] DEFAULT_USER_TYPES = { "internal", "external", "service_account" };
|
||||
|
||||
private static final Map<String, AuthentikTableSpec> SPECS = new LinkedHashMap<>();
|
||||
|
||||
static
|
||||
{
|
||||
register(users());
|
||||
register(groups());
|
||||
register(groupMembers());
|
||||
register(sessions());
|
||||
register(events());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Not instantiable.
|
||||
*******************************************************************************/
|
||||
private AuthentikTables()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up a spec by unprefixed table name, or null.
|
||||
*******************************************************************************/
|
||||
public static AuthentikTableSpec get(String name)
|
||||
{
|
||||
return (SPECS.get(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All specs.
|
||||
*******************************************************************************/
|
||||
public static Map<String, AuthentikTableSpec> all()
|
||||
{
|
||||
return (SPECS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void register(AuthentikTableSpec spec)
|
||||
{
|
||||
SPECS.put(spec.getName(), spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** /core/users/ (int pk). The detail GET takes no include_* parameters and
|
||||
** always returns groups_obj and roles_obj.
|
||||
*******************************************************************************/
|
||||
static AuthentikTableSpec users()
|
||||
{
|
||||
AuthentikTableSpec spec = new AuthentikTableSpec(USER, "Users", "person", "/core/users/", "pk")
|
||||
.withDefaultListParam("include_groups", "true")
|
||||
.withDefaultListParam("include_roles", "true")
|
||||
.withDefaultOrdering("username")
|
||||
.withRecordLabelFields("username");
|
||||
|
||||
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.INTEGER, integer("pk")));
|
||||
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, string("username"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "username").withSearchable().withOrdering("username"));
|
||||
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "name").withSearchable().withOrdering("name"));
|
||||
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, string("email"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "email").withSearchable().withOrdering("email"));
|
||||
spec.withField(new AuthentikFieldSpec("isActive", "Active", QFieldType.BOOLEAN, bool("is_active"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "is_active").withOrdering("is_active"));
|
||||
spec.withField(new AuthentikFieldSpec("type", "Type", QFieldType.STRING, string("type"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "type").withFilter(QCriteriaOperator.IN, "type"));
|
||||
spec.withField(new AuthentikFieldSpec("isSuperuser", "Superuser", QFieldType.BOOLEAN, bool("is_superuser"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "is_superuser"));
|
||||
spec.withField(new AuthentikFieldSpec("isProtected", "Protected", QFieldType.BOOLEAN, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("groupNames", "Groups", QFieldType.STRING, joined("groups_obj", "name"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "groups_by_name").withFilter(QCriteriaOperator.IN, "groups_by_name"));
|
||||
spec.withField(new AuthentikFieldSpec("roleNames", "Roles", QFieldType.STRING, joined("roles_obj", "name"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "roles_by_name").withFilter(QCriteriaOperator.IN, "roles_by_name"));
|
||||
spec.withField(new AuthentikFieldSpec("lastLogin", "Last Login", QFieldType.DATE_TIME, instant("last_login"))
|
||||
.withFilter(QCriteriaOperator.GREATER_THAN, "last_login__gt").withFilter(QCriteriaOperator.LESS_THAN, "last_login__lt")
|
||||
.withFilter(QCriteriaOperator.IS_BLANK, "last_login__isnull").withFilter(QCriteriaOperator.IS_NOT_BLANK, "last_login__isnull")
|
||||
.withOrdering("last_login"));
|
||||
spec.withField(new AuthentikFieldSpec("dateJoined", "Date Joined", QFieldType.DATE_TIME, instant("date_joined"))
|
||||
.withFilter(QCriteriaOperator.GREATER_THAN, "date_joined__gt").withFilter(QCriteriaOperator.LESS_THAN, "date_joined__lt")
|
||||
.withOrdering("date_joined"));
|
||||
spec.withField(new AuthentikFieldSpec("lastUpdated", "Last Updated", QFieldType.DATE_TIME, instant("last_updated"))
|
||||
.withFilter(QCriteriaOperator.GREATER_THAN, "last_updated__gt").withFilter(QCriteriaOperator.LESS_THAN, "last_updated__lt"));
|
||||
spec.withField(new AuthentikFieldSpec("path", "Path", QFieldType.STRING, string("path"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "path").withFilter(QCriteriaOperator.STARTS_WITH, "path_startswith"));
|
||||
spec.withField(new AuthentikFieldSpec("uuid", "UUID", QFieldType.STRING, string("uuid"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "uuid").withHidden());
|
||||
spec.withField(new AuthentikFieldSpec("qqqAttributes", "QQQ Attributes", QFieldType.TEXT, qqqAttributes()));
|
||||
spec.withField(new AuthentikFieldSpec("effectivePermissions", "Effective QQQ Permissions", QFieldType.TEXT, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("passwordChangeDate", "Password Changed", QFieldType.DATE_TIME, instant("password_change_date"))
|
||||
.withSensitive());
|
||||
spec.withField(new AuthentikFieldSpec("attributes", "Attributes", QFieldType.TEXT, json("attributes"))
|
||||
.withSensitive());
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** /core/groups/ (uuid pk). Note: Group has no last_updated in 2026.8.3.
|
||||
*******************************************************************************/
|
||||
static AuthentikTableSpec groups()
|
||||
{
|
||||
AuthentikTableSpec spec = new AuthentikTableSpec(GROUP, "Groups", "groups", "/core/groups/", "pk")
|
||||
.withDefaultListParam("include_users", "false")
|
||||
.withDefaultListParam("include_parents", "true")
|
||||
.withDefaultListParam("include_children", "true")
|
||||
.withDefaultGetParam("include_users", "false")
|
||||
.withDefaultGetParam("include_parents", "true")
|
||||
.withDefaultGetParam("include_children", "true")
|
||||
.withDefaultOrdering("name")
|
||||
.withRecordLabelFields("name");
|
||||
|
||||
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
|
||||
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "name").withSearchable().withOrdering("name"));
|
||||
spec.withField(new AuthentikFieldSpec("isSuperuser", "Superuser (own flag)", QFieldType.BOOLEAN, bool("is_superuser"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "is_superuser"));
|
||||
spec.withField(new AuthentikFieldSpec("isSuperuserInherited", "Superuser (incl. parents)", QFieldType.BOOLEAN, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("parentNames", "Parents", QFieldType.STRING, joined("parents_obj", "name")));
|
||||
spec.withField(new AuthentikFieldSpec("childNames", "Children", QFieldType.STRING, joined("children_obj", "name")));
|
||||
spec.withField(new AuthentikFieldSpec("memberCount", "Members", QFieldType.INTEGER, AuthentikJson.arrayLength("users")));
|
||||
spec.withField(new AuthentikFieldSpec("memberUsername", "Member Username", QFieldType.STRING, o -> null)
|
||||
.withFilter(QCriteriaOperator.EQUALS, "members_by_username").withFilter(QCriteriaOperator.IN, "members_by_username").withHidden());
|
||||
spec.withField(new AuthentikFieldSpec("permissions", "QQQ Permissions", QFieldType.TEXT, attributeList(AuthentikJson.ATTR_PERMISSIONS)));
|
||||
spec.withField(new AuthentikFieldSpec("effectivePermissions", "Effective QQQ Permissions (incl. parents)", QFieldType.TEXT, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("appAccess", "App Access", QFieldType.TEXT, attributeList(AuthentikJson.ATTR_APP_ACCESS)));
|
||||
spec.withField(new AuthentikFieldSpec("roleNames", "Roles", QFieldType.STRING, joined("roles_obj", "name")));
|
||||
spec.withField(new AuthentikFieldSpec("numPk", "Numeric Id", QFieldType.INTEGER, integer("num_pk")).withHidden());
|
||||
spec.withField(new AuthentikFieldSpec("parentPks", "Parent Ids", QFieldType.TEXT, json("parents")).withHidden());
|
||||
spec.withField(new AuthentikFieldSpec("attributes", "Attributes", QFieldType.TEXT, json("attributes")).withSensitive());
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual membership table (group x user). Served by AuthentikActionUtil
|
||||
** from GET /core/groups/{pk}/?include_users=true, GET /core/users/{pk}/
|
||||
** (groups_obj) or a group sweep; rows are synthetic objects keyed by these
|
||||
** field names. Key: "groupPk:userPk".
|
||||
*******************************************************************************/
|
||||
static AuthentikTableSpec groupMembers()
|
||||
{
|
||||
AuthentikTableSpec spec = new AuthentikTableSpec(GROUP_MEMBER, "Group Memberships", "group_add", "/core/groups/", "id")
|
||||
.withVirtual()
|
||||
.withDefaultOrdering("groupName")
|
||||
.withRecordLabelFields("groupName", "username");
|
||||
|
||||
spec.withField(new AuthentikFieldSpec("id", "Id", QFieldType.STRING, string("id")));
|
||||
spec.withField(new AuthentikFieldSpec("groupName", "Group", QFieldType.STRING, string("groupName"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "groupName").withOrdering("groupName"));
|
||||
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, string("username"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "username").withOrdering("username"));
|
||||
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")));
|
||||
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, string("email")));
|
||||
spec.withField(new AuthentikFieldSpec("isActive", "Active", QFieldType.BOOLEAN, bool("isActive")));
|
||||
spec.withField(new AuthentikFieldSpec("groupPk", "Group Id", QFieldType.STRING, string("groupPk"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "groupPk").withFilter(QCriteriaOperator.IN, "groupPk"));
|
||||
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, integer("userPk"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "userPk"));
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** /core/authenticated_sessions/ (uuid). IPs and user agents need audit.read.
|
||||
*******************************************************************************/
|
||||
static AuthentikTableSpec sessions()
|
||||
{
|
||||
AuthentikTableSpec spec = new AuthentikTableSpec(SESSION, "Sessions", "devices", "/core/authenticated_sessions/", "uuid")
|
||||
.withReadPermission(IdentityPermission.AUDIT_READ)
|
||||
.withRecordLabelFields("uuid");
|
||||
|
||||
spec.withField(new AuthentikFieldSpec("uuid", "Id", QFieldType.STRING, string("uuid")));
|
||||
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, integer("user")));
|
||||
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, o -> null)
|
||||
.withFilter(QCriteriaOperator.EQUALS, "user__username").withHidden());
|
||||
spec.withField(new AuthentikFieldSpec("lastIp", "Last IP", QFieldType.STRING, string("last_ip"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "session__last_ip"));
|
||||
spec.withField(new AuthentikFieldSpec("lastUserAgent", "Last User Agent", QFieldType.TEXT, string("last_user_agent")));
|
||||
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, instant("last_used")));
|
||||
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
|
||||
spec.withField(new AuthentikFieldSpec("current", "Current", QFieldType.BOOLEAN, bool("current")));
|
||||
spec.withField(new AuthentikFieldSpec("geoIp", "Geo IP", QFieldType.TEXT, json("geo_ip")));
|
||||
spec.withField(new AuthentikFieldSpec("asn", "ASN", QFieldType.TEXT, json("asn")));
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** /events/events/ (uuid). Event contexts and client IPs need audit.read.
|
||||
*******************************************************************************/
|
||||
static AuthentikTableSpec events()
|
||||
{
|
||||
AuthentikTableSpec spec = new AuthentikTableSpec(EVENT, "Events", "receipt_long", "/events/events/", "pk")
|
||||
.withReadPermission(IdentityPermission.AUDIT_READ)
|
||||
.withDefaultOrdering("-created")
|
||||
.withRecordLabelFields("action", "created");
|
||||
|
||||
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
|
||||
spec.withField(new AuthentikFieldSpec("created", "Created", QFieldType.DATE_TIME, instant("created")).withOrdering("created"));
|
||||
spec.withField(new AuthentikFieldSpec("action", "Action", QFieldType.STRING, string("action"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "action").withFilter(QCriteriaOperator.IN, "actions"));
|
||||
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, at("user", "username"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "username"));
|
||||
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, intAt("user", "pk")));
|
||||
spec.withField(new AuthentikFieldSpec("app", "App", QFieldType.STRING, string("app")));
|
||||
spec.withField(new AuthentikFieldSpec("clientIp", "Client IP", QFieldType.STRING, string("client_ip"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "client_ip"));
|
||||
spec.withField(new AuthentikFieldSpec("modelApp", "Model App", QFieldType.STRING, at("context", "model", "app"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "context_model_app"));
|
||||
spec.withField(new AuthentikFieldSpec("modelName", "Model", QFieldType.STRING, at("context", "model", "model_name"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "context_model_name"));
|
||||
spec.withField(new AuthentikFieldSpec("modelPk", "Model Id", QFieldType.STRING, at("context", "model", "pk"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "context_model_pk"));
|
||||
spec.withField(new AuthentikFieldSpec("modelLabel", "Object", QFieldType.STRING, at("context", "model", "name")));
|
||||
spec.withField(new AuthentikFieldSpec("authorizedApp", "Authorized App", QFieldType.STRING, at("context", "authorized_application", "slug"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "context_authorized_app"));
|
||||
spec.withField(new AuthentikFieldSpec("context", "Context", QFieldType.TEXT, json("context")));
|
||||
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
|
||||
return (spec);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.util.function.LongConsumer;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Self-imposed client-side rate limit (DESIGN.md section 9.3). Authentik does
|
||||
** not throttle authenticated API calls at all, and an Authentik may share the
|
||||
** Postgres of other applications, so the qbit limits itself: a token bucket refilled at
|
||||
** requestsPerSecond, holding at most burst tokens. acquire() blocks until a
|
||||
** token is available. Shared by all requests of one qbit instance.
|
||||
*******************************************************************************/
|
||||
public class TokenBucketThrottle
|
||||
{
|
||||
private final double tokensPerNano;
|
||||
private final double burst;
|
||||
private final LongSupplier nanoClock;
|
||||
private final LongConsumer nanoSleeper;
|
||||
|
||||
private double tokens;
|
||||
private long lastRefillNanos;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor using the system clock.
|
||||
*******************************************************************************/
|
||||
public TokenBucketThrottle(int requestsPerSecond, int burst)
|
||||
{
|
||||
this(requestsPerSecond, burst, System::nanoTime, TokenBucketThrottle::sleepNanos);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor with an injectable clock and sleeper (for tests).
|
||||
*******************************************************************************/
|
||||
public TokenBucketThrottle(int requestsPerSecond, int burst, LongSupplier nanoClock, LongConsumer nanoSleeper)
|
||||
{
|
||||
if(requestsPerSecond < 1)
|
||||
{
|
||||
throw (new IllegalArgumentException("requestsPerSecond must be at least 1"));
|
||||
}
|
||||
this.tokensPerNano = requestsPerSecond / 1_000_000_000.0;
|
||||
this.burst = Math.max(1, burst);
|
||||
this.nanoClock = nanoClock;
|
||||
this.nanoSleeper = nanoSleeper;
|
||||
this.tokens = this.burst;
|
||||
this.lastRefillNanos = nanoClock.getAsLong();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Take one token, waiting as long as needed.
|
||||
*******************************************************************************/
|
||||
public void acquire()
|
||||
{
|
||||
long waitNanos;
|
||||
synchronized(this)
|
||||
{
|
||||
refill();
|
||||
tokens -= 1;
|
||||
waitNanos = tokens >= 0 ? 0 : (long) Math.ceil(-tokens / tokensPerNano);
|
||||
}
|
||||
|
||||
if(waitNanos > 0)
|
||||
{
|
||||
nanoSleeper.accept(waitNanos);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tokens currently available (may be negative while callers are waiting).
|
||||
*******************************************************************************/
|
||||
public synchronized double getAvailableTokens()
|
||||
{
|
||||
refill();
|
||||
return (tokens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void refill()
|
||||
{
|
||||
long now = nanoClock.getAsLong();
|
||||
long elapsed = now - lastRefillNanos;
|
||||
if(elapsed > 0)
|
||||
{
|
||||
tokens = Math.min(burst, tokens + elapsed * tokensPerNano);
|
||||
lastRefillNanos = now;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void sleepNanos(long nanos)
|
||||
{
|
||||
try
|
||||
{
|
||||
Thread.sleep(nanos / 1_000_000, (int) (nanos % 1_000_000));
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.drift;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The identity drift report (DESIGN.md sections 4.5, 5.1 and 8.2): checks
|
||||
** that Authentik is in the shape the qbit relies on, using only the service
|
||||
** account's read permissions. Each check is independent; a check that cannot
|
||||
** run reports why and the rest still run.
|
||||
**
|
||||
** Checks:
|
||||
** 1. version: server major.minor = expectedServerVersion
|
||||
** 2. blueprints: every blueprint instance applied successfully
|
||||
** 3. service account: exists, is a service account, has no groups, is not a
|
||||
** superuser, is protected; exactly one API token (it can mint its own)
|
||||
** 4. break-glass: at least one active, protected superuser exists
|
||||
** 5. identity admins: at least one active, unprotected human holds both
|
||||
** authentik.memberships.manage and authentik.permissions.manage (last-admin)
|
||||
** 6. group attributes: qqq.io/permissions entries that grant nothing known;
|
||||
** qqq.io/app-access entries outside applicationsInScope; superuser groups
|
||||
** (or their descendants) carrying qqq.io/permissions
|
||||
** 7. service-account activity: events by the service account touching a
|
||||
** superuser or protected user, or a superuser group (lockout detection)
|
||||
*******************************************************************************/
|
||||
public class AuthentikDriftReport
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikDriftReport.class);
|
||||
|
||||
private final AuthentikActionUtil client;
|
||||
private final AuthentikQBitConfig config;
|
||||
private final AuthentikGuard guard;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public AuthentikDriftReport(AuthentikActionUtil client, AuthentikQBitConfig config)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
this.guard = new AuthentikGuard(config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run every check; findings sorted by severity (errors first).
|
||||
*******************************************************************************/
|
||||
public List<DriftFinding> run()
|
||||
{
|
||||
List<DriftFinding> findings = new ArrayList<>();
|
||||
runCheck(findings, "version", this::checkVersion);
|
||||
runCheck(findings, "blueprints", this::checkBlueprints);
|
||||
runCheck(findings, "service account", this::checkServiceAccount);
|
||||
runCheck(findings, "break-glass", this::checkBreakGlass);
|
||||
runCheck(findings, "identity admins", this::checkIdentityAdmins);
|
||||
runCheck(findings, "group attributes", this::checkGroupAttributes);
|
||||
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
|
||||
findings.sort(Comparator.comparing(DriftFinding::severity));
|
||||
return (findings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A check.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface Check
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
void run(List<DriftFinding> findings) throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void runCheck(List<DriftFinding> findings, String name, Check check)
|
||||
{
|
||||
try
|
||||
{
|
||||
check.run(findings);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
LOG.warn("Drift check could not run", logPair("check", name), logPair("error", AuthentikRedactor.redactText(message, config.getResolvedApiToken())));
|
||||
findings.add(DriftFinding.error(name, "-", "Check could not run, so its result is UNKNOWN: " + AuthentikRedactor.redactText(message, config.getResolvedApiToken())));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String encode(String value)
|
||||
{
|
||||
return (java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 1
|
||||
*******************************************************************************/
|
||||
void checkVersion(List<DriftFinding> findings)
|
||||
{
|
||||
String expected = config.getExpectedServerVersion();
|
||||
String actual = client.fetchServerVersion().orElse(null);
|
||||
if(expected == null || expected.isBlank())
|
||||
{
|
||||
findings.add(DriftFinding.info("version", actual == null ? "Authentik" : "Authentik " + actual, "No expectedServerVersion configured."));
|
||||
}
|
||||
else if(actual == null)
|
||||
{
|
||||
findings.add(DriftFinding.warning("version", "Authentik", "Server version unavailable (GET /admin/version/ failed)."));
|
||||
}
|
||||
else if(!(actual.equals(expected) || actual.startsWith(expected + ".")))
|
||||
{
|
||||
findings.add(DriftFinding.warning("version", "Authentik " + actual, "qbit-authentik was tested against " + expected + ". Run its contract tests against " + actual + " before relying on it."));
|
||||
}
|
||||
else
|
||||
{
|
||||
findings.add(DriftFinding.ok("version", "Authentik " + actual, "Matches the tested version " + expected + "."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 2
|
||||
*******************************************************************************/
|
||||
void checkBlueprints(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
int failing = 0;
|
||||
for(JSONObject blueprint : client.getAllPages("/managed/blueprints/", 1000))
|
||||
{
|
||||
String status = blueprint.optString("status", "unknown");
|
||||
if(!blueprint.optBoolean("enabled", true))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!"successful".equals(status))
|
||||
{
|
||||
failing++;
|
||||
findings.add(DriftFinding.error("blueprints", blueprint.optString("name"),
|
||||
"Blueprint status is \"" + status + "\" (last applied " + blueprint.optString("last_applied", "never") + "). A failed blueprint rolls back entirely; check the worker logs."));
|
||||
}
|
||||
}
|
||||
if(failing == 0)
|
||||
{
|
||||
findings.add(DriftFinding.ok("blueprints", "all", "Every enabled blueprint applied successfully."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 3
|
||||
*******************************************************************************/
|
||||
void checkServiceAccount(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
String username = config.getEffectiveServiceAccountUsername();
|
||||
JSONArray results = client.getApiJson("/core/users/?include_groups=true&username=" + encode(username)).optJSONArray("results");
|
||||
JSONObject user = results == null || results.isEmpty() ? null : results.getJSONObject(0);
|
||||
if(user == null)
|
||||
{
|
||||
findings.add(DriftFinding.error("service account", username, "Not found, or not visible to the API token's user. Is the token minted for this account?"));
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> problems = new ArrayList<>();
|
||||
if(!"service_account".equals(user.optString("type")))
|
||||
{
|
||||
problems.add("type is " + user.optString("type") + ", not service_account");
|
||||
}
|
||||
if(user.optBoolean("is_superuser", false))
|
||||
{
|
||||
problems.add("it is a SUPERUSER: remove it from every superuser group now");
|
||||
}
|
||||
JSONArray groups = user.optJSONArray("groups_obj");
|
||||
if(groups != null && !groups.isEmpty())
|
||||
{
|
||||
problems.add("it is in " + groups.length() + " group(s); it should be in none");
|
||||
}
|
||||
if(!user.optBoolean("is_active", true))
|
||||
{
|
||||
problems.add("it is inactive");
|
||||
}
|
||||
JSONObject attributes = user.optJSONObject("attributes");
|
||||
if(!AuthentikGuard.isMarkedProtected(user))
|
||||
{
|
||||
problems.add("qqq.io/protected is not true");
|
||||
}
|
||||
for(String problem : problems)
|
||||
{
|
||||
findings.add((problem.contains("SUPERUSER") ? DriftFinding.error("service account", username, problem) : DriftFinding.warning("service account", username, problem)));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// tokens: listing without view_token returns the caller's own tokens //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
List<JSONObject> tokens = client.getAllPages("/core/tokens/?intent=api&user__username=" + encode(username), 200);
|
||||
int apiOwned = 0;
|
||||
if(tokens.size() >= 200)
|
||||
{
|
||||
findings.add(DriftFinding.error("service account", username, "200 or more API tokens for the service account; something is minting tokens."));
|
||||
}
|
||||
for(JSONObject token : tokens)
|
||||
{
|
||||
JSONObject owner = token.optJSONObject("user_obj");
|
||||
if(owner != null && username.equals(owner.optString("username")) && "api".equals(token.optString("intent")))
|
||||
{
|
||||
apiOwned++;
|
||||
}
|
||||
}
|
||||
if(apiOwned == 0)
|
||||
{
|
||||
findings.add(DriftFinding.warning("service account", username, "The service account's own API token is not visible, so extra tokens cannot be detected."));
|
||||
}
|
||||
else if(apiOwned > 1)
|
||||
{
|
||||
findings.add(DriftFinding.warning("service account", username, apiOwned + " API tokens exist for the service account; expected 1. It can mint its own tokens, so an extra one may be a leftover rotation or persistence after a compromise. Delete the ones not in Infisical."));
|
||||
}
|
||||
|
||||
if(problems.isEmpty() && apiOwned == 1)
|
||||
{
|
||||
findings.add(DriftFinding.ok("service account", username, "Service account, no groups, not a superuser, protected, one API token."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 4
|
||||
*******************************************************************************/
|
||||
void checkBreakGlass(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
List<JSONObject> protectedUsers = client.getAllPages("/core/users/?attributes=" + java.net.URLEncoder.encode("{\"" + AuthentikJson.ATTR_PROTECTED + "\": true}", java.nio.charset.StandardCharsets.UTF_8), 200);
|
||||
List<String> breakGlass = new ArrayList<>();
|
||||
for(JSONObject user : protectedUsers)
|
||||
{
|
||||
if(user.optBoolean("is_superuser", false) && user.optBoolean("is_active", true) && AuthentikGuard.isMarkedProtected(user))
|
||||
{
|
||||
breakGlass.add(user.optString("username"));
|
||||
}
|
||||
}
|
||||
if(breakGlass.isEmpty())
|
||||
{
|
||||
findings.add(DriftFinding.error("break-glass", "-", "No active superuser marked qqq.io/protected. Without one, a lockout can only be undone from the cluster (ak shell)."));
|
||||
}
|
||||
else
|
||||
{
|
||||
findings.add(DriftFinding.ok("break-glass", String.join(", ", breakGlass), "Active, protected superuser present."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 5
|
||||
*******************************************************************************/
|
||||
void checkIdentityAdmins(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
List<JSONObject> groups = client.getAllPages("/core/groups/?include_users=true&ordering=name", AuthentikActionUtil.MAX_SWEEP_OBJECTS);
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups);
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
|
||||
|
||||
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
|
||||
Set<Integer> humans = new HashSet<>();
|
||||
for(JSONObject human : client.getAllPages("/core/users/?type=internal&type=external", AuthentikActionUtil.MAX_SWEEP_OBJECTS))
|
||||
{
|
||||
humans.add(human.getInt("pk"));
|
||||
}
|
||||
Set<Integer> checked = new HashSet<>();
|
||||
List<String> admins = new ArrayList<>();
|
||||
for(JSONObject group : groups)
|
||||
{
|
||||
JSONArray users = group.optJSONArray("users_obj");
|
||||
for(int i = 0; users != null && i < users.length(); i++)
|
||||
{
|
||||
JSONObject user = users.getJSONObject(i);
|
||||
if(!checked.add(user.getInt("pk")) || !user.optBoolean("is_active", true) || !humans.contains(user.getInt("pk")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// members are partial users (no is_superuser): derive it from the //
|
||||
// groups, so superusers (who cannot be managed from QQQ) and other //
|
||||
// protected accounts are not counted //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
boolean superuser = graph.groupsOfUser(user.getInt("pk")).stream().anyMatch(graph::isSuperuserInherited);
|
||||
if(superuser || guard.protection(user).isProtected())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(EffectivePermissionResolver.holdsAll(resolver.resolveUser(user.getInt("pk")), required))
|
||||
{
|
||||
admins.add(user.optString("username"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(admins.isEmpty())
|
||||
{
|
||||
String bootstrap = config.getResolvedBootstrapOperators().isEmpty() ? "" : " bootstrapOperators is set, which covers sign-in for now.";
|
||||
findings.add(DriftFinding.error("identity admins", "-", "Nobody can administer identities from QQQ: no active, unprotected human user holds authentik.memberships.manage and authentik.permissions.manage. Add an administrator to a group carrying authentik.* (e.g. identity-admins) in the Authentik UI." + bootstrap));
|
||||
}
|
||||
else if(admins.size() == 1)
|
||||
{
|
||||
findings.add(DriftFinding.info("identity admins", admins.get(0), "Only one identity administrator. Two-person approval of authentik.* grants needs a second one (or the Authentik UI)."));
|
||||
}
|
||||
else
|
||||
{
|
||||
findings.add(DriftFinding.ok("identity admins", String.join(", ", admins), admins.size() + " identity administrators."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 6
|
||||
*******************************************************************************/
|
||||
void checkGroupAttributes(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
GroupGraph graph = client.groupGraph();
|
||||
EffectivePermissionResolver resolver = client.resolver();
|
||||
Set<String> inScope = new HashSet<>(config.getApplicationsInScope() == null ? List.of() : config.getApplicationsInScope());
|
||||
int issues = 0;
|
||||
|
||||
for(GroupGraph.Node node : graph.nodes())
|
||||
{
|
||||
for(String bad : node.malformed())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings."));
|
||||
}
|
||||
for(String permission : node.permissions())
|
||||
{
|
||||
if(resolver.expand(permission).isEmpty())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard)."));
|
||||
}
|
||||
}
|
||||
for(String app : node.appAccess())
|
||||
{
|
||||
if(!inScope.isEmpty() && !inScope.contains(app))
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.info("group attributes", node.name(), "qqq.io/app-access lists \"" + app + "\", which is not in applicationsInScope."));
|
||||
}
|
||||
}
|
||||
if(graph.isSuperuserInherited(node.pk()) && !node.permissions().isEmpty())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups."));
|
||||
}
|
||||
}
|
||||
if(issues == 0)
|
||||
{
|
||||
findings.add(DriftFinding.ok("group attributes", graph.nodes().size() + " groups", "Every qqq.io/* attribute is valid."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 7
|
||||
*******************************************************************************/
|
||||
void checkServiceAccountActivity(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
String username = config.getEffectiveServiceAccountUsername();
|
||||
List<JSONObject> events = client.getAllPages("/events/events/?ordering=-created&username=" + encode(username), 200);
|
||||
GroupGraph graph = client.groupGraph();
|
||||
int flagged = 0;
|
||||
|
||||
for(JSONObject event : events)
|
||||
{
|
||||
JSONObject context = event.optJSONObject("context");
|
||||
JSONObject model = context == null ? null : context.optJSONObject("model");
|
||||
if(model == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String modelName = model.optString("model_name");
|
||||
String pk = model.optString("pk");
|
||||
boolean sensitive = false;
|
||||
if(!"user".equals(modelName) && !"group".equals(modelName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if("model_deleted".equals(event.optString("action")))
|
||||
{
|
||||
sensitive = true;
|
||||
}
|
||||
else if("group".equals(modelName))
|
||||
{
|
||||
sensitive = graph.byPk(pk) == null || graph.isSuperuserInherited(pk);
|
||||
}
|
||||
else if(pk.matches("[0-9]+"))
|
||||
{
|
||||
JSONObject user = client.getApiJsonOrNull("/core/users/" + pk + "/");
|
||||
sensitive = user == null || (guard.protection(user).isProtected() && !username.equals(user.optString("username")));
|
||||
}
|
||||
if(sensitive)
|
||||
{
|
||||
flagged++;
|
||||
findings.add(DriftFinding.error("service-account activity", model.optString("name", pk),
|
||||
"The qbit's service account changed a protected or superuser object, deleted one, or changed one that no longer exists (" + event.optString("action") + " at " + event.optString("created") + "). QQQ never does this; investigate as a possible compromise."));
|
||||
}
|
||||
}
|
||||
if(flagged == 0)
|
||||
{
|
||||
findings.add(DriftFinding.ok("service-account activity", username, "No changes to protected or superuser objects in the last " + events.size() + " service-account events."));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.drift;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the drift report.
|
||||
**
|
||||
** @param severity ERROR (identity management is broken or unsafe), WARNING
|
||||
** (needs attention), INFO (worth knowing), OK (a check passed)
|
||||
** @param check the check's short name, e.g. "blueprints"
|
||||
** @param subject what the finding is about, e.g. a group name
|
||||
** @param message plain-language explanation and what to do
|
||||
*******************************************************************************/
|
||||
public record DriftFinding(Severity severity, String check, String subject, String message)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Severity, most severe first.
|
||||
*******************************************************************************/
|
||||
public enum Severity
|
||||
{
|
||||
ERROR, WARNING, INFO, OK
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static DriftFinding error(String check, String subject, String message)
|
||||
{
|
||||
return (new DriftFinding(Severity.ERROR, check, subject, message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static DriftFinding warning(String check, String subject, String message)
|
||||
{
|
||||
return (new DriftFinding(Severity.WARNING, check, subject, message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static DriftFinding info(String check, String subject, String message)
|
||||
{
|
||||
return (new DriftFinding(Severity.INFO, check, subject, message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static DriftFinding ok(String check, String subject, String message)
|
||||
{
|
||||
return (new DriftFinding(Severity.OK, check, subject, message));
|
||||
}
|
||||
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikFieldSpec;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APITableBackendDetails;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds a read-only QQQ table from an AuthentikTableSpec and registers it
|
||||
** with the runtime. Subclasses name the spec.
|
||||
*******************************************************************************/
|
||||
public abstract class AbstractAuthentikTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** The unprefixed spec name, e.g. AuthentikTables.USER.
|
||||
*******************************************************************************/
|
||||
protected abstract String getSpecName();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables need READ; subclasses may add conditions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (buildTable(getQBitConfig(), AuthentikTables.get(getSpecName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the table and register it with the config's runtime.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildTable(AuthentikQBitConfig config, AuthentikTableSpec spec)
|
||||
{
|
||||
String tableName = config.applyTablePrefix(spec.getName());
|
||||
|
||||
QTableMetaData table = new QTableMetaData()
|
||||
.withName(tableName)
|
||||
.withLabel(spec.getLabel())
|
||||
.withIcon(new QIcon(spec.getIcon()))
|
||||
.withBackendName(config.getApiBackendName())
|
||||
.withPrimaryKeyField(spec.getKeyFieldName())
|
||||
.withBackendDetails(new APITableBackendDetails().withTablePath(spec.getPath()))
|
||||
.withPermissionRules(spec.getReadPermission().rules())
|
||||
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_EXPORT)
|
||||
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE, Capability.QUERY_STATS);
|
||||
|
||||
for(AuthentikFieldSpec field : spec.getFields())
|
||||
{
|
||||
QFieldMetaData fieldMetaData = new QFieldMetaData(field.getName(), field.getType()).withLabel(field.getLabel());
|
||||
if(field.isHidden())
|
||||
{
|
||||
fieldMetaData.withIsHidden(true);
|
||||
}
|
||||
table.withField(fieldMetaData);
|
||||
}
|
||||
|
||||
if(!spec.getRecordLabelFields().isEmpty())
|
||||
{
|
||||
table.withRecordLabelFormat(String.join(" ", spec.getRecordLabelFields().stream().map(f -> "%s").toList()));
|
||||
table.withRecordLabelFields(spec.getRecordLabelFields());
|
||||
}
|
||||
|
||||
AuthentikRuntime.forBackend(config.getApiBackendName()).registerTable(tableName, spec);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** After the backend.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (200);
|
||||
}
|
||||
|
||||
}
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Identity" app (DESIGN.md section 8.1): the qbit's tables (and, from
|
||||
** phase 1, its widgets), gated by authentik.users.read. Hosts may re-parent it,
|
||||
** for example under an Admin app; its name stays the same. Processes are only
|
||||
** ever table or record actions, never navigation entries.
|
||||
*******************************************************************************/
|
||||
public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<QAppMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
public static final String APP_NAME = "admin";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QAppMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = getQBitConfig();
|
||||
|
||||
QAppMetaData app = new QAppMetaData()
|
||||
.withName(config.applyPrefix(APP_NAME))
|
||||
.withLabel("Identity")
|
||||
.withIcon(new QIcon("badge"))
|
||||
.withPermissionRules(IdentityPermission.USERS_READ.rules());
|
||||
|
||||
MetaDataProducerMultiOutput produced = QBitProductionContext.peekMetaDataProducerMultiOutput();
|
||||
if(produced != null)
|
||||
{
|
||||
for(String specName : AuthentikTables.all().keySet())
|
||||
{
|
||||
QTableMetaData table = produced.get(QTableMetaData.class, config.applyTablePrefix(specName));
|
||||
if(table != null)
|
||||
{
|
||||
app.withChild(table);
|
||||
}
|
||||
}
|
||||
|
||||
String driftReport = config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME);
|
||||
if(produced.get(com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData.class, driftReport) != null)
|
||||
{
|
||||
app.withWidgets(java.util.List.of(driftReport));
|
||||
}
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Last, so the tables exist in the output.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (900);
|
||||
}
|
||||
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.module.api.model.AuthorizationType;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The API backend all Authentik tables use. Authorization is CUSTOM: the
|
||||
** action util adds the bearer token itself, from the qbit config, so the token
|
||||
** never sits in backend metadata (which QQQ can serialize).
|
||||
*******************************************************************************/
|
||||
public class AuthentikBackendMetaDataProducer extends QBitComponentMetaDataProducer<APIBackendMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public APIBackendMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = getQBitConfig();
|
||||
APIBackendMetaData backend = new APIBackendMetaData()
|
||||
.withBaseUrl(config.getApiRoot())
|
||||
.withAuthorizationType(AuthorizationType.CUSTOM)
|
||||
.withContentType("application/json")
|
||||
.withActionUtil(new QCodeReference(AuthentikActionUtil.class));
|
||||
backend.setName(config.getApiBackendName());
|
||||
return (backend);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** First: the tables reference it.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (100);
|
||||
}
|
||||
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Identity Drift Report" widget: a standard TABLE widget (generic UI, no
|
||||
** frontend work) listing the AuthentikDriftReport findings. Needs
|
||||
** authentik.admin. The renderer finds the qbit through the "backendName"
|
||||
** default value.
|
||||
*******************************************************************************/
|
||||
public class AuthentikDriftReportWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
public static final String NAME = "driftReport";
|
||||
public static final String BACKEND_NAME_KEY = "backendName";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = getQBitConfig();
|
||||
return (new QWidgetMetaData()
|
||||
.withName(config.applyPrefix(NAME))
|
||||
.withLabel("Identity Drift Report")
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(12)
|
||||
.withIsCard(true)
|
||||
.withCodeReference(new QCodeReference(AuthentikDriftReportWidgetRenderer.class))
|
||||
.withPermissionRules(IdentityPermission.ADMIN.rules())
|
||||
.withDefaultValue(BACKEND_NAME_KEY, config.getApiBackendName()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** After the tables, before the app.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (500);
|
||||
}
|
||||
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the drift report as a TableData. Re-checks authentik.admin itself
|
||||
** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
*******************************************************************************/
|
||||
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN))
|
||||
{
|
||||
throw (new QPermissionDeniedException("You do not have permission to view the identity drift report."));
|
||||
}
|
||||
|
||||
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(AuthentikDriftReportWidgetProducer.BACKEND_NAME_KEY));
|
||||
AuthentikRuntime runtime = AuthentikRuntime.forBackend(backendName);
|
||||
AuthentikActionUtil client = AuthentikActionUtil.forBackend(backendName);
|
||||
List<DriftFinding> findings = new AuthentikDriftReport(client, runtime.getConfig()).run();
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(DriftFinding finding : findings)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("severity", finding.severity().name());
|
||||
row.put("check", finding.check());
|
||||
row.put("subject", finding.subject());
|
||||
row.put("message", finding.message());
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("default", "Severity", "severity", "10%", "left"),
|
||||
new TableData.Column("default", "Check", "check", "15%", "left"),
|
||||
new TableData.Column("default", "Subject", "subject", "20%", "left"),
|
||||
new TableData.Column("default", "Finding", "message", "55%", "left"));
|
||||
|
||||
TableData tableData = new TableData("Identity Drift Report", columns, rows)
|
||||
.withRowsPerPage(25)
|
||||
.withNoRowsFoundHTML("No findings.");
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** authentik_event: /events/events/ (needs authentik.audit.read).
|
||||
*******************************************************************************/
|
||||
public class AuthentikEventTableProducer extends AbstractAuthentikTableProducer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getSpecName()
|
||||
{
|
||||
return (AuthentikTables.EVENT);
|
||||
}
|
||||
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** authentik_group_member: virtual membership table (group x user).
|
||||
*******************************************************************************/
|
||||
public class AuthentikGroupMemberTableProducer extends AbstractAuthentikTableProducer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getSpecName()
|
||||
{
|
||||
return (AuthentikTables.GROUP_MEMBER);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** authentik_group: /core/groups/.
|
||||
*******************************************************************************/
|
||||
public class AuthentikGroupTableProducer extends AbstractAuthentikTableProducer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getSpecName()
|
||||
{
|
||||
return (AuthentikTables.GROUP);
|
||||
}
|
||||
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** authentik_session: /core/authenticated_sessions/ (needs authentik.audit.read).
|
||||
*******************************************************************************/
|
||||
public class AuthentikSessionTableProducer extends AbstractAuthentikTableProducer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getSpecName()
|
||||
{
|
||||
return (AuthentikTables.SESSION);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** authentik_user: /core/users/.
|
||||
*******************************************************************************/
|
||||
public class AuthentikUserTableProducer extends AbstractAuthentikTableProducer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getSpecName()
|
||||
{
|
||||
return (AuthentikTables.USER);
|
||||
}
|
||||
|
||||
}
|
||||
-243
@@ -1,243 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sign-in permissions from Authentik groups (DESIGN.md section 5.6). Use as
|
||||
** (or extend in) the host's OAuth2 authentication customizer.
|
||||
**
|
||||
** 1. Group names come from the token's "groups" claim (a typical profile
|
||||
** mapping emits direct groups only).
|
||||
** 2. Each group is resolved with its ancestors through the cached group
|
||||
** graph; the union of qqq.io/permissions becomes session permissions,
|
||||
** expanded and checked against the instance's permission catalog.
|
||||
** 3. Static fallbacks (withGroupPermissions) and bootstrapOperators are added.
|
||||
** 4. If Authentik cannot be read, only the static fallbacks (and bootstrap
|
||||
** operators) apply: it never grants everything.
|
||||
**
|
||||
** The host keeps its own user/full-name handling; this only adds permissions.
|
||||
** QQQ instantiates customizers with no arguments, so the API backend is found
|
||||
** by name (default "authentikApi"; override getApiBackendName for a prefix).
|
||||
*******************************************************************************/
|
||||
public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikGroupPermissionMapper.class);
|
||||
|
||||
public static final String CLAIMS_CONTEXT_KEY = "jwtPayloadJsonObject";
|
||||
|
||||
private final Map<String, List<String>> staticGroupPermissions = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's API backend name (default: the unprefixed-namespace default).
|
||||
*******************************************************************************/
|
||||
protected String getApiBackendName()
|
||||
{
|
||||
return ("authentikApi");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static fallback: always grant these permissions to members of a group
|
||||
** (useful in tests and dev, and as the floor when Authentik is down).
|
||||
*******************************************************************************/
|
||||
public AuthentikGroupPermissionMapper withGroupPermissions(String groupName, List<String> permissions)
|
||||
{
|
||||
staticGroupPermissions.put(groupName, new ArrayList<>(permissions));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add permissions from the session's token claims.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
|
||||
{
|
||||
JSONObject claims = context == null ? null : asClaims(context.get(CLAIMS_CONTEXT_KEY));
|
||||
if(claims == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<String> groups = groupNames(claims);
|
||||
String email = claims.optString("email", "").trim().toLowerCase();
|
||||
|
||||
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, email));
|
||||
if(qSession.getPermissions() != null)
|
||||
{
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
}
|
||||
qSession.setPermissions(permissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permissions for a user's group names and email.
|
||||
*******************************************************************************/
|
||||
public Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String email)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(String group : groupNames)
|
||||
{
|
||||
rs.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
|
||||
}
|
||||
|
||||
AuthentikRuntime runtime = runtime();
|
||||
if(runtime != null && email != null && runtime.getConfig().getResolvedBootstrapOperators().contains(email))
|
||||
{
|
||||
LOG.warn("bootstrapOperators grants all identity permissions; remove it once an identity administrator is set up in Authentik", logPair("email", email));
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
rs.add(permission.getSessionPermission());
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AuthentikActionUtil client = client(qInstance);
|
||||
GroupGraph graph = client.groupGraph();
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog());
|
||||
List<String> pks = new ArrayList<>();
|
||||
for(String name : groupNames)
|
||||
{
|
||||
GroupGraph.Node node = graph.byName(name);
|
||||
if(node != null)
|
||||
{
|
||||
pks.add(node.pk());
|
||||
}
|
||||
}
|
||||
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(pks);
|
||||
if(!resolution.unknown().isEmpty())
|
||||
{
|
||||
LOG.info("Group attributes name permissions this app does not have", logPair("unknown", resolution.unknown()));
|
||||
}
|
||||
rs.addAll(resolution.sessionPermissions());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Authentik groups could not be read at sign-in; granting only the static fallbacks", logPair("error", AuthentikRedactor.redactText(e.getMessage(), runtime == null ? null : runtime.getConfig().getResolvedApiToken())));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AuthentikRuntime runtime()
|
||||
{
|
||||
try
|
||||
{
|
||||
return (AuthentikRuntime.forBackend(getApiBackendName()));
|
||||
}
|
||||
catch(IllegalStateException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A client bound to the qbit backend of the given instance.
|
||||
*******************************************************************************/
|
||||
protected AuthentikActionUtil client(QInstance qInstance)
|
||||
{
|
||||
if(qInstance == null || !(qInstance.getBackend(getApiBackendName()) instanceof APIBackendMetaData backend))
|
||||
{
|
||||
throw (new IllegalStateException("No Authentik API backend named " + getApiBackendName()));
|
||||
}
|
||||
BaseAPIActionUtil util = QCodeLoader.getAdHoc(BaseAPIActionUtil.class, backend.getActionUtil());
|
||||
util.setBackendMetaData(backend);
|
||||
return ((AuthentikActionUtil) util);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static JSONObject asClaims(Object value)
|
||||
{
|
||||
if(value instanceof JSONObject object)
|
||||
{
|
||||
return (object);
|
||||
}
|
||||
if(value instanceof Map<?, ?> map)
|
||||
{
|
||||
return (new JSONObject(map));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group names from the "groups" claim (array, or a single string).
|
||||
*******************************************************************************/
|
||||
static List<String> groupNames(JSONObject claims)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
Object groups = claims.opt("groups");
|
||||
if(groups instanceof JSONArray array)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
String name = array.optString(i, "").trim();
|
||||
if(!name.isEmpty())
|
||||
{
|
||||
rs.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(groups instanceof String name && !name.isBlank())
|
||||
{
|
||||
rs.add(name.trim());
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
}
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Turns groups (and their ancestors) into QQQ permissions (DESIGN.md
|
||||
** section 5.6). Pure: give it a GroupGraph and a permission catalog.
|
||||
**
|
||||
** Rules:
|
||||
** - A group confers the union of qqq.io/permissions on itself and every
|
||||
** ancestor ("inherited parents").
|
||||
** - "authentik.*" expands to every identity permission; an identity short
|
||||
** name ("authentik.users.read") becomes its session form
|
||||
** ("authentik.users.read.hasAccess").
|
||||
** - Any other name ending in ".*" expands against the catalog (prefix match);
|
||||
** with no catalog it is dropped, never granted as a literal wildcard.
|
||||
** - Names not in the catalog are reported as unknown and NOT granted (when a
|
||||
** catalog is given); with no catalog they pass through unchanged.
|
||||
*******************************************************************************/
|
||||
public class EffectivePermissionResolver
|
||||
{
|
||||
private final GroupGraph graph;
|
||||
private final Set<String> catalog;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result for a set of groups.
|
||||
*******************************************************************************/
|
||||
public record Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
**
|
||||
** @param graph the group hierarchy
|
||||
** @param catalog QQQ permission names that may be granted (session form,
|
||||
** e.g. from PermissionsHelper.getAllAvailablePermissionNames);
|
||||
** null to pass non-identity names through unchecked
|
||||
*******************************************************************************/
|
||||
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog)
|
||||
{
|
||||
this.graph = graph;
|
||||
this.catalog = catalog == null ? null : new TreeSet<>(catalog);
|
||||
if(this.catalog != null)
|
||||
{
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
this.catalog.add(permission.getSessionPermission());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Effective permissions of a set of groups (e.g. a user's direct groups).
|
||||
*******************************************************************************/
|
||||
public Resolution resolveGroups(Collection<String> groupPks)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
Map<String, Set<String>> grantedBy = new LinkedHashMap<>();
|
||||
Set<String> unknown = new TreeSet<>();
|
||||
Set<String> appAccess = new TreeSet<>();
|
||||
|
||||
Set<String> visited = new LinkedHashSet<>();
|
||||
for(String pk : groupPks)
|
||||
{
|
||||
for(GroupGraph.Node node : graph.selfAndAncestors(pk))
|
||||
{
|
||||
if(!visited.add(node.pk()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
appAccess.addAll(node.appAccess());
|
||||
for(String raw : node.permissions())
|
||||
{
|
||||
Set<String> expanded = expand(raw);
|
||||
if(expanded.isEmpty())
|
||||
{
|
||||
unknown.add(raw);
|
||||
}
|
||||
for(String permission : expanded)
|
||||
{
|
||||
permissions.add(permission);
|
||||
grantedBy.computeIfAbsent(permission, k -> new TreeSet<>()).add(node.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new Resolution(permissions, grantedBy, unknown, appAccess));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Effective permissions of a user from the graph's memberships.
|
||||
*******************************************************************************/
|
||||
public Resolution resolveUser(int userPk)
|
||||
{
|
||||
return (resolveGroups(graph.groupsOfUser(userPk)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expand one attribute entry into session permissions (empty when it
|
||||
** grants nothing known).
|
||||
*******************************************************************************/
|
||||
public Set<String> expand(String raw)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
if(raw == null || raw.isBlank())
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
String name = raw.trim();
|
||||
|
||||
if(name.equals(IdentityPermission.WILDCARD))
|
||||
{
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
rs.add(permission.getSessionPermission());
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
if(IdentityPermission.fromName(name).isPresent())
|
||||
{
|
||||
rs.add(IdentityPermission.fromName(name).get().getSessionPermission());
|
||||
return (rs);
|
||||
}
|
||||
|
||||
if(name.endsWith(".*"))
|
||||
{
|
||||
String prefix = name.substring(0, name.length() - 1);
|
||||
if(catalog != null)
|
||||
{
|
||||
catalog.stream().filter(c -> c.startsWith(prefix)).forEach(rs::add);
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
if(catalog == null || catalog.contains(name))
|
||||
{
|
||||
rs.add(name);
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a resolution holds every given identity permission.
|
||||
*******************************************************************************/
|
||||
public static boolean holdsAll(Resolution resolution, List<IdentityPermission> required)
|
||||
{
|
||||
return (required.stream().allMatch(p -> resolution.sessionPermissions().contains(p.getSessionPermission())));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Snapshot of Authentik's group hierarchy: groups by pk with their parents
|
||||
** (many-to-many since 2025.12), own superuser flag, qqq.io/* attributes and
|
||||
** member pks. Ancestry closures are computed on demand and cycle-safe.
|
||||
*******************************************************************************/
|
||||
public class GroupGraph
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** One group.
|
||||
*******************************************************************************/
|
||||
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
private final Map<String, Node> byPk = new LinkedHashMap<>();
|
||||
private final Map<String, Node> byName = new LinkedHashMap<>();
|
||||
private final Map<String, Set<String>> ancestors = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build from Authentik group objects (as returned by /core/groups/).
|
||||
*******************************************************************************/
|
||||
public static GroupGraph fromGroups(Collection<JSONObject> groups)
|
||||
{
|
||||
GroupGraph graph = new GroupGraph();
|
||||
for(JSONObject group : groups)
|
||||
{
|
||||
graph.add(new Node(
|
||||
group.getString("pk"),
|
||||
group.optString("name", ""),
|
||||
group.optBoolean("is_superuser", false),
|
||||
strings(group.optJSONArray("parents")),
|
||||
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)),
|
||||
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)),
|
||||
ints(group.optJSONArray("users")),
|
||||
malformed(group.optJSONObject("attributes"))));
|
||||
}
|
||||
return (graph);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a node.
|
||||
*******************************************************************************/
|
||||
public GroupGraph add(Node node)
|
||||
{
|
||||
byPk.put(node.pk(), node);
|
||||
byName.put(node.name(), node);
|
||||
ancestors.clear();
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All groups.
|
||||
*******************************************************************************/
|
||||
public Collection<Node> nodes()
|
||||
{
|
||||
return (byPk.values());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group by pk, or null.
|
||||
*******************************************************************************/
|
||||
public Node byPk(String pk)
|
||||
{
|
||||
return (byPk.get(pk));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group by name, or null.
|
||||
*******************************************************************************/
|
||||
public Node byName(String name)
|
||||
{
|
||||
return (byName.get(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All ancestors of a group (parents, their parents, ...), not including the
|
||||
** group itself. Unknown parents are ignored; cycles terminate.
|
||||
*******************************************************************************/
|
||||
public Set<String> ancestorPks(String pk)
|
||||
{
|
||||
Set<String> cached = ancestors.get(pk);
|
||||
if(cached != null)
|
||||
{
|
||||
return (cached);
|
||||
}
|
||||
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
Deque<String> queue = new ArrayDeque<>();
|
||||
Node start = byPk.get(pk);
|
||||
if(start != null)
|
||||
{
|
||||
queue.addAll(start.parentPks());
|
||||
}
|
||||
while(!queue.isEmpty())
|
||||
{
|
||||
String next = queue.removeFirst();
|
||||
if(next.equals(pk) || !seen.add(next))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Node parent = byPk.get(next);
|
||||
if(parent != null)
|
||||
{
|
||||
queue.addAll(parent.parentPks());
|
||||
}
|
||||
}
|
||||
Set<String> immutable = java.util.Collections.unmodifiableSet(seen);
|
||||
ancestors.put(pk, immutable);
|
||||
return (immutable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The group and all of its ancestors.
|
||||
*******************************************************************************/
|
||||
public List<Node> selfAndAncestors(String pk)
|
||||
{
|
||||
List<Node> rs = new ArrayList<>();
|
||||
Node self = byPk.get(pk);
|
||||
if(self != null)
|
||||
{
|
||||
rs.add(self);
|
||||
}
|
||||
for(String ancestor : ancestorPks(pk))
|
||||
{
|
||||
Node node = byPk.get(ancestor);
|
||||
if(node != null)
|
||||
{
|
||||
rs.add(node);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a group grants superuser status, itself or through any ancestor
|
||||
** (Authentik's User.all_groups() includes ancestors).
|
||||
*******************************************************************************/
|
||||
public boolean isSuperuserInherited(String pk)
|
||||
{
|
||||
return (selfAndAncestors(pk).stream().anyMatch(Node::ownSuperuser));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** pks of the groups a user is a direct member of.
|
||||
*******************************************************************************/
|
||||
public List<String> groupsOfUser(int userPk)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(Node node : byPk.values())
|
||||
{
|
||||
if(node.memberPks().contains(userPk))
|
||||
{
|
||||
rs.add(node.pk());
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> strings(JSONArray array)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
if(array != null)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
Object value = array.get(i);
|
||||
rs.add(value instanceof JSONObject object ? object.optString("pk") : String.valueOf(value));
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<Integer> ints(JSONArray array)
|
||||
{
|
||||
List<Integer> rs = new ArrayList<>();
|
||||
if(array != null)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
Object value = array.get(i);
|
||||
if(value instanceof Number number)
|
||||
{
|
||||
rs.add(number.intValue());
|
||||
}
|
||||
else if(value instanceof JSONObject object && object.has("pk"))
|
||||
{
|
||||
rs.add(object.getInt("pk"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> malformed(JSONObject attributes)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(String key : List.of(AuthentikJson.ATTR_PERMISSIONS, AuthentikJson.ATTR_APP_ACCESS))
|
||||
{
|
||||
for(String bad : AuthentikJson.malformedEntries(attributes, key))
|
||||
{
|
||||
rs.add(key + ": " + bad);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> nonNull(List<String> list)
|
||||
{
|
||||
return (list == null ? List.of() : list);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.security;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's own enforcement point (DESIGN.md section 5.3). QQQ core actions
|
||||
** do not enforce permissions for non-HTTP callers (QRun-IO/qqq#1061), so every
|
||||
** call into AuthentikActionUtil is checked here first:
|
||||
** - reads need the table's authentik.* permission (system sessions may read,
|
||||
** for the mirror sync);
|
||||
** - sensitive fields need authentik.audit.read;
|
||||
** - writes are refused in this release (read-only MVP), and always refused
|
||||
** for system sessions, in readOnly mode, or without the feature;
|
||||
** - records outside userScope are invisible.
|
||||
**
|
||||
** Protection rules for targets (protected accounts, superuser ancestry) are
|
||||
** computed here too, so the UI can show them before writes exist.
|
||||
*******************************************************************************/
|
||||
public class AuthentikGuard
|
||||
{
|
||||
private final AuthentikQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public AuthentikGuard(AuthentikQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check that the current session may read a table.
|
||||
*******************************************************************************/
|
||||
public void checkRead(AuthentikTableSpec spec) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(!config.hasFeature(AuthentikFeature.READ))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Authentik reads are not enabled."));
|
||||
}
|
||||
if(session instanceof QSystemUserSession)
|
||||
{
|
||||
return;
|
||||
}
|
||||
requireUser(session);
|
||||
if(!has(session, spec.getReadPermission()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("You do not have permission to view " + spec.getLabel() + "."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes: refused in this release. Kept as the single gate later phases
|
||||
** extend (feature flags, readOnly, system sessions, per-target rules).
|
||||
*******************************************************************************/
|
||||
public void checkWrite(AuthentikTableSpec spec, String operation) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session instanceof QSystemUserSession)
|
||||
{
|
||||
throw (new QPermissionDeniedException("System sessions may not change Authentik data."));
|
||||
}
|
||||
if(Boolean.TRUE.equals(config.getReadOnly()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("The Identity screens are in read-only mode."));
|
||||
}
|
||||
throw (new QPermissionDeniedException(spec.getLabel() + " cannot be changed from QQQ in this release (" + operation + " is not available; use the Authentik admin UI)."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the current session may see and filter sensitive fields.
|
||||
*******************************************************************************/
|
||||
public boolean maySeeSensitive()
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
return (session instanceof QSystemUserSession || has(session, IdentityPermission.AUDIT_READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a user object is inside the configured user scope. Applied to
|
||||
** every record returned, in addition to the server-side scope filters.
|
||||
*******************************************************************************/
|
||||
public boolean isUserInScope(JSONObject user)
|
||||
{
|
||||
String type = user.optString("type", "");
|
||||
if(!List.of(AuthentikTables.DEFAULT_USER_TYPES).contains(type))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(config.getUserScopePathStartsWith()) && !user.optString("path", "").startsWith(config.getUserScopePathStartsWith()))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
List<String> scopeGroups = CollectionUtils.nonNullList(config.getUserScopeGroupNames());
|
||||
if(!scopeGroups.isEmpty())
|
||||
{
|
||||
JSONArray groups = user.optJSONArray("groups_obj");
|
||||
if(groups == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
for(int i = 0; i < groups.length(); i++)
|
||||
{
|
||||
JSONObject group = groups.optJSONObject(i);
|
||||
if(group != null && scopeGroups.contains(group.optString("name")))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a user is protected from all qbit actions (DESIGN.md section
|
||||
** 5.4 rule 1): the service account, users with qqq.io/protected: true,
|
||||
** configured protectedUsernames, and superusers (including superuser
|
||||
** status inherited through group parents, which Authentik reports in
|
||||
** is_superuser).
|
||||
*******************************************************************************/
|
||||
public ProtectionResult protection(JSONObject user)
|
||||
{
|
||||
String username = user.optString("username", "");
|
||||
if(username.equalsIgnoreCase(config.getEffectiveServiceAccountUsername()))
|
||||
{
|
||||
return (ProtectionResult.of("the qbit's own service account"));
|
||||
}
|
||||
if(user.optBoolean("is_superuser", false))
|
||||
{
|
||||
return (ProtectionResult.of("Authentik superuser"));
|
||||
}
|
||||
if(isMarkedProtected(user))
|
||||
{
|
||||
return (ProtectionResult.of("marked qqq.io/protected"));
|
||||
}
|
||||
for(String protectedName : CollectionUtils.nonNullList(config.getProtectedUsernames()))
|
||||
{
|
||||
if(username.equalsIgnoreCase(protectedName))
|
||||
{
|
||||
return (ProtectionResult.of("listed in protectedUsernames"));
|
||||
}
|
||||
}
|
||||
if("internal_service_account".equals(user.optString("type")))
|
||||
{
|
||||
return (ProtectionResult.of("Authentik internal service account"));
|
||||
}
|
||||
return (ProtectionResult.NOT_PROTECTED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a user carries qqq.io/protected: true (boolean or "true").
|
||||
*******************************************************************************/
|
||||
public static boolean isMarkedProtected(JSONObject user)
|
||||
{
|
||||
JSONObject attributes = user == null ? null : user.optJSONObject("attributes");
|
||||
return (attributes != null && isTrue(attributes.opt(AuthentikJson.ATTR_PROTECTED)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the session holds an identity permission.
|
||||
*******************************************************************************/
|
||||
public static boolean has(QSession session, IdentityPermission permission)
|
||||
{
|
||||
return (session != null && session.hasPermission(permission.getSessionPermission()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void requireUser(QSession session) throws QPermissionDeniedException
|
||||
{
|
||||
if(session == null || session.getUser() == null)
|
||||
{
|
||||
throw (new QPermissionDeniedException("A signed-in user is required."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean isTrue(Object value)
|
||||
{
|
||||
if(value instanceof Boolean b)
|
||||
{
|
||||
return (b);
|
||||
}
|
||||
return (value != null && String.valueOf(value).trim().toLowerCase(Locale.ROOT).equals("true"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result of a protection check.
|
||||
*******************************************************************************/
|
||||
public record ProtectionResult(boolean isProtected, String reason)
|
||||
{
|
||||
public static final ProtectionResult NOT_PROTECTED = new ProtectionResult(false, null);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A protected result with a reason.
|
||||
*******************************************************************************/
|
||||
public static ProtectionResult of(String reason)
|
||||
{
|
||||
return (new ProtectionResult(true, reason));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.security;
|
||||
|
||||
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's own QQQ permissions (DESIGN.md section 5.2).
|
||||
**
|
||||
** Operators write the short name (e.g. "authentik.users.read") into a group's
|
||||
** qqq.io/permissions attribute; "authentik.*" means all of them. In a QQQ
|
||||
** session each one is held as its HAS_ACCESS form, the short name plus
|
||||
** ".hasAccess", which is what QQQ's PermissionsHelper checks for metadata
|
||||
** guarded by {@link #rules()}.
|
||||
*******************************************************************************/
|
||||
public enum IdentityPermission
|
||||
{
|
||||
USERS_READ("authentik.users.read"),
|
||||
USERS_MANAGE("authentik.users.manage"),
|
||||
GROUPS_MANAGE("authentik.groups.manage"),
|
||||
MEMBERSHIPS_MANAGE("authentik.memberships.manage"),
|
||||
PERMISSIONS_MANAGE("authentik.permissions.manage"),
|
||||
ROLES_MANAGE("authentik.roles.manage"),
|
||||
CREDENTIALS_RESET("authentik.credentials.reset"),
|
||||
SESSIONS_REVOKE("authentik.sessions.revoke"),
|
||||
AUDIT_READ("authentik.audit.read"),
|
||||
APPROVE("authentik.approve"),
|
||||
ADMIN("authentik.admin");
|
||||
|
||||
/** The wildcard operators write for "all identity permissions". */
|
||||
public static final String WILDCARD = "authentik.*";
|
||||
|
||||
/** Suffix QQQ uses for HAS_ACCESS permissions. */
|
||||
public static final String HAS_ACCESS_SUFFIX = ".hasAccess";
|
||||
|
||||
/** Prefix of the permission names before the owner naming rule (2026-10-12); see UPGRADE.md. */
|
||||
@Deprecated
|
||||
public static final String LEGACY_PREFIX = "identity.";
|
||||
|
||||
/** Wildcard before the owner naming rule (2026-10-12); no longer granted. */
|
||||
@Deprecated
|
||||
public static final String LEGACY_WILDCARD = "identity.*";
|
||||
|
||||
private final String shortName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
IdentityPermission(String shortName)
|
||||
{
|
||||
this.shortName = shortName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The name written in qqq.io/permissions, e.g. "authentik.users.read".
|
||||
*******************************************************************************/
|
||||
public String getShortName()
|
||||
{
|
||||
return (shortName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The name this permission had before the owner naming rule (2026-10-12),
|
||||
** e.g. "identity.users.read". Only for finding stored grants when upgrading;
|
||||
** legacy names are never granted.
|
||||
*******************************************************************************/
|
||||
@Deprecated
|
||||
public String getLegacyShortName()
|
||||
{
|
||||
return (LEGACY_PREFIX + shortName.substring(shortName.indexOf('.') + 1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The QQQ session permission, e.g. "authentik.users.read.hasAccess".
|
||||
*******************************************************************************/
|
||||
public String getSessionPermission()
|
||||
{
|
||||
return (shortName + HAS_ACCESS_SUFFIX);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission rules guarding metadata by this permission.
|
||||
*******************************************************************************/
|
||||
public QPermissionRules rules()
|
||||
{
|
||||
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(shortName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up by short name or by session permission name.
|
||||
*******************************************************************************/
|
||||
public static Optional<IdentityPermission> fromName(String name)
|
||||
{
|
||||
if(name == null)
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
|
||||
for(IdentityPermission permission : values())
|
||||
{
|
||||
if(permission.shortName.equals(name) || permission.getSessionPermission().equals(name))
|
||||
{
|
||||
return (Optional.of(permission));
|
||||
}
|
||||
}
|
||||
return (Optional.empty());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*******************************************************************************
|
||||
** Configuration for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration class for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
public class ExampleExtensionQBitConfig implements QBitConfig
|
||||
{
|
||||
private String targetTableName;
|
||||
private Boolean enableFeatureX = true;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate configuration before QBit is produced.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, List<String> errors)
|
||||
{
|
||||
if(targetTableName != null && qInstance.getTable(targetTableName) == null)
|
||||
{
|
||||
errors.add("Target table not found: " + targetTableName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetTableName
|
||||
*******************************************************************************/
|
||||
public String getTargetTableName()
|
||||
{
|
||||
return targetTableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for targetTableName
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitConfig withTargetTableName(String targetTableName)
|
||||
{
|
||||
this.targetTableName = targetTableName;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enableFeatureX
|
||||
*******************************************************************************/
|
||||
public Boolean getEnableFeatureX()
|
||||
{
|
||||
return enableFeatureX;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enableFeatureX
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitConfig withEnableFeatureX(Boolean enableFeatureX)
|
||||
{
|
||||
this.enableFeatureX = enableFeatureX;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*******************************************************************************
|
||||
** Producer for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer class for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
public class ExampleExtensionQBitProducer implements QBitProducer
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-example-extension";
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
private ExampleExtensionQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce this QBit into the given QInstance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void produce(QInstance qInstance, String namespace) throws QException
|
||||
{
|
||||
QBitMetaData qBitMetaData = new QBitMetaData()
|
||||
.withGroupId(GROUP_ID)
|
||||
.withArtifactId(ARTIFACT_ID)
|
||||
.withVersion(VERSION)
|
||||
.withNamespace(namespace)
|
||||
.withConfig(config);
|
||||
|
||||
qInstance.addQBit(qBitMetaData);
|
||||
|
||||
if(config != null && config.getTargetTableName() != null)
|
||||
{
|
||||
applyTableCustomizations(qInstance, config.getTargetTableName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply customizations to the target table.
|
||||
*******************************************************************************/
|
||||
private void applyTableCustomizations(QInstance qInstance, String tableName)
|
||||
{
|
||||
// Implement your extension logic here
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for config
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitProducer withConfig(ExampleExtensionQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*******************************************************************************
|
||||
** Example table customizer.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.customizers;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Example table customizer that adds behavior to existing tables.
|
||||
*******************************************************************************/
|
||||
public class ExampleTableCustomizer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply customizations to the given table.
|
||||
*******************************************************************************/
|
||||
public void customize(QTableMetaData table)
|
||||
{
|
||||
// Example: Add audit tracking fields
|
||||
// table.withField(new QFieldMetaData("createdAt", QFieldType.DATE_TIME));
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
version=${project.version}
|
||||
@@ -1,175 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for AuthentikQBitConfig.
|
||||
*******************************************************************************/
|
||||
class AuthentikQBitConfigTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> errors(AuthentikQBitConfig config)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.validate(new QInstance(), errors);
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validConfigHasNoErrors()
|
||||
{
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig())).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void requiredSettings()
|
||||
{
|
||||
List<String> errors = errors(new AuthentikQBitConfig());
|
||||
assertThat(errors).anyMatch(e -> e.contains("baseUrl is required"));
|
||||
assertThat(errors).anyMatch(e -> e.contains("apiToken is required"));
|
||||
assertThat(errors).anyMatch(e -> e.contains("appKey is required"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void baseUrlMustBeHostRoot()
|
||||
{
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withBaseUrl("ftp://x"))).anyMatch(e -> e.contains("absolute http(s)"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withBaseUrl("https://auth.example.test/api/v3"))).anyMatch(e -> e.contains("host root"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withBaseUrl("not a url at all"))).isNotEmpty();
|
||||
assertThat(AuthentikTestSupport.unitConfig().withBaseUrl("https://auth.example.test//").getApiRoot()).isEqualTo("https://auth.example.test/api/v3");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableFeaturesAndDependencies()
|
||||
{
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MEMBERSHIP))))
|
||||
.anyMatch(e -> e.contains("MEMBERSHIP is not available"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.MIRROR)))).anyMatch(e -> e.contains("READ is required"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MIRROR)))).anyMatch(e -> e.contains("mirrorBackendName"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MIRROR)).withMirrorBackendName("nope")))
|
||||
.anyMatch(e -> e.contains("backend not found"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS)))).anyMatch(e -> e.contains("recoveryEmailStage"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.WEBHOOK)).withWebhookSecret(null))).anyMatch(e -> e.contains("webhookSecret"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void numericAndNameSettings()
|
||||
{
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withThrottleRequestsPerSecond(0))).anyMatch(e -> e.contains("at least 1"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withListCacheTtlSeconds(-1))).anyMatch(e -> e.contains("non-negative"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withTableNamePrefix("9bad"))).anyMatch(e -> e.contains("tableNamePrefix"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withAppKey("Bad Key"))).anyMatch(e -> e.contains("appKey"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void secretsResolveAndNeverPrint() throws Exception
|
||||
{
|
||||
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withApiToken("${env.QBIT_AUTHENTIK_TEST_UNSET_VARIABLE}");
|
||||
assertThat(config.getResolvedApiToken()).isNull();
|
||||
assertThat(errors(config)).anyMatch(e -> e.contains("apiToken"));
|
||||
|
||||
AuthentikQBitConfig literal = AuthentikTestSupport.unitConfig();
|
||||
assertThat(literal.getResolvedApiToken()).isEqualTo(AuthentikTestSupport.TEST_TOKEN);
|
||||
assertThat(literal.toString()).doesNotContain(AuthentikTestSupport.TEST_TOKEN).contains("******");
|
||||
|
||||
String json = new ObjectMapper().writeValueAsString(literal.withWebhookSecret("hook-secret-value"));
|
||||
assertThat(json).doesNotContain(AuthentikTestSupport.TEST_TOKEN).doesNotContain("hook-secret-value");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void derivedNames()
|
||||
{
|
||||
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig();
|
||||
assertThat(config.applyPrefix("admin")).isEqualTo("authentikAdmin");
|
||||
assertThat(config.applyTablePrefix("user")).isEqualTo("authentik_user");
|
||||
assertThat(config.applyTablePrefix("groupMember")).isEqualTo("authentik_group_member");
|
||||
assertThat(config.getApiBackendName()).isEqualTo("authentikApi");
|
||||
assertThat(config.getEffectiveServiceAccountUsername()).isEqualTo("qqq-kof22-admin-idadmin");
|
||||
assertThat(config.withServiceAccountUsername("custom-sa").getEffectiveServiceAccountUsername()).isEqualTo("custom-sa");
|
||||
assertThat(new AuthentikQBitConfig().withTableNamePrefix("").applyTablePrefix("user")).isEqualTo("user");
|
||||
assertThat(new AuthentikQBitConfig().withTableNamePrefix("").applyTablePrefix("groupMember")).isEqualTo("group_member");
|
||||
assertThat(new AuthentikQBitConfig().withTableNamePrefix("myAuth").applyTablePrefix("groupMember")).isEqualTo("my_auth_group_member");
|
||||
assertThat(config.withBootstrapOperators(" A@x.test, ,b@x.test ").getResolvedBootstrapOperators()).containsExactly("a@x.test", "b@x.test");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void toSnakeCase()
|
||||
{
|
||||
assertThat(AuthentikQBitConfig.toSnakeCase(null)).isNull();
|
||||
assertThat(AuthentikQBitConfig.toSnakeCase("")).isEqualTo("");
|
||||
assertThat(AuthentikQBitConfig.toSnakeCase("user")).isEqualTo("user");
|
||||
assertThat(AuthentikQBitConfig.toSnakeCase("groupMember")).isEqualTo("group_member");
|
||||
assertThat(AuthentikQBitConfig.toSnakeCase("GroupMember")).isEqualTo("group_member");
|
||||
assertThat(AuthentikQBitConfig.toSnakeCase("group_member")).isEqualTo("group_member");
|
||||
assertThat(AuthentikQBitConfig.toSnakeCase("mfaTOTPDevice")).isEqualTo("mfa_totp_device");
|
||||
assertThat(AuthentikQBitConfig.toSnakeCase("auth2Factor")).isEqualTo("auth2_factor");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.api.model.AuthorizationType;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for AuthentikQBitProducer.
|
||||
*******************************************************************************/
|
||||
class AuthentikQBitProducerTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void producesBackendTablesAndApp() throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig();
|
||||
QInstance qInstance = AuthentikTestSupport.produce(config);
|
||||
|
||||
APIBackendMetaData backend = (APIBackendMetaData) qInstance.getBackend("authentikApi");
|
||||
assertThat(backend.getBaseUrl()).isEqualTo("https://authentik.example.test/api/v3");
|
||||
assertThat(backend.getAuthorizationType()).isEqualTo(AuthorizationType.CUSTOM);
|
||||
assertThat(backend.getApiKey()).isNull();
|
||||
assertThat(backend.getActionUtil().getName()).isEqualTo(AuthentikActionUtil.class.getName());
|
||||
|
||||
QTableMetaData users = qInstance.getTable("authentik_user");
|
||||
assertThat(users.getBackendName()).isEqualTo("authentikApi");
|
||||
assertThat(users.getPrimaryKeyField()).isEqualTo("pk");
|
||||
assertThat(users.getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.users.read");
|
||||
assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_QUERY)).isTrue();
|
||||
assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_INSERT)).isFalse();
|
||||
assertThat(users.isCapabilityEnabled(qInstance.getBackend("authentikApi"), Capability.TABLE_DELETE)).isFalse();
|
||||
assertThat(users.getField("uuid").getIsHidden()).isTrue();
|
||||
assertThat(qInstance.getTable("authentik_group")).isNotNull();
|
||||
|
||||
QAppMetaData app = qInstance.getApp("authentikAdmin");
|
||||
assertThat(app.getLabel()).isEqualTo("Identity");
|
||||
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("authentik_user", "authentik_group", "authentik_group_member", "authentik_session", "authentik_event");
|
||||
assertThat(app.getWidgets()).containsExactly("authentikDriftReport");
|
||||
assertThat(qInstance.getTable("authentik_session").getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.audit.read");
|
||||
assertThat(qInstance.getWidget("authentikDriftReport").getPermissionRules().getPermissionBaseName()).isEqualTo("authentik.admin");
|
||||
|
||||
assertThat(PermissionsHelper.getAllAvailablePermissionNames(qInstance)).contains(IdentityPermission.USERS_READ.getSessionPermission());
|
||||
assertThat(qInstance.getQBits()).containsKey("com.kingsrook.qbits:authentik");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void namespaceBecomesPrefix() throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withTableNamePrefix(null);
|
||||
QInstance qInstance = new QInstance();
|
||||
new AuthentikQBitProducer().withQBitConfig(config).withNamespace("idp").produce(qInstance);
|
||||
assertThat(qInstance.getTable("idp_user")).isNotNull();
|
||||
assertThat(qInstance.getBackend("idpApi")).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invalidConfigFailsWithAllErrors()
|
||||
{
|
||||
assertThatThrownBy(() -> new AuthentikQBitProducer().produce(new QInstance())).hasMessageContaining("no config");
|
||||
assertThatThrownBy(() -> new AuthentikQBitProducer().withQBitConfig(new AuthentikQBitConfig()).produce(new QInstance()))
|
||||
.hasMessageContaining("baseUrl").hasMessageContaining("apiToken").hasMessageContaining("appKey");
|
||||
assertThat(new AuthentikQBitProducer().isEnabled()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void identityAndVersion()
|
||||
{
|
||||
AuthentikQBitProducer producer = new AuthentikQBitProducer();
|
||||
producer.setQBitConfig(AuthentikTestSupport.unitConfig());
|
||||
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("authentik");
|
||||
assertThat(AuthentikQBitProducer.getVersion()).isNotBlank();
|
||||
assertThat(IdentityPermission.fromName("authentik.audit.read")).contains(IdentityPermission.AUDIT_READ);
|
||||
assertThat(IdentityPermission.fromName("authentik.audit.read.hasAccess")).contains(IdentityPermission.AUDIT_READ);
|
||||
assertThat(IdentityPermission.fromName("nope")).isEmpty();
|
||||
assertThat(IdentityPermission.fromName(null)).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared test helpers: build a QInstance with the qbit, swap in a test action
|
||||
** util, and log in with chosen identity permissions.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikTestSupport
|
||||
{
|
||||
public static final String TEST_TOKEN = "unit-test-token-abcdef0123456789";
|
||||
public static final String OPERATOR = "operator@example.test";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Not instantiable.
|
||||
*******************************************************************************/
|
||||
private AuthentikTestSupport()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A valid config for unit tests (fake host, literal token).
|
||||
*******************************************************************************/
|
||||
public static AuthentikQBitConfig unitConfig()
|
||||
{
|
||||
return (new AuthentikQBitConfig()
|
||||
.withBaseUrl("https://authentik.example.test")
|
||||
.withApiToken(TEST_TOKEN)
|
||||
.withAppKey("kof22-admin")
|
||||
.withListCacheTtlSeconds(0)
|
||||
.withThrottleRequestsPerSecond(1000)
|
||||
.withThrottleBurst(1000));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A QInstance (MOCK auth) with the qbit produced from the config.
|
||||
*******************************************************************************/
|
||||
public static QInstance produce(AuthentikQBitConfig config) throws QException
|
||||
{
|
||||
QInstance qInstance = new QInstance();
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
return (qInstance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Point the qbit's API backend at a different action util class.
|
||||
*******************************************************************************/
|
||||
public static void useActionUtil(QInstance qInstance, AuthentikQBitConfig config, Class<?> actionUtilClass)
|
||||
{
|
||||
((APIBackendMetaData) qInstance.getBackend(config.getApiBackendName())).setActionUtil(new QCodeReference(actionUtilClass));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Log in as the operator with the given identity permissions.
|
||||
*******************************************************************************/
|
||||
public static QSession loginWith(QInstance qInstance, IdentityPermission... permissions)
|
||||
{
|
||||
Set<String> names = new HashSet<>();
|
||||
Arrays.stream(permissions).forEach(p -> names.add(p.getSessionPermission()));
|
||||
return (loginWithPermissions(qInstance, names));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Log in as the operator with raw QQQ permission names.
|
||||
*******************************************************************************/
|
||||
public static QSession loginWithPermissions(QInstance qInstance, Set<String> permissionNames)
|
||||
{
|
||||
QSession session = new QSession();
|
||||
session.setUser(new QUser().withIdReference(OPERATOR).withFullName("Test Operator"));
|
||||
session.setPermissions(new HashSet<>(permissionNames));
|
||||
QContext.init(qInstance, session);
|
||||
return (session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a classpath resource as text.
|
||||
*******************************************************************************/
|
||||
public static String resource(String path)
|
||||
{
|
||||
try(InputStream inputStream = AuthentikTestSupport.class.getClassLoader().getResourceAsStream(path))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("No such test resource: " + path));
|
||||
}
|
||||
return (new String(inputStream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
|
||||
import org.junit.jupiter.api.extension.ExecutionCondition;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** JUnit condition for integration tests that need Docker.
|
||||
**
|
||||
** Without Docker, the tests are skipped on a developer machine, but fail when
|
||||
** CI=true, so a CI build can never pass with its integration tests silently
|
||||
** skipped. Register it ahead of @Testcontainers, so no container is started
|
||||
** when the tests are disabled.
|
||||
*******************************************************************************/
|
||||
public class RequiresDockerCondition implements ExecutionCondition
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Evaluate against the local Docker environment and the CI env var.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context)
|
||||
{
|
||||
return (evaluate(DockerClientFactory.instance().isDockerAvailable(), System.getenv("CI")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decide whether Docker-backed tests run, skip, or fail.
|
||||
**
|
||||
** @param dockerAvailable whether a usable Docker environment was found
|
||||
** @param ciValue value of the CI environment variable (may be null)
|
||||
** @return enabled when Docker is available; disabled when it is not and CI
|
||||
** is not "true"
|
||||
** @throws IllegalStateException when Docker is unavailable and CI is "true"
|
||||
*******************************************************************************/
|
||||
static ConditionEvaluationResult evaluate(Boolean dockerAvailable, String ciValue)
|
||||
{
|
||||
if(Boolean.TRUE.equals(dockerAvailable))
|
||||
{
|
||||
return (ConditionEvaluationResult.enabled("Docker is available"));
|
||||
}
|
||||
|
||||
if("true".equalsIgnoreCase(ciValue))
|
||||
{
|
||||
throw (new IllegalStateException("Docker is required for integration tests when CI=true, but no usable Docker environment was found"));
|
||||
}
|
||||
|
||||
return (ConditionEvaluationResult.disabled("Docker is not available; skipping (with CI=true these tests fail instead)"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for RequiresDockerCondition.
|
||||
*******************************************************************************/
|
||||
class RequiresDockerConditionTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Docker available: integration tests run, whether or not CI is set.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEvaluate_dockerAvailable_enabled()
|
||||
{
|
||||
ConditionEvaluationResult local = RequiresDockerCondition.evaluate(true, null);
|
||||
ConditionEvaluationResult ci = RequiresDockerCondition.evaluate(true, "true");
|
||||
|
||||
assertThat(local.isDisabled()).isFalse();
|
||||
assertThat(ci.isDisabled()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No Docker outside CI: integration tests are skipped.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEvaluate_noDockerOutsideCi_disabled()
|
||||
{
|
||||
assertThat(RequiresDockerCondition.evaluate(false, null).isDisabled()).isTrue();
|
||||
assertThat(RequiresDockerCondition.evaluate(false, "").isDisabled()).isTrue();
|
||||
assertThat(RequiresDockerCondition.evaluate(false, "false").isDisabled()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No Docker with CI=true: evaluation throws, so the tests fail instead of
|
||||
** silently skipping.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEvaluate_noDockerInCi_throws()
|
||||
{
|
||||
assertThatThrownBy(() -> RequiresDockerCondition.evaluate(false, "true"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("CI=true");
|
||||
|
||||
assertThatThrownBy(() -> RequiresDockerCondition.evaluate(false, "TRUE"))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureRouter;
|
||||
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Regression tests for the phase 0/1 review findings.
|
||||
*******************************************************************************/
|
||||
class ReviewFixesTest
|
||||
{
|
||||
private final AuthentikFilterMapper mapper = new AuthentikFilterMapper();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Search is plain text only (QLSearch could reach sensitive fields).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void searchIsPlainTextOnly() throws QException
|
||||
{
|
||||
for(String term : List.of("attributes.x = \"y\"", "a ~ b", "(x)", "it's", "a and b", "x OR y", "a\\b"))
|
||||
{
|
||||
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, term)), true))
|
||||
.as(term).isInstanceOf(QUserFacingException.class).hasMessageContaining("plain text");
|
||||
}
|
||||
assertThat(mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, "read.probe-1")), false).params().get(0).getValue())
|
||||
.isEqualTo("read.probe-1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sorting by the key falls back to the default; booleans are strict; a
|
||||
** criterion without a field is refused.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void orderingBooleansAndNullFields() throws QException
|
||||
{
|
||||
assertThat(mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter().withOrderBy(new QFilterOrderBy("pk", false)), false).params().get(0).getValue()).isEqualTo("username");
|
||||
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, "maybe")), false))
|
||||
.isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria(null, QCriteriaOperator.EQUALS, "x")), false))
|
||||
.isInstanceOf(QUserFacingException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Config: https unless loopback or explicitly allowed; groupScope refused.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configChecks()
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
AuthentikTestSupport.unitConfig().withBaseUrl("http://auth.example.test").validate(new QInstance(), errors);
|
||||
assertThat(errors).anyMatch(e -> e.contains("https"));
|
||||
|
||||
for(String ok : List.of("http://localhost:9000", "http://127.0.0.1:9000"))
|
||||
{
|
||||
List<String> none = new ArrayList<>();
|
||||
AuthentikTestSupport.unitConfig().withBaseUrl(ok).validate(new QInstance(), none);
|
||||
assertThat(none).as(ok).isEmpty();
|
||||
}
|
||||
List<String> allowed = new ArrayList<>();
|
||||
AuthentikTestSupport.unitConfig().withBaseUrl("http://auth.internal").withAllowInsecureHttp(true).validate(new QInstance(), allowed);
|
||||
assertThat(allowed).isEmpty();
|
||||
|
||||
List<String> scope = new ArrayList<>();
|
||||
AuthentikTestSupport.unitConfig().withGroupScopeNamePrefix("kof").validate(new QInstance(), scope);
|
||||
assertThat(scope).anyMatch(e -> e.contains("groupScopeNamePrefix"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Malformed qqq.io/* attributes are kept as findings, not silently dropped.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void malformedAttributes()
|
||||
{
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(new JSONObject().put("pk", "g").put("name", "g").put("parents", new JSONArray())
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("cms.edit").put(5).put(new JSONObject())).put("qqq.io/app-access", 12))));
|
||||
assertThat(graph.byPk("g").permissions()).containsExactly("cms.edit");
|
||||
assertThat(graph.byPk("g").malformed()).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Through the action util: limit 0, ".." keys, scope widening refused,
|
||||
** malformed attributes reported by the drift report.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void actionUtilAndDrift() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0).withUserScopePathStartsWith("users");
|
||||
QInstance qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
|
||||
FixtureRouter router = new FixtureRouter().install();
|
||||
|
||||
QueryInput zero = new QueryInput(config.applyTablePrefix("user"));
|
||||
zero.setFilter(new QQueryFilter().withLimit(0));
|
||||
assertThat(new QueryAction().execute(zero).getRecords()).isEmpty();
|
||||
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("group")).withPrimaryKey(".."))).isNull();
|
||||
assertThat(FixtureActionUtil.REQUESTS.stream().noneMatch(r -> r.getURI().getPath().contains(".."))).isTrue();
|
||||
|
||||
QueryInput widen = new QueryInput(config.applyTablePrefix("user"));
|
||||
widen.setFilter(new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.EQUALS, "internal_service_account")));
|
||||
assertThatThrownBy(() -> new QueryAction().execute(widen)).hasMessageContaining("outside the users");
|
||||
QueryInput otherPath = new QueryInput(config.applyTablePrefix("user"));
|
||||
otherPath.setFilter(new QQueryFilter(new QFilterCriteria("path", QCriteriaOperator.STARTS_WITH, "service-accounts")));
|
||||
assertThatThrownBy(() -> new QueryAction().execute(otherPath)).hasMessageContaining("outside the users");
|
||||
|
||||
router.groups.get(0).put("attributes", new JSONObject().put("qqq.io/permissions", 42));
|
||||
router.refreshGraph();
|
||||
List<DriftFinding> findings = new AuthentikDriftReport(com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run();
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.message().startsWith("Malformed entry"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikApiSurface;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Schema drift test (DESIGN.md section 10): every operation and query
|
||||
** parameter the qbit uses exists in the Authentik OpenAPI schema.
|
||||
**
|
||||
** By default it reads the pinned 2026.8.3 schema
|
||||
** (src/test/resources/authentik/schema-2026.8.3.yml, fetched unauthenticated
|
||||
** from https://auth.kof22.com/api/v3/schema/). The scheduled schema-drift
|
||||
** workflow runs it with -Dauthentik.schema.file=<newer release schema>.
|
||||
*******************************************************************************/
|
||||
public class SchemaDriftTest
|
||||
{
|
||||
public static final String PINNED_VERSION = "2026.8.3";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse an OpenAPI YAML file (the schema is ~1.8 MB).
|
||||
*******************************************************************************/
|
||||
public static JsonNode loadSchema(String text) throws IOException
|
||||
{
|
||||
LoaderOptions options = new LoaderOptions();
|
||||
options.setCodePointLimit(64 * 1024 * 1024);
|
||||
YAMLFactory factory = YAMLFactory.builder().loaderOptions(options).build();
|
||||
return (new ObjectMapper(factory).readTree(text));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static JsonNode schemaUnderTest() throws IOException
|
||||
{
|
||||
String file = System.getProperty("authentik.schema.file");
|
||||
if(file != null && !file.isBlank())
|
||||
{
|
||||
return (loadSchema(java.nio.file.Files.readString(new File(file).toPath())));
|
||||
}
|
||||
return (loadSchema(AuthentikTestSupport.resource("authentik/schema-" + PINNED_VERSION + ".yml")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The pinned file is the version we claim.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void pinnedSchemaIsTheTestedVersion() throws IOException
|
||||
{
|
||||
JsonNode schema = loadSchema(AuthentikTestSupport.resource("authentik/schema-" + PINNED_VERSION + ".yml"));
|
||||
assertThat(schema.at("/info/version").asText()).isEqualTo(PINNED_VERSION);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every used operation and parameter exists.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyUsedOperationAndParameterExists() throws IOException
|
||||
{
|
||||
List<String> problems = findDrift(schemaUnderTest());
|
||||
assertThat(problems).as("Authentik API drift").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare the used surface against a schema; returns human-readable problems.
|
||||
*******************************************************************************/
|
||||
public static List<String> findDrift(JsonNode schema)
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
JsonNode paths = schema.get("paths");
|
||||
for(AuthentikApiSurface.Operation operation : AuthentikApiSurface.operations())
|
||||
{
|
||||
String schemaPath = findPath(paths, operation.path());
|
||||
JsonNode op = schemaPath == null ? null : paths.get(schemaPath).get(operation.method());
|
||||
if(op == null)
|
||||
{
|
||||
problems.add("missing operation " + operation.method().toUpperCase() + " " + operation.path());
|
||||
continue;
|
||||
}
|
||||
Set<String> available = parameterNames(schema, op);
|
||||
for(String param : operation.params())
|
||||
{
|
||||
if(!available.contains(param))
|
||||
{
|
||||
problems.add("missing parameter " + param + " on " + operation.method().toUpperCase() + " " + schemaPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parameters of an operation, resolving $refs.
|
||||
*******************************************************************************/
|
||||
public static Set<String> parameterNames(JsonNode schema, JsonNode operation)
|
||||
{
|
||||
Set<String> rs = new LinkedHashSet<>();
|
||||
for(JsonNode parameter : operation.path("parameters"))
|
||||
{
|
||||
JsonNode resolved = parameter;
|
||||
if(parameter.has("$ref"))
|
||||
{
|
||||
String ref = parameter.get("$ref").asText();
|
||||
resolved = schema.at(ref.substring(1));
|
||||
}
|
||||
rs.add(resolved.path("name").asText());
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find the schema path for a surface path; "{key}" matches any {param}.
|
||||
*******************************************************************************/
|
||||
static String findPath(JsonNode paths, String surfacePath)
|
||||
{
|
||||
if(paths.has(surfacePath))
|
||||
{
|
||||
return (surfacePath);
|
||||
}
|
||||
String regex = surfacePath.replace("{key}", "\\{[^/]+\\}");
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = paths.fields();
|
||||
while(fields.hasNext())
|
||||
{
|
||||
String candidate = fields.next().getKey();
|
||||
if(candidate.matches(regex))
|
||||
{
|
||||
return (candidate);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import org.apache.http.client.methods.HttpRequestBase;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for AuthentikActionUtil over recorded 2026.8.3 fixtures
|
||||
** (src/test/resources/fixtures/2026.8.3, captured by the contract tests).
|
||||
*******************************************************************************/
|
||||
class AuthentikActionUtilTest
|
||||
{
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
private String userTable;
|
||||
private String groupTable;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
config = AuthentikTestSupport.unitConfig();
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
|
||||
userTable = config.applyTablePrefix("user");
|
||||
groupTable = config.applyTablePrefix("group");
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The recorded users page, sliced into Authentik-shaped pages.
|
||||
*******************************************************************************/
|
||||
private static JSONArray recordedUsers()
|
||||
{
|
||||
return (new JSONObject(FixtureActionUtil.fixture("users-list")).getJSONArray("results"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve /core/users/ with real paging semantics over the recorded users.
|
||||
*******************************************************************************/
|
||||
private static void serveUsersPaged()
|
||||
{
|
||||
JSONArray all = recordedUsers();
|
||||
FixtureActionUtil.handler = request ->
|
||||
{
|
||||
int page = Integer.parseInt(param(request, "page", "1"));
|
||||
int pageSize = Integer.parseInt(param(request, "page_size", "20"));
|
||||
int from = (page - 1) * pageSize;
|
||||
JSONArray slice = new JSONArray();
|
||||
for(int i = from; i < Math.min(all.length(), from + pageSize); i++)
|
||||
{
|
||||
slice.put(all.get(i));
|
||||
}
|
||||
int totalPages = (int) Math.ceil(all.length() / (double) pageSize);
|
||||
JSONObject body = new JSONObject()
|
||||
.put("pagination", new JSONObject().put("count", all.length()).put("current", page).put("total_pages", totalPages))
|
||||
.put("results", slice);
|
||||
return (FixtureActionUtil.response(200, body.toString()));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String param(HttpRequestBase request, String name, String defaultValue)
|
||||
{
|
||||
String query = request.getURI().getRawQuery();
|
||||
if(query != null)
|
||||
{
|
||||
for(String pair : query.split("&"))
|
||||
{
|
||||
String[] parts = pair.split("=", 2);
|
||||
if(parts[0].equals(name))
|
||||
{
|
||||
return (URLDecoder.decode(parts[1], StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (defaultValue);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> params(HttpRequestBase request, String name)
|
||||
{
|
||||
String query = request.getURI().getRawQuery();
|
||||
return (query == null ? List.of() : java.util.Arrays.stream(query.split("&")).filter(p -> p.startsWith(name + "=")).map(p -> URLDecoder.decode(p.substring(name.length() + 1), StandardCharsets.UTF_8)).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The last /core/users/ list request (the once-per-JVM version check may
|
||||
** come after it).
|
||||
*******************************************************************************/
|
||||
private static HttpRequestBase lastUsersRequest()
|
||||
{
|
||||
List<HttpRequestBase> users = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/core/users/")).toList();
|
||||
return (users.get(users.size() - 1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> query(QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(userTable);
|
||||
input.setFilter(filter);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Requests carry the bearer token, the default scope types, and
|
||||
** include_groups; pages are page/page_size based.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void queryBuildsAuthentikRequests() throws QException
|
||||
{
|
||||
serveUsersPaged();
|
||||
List<QRecord> records = query(new QQueryFilter().withLimit(2).withSkip(2));
|
||||
assertThat(records).hasSize(2);
|
||||
|
||||
HttpRequestBase request = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/core/users/")).findFirst().orElseThrow();
|
||||
assertThat(request.getFirstHeader("Authorization").getValue()).isEqualTo("Bearer " + AuthentikTestSupport.TEST_TOKEN);
|
||||
assertThat(request.getURI().toString()).startsWith("https://authentik.example.test/api/v3/core/users/?");
|
||||
assertThat(params(request, "type")).containsExactly("internal", "external", "service_account");
|
||||
assertThat(param(request, "include_groups", null)).isEqualTo("true");
|
||||
assertThat(param(request, "page", null)).isEqualTo("2");
|
||||
assertThat(param(request, "page_size", null)).isEqualTo("2");
|
||||
assertThat(param(request, "ordering", null)).isEqualTo("username");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Non-aligned skip: 100-row pages from the right page and offset; an
|
||||
** unlimited query walks every page.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unalignedSkipAndUnlimitedQueries() throws QException
|
||||
{
|
||||
serveUsersPaged();
|
||||
JSONArray all = recordedUsers();
|
||||
|
||||
List<QRecord> odd = query(new QQueryFilter().withLimit(3).withSkip(1));
|
||||
assertThat(odd).extracting(r -> r.getValueString("username")).containsExactly(
|
||||
all.getJSONObject(1).getString("username"), all.getJSONObject(2).getString("username"), all.getJSONObject(3).getString("username"));
|
||||
assertThat(param(lastUsersRequest(), "page_size", null)).isEqualTo("100");
|
||||
|
||||
assertThat(query(new QQueryFilter())).hasSize(all.length());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count reads pagination.count from a page_size=1 request.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void countUsesPaginationCount() throws QException
|
||||
{
|
||||
serveUsersPaged();
|
||||
assertThat(new CountAction().execute(new CountInput(userTable)).getCount()).isEqualTo(recordedUsers().length());
|
||||
assertThat(param(lastUsersRequest(), "page_size", null)).isEqualTo("1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record mapping from the recorded user (computed and joined fields).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void getMapsRecordedUser() throws QException
|
||||
{
|
||||
JSONObject user = new JSONObject(FixtureActionUtil.fixture("user-get"));
|
||||
FixtureActionUtil.handler = r -> FixtureActionUtil.response(200, user.toString());
|
||||
|
||||
QRecord record = new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(user.getInt("pk")));
|
||||
assertThat(record.getValueInteger("pk")).isEqualTo(user.getInt("pk"));
|
||||
assertThat(record.getValueString("username")).isEqualTo(user.getString("username"));
|
||||
assertThat(record.getValueBoolean("isActive")).isEqualTo(user.getBoolean("is_active"));
|
||||
assertThat(record.getValue("dateJoined")).isInstanceOf(java.time.Instant.class);
|
||||
assertThat(record.getValueBoolean("isProtected")).isFalse();
|
||||
assertThat(record.getValues()).doesNotContainKey("attributes").doesNotContainKey("passwordChangeDate");
|
||||
assertThat(FixtureActionUtil.REQUESTS.get(0).getURI().getPath()).isEqualTo("/api/v3/core/users/" + user.getInt("pk") + "/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 404 and out-of-scope records come back as "no record".
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void getMissingAndOutOfScope() throws QException
|
||||
{
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(424242))).isNull();
|
||||
|
||||
JSONObject outpost = new JSONObject(FixtureActionUtil.fixture("user-get")).put("type", "internal_service_account");
|
||||
FixtureActionUtil.handler = r -> FixtureActionUtil.response(200, outpost.toString());
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(1))).isNull();
|
||||
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey("../../admin"))).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Protection: superusers, qqq.io/protected, configured names, the service
|
||||
** account.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void protectionMarkers() throws QException
|
||||
{
|
||||
JSONObject base = new JSONObject(FixtureActionUtil.fixture("user-get"));
|
||||
for(JSONObject variant : List.of(
|
||||
new JSONObject(base.toString()).put("is_superuser", true),
|
||||
new JSONObject(base.toString()).put("attributes", new JSONObject().put("qqq.io/protected", "true")),
|
||||
new JSONObject(base.toString()).put("username", "qqq-kof22-admin-idadmin")))
|
||||
{
|
||||
FixtureActionUtil.handler = r -> FixtureActionUtil.response(200, variant.toString());
|
||||
QRecord record = new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(1));
|
||||
assertThat(record.getValueBoolean("isProtected")).isTrue();
|
||||
assertThat(record.getDisplayValue("isProtected")).startsWith("Protected: ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Key lookups fetch each id; sensitive fields appear with audit.read.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void keyLookupAndSensitiveFields() throws QException
|
||||
{
|
||||
JSONObject user = new JSONObject(FixtureActionUtil.fixture("user-get"));
|
||||
FixtureActionUtil.handler = r -> r.getURI().getPath().endsWith("/" + user.getInt("pk") + "/") ? FixtureActionUtil.response(200, user.toString()) : FixtureActionUtil.response(404, "{}");
|
||||
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
|
||||
List<QRecord> records = query(new QQueryFilter(new QFilterCriteria("pk", QCriteriaOperator.IN, List.of(user.getInt("pk"), 999))));
|
||||
assertThat(records).hasSize(1);
|
||||
assertThat(records.get(0).getValues()).containsKey("attributes");
|
||||
assertThat(new CountAction().execute(new CountInput(userTable).withFilter(new QQueryFilter(new QFilterCriteria("pk", QCriteriaOperator.IN, List.of(user.getInt("pk"), 999))))).getCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Groups from the recorded list: joined names and qqq.io attributes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void groupsFromRecordedList() throws QException
|
||||
{
|
||||
String body = FixtureActionUtil.fixture("groups-list");
|
||||
FixtureActionUtil.handler = r -> FixtureActionUtil.response(200, body);
|
||||
QueryInput input = new QueryInput(groupTable);
|
||||
List<QRecord> groups = new QueryAction().execute(input).getRecords();
|
||||
QRecord identityAdmins = groups.stream().filter(g -> "identity-admins".equals(g.getValueString("name"))).findFirst().orElseThrow();
|
||||
assertThat(identityAdmins.getValueString("permissions")).isEqualTo("authentik.*");
|
||||
assertThat(identityAdmins.getValueString("appAccess")).isEqualTo("kof22-website-prod");
|
||||
assertThat(identityAdmins.getValueBoolean("isSuperuser")).isFalse();
|
||||
assertThat(param(FixtureActionUtil.REQUESTS.get(0), "include_users", null)).isEqualTo("false");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permissions: none, system session, writes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void guardDecisions() throws QException
|
||||
{
|
||||
serveUsersPaged();
|
||||
AuthentikTestSupport.loginWith(qInstance);
|
||||
assertThatThrownBy(() -> query(new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new CountAction().execute(new CountInput(userTable))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new GetAction().execute(new GetInput(userTable).withPrimaryKey(1))).isInstanceOf(QPermissionDeniedException.class);
|
||||
|
||||
QContext.init(qInstance, new QSystemUserSession());
|
||||
assertThat(query(new QQueryFilter().withLimit(1))).hasSize(1);
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(userTable).withRecord(new QRecord().withValue("username", "x"))))
|
||||
.hasMessageContaining("System sessions");
|
||||
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(userTable).withRecord(new QRecord().withValue("username", "x")))).hasMessageContaining("cannot be changed");
|
||||
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(userTable).withRecord(new QRecord().withValue("pk", 1)))).hasMessageContaining("cannot be changed");
|
||||
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(userTable).withPrimaryKey(1))).hasMessageContaining("cannot be changed");
|
||||
|
||||
config.withReadOnly(true);
|
||||
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(userTable).withPrimaryKey(1))).hasMessageContaining("read-only");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recorded error bodies map to user-facing / permission exceptions, with
|
||||
** the token never in a message.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void errorMapping() throws QException
|
||||
{
|
||||
String badFilter = FixtureActionUtil.fixture("error-400-bad-filter");
|
||||
FixtureActionUtil.handler = r -> FixtureActionUtil.response(400, badFilter);
|
||||
assertThatThrownBy(() -> query(new QQueryFilter())).isInstanceOf(QUserFacingException.class).hasMessageContaining("last_login__lt");
|
||||
|
||||
String forbidden = FixtureActionUtil.fixture("error-403");
|
||||
FixtureActionUtil.handler = r -> FixtureActionUtil.response(403, forbidden);
|
||||
assertThatThrownBy(() -> query(new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class).hasMessageContaining("403");
|
||||
|
||||
FixtureActionUtil.handler = r -> FixtureActionUtil.response(401, "{\"detail\":\"Token invalid " + AuthentikTestSupport.TEST_TOKEN + "\"}");
|
||||
assertThatThrownBy(() -> query(new QQueryFilter())).hasMessageContaining("401").satisfies(e -> assertThat(e.getMessage()).doesNotContain(AuthentikTestSupport.TEST_TOKEN));
|
||||
|
||||
FixtureActionUtil.handler = r -> FixtureActionUtil.response(418, "not json " + AuthentikTestSupport.TEST_TOKEN);
|
||||
assertThatThrownBy(() -> query(new QQueryFilter())).hasMessageContaining("418").satisfies(e -> assertThat(e.getMessage()).doesNotContain(AuthentikTestSupport.TEST_TOKEN));
|
||||
|
||||
FixtureActionUtil.handler = r -> FixtureActionUtil.response(200, "<html>");
|
||||
assertThatThrownBy(() -> query(new QQueryFilter())).hasMessageContaining("non-JSON");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 429 honours Retry-After; GET 5xx and transport errors are retried.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retries() throws QException
|
||||
{
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
String body = FixtureActionUtil.fixture("users-list");
|
||||
FixtureActionUtil.handler = r -> switch(calls.incrementAndGet())
|
||||
{
|
||||
case 1 -> FixtureActionUtil.response(429, "{}", FixtureActionUtil.retryAfter(0));
|
||||
case 2 -> FixtureActionUtil.response(502, "bad gateway");
|
||||
default -> FixtureActionUtil.response(200, body);
|
||||
};
|
||||
assertThat(query(new QQueryFilter().withLimit(1))).hasSize(1);
|
||||
assertThat(calls.get()).isEqualTo(3);
|
||||
|
||||
calls.set(0);
|
||||
FixtureActionUtil.handler = r ->
|
||||
{
|
||||
calls.incrementAndGet();
|
||||
throw (new IllegalStateException("connection reset; Bearer " + AuthentikTestSupport.TEST_TOKEN));
|
||||
};
|
||||
assertThatThrownBy(() -> query(new QQueryFilter())).hasMessageContaining("Could not reach Authentik").satisfies(e -> assertThat(e.getMessage()).doesNotContain(AuthentikTestSupport.TEST_TOKEN));
|
||||
assertThat(calls.get()).isEqualTo(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing token fails before any request.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingTokenFailsFast() throws QException
|
||||
{
|
||||
config.withApiToken("${env.QBIT_AUTHENTIK_TEST_UNSET_VARIABLE}");
|
||||
assertThatThrownBy(() -> query(new QQueryFilter())).hasMessageContaining("token is not configured");
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The response cache serves repeated identical reads.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void responseCache() throws QException
|
||||
{
|
||||
config.withListCacheTtlSeconds(30);
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
|
||||
serveUsersPaged();
|
||||
|
||||
query(new QQueryFilter().withLimit(2));
|
||||
int afterFirst = FixtureActionUtil.REQUESTS.size();
|
||||
query(new QQueryFilter().withLimit(2));
|
||||
assertThat(FixtureActionUtil.REQUESTS.size()).isEqualTo(afterFirst);
|
||||
|
||||
com.kingsrook.qbits.authentik.AuthentikRuntime.forBackend(config.getApiBackendName()).invalidateCache();
|
||||
query(new QQueryFilter().withLimit(2));
|
||||
assertThat(FixtureActionUtil.REQUESTS.size()).isGreaterThan(afterFirst);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Error-detail flattening and path stripping.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void helpers()
|
||||
{
|
||||
assertThat(AuthentikActionUtil.errorDetail("{\"detail\":\"Nope.\"}")).isEqualTo("Nope.");
|
||||
assertThat(AuthentikActionUtil.errorDetail("{\"name\":[\"a\",\"b\"],\"non_field_errors\":[\"c\"]}")).contains("name: a b").contains("c");
|
||||
assertThat(AuthentikActionUtil.errorDetail(null)).isEqualTo("(no details)");
|
||||
assertThat(AuthentikActionUtil.errorDetail("x".repeat(300))).endsWith("...");
|
||||
assertThat(AuthentikActionUtil.pathOf("https://h/api/v3/core/users/?search=alice%40x")).isEqualTo("/api/v3/core/users/");
|
||||
assertThat(AuthentikActionUtil.pathOf("not a uri ?q=1")).isEqualTo("not a uri ");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset;
|
||||
import org.apache.http.NameValuePair;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for AuthentikFilterMapper.
|
||||
*******************************************************************************/
|
||||
class AuthentikFilterMapperTest
|
||||
{
|
||||
private final AuthentikFilterMapper mapper = new AuthentikFilterMapper();
|
||||
private final AuthentikTableSpec users = AuthentikTables.get(AuthentikTables.USER);
|
||||
private final AuthentikTableSpec groups = AuthentikTables.get(AuthentikTables.GROUP);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> params(AuthentikTableSpec spec, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (mapper.map(spec, filter, false).params().stream().map(p -> p.getName() + "=" + p.getValue()).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Appendix A example of the design.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void designAppendixExample() throws QException
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(
|
||||
new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, false),
|
||||
new QFilterCriteria("lastLogin", QCriteriaOperator.LESS_THAN, LocalDate.of(2026, 7, 13)))
|
||||
.withOrderBy(new QFilterOrderBy("username"));
|
||||
assertThat(params(users, filter)).containsExactly("is_active=false", "last_login__lt=2026-07-13T00:00:00Z", "ordering=username");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void nullFilterUsesDefaultOrdering() throws QException
|
||||
{
|
||||
assertThat(params(users, null)).containsExactly("ordering=username");
|
||||
assertThat(params(groups, new QQueryFilter())).containsExactly("ordering=name");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void inUsesRepeatedPlainNames() throws QException
|
||||
{
|
||||
assertThat(params(users, new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.IN, List.of("internal", "external")))))
|
||||
.containsExactly("type=internal", "type=external", "ordering=username");
|
||||
assertThat(params(groups, new QQueryFilter(new QFilterCriteria("memberUsername", QCriteriaOperator.IN, List.of("a", "b")))))
|
||||
.containsExactly("members_by_username=a", "members_by_username=b", "ordering=name");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void inclusiveDateBoundsAndBetween() throws QException
|
||||
{
|
||||
Instant at = Instant.parse("2026-01-01T00:00:00Z");
|
||||
assertThat(params(users, new QQueryFilter(new QFilterCriteria("dateJoined", QCriteriaOperator.GREATER_THAN_OR_EQUALS, at))))
|
||||
.contains("date_joined__gt=2025-12-31T23:59:59.999Z");
|
||||
assertThat(params(users, new QQueryFilter(new QFilterCriteria("dateJoined", QCriteriaOperator.LESS_THAN_OR_EQUALS, "2026-01-01T00:00:00Z"))))
|
||||
.contains("date_joined__lt=2026-01-01T00:00:00.001Z");
|
||||
assertThat(params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.BETWEEN, List.of("2026-01-01", "2026-02-01")))))
|
||||
.contains("last_login__gt=2025-12-31T23:59:59.999Z", "last_login__lt=2026-02-01T00:00:00.001Z");
|
||||
assertThat(params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.GREATER_THAN, "2026-03-04T05:06:07+02:00"))))
|
||||
.contains("last_login__gt=2026-03-04T03:06:07Z");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void expressionsAreEvaluated() throws QException
|
||||
{
|
||||
List<String> params = params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.LESS_THAN, NowWithOffset.minus(90, java.time.temporal.ChronoUnit.DAYS))));
|
||||
assertThat(params.get(0)).startsWith("last_login__lt=20");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void blankChecksAndSearch() throws QException
|
||||
{
|
||||
assertThat(params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.IS_BLANK)))).contains("last_login__isnull=true");
|
||||
assertThat(params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.IS_NOT_BLANK)))).contains("last_login__isnull=false");
|
||||
assertThat(params(users, new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.CONTAINS, " kof22 ")))).contains("search=kof22");
|
||||
assertThat(params(users, new QQueryFilter(new QFilterCriteria("path", QCriteriaOperator.STARTS_WITH, "users/")))).contains("path_startswith=users/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void orderingDescendingAndMultiple() throws QException
|
||||
{
|
||||
assertThat(params(users, new QQueryFilter().withOrderBy(new QFilterOrderBy("lastLogin", false)).withOrderBy(new QFilterOrderBy("username"))))
|
||||
.containsExactly("ordering=-last_login,username");
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter().withOrderBy(new QFilterOrderBy("groupNames")))).isInstanceOf(QUserFacingException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Key-only filters become key lookups (users have no pk list filter).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void keyLookups() throws QException
|
||||
{
|
||||
AuthentikFilterMapper.MappedFilter mapped = mapper.map(users, new QQueryFilter(new QFilterCriteria("pk", QCriteriaOperator.IN, List.of(1, 2, 3)), new QFilterCriteria("pk", QCriteriaOperator.EQUALS, 2)), false);
|
||||
assertThat(mapped.isKeyLookup()).isTrue();
|
||||
assertThat(mapped.keyLookup()).containsExactly(2);
|
||||
assertThat(mapper.map(users, new QQueryFilter(new QFilterCriteria("pk", QCriteriaOperator.EQUALS, 7)), false).keyLookup()).containsExactly(7);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Everything Authentik cannot evaluate is rejected, never post-filtered.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unsupportedFiltersAreRejected()
|
||||
{
|
||||
QFilterCriteria a = new QFilterCriteria("username", QCriteriaOperator.EQUALS, "a");
|
||||
QFilterCriteria b = new QFilterCriteria("email", QCriteriaOperator.EQUALS, "b");
|
||||
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(a, b).withBooleanOperator(QQueryFilter.BooleanOperator.OR))).isInstanceOf(QUserFacingException.class).hasMessageContaining("OR");
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(a).withSubFilters(List.of(new QQueryFilter(b))))).isInstanceOf(QUserFacingException.class).hasMessageContaining("sub-filters");
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.NOT_EQUALS, "a")))).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("nope", QCriteriaOperator.EQUALS, "a")))).isInstanceOf(QUserFacingException.class).hasMessageContaining("Unknown field");
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "a"), new QFilterCriteria("username", QCriteriaOperator.EQUALS, "b")))).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, "a"), new QFilterCriteria("email", QCriteriaOperator.CONTAINS, "b")))).isInstanceOf(QUserFacingException.class).hasMessageContaining("one text-search");
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.IN, List.of())))).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.BETWEEN, List.of("2026-01-01"))))).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.LESS_THAN, "yesterday-ish")))).isInstanceOf(QUserFacingException.class).hasMessageContaining("valid date");
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS)))).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, " ")))).isInstanceOf(QUserFacingException.class);
|
||||
|
||||
QFilterCriteria fieldToField = new QFilterCriteria("username", QCriteriaOperator.EQUALS, List.of());
|
||||
fieldToField.setOtherFieldName("email");
|
||||
assertThatThrownBy(() -> params(users, new QQueryFilter(fieldToField))).isInstanceOf(QUserFacingException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sensitive fields can't be filtered without authentik.audit.read.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sensitiveFieldFilters() throws QException
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("passwordChangeDate", QCriteriaOperator.IS_BLANK));
|
||||
assertThatThrownBy(() -> mapper.map(users, filter, false)).isInstanceOf(QUserFacingException.class).hasMessageContaining("permission");
|
||||
assertThatThrownBy(() -> mapper.map(users, filter, true)).isInstanceOf(QUserFacingException.class).hasMessageContaining("not supported");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void booleanFormatting() throws QException
|
||||
{
|
||||
for(java.io.Serializable value : List.<java.io.Serializable>of(true, "TRUE", 1, "yes"))
|
||||
{
|
||||
List<NameValuePair> mapped = mapper.map(users, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, value)), false).params();
|
||||
assertThat(mapped.get(0).getValue()).isEqualTo("true");
|
||||
}
|
||||
assertThat(mapper.map(users, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, "no")), false).params().get(0).getValue()).isEqualTo("false");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for AuthentikRedactor.
|
||||
*******************************************************************************/
|
||||
class AuthentikRedactorTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tokenAndBearerValuesAreMasked()
|
||||
{
|
||||
String text = "auth failed for token abc123secret with header Authorization: Bearer xyz.789-other";
|
||||
String safe = AuthentikRedactor.redactText(text, "abc123secret");
|
||||
assertThat(safe).doesNotContain("abc123secret").doesNotContain("xyz.789-other").contains("Bearer ******");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void secretLookingJsonValuesAreMasked()
|
||||
{
|
||||
String body = "{\"identifier\":\"t\",\"key\":\"s3cr3t\",\"link\":\"https://x/recover?token=abc\",\"Password\":\"p\\\"w\",\"name\":\"ok\"}";
|
||||
String safe = AuthentikRedactor.redactText(body, null);
|
||||
assertThat(safe).doesNotContain("s3cr3t").doesNotContain("recover?token").doesNotContain("p\\\"w").contains("\"name\":\"ok\"").contains("\"key\":\"******\"");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sensitiveEndpointBodiesAreDropped()
|
||||
{
|
||||
assertThat(AuthentikRedactor.redactBody("/api/v3/core/users/4/recovery_email/", "{\"email_stage\":\"x\"}", null)).doesNotContain("email_stage");
|
||||
assertThat(AuthentikRedactor.redactBody("/api/v3/core/tokens/abc/view_key/", "{\"key\":\"k\"}", null)).contains("omitted");
|
||||
assertThat(AuthentikRedactor.redactBody("/api/v3/core/users/", "{\"username\":\"u\"}", null)).contains("\"username\":\"u\"");
|
||||
assertThat(AuthentikRedactor.redactBody("/api/v3/core/users/", null, null)).isNull();
|
||||
assertThat(AuthentikRedactor.redactText(null, "t")).isNull();
|
||||
assertThat(AuthentikRedactor.isSensitivePath(null)).isFalse();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
|
||||
import org.apache.http.Header;
|
||||
import org.apache.http.client.methods.HttpRequestBase;
|
||||
import org.apache.http.message.BasicHeader;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** AuthentikActionUtil with the network replaced: requests are captured and
|
||||
** answered by a handler (usually serving recorded 2026.8.3 fixtures).
|
||||
*******************************************************************************/
|
||||
public class FixtureActionUtil extends AuthentikActionUtil
|
||||
{
|
||||
public static final String EMPTY_PAGE = "{\"pagination\":{\"count\":0,\"current\":1,\"total_pages\":1},\"results\":[]}";
|
||||
|
||||
public static final List<HttpRequestBase> REQUESTS = new ArrayList<>();
|
||||
|
||||
public static Function<HttpRequestBase, QHttpResponse> handler = r -> response(404, "{\"detail\":\"Not found.\"}");
|
||||
|
||||
/** Body served for the group-graph sweep (GET /core/groups/?include_users=false&include_parents=false...). */
|
||||
public static String groupGraphBody = EMPTY_PAGE;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reset captured requests and the handler.
|
||||
*******************************************************************************/
|
||||
public static void reset()
|
||||
{
|
||||
REQUESTS.clear();
|
||||
handler = r -> response(404, "{\"detail\":\"Not found.\"}");
|
||||
groupGraphBody = EMPTY_PAGE;
|
||||
AuthentikActionUtil.resetVersionCheck();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected QHttpResponse send(HttpRequestBase request)
|
||||
{
|
||||
REQUESTS.add(request);
|
||||
if(request.getURI().getPath().endsWith("/admin/version/"))
|
||||
{
|
||||
return (response(200, AuthentikTestSupport.resource("fixtures/2026.8.3/admin-version.json")));
|
||||
}
|
||||
if(request.getURI().getPath().endsWith("/core/groups/") && String.valueOf(request.getURI().getRawQuery()).contains("include_parents=false"))
|
||||
{
|
||||
return (response(200, groupGraphBody));
|
||||
}
|
||||
return (handler.apply(request));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a response.
|
||||
*******************************************************************************/
|
||||
public static QHttpResponse response(int status, String body, Header... headers)
|
||||
{
|
||||
return (new QHttpResponse().withStatusCode(status).withContent(body).withHeaderList(List.of(headers)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A Retry-After header.
|
||||
*******************************************************************************/
|
||||
public static Header retryAfter(int seconds)
|
||||
{
|
||||
return (new BasicHeader("Retry-After", String.valueOf(seconds)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A recorded fixture.
|
||||
*******************************************************************************/
|
||||
public static String fixture(String name)
|
||||
{
|
||||
return (AuthentikTestSupport.resource("fixtures/2026.8.3/" + name + ".json"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
|
||||
import org.apache.http.client.methods.HttpRequestBase;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A FixtureActionUtil handler that answers like a small Authentik built from
|
||||
** the recorded 2026.8.3 fixtures: users (with groups), groups (with users),
|
||||
** tokens, blueprints, events and sessions, honouring the query parameters
|
||||
** the qbit sends. Tests mutate the JSON to build scenarios.
|
||||
*******************************************************************************/
|
||||
public class FixtureRouter implements Function<HttpRequestBase, QHttpResponse>
|
||||
{
|
||||
public final List<JSONObject> users = results("users-list");
|
||||
public final List<JSONObject> groups = results("groups-list-with-users");
|
||||
public final List<JSONObject> tokens = results("tokens-list");
|
||||
public final List<JSONObject> blueprints = results("blueprints-list");
|
||||
public final List<JSONObject> events = results("events-by-service-account");
|
||||
public final List<JSONObject> sessions = results("sessions-list");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Results of a recorded list fixture (mutable copies).
|
||||
*******************************************************************************/
|
||||
public static List<JSONObject> results(String fixture)
|
||||
{
|
||||
JSONArray array = new JSONObject(FixtureActionUtil.fixture(fixture)).getJSONArray("results");
|
||||
List<JSONObject> rs = new ArrayList<>();
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
rs.add(new JSONObject(array.getJSONObject(i).toString()));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install as the FixtureActionUtil handler (and as the group graph).
|
||||
*******************************************************************************/
|
||||
public FixtureRouter install()
|
||||
{
|
||||
FixtureActionUtil.handler = this;
|
||||
FixtureActionUtil.groupGraphBody = page(groups).toString();
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refresh the group-graph body after mutating groups.
|
||||
*******************************************************************************/
|
||||
public void refreshGraph()
|
||||
{
|
||||
FixtureActionUtil.groupGraphBody = page(groups).toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QHttpResponse apply(HttpRequestBase request)
|
||||
{
|
||||
String path = request.getURI().getPath().replaceFirst("^/api/v3", "");
|
||||
if(path.equals("/core/users/"))
|
||||
{
|
||||
return (ok(page(filter(users, request, "username", "username"))));
|
||||
}
|
||||
if(path.matches("/core/users/[0-9]+/"))
|
||||
{
|
||||
int pk = Integer.parseInt(path.split("/")[3]);
|
||||
return (users.stream().filter(u -> u.getInt("pk") == pk).findFirst().map(FixtureRouter::ok).orElse(FixtureActionUtil.response(404, "{}")));
|
||||
}
|
||||
if(path.equals("/core/groups/"))
|
||||
{
|
||||
return (ok(page(filter(groups, request, "name", "name"))));
|
||||
}
|
||||
if(path.matches("/core/groups/[^/]+/"))
|
||||
{
|
||||
String pk = path.split("/")[3];
|
||||
return (groups.stream().filter(g -> g.getString("pk").equals(pk)).findFirst().map(FixtureRouter::ok).orElse(FixtureActionUtil.response(404, "{}")));
|
||||
}
|
||||
if(path.equals("/core/tokens/"))
|
||||
{
|
||||
return (ok(page(tokens)));
|
||||
}
|
||||
if(path.equals("/managed/blueprints/"))
|
||||
{
|
||||
return (ok(page(blueprints)));
|
||||
}
|
||||
if(path.equals("/events/events/"))
|
||||
{
|
||||
return (ok(page(filter(events, request, "username", null))));
|
||||
}
|
||||
if(path.equals("/core/authenticated_sessions/"))
|
||||
{
|
||||
return (ok(page(sessions)));
|
||||
}
|
||||
return (FixtureActionUtil.response(404, "{\"detail\":\"Not found.\"}"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply a simple equality query parameter, plus the users attributes filter.
|
||||
*******************************************************************************/
|
||||
private static List<JSONObject> filter(List<JSONObject> objects, HttpRequestBase request, String param, String property)
|
||||
{
|
||||
String value = param(request, param);
|
||||
String attrs = param(request, "attributes");
|
||||
Predicate<JSONObject> keep = o -> true;
|
||||
if(value != null && property != null)
|
||||
{
|
||||
keep = keep.and(o -> value.equals(o.optString(property)));
|
||||
}
|
||||
if(attrs != null)
|
||||
{
|
||||
JSONObject wanted = new JSONObject(attrs);
|
||||
keep = keep.and(o ->
|
||||
{
|
||||
JSONObject attributes = o.optJSONObject("attributes");
|
||||
return (attributes != null && wanted.keySet().stream().allMatch(k -> String.valueOf(wanted.get(k)).equals(String.valueOf(attributes.opt(k)))));
|
||||
});
|
||||
}
|
||||
return (objects.stream().filter(keep).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String param(HttpRequestBase request, String name)
|
||||
{
|
||||
String query = request.getURI().getRawQuery();
|
||||
if(query == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(String pair : query.split("&"))
|
||||
{
|
||||
String[] parts = pair.split("=", 2);
|
||||
if(parts[0].equals(name) && parts.length == 2)
|
||||
{
|
||||
return (URLDecoder.decode(parts[1], StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static JSONObject page(List<JSONObject> objects)
|
||||
{
|
||||
return (new JSONObject()
|
||||
.put("pagination", new JSONObject().put("count", objects.size()).put("current", 1).put("total_pages", 1))
|
||||
.put("results", new JSONArray(objects)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QHttpResponse ok(JSONObject body)
|
||||
{
|
||||
return (FixtureActionUtil.response(200, body.toString()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Phase 1 tables over the recorded fixtures: the virtual membership table,
|
||||
** sessions, events, and the graph-derived fields on users and groups.
|
||||
*******************************************************************************/
|
||||
class Phase1TablesTest
|
||||
{
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
private FixtureRouter router;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0);
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
|
||||
router = new FixtureRouter().install();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(config.applyTablePrefix(table));
|
||||
input.setFilter(filter);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private JSONObject group(String name)
|
||||
{
|
||||
return (router.groups.stream().filter(g -> g.getString("name").equals(name)).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Memberships: by group name / pk, by user, by key, sweep, ordering, paging.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void memberships() throws QException
|
||||
{
|
||||
JSONObject child = group("phase1-child-of-identity-admins");
|
||||
JSONObject member = child.getJSONArray("users_obj").getJSONObject(0);
|
||||
int userPk = member.getInt("pk");
|
||||
String groupPk = child.getString("pk");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// the recorded users list predates this user: add it, with its groups //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
router.users.add(new JSONObject(member.toString()).put("type", "internal").put("path", "users")
|
||||
.put("groups_obj", new org.json.JSONArray().put(new JSONObject().put("pk", groupPk).put("name", child.getString("name")))));
|
||||
|
||||
List<QRecord> byName = query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")));
|
||||
assertThat(byName).extracting(r -> r.getValueString("username")).containsExactly("phase1.member");
|
||||
assertThat(byName.get(0).getValueString("id")).isEqualTo(groupPk + ":" + userPk);
|
||||
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("groupPk", QCriteriaOperator.IN, List.of(groupPk, "missing"))))).hasSize(1);
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, userPk)))).extracting(r -> r.getValueString("groupName")).contains("phase1-child-of-identity-admins");
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "phase1.member")))).isNotEmpty();
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "nobody")))).isEmpty();
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, 999999)))).isEmpty();
|
||||
|
||||
QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk));
|
||||
assertThat(byKey.getValueString("username")).isEqualTo("phase1.member");
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("groupMember")).withPrimaryKey("no-colon"))).isNull();
|
||||
|
||||
List<QRecord> all = query("groupMember", new QQueryFilter());
|
||||
int count = new CountAction().execute(new CountInput(config.applyTablePrefix("groupMember"))).getCount();
|
||||
assertThat(all).hasSize(count).isNotEmpty();
|
||||
assertThat(all).extracting(r -> r.getValueString("groupName")).isSortedAccordingTo(String.CASE_INSENSITIVE_ORDER);
|
||||
|
||||
List<QRecord> byUserDesc = query("groupMember", new QQueryFilter().withOrderBy(new QFilterOrderBy("username", false)).withLimit(2).withSkip(1));
|
||||
assertThat(byUserDesc).hasSize(2);
|
||||
|
||||
assertThatThrownBy(() -> query("groupMember", new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.EQUALS, "x")))).isInstanceOf(QUserFacingException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With a user scope, membership rows are checked member by member.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void membershipsHonourUserScope() throws QException
|
||||
{
|
||||
config.withUserScopePathStartsWith("nowhere/");
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Graph-derived fields: effective permissions (inherited) and inherited
|
||||
** superuser.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void derivedFields() throws QException
|
||||
{
|
||||
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/core/groups/")
|
||||
? FixtureActionUtil.response(200, FixtureRouter.page(router.groups).toString())
|
||||
: router.apply(request);
|
||||
|
||||
List<QRecord> groups = query("group", new QQueryFilter());
|
||||
QRecord child = groups.stream().filter(g -> "phase1-child-of-identity-admins".equals(g.getValueString("name"))).findFirst().orElseThrow();
|
||||
assertThat(child.getValueString("effectivePermissions")).contains("authentik.memberships.manage.hasAccess");
|
||||
assertThat(child.getValueBoolean("isSuperuserInherited")).isFalse();
|
||||
QRecord admins = groups.stream().filter(g -> "authentik Admins".equals(g.getValueString("name"))).findFirst().orElseThrow();
|
||||
assertThat(admins.getValueBoolean("isSuperuserInherited")).isTrue();
|
||||
|
||||
JSONObject member = router.users.stream().filter(u -> u.getString("username").equals("read.probe1")).findFirst().orElseThrow();
|
||||
FixtureActionUtil.handler = router;
|
||||
QRecord user = new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("user")).withPrimaryKey(member.getInt("pk")));
|
||||
assertThat(user.getValue("effectivePermissions")).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sessions and events need authentik.audit.read and map recorded rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sessionsAndEvents() throws QException
|
||||
{
|
||||
assertThatThrownBy(() -> query("session", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
|
||||
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
|
||||
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/events/events/")
|
||||
? FixtureActionUtil.response(200, FixtureActionUtil.fixture("events-list"))
|
||||
: router.apply(request);
|
||||
|
||||
List<QRecord> sessions = query("session", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")));
|
||||
assertThat(sessions).isNotEmpty();
|
||||
assertThat(sessions.get(0).getValueString("uuid")).isNotBlank();
|
||||
assertThat(sessions.get(0).getValue("lastUsed")).isInstanceOf(java.time.Instant.class);
|
||||
|
||||
List<QRecord> events = query("event", new QQueryFilter(new QFilterCriteria("modelName", QCriteriaOperator.EQUALS, "group")).withLimit(5));
|
||||
assertThat(events).isNotEmpty().allMatch(e -> "group".equals(e.getValueString("modelName")));
|
||||
assertThat(events.get(0).getValueString("modelApp")).isEqualTo("authentik_core");
|
||||
assertThat(events.get(0).getValue("created")).isInstanceOf(java.time.Instant.class);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for TokenBucketThrottle, on a fake clock.
|
||||
*******************************************************************************/
|
||||
class TokenBucketThrottleTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** 10 rps, burst 20: the first 20 calls are free, then each waits 100 ms of
|
||||
** clock time; sleeping advances the fake clock.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void defaultTenPerSecondWithBurstTwenty()
|
||||
{
|
||||
AtomicLong clock = new AtomicLong(0);
|
||||
List<Long> sleeps = new ArrayList<>();
|
||||
TokenBucketThrottle throttle = new TokenBucketThrottle(10, 20, clock::get, nanos ->
|
||||
{
|
||||
sleeps.add(nanos);
|
||||
clock.addAndGet(nanos);
|
||||
});
|
||||
|
||||
for(int i = 0; i < 20; i++)
|
||||
{
|
||||
throttle.acquire();
|
||||
}
|
||||
assertThat(sleeps).isEmpty();
|
||||
|
||||
throttle.acquire();
|
||||
assertThat(sleeps).hasSize(1);
|
||||
assertThat(sleeps.get(0)).isBetween(99_000_000L, 101_000_000L);
|
||||
|
||||
for(int i = 0; i < 9; i++)
|
||||
{
|
||||
throttle.acquire();
|
||||
}
|
||||
long total = sleeps.stream().mapToLong(Long::longValue).sum();
|
||||
assertThat(total).isBetween(990_000_000L, 1_010_000_000L);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Idle time refills, capped at the burst.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refillsWhileIdleUpToBurst()
|
||||
{
|
||||
AtomicLong clock = new AtomicLong(0);
|
||||
TokenBucketThrottle throttle = new TokenBucketThrottle(10, 5, clock::get, clock::addAndGet);
|
||||
for(int i = 0; i < 5; i++)
|
||||
{
|
||||
throttle.acquire();
|
||||
}
|
||||
assertThat(throttle.getAvailableTokens()).isLessThan(0.01);
|
||||
|
||||
clock.addAndGet(60_000_000_000L);
|
||||
assertThat(throttle.getAvailableTokens()).isEqualTo(5.0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rejectsZeroRate()
|
||||
{
|
||||
assertThatThrownBy(() -> new TokenBucketThrottle(0, 1)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The real clock: a burst of 1 at 1000 rps makes the second call wait a
|
||||
** little, without failing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void systemClockConstructor()
|
||||
{
|
||||
TokenBucketThrottle throttle = new TokenBucketThrottle(1000, 1);
|
||||
long start = System.nanoTime();
|
||||
throttle.acquire();
|
||||
throttle.acquire();
|
||||
assertThat(System.nanoTime() - start).isLessThan(1_000_000_000L);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.APP_SLUG;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open point (iii): what the access matrix can rely on.
|
||||
**
|
||||
** - check_access honours for_user ONLY for superusers. For the service
|
||||
** account it silently evaluates the CALLER instead: the answer would be
|
||||
** wrong, not refused. No permission fixes this, and the service account must
|
||||
** never be a superuser, so the qbit computes access itself.
|
||||
** - POST /policies/all/{uuid}/test/ (the alternative) fails with HTTP 500
|
||||
** (WrongAppError) for non-superusers on expression policies in 2026.8.3.
|
||||
** - The application LIST shows a non-superuser only the apps it can itself
|
||||
** access (here: none); GET by slug works with view_application. So
|
||||
** applicationsInScope must be explicit slugs.
|
||||
** - The app's policy bindings are readable with view_policybinding.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(40)
|
||||
class AccessCheckIT
|
||||
{
|
||||
private static TestApi admin;
|
||||
private static TestApi sa;
|
||||
private static int publisher;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll()
|
||||
{
|
||||
AuthentikTestStack stack = AuthentikTestStack.get();
|
||||
admin = stack.admin();
|
||||
sa = stack.serviceAccount();
|
||||
admin.post("/core/users/", new JSONObject().put("username", "access.publisher").put("name", "Access Publisher").put("type", "internal").put("path", "users"));
|
||||
publisher = admin.userPk("access.publisher");
|
||||
admin.post("/core/groups/" + admin.groupPk("kof-website-publishers") + "/add_user/", new JSONObject().put("pk", publisher));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void checkAccessForUserIsIgnoredForNonSuperusers()
|
||||
{
|
||||
assertThat(admin.getOk("/core/applications/" + APP_SLUG + "/check_access/?for_user=" + publisher).getBoolean("passing")).isTrue();
|
||||
|
||||
TestApi.Response asServiceAccount = sa.get("/core/applications/" + APP_SLUG + "/check_access/?for_user=" + publisher);
|
||||
assertThat(asServiceAccount.status()).isEqualTo(200);
|
||||
assertThat(asServiceAccount.json().getBoolean("passing")).as("evaluated for the service account itself, not for_user").isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void policyTestEndpointFailsForNonSuperusers()
|
||||
{
|
||||
String policy = admin.first("/policies/all/?search=kof22-require-admins-group").getString("pk");
|
||||
assertThat(admin.post("/policies/all/" + policy + "/test/", new JSONObject().put("user", publisher)).json().getBoolean("passing")).isTrue();
|
||||
|
||||
String role = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
|
||||
org.json.JSONArray permission = new org.json.JSONArray().put("authentik_policies.view_policy");
|
||||
admin.post("/rbac/permissions/assigned_by_roles/" + role + "/assign/", new JSONObject().put("permissions", permission));
|
||||
try
|
||||
{
|
||||
assertThat(sa.post("/policies/all/" + policy + "/test/", new JSONObject().put("user", publisher)).status()).isEqualTo(500);
|
||||
}
|
||||
finally
|
||||
{
|
||||
admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", new JSONObject().put("permissions", permission));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void applicationsAreReadableBySlugButNotListed()
|
||||
{
|
||||
assertThat(sa.getOk("/core/applications/").getJSONArray("results")).isEmpty();
|
||||
assertThat(sa.getOk("/core/applications/?superuser_full_list=true").getJSONArray("results")).isEmpty();
|
||||
|
||||
JSONObject application = sa.getOk("/core/applications/" + APP_SLUG + "/");
|
||||
assertThat(application.getString("policy_engine_mode")).isEqualTo("any");
|
||||
|
||||
JSONObject bindings = sa.getOk("/policies/bindings/?target=" + application.getString("pbm_uuid"));
|
||||
assertThat(bindings.getJSONArray("results").length()).isEqualTo(1);
|
||||
assertThat(bindings.getJSONArray("results").getJSONObject(0).getJSONObject("policy_obj").getString("name")).isEqualTo("kof22-require-admins-group");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.json.JSONObject;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.Network;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.containers.wait.strategy.Wait;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import org.testcontainers.utility.MountableFile;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Disposable Authentik for contract tests (DESIGN.md section 10): Authentik
|
||||
** server + worker (2026.8.3 unless -Dauthentik.test.image), Postgres, and
|
||||
** Mailpit, on a private network. Started once per JVM and shared by every
|
||||
** contract test class.
|
||||
**
|
||||
** The two kof22-shaped blueprints from src/test/resources/blueprints are in
|
||||
** /blueprints/custom BEFORE the first boot, so the stack starts as an EMPTY
|
||||
** instance applying the bootstrap: that is contract test (h).
|
||||
**
|
||||
** Credentials here are test-only constants for a throwaway container: the
|
||||
** bootstrap admin token (AUTHENTIK_BOOTSTRAP_TOKEN), and a service-account
|
||||
** token minted through the admin API at start-up.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikTestStack
|
||||
{
|
||||
public static final String BOOTSTRAP_TOKEN = "contract-test-bootstrap-token-0123456789";
|
||||
public static final String BOOTSTRAP_PASSWORD = "contract-test-password-123";
|
||||
public static final String SERVICE_ACCOUNT = "qqq-kof22-admin-idadmin";
|
||||
public static final String BREAK_GLASS = "kof22-breakglass";
|
||||
public static final String SUPERUSER_GROUP = "authentik Admins";
|
||||
public static final String INFRA_BLUEPRINT = "kof22-oauth2-apps";
|
||||
public static final String BOOTSTRAP_BLUEPRINT = "kof22-identity-bootstrap";
|
||||
public static final String APP_SLUG = "kof22-website-prod";
|
||||
|
||||
private static final String SECRET_KEY = "contract-test-secret-key-0123456789abcdefghijklmnop";
|
||||
private static final String SA_TOKEN_ID = "qqq-contract-test";
|
||||
|
||||
private static AuthentikTestStack instance;
|
||||
|
||||
private final Network network = Network.newNetwork();
|
||||
private final PostgreSQLContainer<?> postgres;
|
||||
private final GenericContainer<?> mailpit;
|
||||
private final GenericContainer<?> server;
|
||||
private final GenericContainer<?> worker;
|
||||
private String serviceAccountToken;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shared, started stack.
|
||||
*******************************************************************************/
|
||||
public static synchronized AuthentikTestStack get()
|
||||
{
|
||||
if(instance == null)
|
||||
{
|
||||
AuthentikTestStack stack = new AuthentikTestStack();
|
||||
stack.start();
|
||||
instance = stack;
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(stack::stop));
|
||||
}
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("resource")
|
||||
private AuthentikTestStack()
|
||||
{
|
||||
String authentikImage = System.getProperty("authentik.test.image", "ghcr.io/goauthentik/server:2026.8.3");
|
||||
String postgresImage = System.getProperty("postgres.test.image", "postgres:16-alpine");
|
||||
String mailpitImage = System.getProperty("mailpit.test.image", "axllent/mailpit:v1.27");
|
||||
|
||||
postgres = new PostgreSQLContainer<>(DockerImageName.parse(postgresImage).asCompatibleSubstituteFor("postgres"))
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("postgresql")
|
||||
.withDatabaseName("authentik")
|
||||
.withUsername("authentik")
|
||||
.withPassword("authentik-test");
|
||||
|
||||
mailpit = new GenericContainer<>(DockerImageName.parse(mailpitImage))
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("mailpit")
|
||||
.withExposedPorts(8025)
|
||||
.waitingFor(Wait.forHttp("/api/v1/info").forPort(8025));
|
||||
|
||||
Map<String, String> env = new HashMap<>();
|
||||
env.put("AUTHENTIK_SECRET_KEY", SECRET_KEY);
|
||||
env.put("AUTHENTIK_POSTGRESQL__HOST", "postgresql");
|
||||
env.put("AUTHENTIK_POSTGRESQL__USER", "authentik");
|
||||
env.put("AUTHENTIK_POSTGRESQL__PASSWORD", "authentik-test");
|
||||
env.put("AUTHENTIK_POSTGRESQL__NAME", "authentik");
|
||||
env.put("AUTHENTIK_BOOTSTRAP_TOKEN", BOOTSTRAP_TOKEN);
|
||||
env.put("AUTHENTIK_BOOTSTRAP_PASSWORD", BOOTSTRAP_PASSWORD);
|
||||
env.put("AUTHENTIK_BOOTSTRAP_EMAIL", "akadmin@example.test");
|
||||
env.put("AUTHENTIK_EMAIL__HOST", "mailpit");
|
||||
env.put("AUTHENTIK_EMAIL__PORT", "1025");
|
||||
env.put("AUTHENTIK_EMAIL__FROM", "authentik@example.test");
|
||||
env.put("AUTHENTIK_ERROR_REPORTING__ENABLED", "false");
|
||||
env.put("AUTHENTIK_DISABLE_UPDATE_CHECK", "true");
|
||||
env.put("AUTHENTIK_DISABLE_STARTUP_ANALYTICS", "true");
|
||||
env.put("KOF22_OAUTH2_CLIENT_SECRET", "contract-test-oauth2-client-secret");
|
||||
|
||||
server = authentik(authentikImage, env, "server")
|
||||
.withNetworkAliases("authentik")
|
||||
.withExposedPorts(9000)
|
||||
.waitingFor(Wait.forHttp("/-/health/ready/").forPort(9000).forStatusCode(200).withStartupTimeout(Duration.ofMinutes(8)));
|
||||
|
||||
worker = authentik(authentikImage, env, "worker");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("resource")
|
||||
private GenericContainer<?> authentik(String image, Map<String, String> env, String command)
|
||||
{
|
||||
return (new GenericContainer<>(DockerImageName.parse(image))
|
||||
.withNetwork(network)
|
||||
.withEnv(env)
|
||||
.withCommand(command)
|
||||
.withCopyFileToContainer(MountableFile.forClasspathResource("blueprints/" + INFRA_BLUEPRINT + ".yaml"), "/blueprints/custom/" + INFRA_BLUEPRINT + ".yaml")
|
||||
.withCopyFileToContainer(MountableFile.forClasspathResource("blueprints/" + BOOTSTRAP_BLUEPRINT + ".yaml"), "/blueprints/custom/" + BOOTSTRAP_BLUEPRINT + ".yaml"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void start()
|
||||
{
|
||||
postgres.start();
|
||||
mailpit.start();
|
||||
worker.start();
|
||||
server.start();
|
||||
|
||||
TestApi admin = admin();
|
||||
waitFor(Duration.ofMinutes(8), "both kof22 blueprints applied", () ->
|
||||
isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT)
|
||||
&& admin.first("/core/users/?username=" + SERVICE_ACCOUNT) != null);
|
||||
|
||||
int serviceAccountPk = admin.userPk(SERVICE_ACCOUNT);
|
||||
TestApi.Response created = admin.post("/core/tokens/", new JSONObject().put("identifier", SA_TOKEN_ID).put("user", serviceAccountPk).put("intent", "api").put("expiring", false));
|
||||
if(created.status() != 201)
|
||||
{
|
||||
throw (new IllegalStateException("Could not create the service-account test token: " + created.status() + " " + created.body()));
|
||||
}
|
||||
serviceAccountToken = admin.getOk("/core/tokens/" + SA_TOKEN_ID + "/view_key/").getString("key");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void stop()
|
||||
{
|
||||
for(GenericContainer<?> container : new GenericContainer<?>[] { server, worker, mailpit, postgres })
|
||||
{
|
||||
try
|
||||
{
|
||||
container.stop();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// best effort at JVM exit
|
||||
}
|
||||
}
|
||||
network.close();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host root, e.g. http://localhost:32768 (the qbit's baseUrl).
|
||||
*******************************************************************************/
|
||||
public String baseUrl()
|
||||
{
|
||||
return ("http://" + server.getHost() + ":" + server.getMappedPort(9000));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** API root, baseUrl + /api/v3.
|
||||
*******************************************************************************/
|
||||
public String apiRoot()
|
||||
{
|
||||
return (baseUrl() + "/api/v3");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mailpit API root.
|
||||
*******************************************************************************/
|
||||
public String mailpitRoot()
|
||||
{
|
||||
return ("http://" + mailpit.getHost() + ":" + mailpit.getMappedPort(8025));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Client acting as the bootstrap superuser (akadmin), for setup.
|
||||
*******************************************************************************/
|
||||
public TestApi admin()
|
||||
{
|
||||
return (new TestApi(apiRoot(), BOOTSTRAP_TOKEN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Client acting as the qbit's service account.
|
||||
*******************************************************************************/
|
||||
public TestApi serviceAccount()
|
||||
{
|
||||
return (new TestApi(apiRoot(), serviceAccountToken));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The service account's token (for configuring the qbit under test).
|
||||
*******************************************************************************/
|
||||
public String serviceAccountToken()
|
||||
{
|
||||
return (serviceAccountToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The blueprint instance with this name.
|
||||
*******************************************************************************/
|
||||
public JSONObject blueprint(String name)
|
||||
{
|
||||
return (admin().first("/managed/blueprints/?name=" + TestApi.encode(name)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-apply a blueprint instance (POST .../apply/, which is asynchronous) and
|
||||
** wait until it has applied again successfully.
|
||||
*******************************************************************************/
|
||||
public void applyAndWait(String name)
|
||||
{
|
||||
JSONObject before = blueprint(name);
|
||||
String lastApplied = before.optString("last_applied", "");
|
||||
admin().post("/managed/blueprints/" + before.getString("pk") + "/apply/", new JSONObject());
|
||||
waitFor(Duration.ofMinutes(3), "blueprint " + name + " re-applied", () ->
|
||||
{
|
||||
JSONObject now = blueprint(name);
|
||||
return (!now.optString("last_applied", "").equals(lastApplied) && "successful".equals(now.optString("status")));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a content-based blueprint instance and wait for it to apply.
|
||||
*******************************************************************************/
|
||||
public JSONObject createContentBlueprint(String name, String yaml)
|
||||
{
|
||||
TestApi.Response created = admin().post("/managed/blueprints/", new JSONObject().put("name", name).put("content", yaml).put("enabled", true).put("path", ""));
|
||||
if(created.status() != 201)
|
||||
{
|
||||
throw (new IllegalStateException("Could not create blueprint " + name + ": " + created.status() + " " + created.body()));
|
||||
}
|
||||
applyAndWait(name);
|
||||
return (blueprint(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean isSuccessful(TestApi admin, String name)
|
||||
{
|
||||
try
|
||||
{
|
||||
JSONObject blueprint = admin.first("/managed/blueprints/?name=" + TestApi.encode(name));
|
||||
return (blueprint != null && "successful".equals(blueprint.optString("status")));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Poll until a condition holds.
|
||||
*******************************************************************************/
|
||||
public static void waitFor(Duration timeout, String what, BooleanSupplier condition)
|
||||
{
|
||||
Instant deadline = Instant.now().plus(timeout);
|
||||
while(Instant.now().isBefore(deadline))
|
||||
{
|
||||
if(condition.getAsBoolean())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Thread.sleep(2_000);
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw (new IllegalStateException("Timed out waiting for " + what));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.BOOTSTRAP_BLUEPRINT;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.BREAK_GLASS;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.INFRA_BLUEPRINT;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.SERVICE_ACCOUNT;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Contract tests (c) and (d): the whole design rests on these.
|
||||
**
|
||||
** (c) With identity objects declared `state: created` and no member lists,
|
||||
** re-applying BOTH blueprints changes nothing made through the API or
|
||||
** the UI (memberships, group attributes, the service account's role
|
||||
** assignment), and re-asserts the qbit role's global permissions after
|
||||
** one was removed by hand.
|
||||
**
|
||||
** (d) The hazard the cluster-gitops lint exists for: a `present` group with
|
||||
** `users:` wipes API-added members, a `present` user with `groups:`
|
||||
** wipes API-added memberships (today's james.maes entry), and `present`
|
||||
** `attributes:` wipes qqq.io/* edits, on every apply.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(30)
|
||||
class BlueprintReapplyIT
|
||||
{
|
||||
private static AuthentikTestStack stack;
|
||||
private static TestApi admin;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll()
|
||||
{
|
||||
stack = AuthentikTestStack.get();
|
||||
admin = stack.admin();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** (c)
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void apiEditsSurviveReapplyOfCreatedEntries()
|
||||
{
|
||||
admin.post("/core/users/", new JSONObject().put("username", "reapply.member").put("name", "Reapply Member").put("type", "internal").put("path", "users"));
|
||||
int member = admin.userPk("reapply.member");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// edits as QQQ (the service account) or the UI (admin) would make //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
String identityAdmins = admin.groupPk("identity-admins");
|
||||
String kof22Admins = admin.groupPk("kof22-admins");
|
||||
assertThat(admin.post("/core/groups/" + identityAdmins + "/add_user/", new JSONObject().put("pk", member)).status()).isEqualTo(204);
|
||||
assertThat(admin.post("/core/groups/" + kof22Admins + "/add_user/", new JSONObject().put("pk", member)).status()).isEqualTo(204);
|
||||
assertThat(admin.patch("/core/groups/" + kof22Admins + "/", new JSONObject().put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray().put("adminEsb.hasAccess")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod")))).status()).isEqualTo(200);
|
||||
assertThat(admin.patch("/core/groups/" + identityAdmins + "/", new JSONObject().put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.*").put("cms.publish")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod")))).status()).isEqualTo(200);
|
||||
|
||||
String roleUuid = admin.first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
|
||||
try
|
||||
{
|
||||
assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + roleUuid + "/unassign/", new JSONObject().put("permissions", new JSONArray().put("authentik_core.view_user"))).status()).isEqualTo(204);
|
||||
assertThat(stack.serviceAccount().get("/core/users/?page_size=1").status()).as("view_user removed in the UI").isEqualTo(403);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stack.applyAndWait(INFRA_BLUEPRINT);
|
||||
stack.applyAndWait(BOOTSTRAP_BLUEPRINT);
|
||||
}
|
||||
|
||||
assertThat(memberNames("identity-admins")).contains("reapply.member");
|
||||
assertThat(memberNames("kof22-admins")).contains("reapply.member");
|
||||
assertThat(admin.first("/core/groups/?name=kof22-admins").getJSONObject("attributes").getJSONArray("qqq.io/permissions").toList()).containsExactly("adminEsb.hasAccess");
|
||||
assertThat(admin.first("/core/groups/?name=identity-admins").getJSONObject("attributes").getJSONArray("qqq.io/permissions").toList()).containsExactly("authentik.*", "cms.publish");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// role permission re-asserted; role assignment and break-glass kept //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
assertThat(stack.serviceAccount().get("/core/users/?page_size=1").status()).as("view_user re-asserted by the re-apply").isEqualTo(200);
|
||||
JSONObject serviceAccount = admin.first("/core/users/?username=" + SERVICE_ACCOUNT + "&include_roles=true");
|
||||
assertThat(BootstrapBlueprintIT.names(serviceAccount.getJSONArray("roles_obj"))).containsExactly(SERVICE_ACCOUNT);
|
||||
assertThat(admin.first("/core/users/?username=" + BREAK_GLASS).getBoolean("is_superuser")).isTrue();
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// put the seed groups back as the bootstrap created them //
|
||||
///////////////////////////////////////////////////////////
|
||||
admin.post("/core/groups/" + identityAdmins + "/remove_user/", new JSONObject().put("pk", member));
|
||||
admin.post("/core/groups/" + kof22Admins + "/remove_user/", new JSONObject().put("pk", member));
|
||||
admin.patch("/core/groups/" + kof22Admins + "/", new JSONObject().put("attributes", new JSONObject()));
|
||||
admin.patch("/core/groups/" + identityAdmins + "/", new JSONObject().put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.*")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** (d)
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void presentEntriesWipeApiEdits()
|
||||
{
|
||||
String yaml = """
|
||||
version: 1
|
||||
metadata:
|
||||
name: lint-demo-present
|
||||
entries:
|
||||
- model: authentik_core.group
|
||||
state: present
|
||||
id: demo-group
|
||||
identifiers:
|
||||
name: lint-demo-group
|
||||
attrs:
|
||||
attributes:
|
||||
note: declared in git
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, akadmin]]
|
||||
- model: authentik_core.user
|
||||
state: present
|
||||
identifiers:
|
||||
username: lint.demo.user
|
||||
attrs:
|
||||
name: Lint Demo User
|
||||
type: internal
|
||||
path: users
|
||||
groups:
|
||||
- !KeyOf demo-group
|
||||
""";
|
||||
stack.createContentBlueprint("lint-demo-present", yaml);
|
||||
|
||||
admin.post("/core/users/", new JSONObject().put("username", "lint.added").put("name", "Added Via API").put("type", "internal").put("path", "users"));
|
||||
int added = admin.userPk("lint.added");
|
||||
int demoUser = admin.userPk("lint.demo.user");
|
||||
String demoGroup = admin.groupPk("lint-demo-group");
|
||||
String publishers = admin.groupPk("kof-website-publishers");
|
||||
|
||||
assertThat(admin.post("/core/groups/" + demoGroup + "/add_user/", new JSONObject().put("pk", added)).status()).isEqualTo(204);
|
||||
assertThat(admin.post("/core/groups/" + publishers + "/add_user/", new JSONObject().put("pk", demoUser)).status()).isEqualTo(204);
|
||||
assertThat(admin.patch("/core/groups/" + demoGroup + "/", new JSONObject().put("attributes", new JSONObject().put("note", "declared in git").put("qqq.io/permissions", new JSONArray().put("cms.publish")))).status()).isEqualTo(200);
|
||||
assertThat(memberNames("lint-demo-group")).contains("lint.added");
|
||||
|
||||
stack.applyAndWait("lint-demo-present");
|
||||
|
||||
assertThat(memberNames("lint-demo-group")).as("present users: replaces the member set").doesNotContain("lint.added").contains("akadmin");
|
||||
assertThat(memberNames("kof-website-publishers")).as("present groups: on a user replaces its memberships").doesNotContain("lint.demo.user");
|
||||
assertThat(admin.first("/core/groups/?name=lint-demo-group").getJSONObject("attributes").has("qqq.io/permissions")).as("present attributes: replaces the dict").isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> memberNames(String group)
|
||||
{
|
||||
JSONArray users = admin.first("/core/groups/?name=" + TestApi.encode(group) + "&include_users=true").getJSONArray("users_obj");
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(int i = 0; i < users.length(); i++)
|
||||
{
|
||||
rs.add(users.getJSONObject(i).getString("username"));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.BOOTSTRAP_BLUEPRINT;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.BREAK_GLASS;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.INFRA_BLUEPRINT;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.SERVICE_ACCOUNT;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Contract test (h): the bootstrap blueprint on an EMPTY Authentik, ordered
|
||||
** by its metaapplyblueprint entry, applies cleanly alongside the
|
||||
** infrastructure blueprint, and creates exactly the bootstrap objects of
|
||||
** DESIGN.md section 4.3. Also open point (i): the role's GLOBAL permissions
|
||||
** come from attrs.permissions on a present role entry.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(10)
|
||||
class BootstrapBlueprintIT
|
||||
{
|
||||
private static AuthentikTestStack stack;
|
||||
private static TestApi admin;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll()
|
||||
{
|
||||
stack = AuthentikTestStack.get();
|
||||
admin = stack.admin();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both blueprints, present in /blueprints/custom at first boot, applied.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bothBlueprintsAppliedOnEmptyInstance()
|
||||
{
|
||||
assertThat(stack.blueprint(INFRA_BLUEPRINT).getString("status")).isEqualTo("successful");
|
||||
assertThat(stack.blueprint(BOOTSTRAP_BLUEPRINT).getString("status")).isEqualTo("successful");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The service account: type, path, protected marker, role, and no groups.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void serviceAccountCreatedWithRoleAndNoGroups()
|
||||
{
|
||||
JSONObject user = admin.first("/core/users/?username=" + SERVICE_ACCOUNT + "&include_roles=true&include_groups=true");
|
||||
assertThat(user.getString("type")).isEqualTo("service_account");
|
||||
assertThat(user.getString("path")).isEqualTo("service-accounts/qqq");
|
||||
assertThat(user.getJSONObject("attributes").getBoolean("qqq.io/protected")).isTrue();
|
||||
assertThat(user.getBoolean("is_superuser")).isFalse();
|
||||
assertThat(user.getJSONArray("groups")).isEmpty();
|
||||
assertThat(names(user.getJSONArray("roles_obj"))).containsExactly(SERVICE_ACCOUNT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Break-glass: a protected superuser through authentik Admins.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void breakGlassIsProtectedSuperuser()
|
||||
{
|
||||
JSONObject user = admin.first("/core/users/?username=" + BREAK_GLASS + "&include_groups=true");
|
||||
assertThat(user.getBoolean("is_superuser")).isTrue();
|
||||
assertThat(user.getJSONObject("attributes").getBoolean("qqq.io/protected")).isTrue();
|
||||
assertThat(names(user.getJSONArray("groups_obj"))).containsExactly(AuthentikTestStack.SUPERUSER_GROUP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The four seed groups exist, with no members; only identity-admins has
|
||||
** attributes, and drafters has no app access (owner decision pending).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seedGroupsCreatedWithoutMembers()
|
||||
{
|
||||
for(String name : List.of("identity-admins", "kof22-admins", "kof-website-publishers", "kof-website-drafters"))
|
||||
{
|
||||
JSONObject group = admin.first("/core/groups/?name=" + name + "&include_users=true");
|
||||
assertThat(group).as(name).isNotNull();
|
||||
assertThat(group.getBoolean("is_superuser")).as(name).isFalse();
|
||||
assertThat(group.getJSONArray("parents")).as(name).isEmpty();
|
||||
}
|
||||
|
||||
JSONObject identityAdmins = admin.first("/core/groups/?name=identity-admins").getJSONObject("attributes");
|
||||
assertThat(identityAdmins.getJSONArray("qqq.io/permissions").toList()).containsExactly("authentik.*");
|
||||
assertThat(identityAdmins.getJSONArray("qqq.io/app-access").toList()).containsExactly(AuthentikTestStack.APP_SLUG);
|
||||
|
||||
JSONObject drafters = admin.first("/core/groups/?name=kof-website-drafters").getJSONObject("attributes");
|
||||
assertThat(drafters.has("qqq.io/app-access")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open point (i): global permissions from attrs.permissions, plus the
|
||||
** object permission on the recovery email stage from `permissions:`.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void roleHasGlobalAndObjectPermissions()
|
||||
{
|
||||
TestApi sa = stack.serviceAccount();
|
||||
assertThat(sa.get("/core/users/?page_size=1").status()).isEqualTo(200);
|
||||
assertThat(sa.get("/core/groups/?page_size=1").status()).isEqualTo(200);
|
||||
assertThat(sa.get("/events/events/?page_size=1").status()).isEqualTo(200);
|
||||
assertThat(sa.get("/managed/blueprints/?page_size=1").status()).isEqualTo(200);
|
||||
assertThat(sa.get("/core/authenticated_sessions/?page_size=1").status()).isEqualTo(200);
|
||||
|
||||
JSONObject stage = admin.first("/stages/email/?name=kof22-recovery-email");
|
||||
assertThat(sa.get("/stages/email/" + stage.getString("pk") + "/").status()).isEqualTo(200);
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// and nothing beyond the phase 0/1 read set //
|
||||
///////////////////////////////////////////////////
|
||||
JSONArray tokens = sa.getOk("/core/tokens/?page_size=100").getJSONArray("results");
|
||||
for(int i = 0; i < tokens.length(); i++)
|
||||
{
|
||||
assertThat(tokens.getJSONObject(i).getJSONObject("user_obj").getString("username")).as("only its own tokens are visible").isEqualTo(SERVICE_ACCOUNT);
|
||||
}
|
||||
assertThat(sa.post("/core/groups/", new JSONObject().put("name", "should-not-exist")).status()).isEqualTo(403);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static List<String> names(JSONArray array)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
rs.add(array.getJSONObject(i).getString("name"));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Records real Authentik responses from contract tests as unit-test fixtures
|
||||
** (DESIGN.md section 10). Always writes to target/recorded-fixtures/<version>;
|
||||
** with -Dauthentik.fixtures.record=true it also refreshes
|
||||
** src/test/resources/fixtures/<version>, which the unit tests read.
|
||||
**
|
||||
** Scrubbing: avatars (data URIs) blanked, secret-looking values masked,
|
||||
** ids left as they are (they come from a throwaway container).
|
||||
*******************************************************************************/
|
||||
public final class FixtureRecorder
|
||||
{
|
||||
public static final String VERSION = "2026.8.3";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Not instantiable.
|
||||
*******************************************************************************/
|
||||
private FixtureRecorder()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scrub and save a JSON response under a name.
|
||||
*******************************************************************************/
|
||||
public static void record(String name, String body)
|
||||
{
|
||||
String scrubbed = scrub(body);
|
||||
write(Path.of("target", "recorded-fixtures", VERSION, name + ".json"), scrubbed);
|
||||
if(Boolean.getBoolean("authentik.fixtures.record"))
|
||||
{
|
||||
write(Path.of("src", "test", "resources", "fixtures", VERSION, name + ".json"), scrubbed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scrub a response body.
|
||||
*******************************************************************************/
|
||||
static String scrub(String body)
|
||||
{
|
||||
Object parsed = body.trim().startsWith("[") ? new JSONArray(body) : new JSONObject(body);
|
||||
scrubValue(parsed);
|
||||
String text = parsed instanceof JSONObject object ? object.toString(2) : ((JSONArray) parsed).toString(2);
|
||||
return (AuthentikRedactor.redactText(text, null) + "\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void scrubValue(Object value)
|
||||
{
|
||||
if(value instanceof JSONObject object)
|
||||
{
|
||||
for(String key : object.keySet())
|
||||
{
|
||||
Object child = object.get(key);
|
||||
if(key.equals("avatar") && child instanceof String)
|
||||
{
|
||||
object.put(key, "");
|
||||
}
|
||||
else
|
||||
{
|
||||
scrubValue(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(value instanceof JSONArray array)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
scrubValue(array.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void write(Path path, String text)
|
||||
{
|
||||
try
|
||||
{
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, text, StandardCharsets.UTF_8);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not write fixture " + path, e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.net.CookieManager;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Signs a user in through Authentik's default authentication flow (flow
|
||||
** executor API), creating a real AuthenticatedSession and login event in the
|
||||
** disposable test instance. Test-only.
|
||||
*******************************************************************************/
|
||||
public final class FlowLogin
|
||||
{
|
||||
private static final String FLOW = "default-authentication-flow";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Not instantiable.
|
||||
*******************************************************************************/
|
||||
private FlowLogin()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run identification + password; returns the final challenge component
|
||||
** (e.g. "xak-flow-redirect" on success).
|
||||
*******************************************************************************/
|
||||
public static String login(String baseUrl, String username, String password) throws Exception
|
||||
{
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.cookieHandler(new CookieManager())
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
String executor = baseUrl + "/api/v3/flows/executor/" + FLOW + "/?query=";
|
||||
|
||||
JSONObject challenge = send(client, HttpRequest.newBuilder(URI.create(executor)).GET());
|
||||
challenge = send(client, post(executor, new JSONObject().put("component", "ak-stage-identification").put("uid_field", username)));
|
||||
if("ak-stage-password".equals(challenge.optString("component")))
|
||||
{
|
||||
challenge = send(client, post(executor, new JSONObject().put("component", "ak-stage-password").put("password", password)));
|
||||
}
|
||||
return (challenge.optString("component"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static HttpRequest.Builder post(String url, JSONObject body)
|
||||
{
|
||||
return (HttpRequest.newBuilder(URI.create(url)).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body.toString())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static JSONObject send(HttpClient client, HttpRequest.Builder request) throws Exception
|
||||
{
|
||||
HttpResponse<String> response = client.send(request.header("Accept", "application/json").timeout(Duration.ofSeconds(30)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
return (response.body() == null || response.body().isBlank() || !response.body().trim().startsWith("{") ? new JSONObject() : new JSONObject(response.body()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Phase 1 read MVP against Authentik 2026.8.3, as the service account with
|
||||
** the phase 0/1 role: memberships (virtual table), sessions and events
|
||||
** (authentik.audit.read), effective permissions and inherited superuser on
|
||||
** groups, and the drift report. Runs after SuperuserFloorIT, whose lockout
|
||||
** probes are exactly what the drift report must flag. Records fixtures.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(70)
|
||||
class Phase1ReadContractIT
|
||||
{
|
||||
private static AuthentikTestStack stack;
|
||||
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A member of a child of identity-admins (inherited authentik.*), and a real
|
||||
** session for akadmin.
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll() throws Exception
|
||||
{
|
||||
stack = AuthentikTestStack.get();
|
||||
TestApi admin = stack.admin();
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "phase1-child-of-identity-admins").put("parents", new JSONArray().put(admin.groupPk("identity-admins"))));
|
||||
admin.post("/core/users/", new JSONObject().put("username", "phase1.member").put("name", "Phase One").put("email", "phase1@example.test").put("type", "internal").put("path", "users"));
|
||||
admin.post("/core/groups/" + admin.groupPk("phase1-child-of-identity-admins") + "/add_user/", new JSONObject().put("pk", admin.userPk("phase1.member")));
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "phase1-bad-attributes").put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray().put("no.such.permission")).put("qqq.io/app-access", new JSONArray().put("not-in-scope-app"))));
|
||||
|
||||
assertThat(FlowLogin.login(stack.baseUrl(), "akadmin", AuthentikTestStack.BOOTSTRAP_PASSWORD)).isEqualTo("xak-flow-redirect");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
config = new AuthentikQBitConfig()
|
||||
.withBaseUrl(stack.baseUrl())
|
||||
.withApiToken(stack.serviceAccountToken())
|
||||
.withAppKey("kof22-admin")
|
||||
.withApplicationsInScope(List.of(AuthentikTestStack.APP_SLUG))
|
||||
.withListCacheTtlSeconds(0)
|
||||
.withGroupCacheTtlSeconds(0);
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, QbitReadContractIT.RecordingActionUtil.class);
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(config.applyTablePrefix(table));
|
||||
input.setFilter(filter);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Memberships by group, by user, by key, and a full sweep.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void memberships() throws QException
|
||||
{
|
||||
TestApi admin = stack.admin();
|
||||
String groupPk = admin.groupPk("phase1-child-of-identity-admins");
|
||||
int userPk = admin.userPk("phase1.member");
|
||||
|
||||
List<QRecord> byGroup = query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")));
|
||||
assertThat(byGroup).extracting(r -> r.getValueString("username")).containsExactly("phase1.member");
|
||||
assertThat(byGroup.get(0).getValueString("id")).isEqualTo(groupPk + ":" + userPk);
|
||||
assertThat(byGroup.get(0).getValueString("email")).isEqualTo("phase1@example.test");
|
||||
FixtureRecorder.record("group-with-users", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/"));
|
||||
|
||||
List<QRecord> byUser = query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, userPk)));
|
||||
assertThat(byUser).extracting(r -> r.getValueString("groupName")).containsExactly("phase1-child-of-identity-admins");
|
||||
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, AuthentikTestStack.BREAK_GLASS))))
|
||||
.extracting(r -> r.getValueString("groupName")).containsExactly(AuthentikTestStack.SUPERUSER_GROUP);
|
||||
|
||||
QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyTablePrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk));
|
||||
assertThat(byKey.getValueString("username")).isEqualTo("phase1.member");
|
||||
|
||||
int all = new CountAction().execute(new CountInput(config.applyTablePrefix("groupMember"))).getCount();
|
||||
assertThat(all).isGreaterThanOrEqualTo(3);
|
||||
FixtureRecorder.record("groups-list-with-users", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Groups: inherited superuser and effective permissions (incl. parents);
|
||||
** users: effective permissions through a child group.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void effectivePermissionsAndInheritedSuperuser() throws QException
|
||||
{
|
||||
QRecord child = query("group", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins"))).get(0);
|
||||
assertThat(child.getValueString("permissions")).isNull();
|
||||
assertThat(child.getValueString("effectivePermissions")).contains("authentik.memberships.manage.hasAccess", "authentik.users.read.hasAccess");
|
||||
assertThat(child.getValueBoolean("isSuperuserInherited")).isFalse();
|
||||
|
||||
QRecord admins = query("group", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, AuthentikTestStack.SUPERUSER_GROUP))).get(0);
|
||||
assertThat(admins.getValueBoolean("isSuperuserInherited")).isTrue();
|
||||
|
||||
QRecord member = query("user", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "phase1.member"))).get(0);
|
||||
assertThat(member.getValueString("effectivePermissions")).contains("authentik.permissions.manage.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sessions and events: audit.read only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sessionsAndEvents() throws QException
|
||||
{
|
||||
assertThatThrownBy(() -> query("session", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> query("event", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
|
||||
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
|
||||
List<QRecord> sessions = query("session", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")));
|
||||
assertThat(sessions).isNotEmpty();
|
||||
assertThat(sessions.get(0).getValueString("lastIp")).isNotBlank();
|
||||
FixtureRecorder.record("sessions-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/authenticated_sessions/"));
|
||||
|
||||
List<QRecord> logins = query("event", new QQueryFilter(new QFilterCriteria("action", QCriteriaOperator.EQUALS, "login"), new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")).withLimit(5));
|
||||
assertThat(logins).isNotEmpty();
|
||||
assertThat(logins.get(0).getValueString("action")).isEqualTo("login");
|
||||
|
||||
List<QRecord> modelEvents = query("event", new QQueryFilter(new QFilterCriteria("action", QCriteriaOperator.IN, List.of("model_created", "model_updated")),
|
||||
new QFilterCriteria("modelName", QCriteriaOperator.EQUALS, "group")).withLimit(10));
|
||||
assertThat(modelEvents).isNotEmpty().allMatch(r -> "group".equals(r.getValueString("modelName")));
|
||||
FixtureRecorder.record("events-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/events/events/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The drift report on the contract instance. Expected: blueprints, service
|
||||
** account and break-glass OK; the bad attributes flagged; and the
|
||||
** service account's lockout probes on break-glass (SuperuserFloorIT)
|
||||
** flagged as an ERROR.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void driftReport() throws QException
|
||||
{
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
|
||||
List<DriftFinding> findings = new AuthentikDriftReport(AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run();
|
||||
findings.forEach(f -> System.out.println("[drift] " + f));
|
||||
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("version") && f.severity() == DriftFinding.Severity.OK);
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("break-glass") && f.severity() == DriftFinding.Severity.OK && f.subject().contains(AuthentikTestStack.BREAK_GLASS));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.severity() == DriftFinding.Severity.OK);
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("identity admins") && f.subject().contains("phase1.member"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.subject().equals("phase1-bad-attributes") && f.message().contains("no.such.permission"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.subject().equals("phase1-bad-attributes") && f.message().contains("not-in-scope-app"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service-account activity") && f.severity() == DriftFinding.Severity.ERROR);
|
||||
assertThat(findings.get(0).severity()).isEqualTo(DriftFinding.Severity.ERROR);
|
||||
|
||||
FixtureRecorder.record("blueprints-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/managed/blueprints/"));
|
||||
FixtureRecorder.record("tokens-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/tokens/"));
|
||||
FixtureRecorder.record("events-by-service-account", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/events/events/"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
|
||||
import org.apache.http.client.methods.HttpRequestBase;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's own read path (AuthentikActionUtil through QQQ actions) against
|
||||
** a real Authentik, as the service account with the phase 0/1 role: paging,
|
||||
** count, get, key lookups, filters, scope, protection and sensitive-field
|
||||
** stripping. Records the responses it sees as unit-test fixtures.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(60)
|
||||
class QbitReadContractIT
|
||||
{
|
||||
private static AuthentikTestStack stack;
|
||||
private static int probeCount = 4;
|
||||
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
private String userTable;
|
||||
private String groupTable;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Records the last response body per request path.
|
||||
*******************************************************************************/
|
||||
public static class RecordingActionUtil extends AuthentikActionUtil
|
||||
{
|
||||
static final Map<String, String> LAST_BODIES = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected QHttpResponse send(HttpRequestBase request) throws Exception
|
||||
{
|
||||
QHttpResponse response = super.send(request);
|
||||
LAST_BODIES.put(request.getMethod() + " " + request.getURI().getPath(), response.getContent() == null ? "" : response.getContent());
|
||||
return (response);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll()
|
||||
{
|
||||
stack = AuthentikTestStack.get();
|
||||
TestApi admin = stack.admin();
|
||||
for(int i = 1; i <= probeCount; i++)
|
||||
{
|
||||
admin.post("/core/users/", new JSONObject().put("username", "read.probe" + i).put("name", "Read Probe " + i).put("email", "read.probe" + i + "@example.test").put("type", "internal").put("path", "users"));
|
||||
}
|
||||
admin.post("/core/groups/" + admin.groupPk("kof-website-publishers") + "/add_user/", new JSONObject().put("pk", admin.userPk("read.probe1")));
|
||||
admin.patch("/core/users/" + admin.userPk("read.probe4") + "/", new JSONObject().put("is_active", false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
config = new AuthentikQBitConfig()
|
||||
.withBaseUrl(stack.baseUrl())
|
||||
.withApiToken(stack.serviceAccountToken())
|
||||
.withAppKey("kof22-admin")
|
||||
.withListCacheTtlSeconds(0);
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, RecordingActionUtil.class);
|
||||
userTable = config.applyTablePrefix("user");
|
||||
groupTable = config.applyTablePrefix("group");
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Paging: aligned pages are disjoint and add up to the count; a
|
||||
** non-aligned skip uses 100-row pages and still lands on the right rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void pagingAndCount() throws QException
|
||||
{
|
||||
int total = new CountAction().execute(new CountInput(userTable)).getCount();
|
||||
assertThat(total).isGreaterThanOrEqualTo(probeCount + 3);
|
||||
|
||||
List<QRecord> all = query(new QQueryFilter().withOrderBy(new QFilterOrderBy("username")));
|
||||
assertThat(all).hasSize(total);
|
||||
FixtureRecorder.record("users-list", RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/users/"));
|
||||
|
||||
List<Serializable> pageOne = keys(query(new QQueryFilter().withOrderBy(new QFilterOrderBy("username")).withLimit(2).withSkip(0)));
|
||||
List<Serializable> pageTwo = keys(query(new QQueryFilter().withOrderBy(new QFilterOrderBy("username")).withLimit(2).withSkip(2)));
|
||||
assertThat(pageOne).hasSize(2).doesNotContainAnyElementsOf(pageTwo);
|
||||
assertThat(pageTwo).containsExactlyElementsOf(keys(all).subList(2, 4));
|
||||
|
||||
List<Serializable> odd = keys(query(new QQueryFilter().withOrderBy(new QFilterOrderBy("username")).withLimit(3).withSkip(1)));
|
||||
assertThat(odd).containsExactlyElementsOf(keys(all).subList(1, 4));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Filters that map to Authentik query parameters.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void filters() throws QException
|
||||
{
|
||||
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, false))))).contains("read.probe4").doesNotContain("read.probe1");
|
||||
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.IN, List.of("service_account")))))).containsExactly(AuthentikTestStack.SERVICE_ACCOUNT);
|
||||
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("groupNames", QCriteriaOperator.EQUALS, "kof-website-publishers"))))).contains("read.probe1").doesNotContain("read.probe2");
|
||||
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, "read.probe"))))).contains("read.probe1", "read.probe2", "read.probe3", "read.probe4");
|
||||
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("lastLogin", QCriteriaOperator.IS_BLANK))))).contains("read.probe2");
|
||||
assertThat(usernames(query(new QQueryFilter(new QFilterCriteria("dateJoined", QCriteriaOperator.GREATER_THAN_OR_EQUALS, "2000-01-01"))))).contains("read.probe3");
|
||||
|
||||
assertThatThrownBy(() -> query(new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "a"), new QFilterCriteria("email", QCriteriaOperator.EQUALS, "b")).withBooleanOperator(QQueryFilter.BooleanOperator.OR)))
|
||||
.isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> query(new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.NOT_EQUALS, "a"))))
|
||||
.isInstanceOf(QUserFacingException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get by key, key lookups, scope (internal service accounts are invisible),
|
||||
** and protection markers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void getKeyLookupScopeAndProtection() throws QException
|
||||
{
|
||||
TestApi admin = stack.admin();
|
||||
int probe = admin.userPk("read.probe2");
|
||||
int breakGlass = admin.userPk(AuthentikTestStack.BREAK_GLASS);
|
||||
int sa = admin.userPk(AuthentikTestStack.SERVICE_ACCOUNT);
|
||||
int outpost = admin.first("/core/users/?type=internal_service_account").getInt("pk");
|
||||
|
||||
QRecord record = new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(probe));
|
||||
assertThat(record.getValueString("username")).isEqualTo("read.probe2");
|
||||
assertThat(record.getValueBoolean("isProtected")).isFalse();
|
||||
FixtureRecorder.record("user-get", RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/users/" + probe + "/"));
|
||||
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(breakGlass)).getValueBoolean("isProtected")).isTrue();
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(sa)).getValueBoolean("isProtected")).isTrue();
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(outpost))).as("internal service accounts are out of scope").isNull();
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(userTable).withPrimaryKey(999_999))).isNull();
|
||||
|
||||
List<QRecord> lookup = query(new QQueryFilter(new QFilterCriteria("pk", QCriteriaOperator.IN, List.of(probe, breakGlass, outpost))));
|
||||
assertThat(usernames(lookup)).containsExactlyInAnyOrder("read.probe2", AuthentikTestStack.BREAK_GLASS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sensitive fields need authentik.audit.read; reading needs users.read.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissionsAndSensitiveFields() throws QException
|
||||
{
|
||||
QRecord plain = query(new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, AuthentikTestStack.BREAK_GLASS))).get(0);
|
||||
assertThat(plain.getValues()).doesNotContainKey("attributes");
|
||||
assertThat(plain.getValueString("qqqAttributes")).contains("qqq.io/protected");
|
||||
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
|
||||
QRecord audit = query(new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, AuthentikTestStack.BREAK_GLASS))).get(0);
|
||||
assertThat(audit.getValueString("attributes")).contains("qqq.io/protected");
|
||||
|
||||
AuthentikTestSupport.loginWith(qInstance);
|
||||
assertThatThrownBy(() -> query(new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Groups: list with parents/children, member filter.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void groups() throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(groupTable);
|
||||
input.setFilter(new QQueryFilter(new QFilterCriteria("memberUsername", QCriteriaOperator.EQUALS, "read.probe1")));
|
||||
List<QRecord> groups = new QueryAction().execute(input).getRecords();
|
||||
assertThat(groups.stream().map(r -> r.getValueString("name")).toList()).containsExactly("kof-website-publishers");
|
||||
|
||||
input.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "identity-admins")));
|
||||
QRecord identityAdmins = new QueryAction().execute(input).getRecords().get(0);
|
||||
assertThat(identityAdmins.getValueString("permissions")).isEqualTo("authentik.*");
|
||||
FixtureRecorder.record("groups-list", RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Error and version responses, for the unit fixtures.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recordErrorAndVersionFixtures()
|
||||
{
|
||||
TestApi sa = stack.serviceAccount();
|
||||
TestApi.Response badDate = sa.get("/core/users/?last_login__lt=not-a-date");
|
||||
assertThat(badDate.status()).isEqualTo(400);
|
||||
FixtureRecorder.record("error-400-bad-filter", badDate.body());
|
||||
|
||||
TestApi.Response forbidden = sa.post("/core/groups/", new JSONObject().put("name", "x"));
|
||||
assertThat(forbidden.status()).isEqualTo(403);
|
||||
FixtureRecorder.record("error-403", forbidden.body());
|
||||
|
||||
FixtureRecorder.record("admin-version", sa.getOk("/admin/version/").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> query(QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(userTable);
|
||||
input.setFilter(filter);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<Serializable> keys(List<QRecord> records)
|
||||
{
|
||||
return (records.stream().map(r -> r.getValue("pk")).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> usernames(List<QRecord> records)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
records.forEach(r -> rs.add(r.getValueString("username")));
|
||||
return (rs);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import com.kingsrook.qbits.authentik.SchemaDriftTest;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikApiSurface;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The schema the running container serves (unauthenticated, as for the
|
||||
** pinned file) has the same parameters as the pinned schema for every
|
||||
** operation the qbit uses. Run with -Dauthentik.test.image=... to rehearse an
|
||||
** upgrade: any difference fails with a list.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(20)
|
||||
class SchemaDriftIT
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void containerSchemaMatchesPinnedForUsedOperations() throws Exception
|
||||
{
|
||||
AuthentikTestStack stack = AuthentikTestStack.get();
|
||||
HttpResponse<String> response = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build().send(
|
||||
HttpRequest.newBuilder(URI.create(stack.apiRoot() + "/schema/")).header("Accept", "application/yaml").build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
|
||||
JsonNode live = SchemaDriftTest.loadSchema(response.body());
|
||||
JsonNode pinned = SchemaDriftTest.loadSchema(AuthentikTestSupport.resource("authentik/schema-" + SchemaDriftTest.PINNED_VERSION + ".yml"));
|
||||
|
||||
assertThat(SchemaDriftTest.findDrift(live)).isEmpty();
|
||||
|
||||
List<String> differences = new ArrayList<>();
|
||||
for(AuthentikApiSurface.Operation operation : AuthentikApiSurface.operations())
|
||||
{
|
||||
Set<String> liveParams = params(live, operation);
|
||||
Set<String> pinnedParams = params(pinned, operation);
|
||||
if(!liveParams.equals(pinnedParams))
|
||||
{
|
||||
differences.add(operation.path() + ": pinned " + pinnedParams + " vs running " + liveParams);
|
||||
}
|
||||
}
|
||||
assertThat(differences).as("parameter differences between the pinned and the running schema").isEmpty();
|
||||
assertThat(live.at("/info/version").asText()).isEqualTo(System.getProperty("authentik.expected.version", SchemaDriftTest.PINNED_VERSION));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Set<String> params(JsonNode schema, AuthentikApiSurface.Operation operation)
|
||||
{
|
||||
String path = operation.path();
|
||||
JsonNode paths = schema.get("paths");
|
||||
for(java.util.Iterator<String> it = paths.fieldNames(); it.hasNext(); )
|
||||
{
|
||||
String candidate = it.next();
|
||||
if(candidate.equals(path) || candidate.matches(path.replace("{key}", "\\{[^/]+\\}")))
|
||||
{
|
||||
return (SchemaDriftTest.parameterNames(schema, paths.get(candidate).get(operation.method())));
|
||||
}
|
||||
}
|
||||
return (Set.of());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.APP_SLUG;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The proposed cluster-gitops infrastructure changes, on the real importer:
|
||||
** - the attribute-driven sign-in policy (qqq.io/app-access, inherited from
|
||||
** parent groups, string or list, no substring matches) with the group-name
|
||||
** fallback (DESIGN.md section 4.4 step 2), evaluated by the admin's
|
||||
** check_access?for_user (superusers may use for_user);
|
||||
** - the webhook plumbing (section 7.1) exists and is INERT: its rule has no
|
||||
** destination, so Authentik creates no notifications (events/models.py
|
||||
** NotificationRule.destination_users yields nobody).
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(45)
|
||||
class SignInPolicyIT
|
||||
{
|
||||
private static TestApi admin;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll()
|
||||
{
|
||||
admin = AuthentikTestStack.get().admin();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a user in a new group with the given attributes (and parent).
|
||||
*******************************************************************************/
|
||||
private int userInGroup(String name, JSONObject attributes, String parentPk)
|
||||
{
|
||||
JSONObject group = new JSONObject().put("name", "signin-" + name).put("attributes", attributes);
|
||||
if(parentPk != null)
|
||||
{
|
||||
group.put("parents", new JSONArray().put(parentPk));
|
||||
}
|
||||
admin.post("/core/groups/", group);
|
||||
admin.post("/core/users/", new JSONObject().put("username", "signin." + name).put("name", "Sign-in " + name).put("type", "internal").put("path", "users"));
|
||||
int user = admin.userPk("signin." + name);
|
||||
admin.post("/core/groups/" + admin.groupPk("signin-" + name) + "/add_user/", new JSONObject().put("pk", user));
|
||||
return (user);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private boolean passes(int user)
|
||||
{
|
||||
return (admin.getOk("/core/applications/" + APP_SLUG + "/check_access/?for_user=" + user).getBoolean("passing"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appAccessAttributeGrantsSignIn()
|
||||
{
|
||||
assertThat(passes(userInGroup("list", new JSONObject().put("qqq.io/app-access", new JSONArray().put("other-app").put(APP_SLUG)), null))).isTrue();
|
||||
assertThat(passes(userInGroup("string", new JSONObject().put("qqq.io/app-access", APP_SLUG), null))).isTrue();
|
||||
assertThat(passes(userInGroup("prefix", new JSONObject().put("qqq.io/app-access", APP_SLUG + "-old"), null))).as("no substring match").isFalse();
|
||||
assertThat(passes(userInGroup("none", new JSONObject(), null))).isFalse();
|
||||
|
||||
String parent = admin.groupPk("signin-list");
|
||||
assertThat(passes(userInGroup("child", new JSONObject(), parent))).as("inherited from a parent group").isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Transition fallback by group name; drafters get nothing until the owner
|
||||
** sets qqq.io/app-access on that group.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void groupNameFallbackAndDrafters()
|
||||
{
|
||||
admin.post("/core/users/", new JSONObject().put("username", "signin.fallback").put("name", "Fallback").put("type", "internal").put("path", "users"));
|
||||
int fallback = admin.userPk("signin.fallback");
|
||||
admin.post("/core/groups/" + admin.groupPk("kof22-admins") + "/add_user/", new JSONObject().put("pk", fallback));
|
||||
assertThat(passes(fallback)).isTrue();
|
||||
|
||||
admin.post("/core/users/", new JSONObject().put("username", "signin.drafter").put("name", "Drafter").put("type", "internal").put("path", "users"));
|
||||
int drafter = admin.userPk("signin.drafter");
|
||||
admin.post("/core/groups/" + admin.groupPk("kof-website-drafters") + "/add_user/", new JSONObject().put("pk", drafter));
|
||||
assertThat(passes(drafter)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void webhookPlumbingIsPresentAndInert()
|
||||
{
|
||||
JSONObject transport = admin.first("/events/transports/?name=qqq-kof22-admin");
|
||||
assertThat(transport.getString("mode")).isEqualTo("webhook");
|
||||
assertThat(transport.getString("webhook_url")).isEqualTo("https://admin.kof22.com/qqq/v1/authentik/webhook");
|
||||
assertThat(transport.getBoolean("send_once")).isTrue();
|
||||
assertThat(transport.isNull("webhook_mapping_body")).isFalse();
|
||||
assertThat(transport.isNull("webhook_mapping_headers")).isFalse();
|
||||
|
||||
JSONObject rule = admin.first("/events/rules/?name=qqq-kof22-admin-sync");
|
||||
assertThat(rule.getBoolean("destination_event_user")).isFalse();
|
||||
assertThat(rule.isNull("destination_group")).isTrue();
|
||||
assertThat(rule.getJSONArray("transports").toList()).containsExactly(transport.getString("pk"));
|
||||
|
||||
JSONArray bindings = admin.getOk("/policies/bindings/?target=" + rule.getString("pk") + "&page_size=50").getJSONArray("results");
|
||||
assertThat(bindings.length()).isEqualTo(11);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.BREAK_GLASS;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.SERVICE_ACCOUNT;
|
||||
import static com.kingsrook.qbits.authentik.contract.AuthentikTestStack.SUPERUSER_GROUP;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Contract test (b) and open point (ii): what Authentik itself refuses the
|
||||
** qbit's service account, holding the phase 2/3 write permissions it will
|
||||
** eventually get, but never enable_group_superuser.
|
||||
**
|
||||
** ESCALATION is refused in every form (add to a superuser group or a
|
||||
** descendant, re-parent under one, user groups PATCH, group users PATCH,
|
||||
** create under one, set is_superuser).
|
||||
**
|
||||
** LOCKOUT is NOT refused: with remove_user_from_group / change_user /
|
||||
** change_group the service account can remove a superuser from the superuser
|
||||
** group, deactivate break-glass, strip qqq.io/protected, or rename the
|
||||
** superuser group. Those tests pin today's behaviour so an Authentik change
|
||||
** is noticed; the qbit's guard is the only control (DESIGN.md section 5.1).
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(50)
|
||||
class SuperuserFloorIT
|
||||
{
|
||||
private static final List<String> PHASE_2_3_PERMISSIONS = List.of(
|
||||
"authentik_core.add_user_to_group", "authentik_core.remove_user_from_group",
|
||||
"authentik_core.add_group", "authentik_core.change_group", "authentik_core.add_user", "authentik_core.change_user");
|
||||
|
||||
private static AuthentikTestStack stack;
|
||||
private static TestApi admin;
|
||||
private static TestApi sa;
|
||||
private static String roleUuid;
|
||||
private static int probeUser;
|
||||
private static String superuserGroup;
|
||||
private static String childOfSuperuser;
|
||||
private static String plainGroup;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Grant the later-phase write permissions (test-only, additive) and create
|
||||
** probe objects.
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll()
|
||||
{
|
||||
stack = AuthentikTestStack.get();
|
||||
admin = stack.admin();
|
||||
sa = stack.serviceAccount();
|
||||
|
||||
roleUuid = admin.first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
|
||||
admin.post("/rbac/permissions/assigned_by_roles/" + roleUuid + "/assign/", new JSONObject().put("permissions", new JSONArray(PHASE_2_3_PERMISSIONS)));
|
||||
|
||||
admin.post("/core/users/", new JSONObject().put("username", "floor.probe").put("name", "Floor Probe").put("type", "internal").put("path", "users"));
|
||||
probeUser = admin.userPk("floor.probe");
|
||||
superuserGroup = admin.groupPk(SUPERUSER_GROUP);
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "floor-child-of-admins").put("parents", new JSONArray().put(superuserGroup)));
|
||||
childOfSuperuser = admin.groupPk("floor-child-of-admins");
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "floor-plain"));
|
||||
plainGroup = admin.groupPk("floor-plain");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove the test-only grants again.
|
||||
*******************************************************************************/
|
||||
@AfterAll
|
||||
static void afterAll()
|
||||
{
|
||||
if(admin != null && roleUuid != null)
|
||||
{
|
||||
admin.patch("/rbac/permissions/assigned_by_roles/" + roleUuid + "/unassign/", new JSONObject().put("permissions", new JSONArray(PHASE_2_3_PERMISSIONS)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sanity: an ordinary membership change is allowed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ordinaryMembershipAllowed()
|
||||
{
|
||||
assertThat(sa.post("/core/groups/" + plainGroup + "/add_user/", new JSONObject().put("pk", probeUser)).status()).isEqualTo(204);
|
||||
assertThat(sa.post("/core/groups/" + plainGroup + "/remove_user/", new JSONObject().put("pk", probeUser)).status()).isEqualTo(204);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** (b) add_user to a superuser group, or to a group under one: 403.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void addToSuperuserAncestryRefused()
|
||||
{
|
||||
TestApi.Response direct = sa.post("/core/groups/" + superuserGroup + "/add_user/", new JSONObject().put("pk", probeUser));
|
||||
assertThat(direct.status()).isEqualTo(403);
|
||||
assertThat(direct.body()).contains("superuser group");
|
||||
|
||||
TestApi.Response child = sa.post("/core/groups/" + childOfSuperuser + "/add_user/", new JSONObject().put("pk", probeUser));
|
||||
assertThat(child.status()).isEqualTo(403);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** (b) / open point (ii): re-parenting under a superuser group or one of its
|
||||
** descendants, creating a group under one, and setting is_superuser: 400.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void reparentUnderSuperuserRefused()
|
||||
{
|
||||
TestApi.Response underAdmins = sa.patch("/core/groups/" + plainGroup + "/", new JSONObject().put("parents", new JSONArray().put(superuserGroup)));
|
||||
assertThat(underAdmins.status()).isEqualTo(400);
|
||||
assertThat(underAdmins.body()).contains("superuser parent group");
|
||||
|
||||
TestApi.Response underChild = sa.patch("/core/groups/" + plainGroup + "/", new JSONObject().put("parents", new JSONArray().put(childOfSuperuser)));
|
||||
assertThat(underChild.status()).isEqualTo(400);
|
||||
|
||||
TestApi.Response create = sa.post("/core/groups/", new JSONObject().put("name", "floor-new-under-admins").put("parents", new JSONArray().put(superuserGroup)));
|
||||
assertThat(create.status()).isEqualTo(400);
|
||||
|
||||
TestApi.Response flag = sa.patch("/core/groups/" + plainGroup + "/", new JSONObject().put("is_superuser", true));
|
||||
assertThat(flag.status()).isEqualTo(400);
|
||||
|
||||
TestApi.Response unflag = sa.patch("/core/groups/" + superuserGroup + "/", new JSONObject().put("is_superuser", false));
|
||||
assertThat(unflag.status()).isEqualTo(400);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** (b) the bulk forms: user.groups and group.users PATCH.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bulkMembershipFormsRefused()
|
||||
{
|
||||
assertThat(sa.patch("/core/users/" + probeUser + "/", new JSONObject().put("groups", new JSONArray().put(superuserGroup))).status()).isEqualTo(400);
|
||||
assertThat(sa.patch("/core/groups/" + superuserGroup + "/", new JSONObject().put("users", new JSONArray().put(probeUser))).status()).isEqualTo(400);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tokens, impersonation, deletion and recovery links stay out of reach.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hardFloorEndpointsRefused()
|
||||
{
|
||||
assertThat(sa.post("/core/users/" + probeUser + "/impersonate/", new JSONObject().put("reason", "contract test")).status()).isEqualTo(403);
|
||||
assertThat(sa.delete("/core/users/" + probeUser + "/").status()).isEqualTo(403);
|
||||
assertThat(sa.post("/core/users/" + probeUser + "/recovery/", new JSONObject()).status()).isEqualTo(403);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Residual risk (pinned): any authenticated user, the service account
|
||||
** included, may create API tokens FOR ITSELF without add_token; a `user`
|
||||
** in the request is ignored. A compromised QQQ could mint a second
|
||||
** service-account token that outlives a rotation, so the drift report
|
||||
** flags service-account tokens beyond the expected one. Creating a token
|
||||
** for another user is not possible.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void serviceAccountCanMintTokensOnlyForItself()
|
||||
{
|
||||
int saPk = admin.userPk(SERVICE_ACCOUNT);
|
||||
try
|
||||
{
|
||||
TestApi.Response created = sa.post("/core/tokens/", new JSONObject().put("identifier", "floor-token").put("user", probeUser).put("intent", "api").put("expiring", true));
|
||||
assertThat(created.status()).isEqualTo(201);
|
||||
JSONObject token = admin.getOk("/core/tokens/floor-token/");
|
||||
assertThat(token.getInt("user")).as("owner forced to the caller").isEqualTo(saPk);
|
||||
|
||||
assertThat(sa.get("/core/tokens/floor-token/view_key/").status()).as("and it can read that token's key").isEqualTo(200);
|
||||
}
|
||||
finally
|
||||
{
|
||||
admin.delete("/core/tokens/floor-token/");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Residual risk (pinned): Authentik does NOT stop the lockout direction.
|
||||
** Each change is reverted immediately by the admin.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void lockoutDirectionIsNotRefusedByAuthentik()
|
||||
{
|
||||
int breakGlass = admin.userPk(BREAK_GLASS);
|
||||
try
|
||||
{
|
||||
assertThat(sa.post("/core/groups/" + superuserGroup + "/remove_user/", new JSONObject().put("pk", breakGlass)).status()).isEqualTo(204);
|
||||
}
|
||||
finally
|
||||
{
|
||||
admin.post("/core/groups/" + superuserGroup + "/add_user/", new JSONObject().put("pk", breakGlass));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
assertThat(sa.patch("/core/users/" + breakGlass + "/", new JSONObject().put("is_active", false)).status()).isEqualTo(200);
|
||||
}
|
||||
finally
|
||||
{
|
||||
admin.patch("/core/users/" + breakGlass + "/", new JSONObject().put("is_active", true));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
assertThat(sa.patch("/core/users/" + breakGlass + "/", new JSONObject().put("attributes", new JSONObject())).status()).isEqualTo(200);
|
||||
}
|
||||
finally
|
||||
{
|
||||
admin.patch("/core/users/" + breakGlass + "/", new JSONObject().put("attributes", new JSONObject().put("qqq.io/protected", true)));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
assertThat(sa.patch("/core/groups/" + superuserGroup + "/", new JSONObject().put("name", SUPERUSER_GROUP + " renamed")).status()).isEqualTo(200);
|
||||
}
|
||||
finally
|
||||
{
|
||||
admin.patch("/core/groups/" + superuserGroup + "/", new JSONObject().put("name", SUPERUSER_GROUP));
|
||||
}
|
||||
|
||||
JSONObject restored = admin.first("/core/users/?username=" + BREAK_GLASS + "&include_groups=true");
|
||||
assertThat(restored.getBoolean("is_active")).isTrue();
|
||||
assertThat(restored.getBoolean("is_superuser")).isTrue();
|
||||
assertThat(restored.getJSONObject("attributes").getBoolean("qqq.io/protected")).isTrue();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal Authentik API client for contract tests, acting as one token's
|
||||
** user (the test-only bootstrap admin, or the qbit's service account). Used
|
||||
** for setup and for asserting Authentik's own behaviour; the qbit's code
|
||||
** path is AuthentikActionUtil.
|
||||
**
|
||||
** Only ever points at the disposable Testcontainers instance.
|
||||
*******************************************************************************/
|
||||
public class TestApi
|
||||
{
|
||||
private static final HttpClient CLIENT = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(10)).build();
|
||||
|
||||
private final String apiRoot;
|
||||
private final String token;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Response: status plus raw body.
|
||||
*******************************************************************************/
|
||||
public record Response(int status, String body)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Body as a JSON object (empty object for an empty body).
|
||||
*******************************************************************************/
|
||||
public JSONObject json()
|
||||
{
|
||||
return (body == null || body.isBlank() ? new JSONObject() : new JSONObject(body));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shorthand for json().getJSONArray("results").
|
||||
*******************************************************************************/
|
||||
public JSONArray results()
|
||||
{
|
||||
return (json().getJSONArray("results"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public TestApi(String apiRoot, String token)
|
||||
{
|
||||
this.apiRoot = apiRoot;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Response get(String path)
|
||||
{
|
||||
return (send("GET", path, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Response post(String path, Object body)
|
||||
{
|
||||
return (send("POST", path, body));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Response patch(String path, Object body)
|
||||
{
|
||||
return (send("PATCH", path, body));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Response delete(String path)
|
||||
{
|
||||
return (send("DELETE", path, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET that must succeed, as JSON.
|
||||
*******************************************************************************/
|
||||
public JSONObject getOk(String path)
|
||||
{
|
||||
Response response = get(path);
|
||||
if(response.status() != 200)
|
||||
{
|
||||
throw (new IllegalStateException("GET " + path + " -> " + response.status() + ": " + response.body()));
|
||||
}
|
||||
return (response.json());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first result of a list GET, or null.
|
||||
*******************************************************************************/
|
||||
public JSONObject first(String path)
|
||||
{
|
||||
JSONArray results = getOk(path).getJSONArray("results");
|
||||
return (results.isEmpty() ? null : results.getJSONObject(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** pk of a group by name.
|
||||
*******************************************************************************/
|
||||
public String groupPk(String name)
|
||||
{
|
||||
JSONObject group = first("/core/groups/?name=" + encode(name));
|
||||
if(group == null)
|
||||
{
|
||||
throw (new IllegalStateException("No group " + name));
|
||||
}
|
||||
return (group.getString("pk"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** pk of a user by username.
|
||||
*******************************************************************************/
|
||||
public int userPk(String username)
|
||||
{
|
||||
JSONObject user = first("/core/users/?username=" + encode(username));
|
||||
if(user == null)
|
||||
{
|
||||
throw (new IllegalStateException("No user " + username));
|
||||
}
|
||||
return (user.getInt("pk"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** URL-encode a query value.
|
||||
*******************************************************************************/
|
||||
public static String encode(String value)
|
||||
{
|
||||
return (URLEncoder.encode(value, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Response send(String method, String path, Object body)
|
||||
{
|
||||
try
|
||||
{
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(apiRoot + path))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Accept", "application/json");
|
||||
if(body != null)
|
||||
{
|
||||
builder.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(body.toString()));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.method(method, HttpRequest.BodyPublishers.noBody());
|
||||
}
|
||||
HttpResponse<String> response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
return (new Response(response.statusCode(), response.body()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(method + " " + path + " failed: " + e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.drift;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureRouter;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikDriftReportWidgetRenderer;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drift report on the recorded 2026.8.3 contract-test instance, and the
|
||||
** scenarios it must catch.
|
||||
*******************************************************************************/
|
||||
class AuthentikDriftReportTest
|
||||
{
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
private FixtureRouter router;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
config = AuthentikTestSupport.unitConfig().withApplicationsInScope(List.of("kof22-website-prod")).withGroupCacheTtlSeconds(0);
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
|
||||
router = new FixtureRouter().install();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<DriftFinding> run() throws QException
|
||||
{
|
||||
router.refreshGraph();
|
||||
return (new AuthentikDriftReport(AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static DriftFinding find(List<DriftFinding> findings, String check, DriftFinding.Severity severity)
|
||||
{
|
||||
return (findings.stream().filter(f -> f.check().equals(check) && f.severity() == severity).findFirst().orElse(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The recorded instance, as the contract test saw it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recordedInstance() throws QException
|
||||
{
|
||||
List<DriftFinding> findings = run();
|
||||
assertThat(find(findings, "version", DriftFinding.Severity.OK)).isNotNull();
|
||||
assertThat(find(findings, "blueprints", DriftFinding.Severity.OK)).isNotNull();
|
||||
assertThat(find(findings, "service account", DriftFinding.Severity.OK)).isNotNull();
|
||||
assertThat(find(findings, "break-glass", DriftFinding.Severity.OK)).isNotNull();
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.message().contains("no.such.permission"));
|
||||
assertThat(find(findings, "service-account activity", DriftFinding.Severity.ERROR)).as("the lockout probes on break-glass").isNotNull();
|
||||
assertThat(findings.get(0).severity()).isEqualTo(DriftFinding.Severity.ERROR);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unsafe states.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unsafeStates() throws QException
|
||||
{
|
||||
JSONObject serviceAccount = router.users.stream().filter(u -> u.getString("username").equals("qqq-kof22-admin-idadmin")).findFirst().orElseThrow();
|
||||
serviceAccount.put("is_superuser", true).put("type", "internal").put("is_active", false).put("attributes", new JSONObject())
|
||||
.put("groups_obj", new JSONArray().put(new JSONObject().put("pk", "x").put("name", "authentik Admins")));
|
||||
router.tokens.add(new JSONObject(router.tokens.get(0).toString()).put("identifier", "second"));
|
||||
router.blueprints.get(0).put("status", "error");
|
||||
router.users.forEach(u -> u.put("attributes", new JSONObject()));
|
||||
router.groups.forEach(g -> g.put("users_obj", new JSONArray()).put("users", new JSONArray()));
|
||||
router.events.clear();
|
||||
|
||||
List<DriftFinding> findings = run();
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.severity() == DriftFinding.Severity.ERROR && f.message().contains("SUPERUSER"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("type is internal"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("inactive"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("qqq.io/protected"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("2 API tokens"));
|
||||
assertThat(find(findings, "blueprints", DriftFinding.Severity.ERROR)).isNotNull();
|
||||
assertThat(find(findings, "break-glass", DriftFinding.Severity.ERROR)).isNotNull();
|
||||
assertThat(find(findings, "identity admins", DriftFinding.Severity.ERROR)).isNotNull();
|
||||
assertThat(find(findings, "service-account activity", DriftFinding.Severity.OK)).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing service account, version mismatch, a check that cannot run.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingAndUnreachable() throws QException
|
||||
{
|
||||
router.users.removeIf(u -> u.getString("username").equals("qqq-kof22-admin-idadmin"));
|
||||
config.withExpectedServerVersion("2099.1");
|
||||
router.blueprints.clear();
|
||||
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/managed/blueprints/")
|
||||
? FixtureActionUtil.response(403, "{\"detail\":\"You do not have permission to perform this action.\"}")
|
||||
: router.apply(request);
|
||||
|
||||
List<DriftFinding> findings = run();
|
||||
assertThat(find(findings, "service account", DriftFinding.Severity.ERROR)).isNotNull();
|
||||
assertThat(find(findings, "version", DriftFinding.Severity.WARNING)).isNotNull();
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("blueprints") && f.message().startsWith("Check could not run"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The widget renders the findings, and needs authentik.admin.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void widgetRendersAndChecksPermission() throws QException
|
||||
{
|
||||
RenderWidgetInput input = new RenderWidgetInput();
|
||||
input.setWidgetMetaData(qInstance.getWidget(config.applyPrefix("driftReport")));
|
||||
TableData table = (TableData) new AuthentikDriftReportWidgetRenderer().render(input).getWidgetData();
|
||||
assertThat(table.getColumns()).hasSize(4);
|
||||
assertThat(table.getRows()).isNotEmpty();
|
||||
assertThat(table.getRows().get(0).get("severity")).isEqualTo("ERROR");
|
||||
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
|
||||
assertThatThrownBy(() -> new AuthentikDriftReportWidgetRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureRouter;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for AuthentikGroupPermissionMapper over the recorded group graph.
|
||||
*******************************************************************************/
|
||||
class AuthentikGroupPermissionMapperTest
|
||||
{
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0).withBootstrapOperators("Boot@Example.Test");
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
|
||||
QContext.init(qInstance, new QSession());
|
||||
new FixtureRouter().install();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> context(String email, Object groups)
|
||||
{
|
||||
Map<String, Object> context = new HashMap<>();
|
||||
context.put(AuthentikGroupPermissionMapper.CLAIMS_CONTEXT_KEY, new JSONObject().put("email", email).put("groups", groups));
|
||||
return (context);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inherited authentik.* through a child group; existing permissions kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void groupsBecomePermissions()
|
||||
{
|
||||
QSession session = new QSession();
|
||||
session.setPermissions(Set.of("already.there"));
|
||||
new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins").put("no-such-group")));
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
assertThat(session.getPermissions()).contains(permission.getSessionPermission());
|
||||
}
|
||||
assertThat(session.getPermissions()).contains("already.there");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static fallbacks, bootstrap operators (case-insensitive), single-string
|
||||
** groups claim; no claims means no change.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fallbacksAndBootstrap()
|
||||
{
|
||||
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper().withGroupPermissions("kof22-admins", List.of("adminEsb.hasAccess"));
|
||||
|
||||
QSession admin = new QSession();
|
||||
mapper.customizeSession(qInstance, admin, context("someone@example.test", "kof22-admins"));
|
||||
assertThat(admin.getPermissions()).contains("adminEsb.hasAccess").doesNotContain(IdentityPermission.USERS_READ.getSessionPermission());
|
||||
|
||||
QSession boot = new QSession();
|
||||
mapper.customizeSession(qInstance, boot, context("boot@example.test", new JSONArray()));
|
||||
assertThat(boot.getPermissions()).contains(IdentityPermission.MEMBERSHIPS_MANAGE.getSessionPermission());
|
||||
|
||||
QSession none = new QSession();
|
||||
mapper.customizeSession(qInstance, none, Map.of());
|
||||
mapper.customizeSession(qInstance, none, null);
|
||||
assertThat(none.getPermissions()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentik unreachable: only the static fallbacks, never everything.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void authentikDownGrantsOnlyFallbacks()
|
||||
{
|
||||
FixtureActionUtil.groupGraphBody = "not json";
|
||||
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("cms.publish"));
|
||||
QSession session = new QSession();
|
||||
mapper.customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins")));
|
||||
assertThat(session.getPermissions()).containsExactly("cms.publish");
|
||||
|
||||
AuthentikGroupPermissionMapper wrongBackend = new AuthentikGroupPermissionMapper()
|
||||
{
|
||||
@Override
|
||||
protected String getApiBackendName()
|
||||
{
|
||||
return ("nope");
|
||||
}
|
||||
};
|
||||
assertThat(wrongBackend.resolve(qInstance, List.of("phase1-child-of-identity-admins"), "x@example.test")).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for GroupGraph and EffectivePermissionResolver.
|
||||
*******************************************************************************/
|
||||
class EffectivePermissionResolverTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** admins (superuser) <- under-admins; identity-admins <- editors <- interns;
|
||||
** publishers (string attribute); a cycle a <-> b.
|
||||
*******************************************************************************/
|
||||
private static GroupGraph graph()
|
||||
{
|
||||
return (GroupGraph.fromGroups(List.of(
|
||||
group("admins", "authentik Admins", true, List.of(), null, null, List.of(1)),
|
||||
group("under", "under-admins", false, List.of("admins"), List.of("cms.publish"), null, List.of(2)),
|
||||
group("ida", "identity-admins", false, List.of(), List.of("authentik.*"), List.of("kof22-website-prod"), List.of(3)),
|
||||
group("editors", "editors", false, List.of("ida"), List.of("cms.edit", "cms.*"), null, List.of(4)),
|
||||
group("interns", "interns", false, List.of("editors"), List.of("authentik.users.read", "nope.unknown"), null, List.of(5)),
|
||||
new JSONObject().put("pk", "pub").put("name", "publishers").put("is_superuser", false).put("parents", new JSONArray())
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", "cms.publish").put("qqq.io/app-access", "kof22-website-prod")).put("users", new JSONArray().put(6)),
|
||||
group("a", "cycle-a", false, List.of("b"), List.of("cms.edit"), null, List.of()),
|
||||
group("b", "cycle-b", false, List.of("a"), List.of(), null, List.of(7)))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static JSONObject group(String pk, String name, boolean superuser, List<String> parents, List<String> permissions, List<String> appAccess, List<Integer> users)
|
||||
{
|
||||
JSONObject attributes = new JSONObject();
|
||||
if(permissions != null)
|
||||
{
|
||||
attributes.put("qqq.io/permissions", new JSONArray(permissions));
|
||||
}
|
||||
if(appAccess != null)
|
||||
{
|
||||
attributes.put("qqq.io/app-access", new JSONArray(appAccess));
|
||||
}
|
||||
return (new JSONObject().put("pk", pk).put("name", name).put("is_superuser", superuser).put("parents", new JSONArray(parents))
|
||||
.put("attributes", attributes).put("users", new JSONArray(users)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static final Set<String> CATALOG = Set.of("cms.publish", "cms.edit", "cms.delete", "adminEsb.hasAccess");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ancestryAndInheritedSuperuser()
|
||||
{
|
||||
GroupGraph graph = graph();
|
||||
assertThat(graph.ancestorPks("interns")).containsExactly("editors", "ida");
|
||||
assertThat(graph.isSuperuserInherited("under")).isTrue();
|
||||
assertThat(graph.isSuperuserInherited("admins")).isTrue();
|
||||
assertThat(graph.isSuperuserInherited("interns")).isFalse();
|
||||
assertThat(graph.ancestorPks("a")).containsExactly("b");
|
||||
assertThat(graph.ancestorPks("unknown")).isEmpty();
|
||||
assertThat(graph.byName("editors").pk()).isEqualTo("editors");
|
||||
assertThat(graph.groupsOfUser(4)).containsExactly("editors");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inheritance, wildcards, identity names, unknown names, the catalog.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void resolvesWithInheritanceAndWildcards()
|
||||
{
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), CATALOG);
|
||||
|
||||
EffectivePermissionResolver.Resolution interns = resolver.resolveUser(5);
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
assertThat(interns.sessionPermissions()).contains(permission.getSessionPermission());
|
||||
}
|
||||
assertThat(interns.sessionPermissions()).contains("cms.edit", "cms.publish", "cms.delete").doesNotContain("adminEsb.hasAccess", "nope.unknown");
|
||||
assertThat(interns.unknown()).containsExactly("nope.unknown");
|
||||
assertThat(interns.grantedBy().get("cms.delete")).containsExactly("editors");
|
||||
assertThat(interns.grantedBy().get(IdentityPermission.USERS_READ.getSessionPermission())).containsExactlyInAnyOrder("identity-admins", "interns");
|
||||
assertThat(interns.appAccess()).containsExactly("kof22-website-prod");
|
||||
|
||||
assertThat(EffectivePermissionResolver.holdsAll(interns, List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE))).isTrue();
|
||||
assertThat(EffectivePermissionResolver.holdsAll(resolver.resolveUser(6), List.of(IdentityPermission.USERS_READ))).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pre-rename identity.* grants (owner naming rule, 2026-10-12) are reported
|
||||
** as unknown and grant nothing until the group attribute is updated.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void legacyIdentityNamesAreUnknown()
|
||||
{
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(
|
||||
group("old", "identity-admins", false, List.of(), List.of(IdentityPermission.LEGACY_WILDCARD, IdentityPermission.USERS_READ.getLegacyShortName()), null, List.of(9))));
|
||||
|
||||
EffectivePermissionResolver.Resolution old = new EffectivePermissionResolver(graph, CATALOG).resolveUser(9);
|
||||
assertThat(old.sessionPermissions()).isEmpty();
|
||||
assertThat(old.unknown()).containsExactlyInAnyOrder("identity.*", "identity.users.read");
|
||||
assertThat(EffectivePermissionResolver.holdsAll(old, List.of(IdentityPermission.USERS_READ))).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A single string attribute is a list of one; cycles terminate.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stringAttributesAndCycles()
|
||||
{
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), CATALOG);
|
||||
assertThat(resolver.resolveUser(6).sessionPermissions()).containsExactly("cms.publish");
|
||||
assertThat(resolver.resolveUser(6).appAccess()).containsExactly("kof22-website-prod");
|
||||
assertThat(resolver.resolveUser(7).sessionPermissions()).containsExactly("cms.edit");
|
||||
assertThat(resolver.resolveUser(99).sessionPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a catalog: names pass through, but wildcards are never granted
|
||||
** literally.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void withoutCatalog()
|
||||
{
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), null);
|
||||
assertThat(resolver.expand("anything.goes")).containsExactly("anything.goes");
|
||||
assertThat(resolver.expand("cms.*")).isEmpty();
|
||||
assertThat(resolver.expand(" authentik.audit.read ")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission());
|
||||
assertThat(resolver.expand("authentik.audit.read.hasAccess")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission());
|
||||
assertThat(resolver.expand("")).isEmpty();
|
||||
assertThat(resolver.expand(null)).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.security;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit test for IdentityPermission: the authentik.* names (owner naming rule,
|
||||
** 2026-10-12) and the legacy identity.* names.
|
||||
*******************************************************************************/
|
||||
class IdentityPermissionTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void namesUseTheQbitPrefix()
|
||||
{
|
||||
assertThat(IdentityPermission.WILDCARD).isEqualTo("authentik.*");
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
assertThat(permission.getShortName()).startsWith("authentik.");
|
||||
assertThat(permission.getSessionPermission()).isEqualTo(permission.getShortName() + ".hasAccess");
|
||||
}
|
||||
assertThat(IdentityPermission.USERS_READ.getShortName()).isEqualTo("authentik.users.read");
|
||||
assertThat(IdentityPermission.ADMIN.getShortName()).isEqualTo("authentik.admin");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void legacyNamesAreKnownButNeverGranted()
|
||||
{
|
||||
assertThat(IdentityPermission.LEGACY_WILDCARD).isEqualTo("identity.*");
|
||||
assertThat(IdentityPermission.USERS_READ.getLegacyShortName()).isEqualTo("identity.users.read");
|
||||
assertThat(IdentityPermission.MEMBERSHIPS_MANAGE.getLegacyShortName()).isEqualTo("identity.memberships.manage");
|
||||
assertThat(IdentityPermission.ADMIN.getLegacyShortName()).isEqualTo("identity.admin");
|
||||
|
||||
assertThat(IdentityPermission.fromName("authentik.users.read")).contains(IdentityPermission.USERS_READ);
|
||||
assertThat(IdentityPermission.fromName("authentik.users.read.hasAccess")).contains(IdentityPermission.USERS_READ);
|
||||
assertThat(IdentityPermission.fromName("identity.users.read")).isEmpty();
|
||||
assertThat(IdentityPermission.fromName("identity.users.read.hasAccess")).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,107 +0,0 @@
|
||||
# Test fixture: the kof22 identity bootstrap blueprint (DESIGN.md section 4.3),
|
||||
# in the shape proposed for cluster-gitops. Identity objects are
|
||||
# `state: created`: created when missing, never updated, never given members.
|
||||
version: 1
|
||||
metadata:
|
||||
name: kof22-identity-bootstrap
|
||||
labels:
|
||||
blueprints.goauthentik.io/instantiate: "true"
|
||||
entries:
|
||||
# Apply the infrastructure blueprint first (it owns the recovery email stage
|
||||
# referenced below). On an empty instance this is what makes the bootstrap
|
||||
# apply cleanly (contract test h).
|
||||
- model: authentik_blueprints.metaapplyblueprint
|
||||
attrs:
|
||||
identifiers:
|
||||
name: kof22-oauth2-apps
|
||||
required: true
|
||||
|
||||
# The role the qbit service account runs as. It is the qbit's own
|
||||
# infrastructure, not identity data, so it is `present`: attrs carry ONLY
|
||||
# `permissions` (global permissions; a blueprint-only RoleSerializer field),
|
||||
# which are assigned additively on every apply, so a permission removed in
|
||||
# the UI comes back within the hour and nothing granted elsewhere is taken
|
||||
# away. No users, no groups. Phase 0/1 = read-only; later phases append here.
|
||||
# Note: matching is by codename, so view_group also grants Django's unused
|
||||
# auth.view_group (harmless).
|
||||
- model: authentik_rbac.role
|
||||
state: present
|
||||
id: qqq-role
|
||||
identifiers:
|
||||
name: qqq-kof22-admin-idadmin
|
||||
attrs:
|
||||
permissions:
|
||||
- authentik_core.view_user
|
||||
- authentik_core.view_group
|
||||
- authentik_core.view_application
|
||||
- authentik_core.view_authenticatedsession
|
||||
- authentik_rbac.view_role
|
||||
- authentik_policies.view_policybinding
|
||||
- authentik_events.view_event
|
||||
- authentik_events.add_event
|
||||
- authentik_blueprints.view_blueprintinstance
|
||||
|
||||
# The qbit service account. Its API token is minted once by hand (never here).
|
||||
- model: authentik_core.user
|
||||
state: created
|
||||
id: qqq-sa
|
||||
identifiers:
|
||||
username: qqq-kof22-admin-idadmin
|
||||
attrs:
|
||||
name: QQQ kof22 Admin identity service
|
||||
type: service_account
|
||||
path: service-accounts/qqq
|
||||
roles:
|
||||
- !KeyOf qqq-role
|
||||
attributes:
|
||||
qqq.io/protected: true
|
||||
|
||||
# Object permission on a stage the infrastructure blueprint owns: the bare
|
||||
# `created` entry is skipped (the stage exists), but its permissions are
|
||||
# still applied, additively, on every run.
|
||||
- model: authentik_stages_email.emailstage
|
||||
state: created
|
||||
identifiers:
|
||||
name: kof22-recovery-email
|
||||
permissions:
|
||||
- role: !KeyOf qqq-role
|
||||
permission: authentik_stages_email.view_emailstage
|
||||
|
||||
# Seed groups: created once, then plain data. No users, no member lists.
|
||||
- model: authentik_core.group
|
||||
state: created
|
||||
identifiers:
|
||||
name: identity-admins
|
||||
attrs:
|
||||
attributes:
|
||||
qqq.io/permissions: ["authentik.*"]
|
||||
qqq.io/app-access: ["kof22-website-prod"]
|
||||
- model: authentik_core.group
|
||||
state: created
|
||||
identifiers:
|
||||
name: kof22-admins
|
||||
- model: authentik_core.group
|
||||
state: created
|
||||
identifiers:
|
||||
name: kof-website-publishers
|
||||
# No qqq.io/app-access yet: drafters sign in to the admin only once the owner
|
||||
# decides to set that one attribute (DESIGN.md section 11.2 item 6).
|
||||
- model: authentik_core.group
|
||||
state: created
|
||||
identifiers:
|
||||
name: kof-website-drafters
|
||||
|
||||
# Break-glass admin: a superuser the qbit cannot touch. Password set once by
|
||||
# hand (1Password); recovery through `ak create_recovery_key`.
|
||||
- model: authentik_core.user
|
||||
state: created
|
||||
identifiers:
|
||||
username: kof22-breakglass
|
||||
attrs:
|
||||
name: Break-glass admin
|
||||
type: internal
|
||||
path: users/breakglass
|
||||
groups:
|
||||
- !Find [authentik_core.group, [name, "authentik Admins"]]
|
||||
attributes:
|
||||
qqq.io/protected: true
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user