Compare commits

...
Author SHA1 Message Date
james.maesandCodex c677055200 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:24:31 -05:00
james.maesandCodex 17486b88d2 ci: adopt central orb 0.7.12 and stable gates (#45)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 09:03:12 -05:00
james.maesandCodex 7d1227adf5 ci: centralize rc release completion in qqq orb (#44)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:35:07 -05:00
james.maesandCodex 7df05f2324 ci: align canonical shared orb workers (#43)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:39:22 -05:00
james.maesandCodex 28cb654d13 ci: adopt shared orb 0.7.11 (#42)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:10:02 -05:00
james.maes 33cdaa1141 Merge pull request #41 from QRun-IO/feature/central-ci
ci: adopt verified central QQQ orb 0.7.1
2026-10-20 18:56:04 -05:00
james.maesandCodex 44a46a3cfc ci: adopt verified central QQQ orb 0.7.2
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:42:36 -05:00
james.maes dbae77da5b Merge pull request #38 from QRun-IO/feature/maintenance-service-principal
feat(maintenance): applyPolicy runs as the maintenance service principal
2026-10-16 13:53:54 -05:00
james.maesandClaude Opus 5.5 1d72ce3cec Merge origin/develop into feature/maintenance-service-principal
Keeps both CHANGELOG entries (#38 maintenance principal; #37/#39 health fixes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:34:32 -05:00
james.maesandClaude Opus 5.5 b784bfd097 build: QQQ 4.1.0-RC.19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:19:05 -05:00
james.maes da92be126d Merge pull request #39 from QRun-IO/feature/mapper-group-attributes-warning
fix(health): name flagged groups and reasons in the mapper check
2026-10-16 11:53:04 -05:00
james.maesandClaude Opus 5.5 86ff924ebd fix(health): name flagged groups and reasons in the mapper check
A degraded authentik.mapper check said only "Mapper drift detected" with
findingCodes=group_attributes.warning, so nobody could tell which group
was wrong or why without opening the drift report.

Group-attribute findings now carry a short, safe reason
(DriftFinding.reason): "grants nothing: <permission>", "malformed
<attribute>" or "superuser group carries qqq.io/permissions". The health
message and the new flaggedGroups and summary details name each flagged
group with its reasons, within the 500-character limits. Typed attribute
values (malformed entries, unprintable permission names) and
service-account activity subjects (people) stay out of the outcome.

The "grants nothing" drift message also notes that a permission an app
checks only in code, not in its metadata, is not in the catalog.

A group with no qqq.io/* attributes (a group relying on the host's
static floor) is not drift; a test pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:37:41 -05:00
james.maesandClaude Opus 5.5 1f12334f4f fix(maintenance): never change a service principal's role group
Maintenance (itself a principal) refuses groups named service:... or under
qqq-service-accounts: only the owner changes principal grants (security
review H3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:15:22 -05:00
james.maes 995a5900aa Merge pull request #37 from QRun-IO/feature/broker-probe-token-exchange
fix(health): token_broker probe uses token exchange (stops 400s every 5 min)
2026-10-16 08:48:26 -05:00
james.maesandClaude Opus 5.5 4d6140f7be feat(maintenance): applyPolicy runs as the maintenance service principal
Drop the self-made MaintenanceSession that granted itself
policies.manage and the document's grants (rejected under OAuth2). Run
as the host's declared maintenance principal: its Authentik service
account's grants decide; the subset rule still applies. The audit
operator is service:{principal} and the job name prefixes the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:21:41 -05:00
james.maes 265dbb0468 Merge pull request #36 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:04:32 -05:00
james.maesandClaude Opus 5.5 bc92be338f fix(health): probe the token broker with token exchange, not client_credentials
The broker provider allows only token exchange, so the client_credentials
probe always got 400 (Invalid grant_type for provider). Probe with token
exchange and no subject. Authentik checks grant type, then secret, then
subject, so invalid_request means the credentials passed. invalid_client
and invalid_grant are DOWN. Refusals carry their OAuth2 error code
(BrokerRefusedException).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:55:32 -05:00
james.maesandClaude Opus 5.5 69830a095e ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:21:07 -05:00
james.maes 33e53263c5 Merge pull request #35 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:15:07 -05:00
james.maesandClaude Opus 5.5 1d8a2a85c7 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:59:43 -05:00
james.maes eba93e68ca Merge pull request #31 from QRun-IO/feature/health-checks
feat(health): register Authentik API, token, mapper and broker checks with core central health
2026-10-14 21:27:00 -05:00
james.maesandClaude Opus 5.5 0df7bb49e2 Merge origin/develop into feature/health-checks; pin QQQ 4.1.0-RC.8
RC.8 ships the central health kernel (QRun-IO/qqq#1207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:15:11 -05:00
james.maes 8c8ef9fd28 Merge pull request #34 from QRun-IO/feature/help-bundle
feat(help): qbit-authentik help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:29:38 -05:00
james.maesandClaude Opus 5.5 5cf668f8e3 feat(help): ship the qbit-authentik help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:17:00 -05:00
james.maes 590788adf8 Merge pull request #33 from QRun-IO/feature/tables-populated-defaults
feat(help): guided empty states for every authentik table
2026-10-14 17:31:47 -05:00
james.maesandClaude Opus 5.5 a6daba12df docs: add removeBy to the STAND-IN markers in the help bundle and changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:10:26 -05:00
james.maesandClaude Opus 5.5 f0648d1ef2 feat(help): guided empty states for every authentik table
Add help/qbit-authentik.yaml (QQQ help-bundle format) for the Identity app and
all 15 tables, applied by AuthentikHelp (STAND-IN for core #1198). API-backed
tables say rows are read live and what an empty list means. Add
AuthentikTablesGuardrailTest (used at runtime, explained, documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:44:21 -05:00
james.maes a5db01a071 Merge pull request #32 from QRun-IO/feature/policy-full-access
feat(policy): full access ("*") statement and maintenance policy API
2026-10-14 15:42:07 -05:00
james.maesandClaude Opus 5.5 c9e5506cdf feat(policy): host-declared permission names join the full-access expansion
Custom-checker and named permissions (for example cdn.purge) are not listed
by PermissionsHelper.getAllAvailablePermissions, so full access missed them.
AuthentikQBitConfig.withFullAccessHostPermissions lets the host declare them;
they are computed at use time and only for the instance holding the runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:24:21 -05:00
james.maesandClaude Opus 5.5 b196109d84 feat(admin): maintenance API to apply a policy through the audited Save Policy path
AuthentikMaintenance.applyPolicy(backend, groupName, policyJson,
operatorName, reason) lets reviewed host code (a run-once job) set a
group's policy as a named maintenance operator (run-once:/maintenance:),
never as a system session. It runs the same Save Policy plan and
execute: audit row before the Authentik call, custom event, guardrails
(protected and superuser groups, scope, readOnly, POLICIES), stale check,
optional two-person approval. An equivalent policy is a NO_CHANGE no-op.
Plain Java API only; nothing is exposed over HTTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:17:28 -05:00
james.maesandClaude Opus 5.5 4d2bd07e4b feat(policy): full-access statement (Resource "*" + Action "*")
Owner request (2026-10-14): a "*" access policy for full access. The
statement {"Effect":"Allow","Action":["*"],"Resource":["*"]} grants
every permission the instance defines, expanded when permissions are
computed (sign-in, resolver, access comparison, member preview), so new
tables, processes, apps, reports, widgets and features are covered with
no edit. Resource "*" with any other action rejects the document. The
canonical form is the single statement; saving needs the operator to
hold the full set. Editor payload: fullAccess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:09:34 -05:00
james.maesandClaude Opus 5.5 24a97823a9 docs: Authentik health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:18 -05:00
james.maesandClaude Opus 5.5 fee7e2ad20 refactor(authentik): Identity home shows central health instead of direct probes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:17 -05:00
james.maesandClaude Opus 5.5 3b117cfff4 feat(health): register Authentik API, token, mapper and broker checks with core central health
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:16 -05:00
james.maesandClaude Opus 5.5 35d9886bbf feat(health): deadline-bounded Authentik client calls for health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:15 -05:00
james.maes 1c1d9bc594 Merge pull request #30 from QRun-IO/feature/log-effective-features
fix(config): startup log lists the effective features (TOKENS on by default)
2026-10-14 13:01:16 -05:00
james.maes cb20aecf18 Merge pull request #29 from QRun-IO/feature/policy-editor-preview
feat(policy): member preview payload for the policy editor
2026-10-14 13:01:11 -05:00
james.maesandClaude Opus 5.5 d0c5c2971e fix(config): startup log lists the effective features (TOKENS on by default)
The "Authentik QBit produced" line logged the configured feature set, so
TOKENS (on by default whenever writes are enabled) never appeared.
getEffectiveFeatures() reports what hasFeature() says is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:42:58 -05:00
james.maesandClaude Opus 5.5 612307bde8 feat(policy): member preview payload for the policy editor
The editor widget now lists the group's members (direct and through
descendant groups, active and in scope, capped at 200) with each
person's sign-in permissions WITHOUT this group's policy
(basePermissions), plus mapperEnabled. The Next widget computes the
live effective permissions for a chosen member and what the edit adds
or removes. EffectivePermissionResolver gains an exclude-one-policy
resolution; loading requires authentik.policies.manage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:13:13 -05:00
james.maes 3bbb073ec7 Merge pull request #28 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:56:34 -05:00
james.maesandClaude Opus 5.5 a977b7a0da ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:06:04 -05:00
james.maes 1b058b617d Merge pull request #26 from QRun-IO/feature/token-scopes
feat(tokens): scopes, last used, reminders and admin views (phase 6c)
2026-10-14 08:11:50 -05:00
james.maesandClaude Opus 5.5 99a302c7ce refactor(tokens): token notifications call core QAlerts directly (RC.6)
QQQ 4.1.0-RC.6 (now the qbit's baseline) ships the notification SPI
(qqq#1125), so the reflection stand-in is gone: guard incidents use
QAlerts.raise, owner reminders QAlerts.notify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:59:08 -05:00
james.maes 65aa816660 Merge remote-tracking branch 'origin/develop' into feature/token-scopes 2026-10-14 07:57:21 -05:00
james.maesandClaude Opus 5.5 3722380db0 feat(tokens): owner decision A (add_token); alarm on service-account creation; core notify SPI
- DESIGN.md decision 31 (owner, 2026-10-14): the qbit role holds
  add_token (needed by service_account and by set_key); view_token_key
  stays not granted.
- TokenContractIT pins 403 without add_token and 200/204 with it, and
  that the alarm notifies identity-admins when the qbit's account creates
  a service account (fixture = cluster-gitops#103 blueprint, verbatim).
- Reminders go through core QAlerts.notify (qqq#1125) by reflection,
  never another qbit's classes (G1); guard alerts through core QAlerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:57:19 -05:00
james.maes 89fcdec2ab Merge pull request #27 from QRun-IO/feature/guardrails-rc6
build: QQQ 4.1.0-RC.6, core table feature permissions, guardrails (#20)
2026-10-14 07:51:11 -05:00
james.maes f78aaa3626 Merge pull request #24 from QRun-IO/feature/token-management
feat(tokens): access-token management (DESIGN.md §13, phase 6b)
2026-10-14 07:44:03 -05:00
james.maesandClaude Opus 5.5 02c18de8c1 build: QQQ 4.1.0-RC.6, real table feature permissions, guardrails enforced
Move to the released 4.1.0-RC.6, which includes core
TableFeaturePermissionsMetaData (QRun-IO/qqq#1136). The policy catalog
discovers features through core only; the test-only injection overload
is removed. The policy feature tests register real metadata instead of
the synthetic catalog, so the expired STAND-IN marker is gone.

Adopt qbit-build-parent 2.1.0 with the guardrails enforced and strict
raw-route mode.

Closes #20 (architecture review QRun-IO/qqq#1150, finding F19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:43:51 -05:00
james.maes eb74cd12db Merge remote-tracking branch 'origin/feature/token-management' into feature/token-scopes 2026-10-14 07:21:06 -05:00
james.maes 9dfef79929 Merge remote-tracking branch 'origin/develop' into feature/token-management
# Conflicts:
#	CHANGELOG.md
2026-10-14 07:20:40 -05:00
james.maes eb5d019fe7 Merge pull request #23 from QRun-IO/feature/arch-rc5
chore(deps): build on QQQ 4.1.0-RC.5 (#20)
2026-10-14 07:09:09 -05:00
james.maesandClaude Opus 5.5 6c56a24960 docs(spec): no client secret for the PAT exchange; async one-time hand-off
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:28:57 -05:00
james.maesandClaude Opus 5.5 3e18458fb3 test(tokens): PAT exchange without a client secret, as users send it
TokenContractIT now exchanges app passwords exactly like the generated
usage text (no client_secret); green on Authentik 2026.8.3. Also logs the
cause when a token scope cannot be verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:18:35 -05:00
james.maesandClaude Opus 5.5 5a3802193f fix(tokens): token-scope lookup runs as the internal system session
The lookup runs while a bearer session is still being built (no user), so
the guard refused the Authentik read and the scope failed closed for
every token. Read Authentik and the scope row as QSystemUserSession, then
restore the caller's context. Found by the kof22 adoption test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:11 -05:00
james.maesandClaude Opus 5.5 8698d627a9 test(tokens): widget type is the core TABLE type
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:00:33 -05:00
james.maesandClaude Opus 5.5 32741508e8 fix(tokens): explicit last_reminder_7d/_1d column names for authentik_token_scope
A host whose RDBMS backend maps camelCase to snake_case got
last_reminder7d, which does not match docs/schema.sql (found by the
kof22 adoption's changeset test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:59:28 -05:00
james.maesandClaude Opus 5.5 2061677e16 fix(tokens): access-token widget uses the core TABLE widget type ("table")
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:54:10 -05:00
james.maesandClaude Opus 5.5 7f8e742120 feat(tokens): scopes, last used, reminders and admin views (DESIGN.md section 13, phase 6c)
- Scopes: Create Token takes read only, apps and tables when
  tokenScopesEnabled (validated against the instance and the operator's
  own access). AuthentikTokenScopes reads the signed qqq_token claim in
  both mappers (and AuthentikTokenScopes.apply for hosts with their own
  customizer): no row = unscoped, deleted token = no permissions, any
  verification failure = no permissions. STAND-IN for core
  QCredentialScope (qqq#1168, RC.7): permissions are intersected; the
  core scope is attached by reflection when present (empty dimension =
  null = unrestricted).
- Last used from Authentik login events (method token, 90 days, cached
  5 minutes); createdAt and scopeSummary from the scope row.
- Daily expiry reminders (7 days, 1 day) through the optional Alerts
  producer, deduplicated on the scope row; guard-bypass alert.
- Admin widget on the app home (authentik.tokens.manage): by owner, by
  age, unused for tokenUnusedDays, deactivated owners; bulk Revoke Token
  on the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:26:53 -05:00
james.maesandClaude Opus 5.5 26a449c9e6 test(tokens): contract tests on Authentik 2026.8.3 and service-account lifetime on the user
- TokenContractIT (9 tests) against the production token blueprint
  (verbatim copy of cluster-gitops kof22-qqq-tokens.yaml): caller-owned
  create, PATCH defaults, expiry cap (brand, groups, user), token
  exchange as the user via the broker, the deny policy for protected
  users, the qqq_token claim (profile only), service_account + set_key +
  PATCH, the detective alarm, and the qbit lifecycle with the one-time
  key absent from audit rows and events.
- Findings now in DESIGN.md: service_account and set_key need add_token
  (safe: a non-superuser create is always the caller's); set_key events
  use the model reference shape.
- Service-account lifetime cap is a user attribute
  (serviceAccountTokenLifetime, default days=365), not a group.
- Test fixtures carry the production role; SuperuserFloorIT accepts the
  400 from /recovery/ with reset_user_password (the qbit never calls it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:59:00 -05:00
james.maesandClaude Opus 5.5 de7c015081 feat(tokens): access-token management (DESIGN.md section 13, phase 6b)
TOKENS feature, on by default with writes (AUTHENTIK_QBIT_TOKENS=false
opts out). Create, rotate, revoke and extend tokens, and create service
accounts, as record and table actions with the standard Review/Result.

- People's tokens are created as that person through the token broker
  (RFC 8693 exchange); service accounts through service_account, set_key
  and PATCH (always intent, user and expires, with a race guard).
- Guardrails: protected owners (superusers, authentik Admins,
  identity-admins, qqq.io/protected, the qbit account, configurable
  groups), self-only for people, required capped expiry, optional
  two-person approval for service-account tokens.
- One-time key via core one-time process output (stand-in until
  qqq#1162 ships; refuses at Review when unavailable), never in tables,
  audit, events, logs or process state; usage text from config.
- authentik_token_scope table; authentik.tokens.manage permission.
- In-memory Authentik models the 2026.8.3 token semantics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:58:46 -05:00
james.maes 38d9776f25 Merge remote-tracking branch 'origin/develop' into feature/arch-rc5
# Conflicts:
#	CHANGELOG.md
2026-10-13 20:18:53 -05:00
james.maes 9eece9cde5 Merge pull request #21 from QRun-IO/feature/token-spec
docs(spec): access tokens as QQQ's personal access tokens (revision 6)
2026-10-13 20:01:45 -05:00
james.maes b1d9df5370 Merge pull request #22 from QRun-IO/feature/mapper-automated-sessions
feat(permissions): Authentik permissions for automated sessions
2026-10-13 19:55:16 -05:00
james.maesandClaude Opus 5.5 d138c5c911 chore(deps): build on QQQ 4.1.0-RC.5
Move the core baseline from 4.1.0-RC.2 to the released 4.1.0-RC.5. No
source changes were needed. Comments now reflect core's opt-in permission
enforcement in 4.1. The synthetic feature-permission catalog in tests is
tagged STAND-IN(core=QRun-IO/qqq#1136, removeBy=4.1.0-RC.6), because
TableFeaturePermissionsMetaData is on develop but not in RC.5.

Refs #20 (architecture review QRun-IO/qqq#1150, finding F19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:51:47 -05:00
james.maesandClaude Opus 5.5 2aaedacffd docs(spec): token lifetime cap on the brand, claim bound to profile, separate broker blueprint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:42 -05:00
james.maesandClaude Opus 5.5 f151a7344a feat(permissions): Authentik permissions for automated sessions
AuthentikGroupPermissionMapper implements customizeAutomatedSessionForUser.
Server-minted sessions (scheduled saved reports, SSH-key users of
qbit-file-gateway) carry no token, so they got no Authentik permissions.
The email idReference must match exactly one active Authentik user; that
user's direct groups and ancestors go through the same resolver, static
fallbacks and cut-over guard as sign-in. Anything else, or an unreadable
API, grants only the static fallbacks. customizeSession is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:33 -05:00
james.maesandClaude Opus 5.5 d9431e7e6b docs(spec): access tokens as QQQ's personal access tokens (revision 6)
Owner decisions (2026-10-13) 23-30: tokens managed in the app and stored
in Authentik, shown once, self-service through Preferences, on by default
with an opt-out, guardrails (a)-(f), optional per-token scopes enforced
through core QCredentialScope (qqq#1153), reminders, last used, admin
views and bulk revoke.

Decision 30 records the owner-approved mechanism revision: Authentik
2026.8.3 always creates a token for the caller unless it is a superuser,
so people's tokens are created as that person through RFC 8693 token
exchange (token broker with a deny policy), service accounts through
service_account, set_key and PATCH. The qbit role gains change_token and
set_token_key and never holds add_token or view_token_key.

Adds docs/ACCESS-TOKENS.md ("Using access tokens").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:42:28 -05:00
james.maes 7fb96d7d2a Merge pull request #19 from QRun-IO/feature/policy-feature-permissions
feat(policy): grant table feature permissions (analyze, comment, ...)
2026-10-13 15:39:36 -05:00
james.maesandClaude Opus 5.5 d3b740785f feat(policy): grant table feature permissions (analyze, comment, ...)
The catalog lists each table's feature permissions, read generically from
PermissionsHelper.getAllAvailablePermissions (core
TableFeaturePermissionsMetaData), as a features array per table resource.
Policies grant them by name; the * wildcard still means the five
standard actions only, so existing policies grant nothing new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:30:15 -05:00
james.maes 0e981bc4f7 Merge pull request #18 from QRun-IO/feature/policy-cutover
feat(policy): safe cut-over to policies (status, compare, guard, generate)
2026-10-13 14:48:20 -05:00
james.maes 716a40df1b Merge pull request #17 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 14:42:55 -05:00
james.maesandClaude Opus 5.5 af970d32db feat(policy): safe cut-over to policies (status, compare, guard, generate)
- AuthentikHostPermissionSource SPI (today's access) and config
  mapperEnabled, cutoverGuardGroups, mapperOverride, staticGroupPermissions.
- Enforcement status OFF/ACTIVE/OVERRIDDEN/BLOCKED: banner widget on group
  and policy records and the Edit Policy step ("Policies are saved but NOT
  enforced at sign-in on this instance (mapper off)"), and a row in the home's
  Connection card.
- Compare Access widget (authentik.admin, read only): per active person,
  today vs the mapper, added/removed/unchanged, guard result.
- Cut-over guard: while a guarded-group member would lose a permission (no
  override), the mapper grants today's access plus the static fallback and
  bootstrap subjects instead of the policies.
- Generate Policy From Current Access group action; operator's access today
  counts for the subset rule.
- DESIGN.md 12.8 with the one-switch rollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:39:05 -05:00
james.maesandClaude Opus 5.5 0a827955b8 ci: automate RC releases; hotfix/* pushes no longer publish
Removes publish_hotfix_release: a hotfix/* branch push published a GA
release to Maven Central with no tag or review (QRun-IO/qbit-cdn#12,
QRun-IO/qqq#1121). hotfix/* branches now run test_only; a hotfix
release publishes from a reviewed vX.Y.Z tag through publish_release.
Adds .github/workflows/release-rc.yml (reusable QRun-IO/.github
qbit-release-rc): a manual dispatch, or merging a develop PR labelled
release:rc, merges develop into the release line and the release/* push
runs the existing CircleCI RC publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:36:57 -05:00
james.maes a5a74af0e2 Merge pull request #16 from QRun-IO/feature/user-group-crud
feat(crud): phase 5c full user and group CRUD
2026-10-13 14:12:24 -05:00
james.maesandClaude Opus 5.5 a79f2d2d4b feat(crud): phase 5c full user and group CRUD
- Edit User: username (validated, unique) and other attributes (JSON object;
  qqq.io/ and goauthentik.io/ keys kept and refused; blank keeps them; needs
  authentik.audit.read, which also gates showing them). Edit Group: other
  attributes the same way.
- Delete User and Delete Group behind the new DELETE feature (off by default;
  Authentik delete_user/delete_group): typed confirmation, never yourself, a
  protected account, a superuser group or the last identity administrator; a
  group needs no members and no child groups; its policy row goes with it.
- Tests on the in-memory Authentik and a delete contract test on 2026.8.3;
  README, STATUS, CHANGELOG, CLAUDE.md invariant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:56:23 -05:00
james.maes a2fbb2516e Merge pull request #15 from QRun-IO/feature/policy-editor
feat(policy): phase 5b policy editor and save to Authentik
2026-10-13 13:54:58 -05:00
james.maesandClaude Opus 5.5 639dc38ca1 feat(policy): phase 5b policy editor and save to Authentik
- Edit Policy record action on groups and policies (feature POLICIES,
  permission authentik.policies.manage): streamed-ETL with the
  AUTHENTIK_POLICY_EDITOR widget on its step; Review parses fail-closed,
  regenerates canonically with the round-trip check, refuses new resources the
  app does not define, applies the subset rule to added permissions and lists
  the changes; Result merge-patches qqq.io/policy into the group's current
  attributes (removed when empty). Optional policyTwoPersonApproval.
- PolicyCatalog: apps, tables, processes (hidden record actions included),
  reports and protected widgets with the actions QQQ checks, grouped by app;
  joins the permission catalog.
- authentik_policy local table (one row per group, field-level audit as the
  change history), nav and child list on the group; DDL in docs/schema.sql.
- Widget authentikPolicyEditor: catalog plus the live document, flagged when
  changed in Authentik since the last QQQ save.
- Tests: catalog, admin rules, process, widget, gating, approval, and a
  savePolicy contract test on Authentik 2026.8.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:47:23 -05:00
james.maes 9751296d6c Merge pull request #14 from QRun-IO/feature/policy-model
feat(policy): phase 5a policy model, parser, generator and runtime mapper
2026-10-13 13:44:04 -05:00
james.maesandClaude Opus 5.5 616e3a8135 feat(policy): phase 5a policy model, parser, generator and runtime mapper
- PolicyParser (fail closed), PolicyGenerator (canonical form, round-trip
  check), PolicyFormat and config (policySpec/Version/Attribute/Claim,
  policyTwoPersonApproval).
- AuthentikPolicyClaimMapper and PolicyPermissions for the token claim, with
  an applySecurityKeys hook for record locks; AuthentikGroupPermissionMapper
  adds the claim too.
- GroupGraph and EffectivePermissionResolver read each group's qqq.io/policy,
  so effective permissions, the subset rule and the last-admin check see
  policy grants; an invalid policy is listed as malformed.
- PolicyClaimMappingIT pins the scope-mapping blueprint example on Authentik
  2026.8.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:34:38 -05:00
james.maes 93d3afa869 Merge pull request #13 from QRun-IO/feature/policy-spec
docs(spec): policies (IAM-style statements) and full user/group CRUD
2026-10-13 13:28:00 -05:00
james.maesandClaude Opus 5.5 077d9abbe8 docs(spec): policies (IAM-style statements) and full user/group CRUD
DESIGN.md revision 5: owner decisions of 2026-10-13 (17-22), new section 12
(policy model stored on Authentik groups, fail-closed parsing, canonical
generator with round-trip check, claim mapping, the policy editor, delete
user/group behind the DELETE feature, authentik.policies.manage), plus
docs/POLICY-FORMAT.md and the scope-mapping blueprint example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:21:27 -05:00
james.maes 746b5ee5d0 Merge pull request #12 from QRun-IO/feature/v1-lifecycle
feat: v1 - full user lifecycle and group/permission management (phases 2-3)
2026-10-12 18:18:34 -05:00
james.maesandClaude Sonnet 5.5 97fe594130 fix: records carry tableName (Next UI record screens), drift uuid, token expiry warning, review lines name targets
Found by running the qbit in a real app with the Next UI against Authentik.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 18:01:49 -05:00
james.maesandClaude Sonnet 5.5 645f782e42 fix(security): bootstrap operators keyed on immutable ids, never email
Users can change their own email in Authentik and emails are not unique, so
bootstrapOperators (emails) let a user become an identity operator by setting
their email to the owner's. Replaced by bootstrapOperatorSubjects: token sub
(email-looking subs ignored) or, with a verified email, the single active user
with that email whose pk or uuid is configured. Emails in config fail validation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 17:02:09 -05:00
james.maesandClaude Sonnet 5.5 ebd56d6ca4 feat: write contract tests, docs, status; recovery email prerequisites
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:54:45 -05:00
james.maesandClaude Sonnet 5.5 c2a5c3265f test: admin service rules (in-memory Authentik), process tests; fix distinct count; prepare step records
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:27:42 -05:00
james.maesandClaude Sonnet 5.5 775e5ea7bd feat: write path scaffolding (guard authorization, admin service, safety rules, local tables, processes, home, relations)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:16:21 -05:00
james.maes 25a8516400 Merge pull request #11 from QRun-IO/feature/ga-only-release-tags
ci: publish only GA tags (vX.Y.Z), never RC tags
2026-10-12 16:04:54 -05:00
james.maesandClaude Opus 5.5 5154cd202a ci: publish only GA tags (vX.Y.Z), never RC tags
An RC tag matched /v.*/ and the orb republished it as the next RC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:58:31 -05:00
james.maes 76931a1861 Merge pull request #6 from QRun-IO/feature/snake-case-table-names
fix(naming): snake_case authentik_ tables and authentik.* permissions (owner naming rule)
2026-10-12 15:57:43 -05:00
144 changed files with 27436 additions and 340 deletions

No files matched your search

+11 -68
View File
@@ -1,71 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.7.12
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.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+74
View File
@@ -0,0 +1,74 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+6 -32
View File
@@ -1,42 +1,16 @@
# 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:
'on':
schedule:
- cron: "17 6 * * 1"
- cron: 17 6 * * 1
workflow_dispatch:
inputs:
version:
description: "Authentik version (e.g. 2026.10.0); empty = latest release"
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"
uses: QRun-IO/qqq-orb/.github/workflows/qbit-schema-drift.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
with:
version: ${{ inputs.version || '' }}
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.7.12
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+16 -9
View File
@@ -1,10 +1,10 @@
# AGENTS.md
# AGENTS.md / CLAUDE.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.
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md 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).
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and writes through the Authentik API (never its DB). Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = the full user lifecycle and group/permission management (phases 0-3), read-only by default.
## Build Commands
@@ -12,6 +12,7 @@ This file provides guidance to Codex and other agents when working with code in
./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
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
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
@@ -29,24 +30,30 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
- `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.
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
- `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`).
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `authentik.admin`.
## Invariants (do not break)
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
- 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.
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
## Code Style
@@ -54,7 +61,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
## 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.
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` (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
+89 -1
View File
@@ -4,8 +4,96 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255).
- Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: <permission>`, `malformed <attribute>` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was.
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
- Replace expired token-delivery and credential-scope reflection bridges with the RC.7 APIs; retain conservative permission narrowing and explicit unavailable-delivery tests.
- Ship the expanded, host-neutral QBIT help bundle using QQQ 4.1.0-RC.7 registration and validation, with coverage for optional features and live references. Preserve inline app/table help for hosts without help storage; document HOST bundle overrides.
- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied inline by `AuthentikHelp` as well as registered with core, which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes.
- `AuthentikQBitConfig.fullAccessHostPermissions` lets a host declare permission names core cannot enumerate (custom checkers, named permissions); full access expands to them at use time, for the instance holding that runtime's backend only.
- `AuthentikMaintenance.applyPolicy` lets reviewed host run-once jobs apply a policy to an exact group name through the existing audited Save Policy path. Dedicated maintenance identities, full-access expansion, canonical no-ops, context restoration, stale checks and optional two-person approval are covered; system-session writes remain forbidden. No HTTP route or process is registered.
- Explicit full-access policy statement (`Action: ["*"], Resource: ["*"]`) resolves against all current core and qbit permissions, including features, READ_WRITE `write`, identity names and future resources. Added canonical round trips, complete-set save authorization, last-admin removal protection and the editor `fullAccess` payload contract; named-resource wildcards retain their five-action meaning.
- Edit Policy supplies per-member preview data for up to 200 active, in-scope direct and descendant members, with relevant direct group names, base permissions excluding only the edited policy, a truncation flag, and the configured mapper-enabled flag. The Next UI can compute effective permissions and additions/removals locally while preserving inherited, named, static and bootstrap grants.
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
### Added (central health)
- `authentik.api`, `authentik.api_token`, `authentik.mapper`, and `authentik.token_broker` dependency checks registered by the producer, with safe details and bounded requests through the existing client.
- Client-credentials broker probe using the existing token transport; its bearer is discarded.
### Removed (central health)
- The Identity home Connection health card, its direct version/token-expiry probes, and its inline mapper status. A single central component status links to System Health. The configuration drift-report widget is retained.
### Added (access tokens 6c)
- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153.
- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked.
- Daily expiry reminders with persistent per-window deduplication and optional Alerts delivery; high-severity guard-bypass alerts.
- A standard Access tokens TABLE widget with owner counts, age buckets and cleanup links, plus the existing record/list bulk revoke action.
### Added (access tokens, DESIGN.md section 13)
- Default-on `TOKENS` with personal create/rotate/revoke/extend, service-account creation and rotation, protected-owner guardrails and optional two-person approval.
- Action-scoped OAuth2 token exchange, response-only one-time key delivery (fail closed on older QQQ), generated usage instructions and non-secret token scope records.
- Token-manager read access, expanded token metadata, secret redaction and in-memory process/persistence tests.
### Changed (core baseline, #20 / F19)
- Build against the released QQQ BOM `4.1.0-RC.6` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. Adopt `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
- Core actions now enforce permissions for non-HTTP callers. Authentik-specific guards remain for direct util calls and domain rules. See [UPGRADE.md](UPGRADE.md) for host impact and feature registration requirements.
- Replace the expired feature-catalog stand-in with real `TableFeaturePermissionsMetaData` registrations and remove the test-only `PolicyCatalog` permission-source overload. Feature discovery uses the core catalog without static mocking.
### Added (automated sessions)
- `AuthentikGroupPermissionMapper` resolves tokenless automated sessions by a unique active Authentik email match, applying direct and inherited group permissions, static fallbacks and the sign-in cut-over guard while preserving existing session permissions.
### Added (table feature permissions)
- Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData
### Added (policy cut-over, DESIGN.md section 12.8)
- `AuthentikHostPermissionSource` SPI (today's access) and config `mapperEnabled`, `cutoverGuardGroups`, `mapperOverride`, `staticGroupPermissions`.
- **Enforcement status**: banner widget `authentikPolicyEnforcement` (first on group and policy records and on the Edit Policy step) and a "Policies at sign-in" row in the home's Connection card: OFF, ACTIVE, OVERRIDDEN or BLOCKED.
- **Compare Access** widget `authentikCompareAccess` on the app home (`authentik.admin`, read only): per active person, today vs the mapper, with added, removed and unchanged, and the guard result.
- **Cut-over guard**: while a member of a guarded group would lose a permission (and no override), the mapper grants today's access plus the static fallback and bootstrap subjects instead of the policies.
- **Generate Policy From Current Access** group action: the expressible part of today's access (joined with the current policy) becomes the policy, named permissions go to `qqq.io/permissions`, the rest is listed; Review shows the document and diff.
### Added (full user and group CRUD, phase 5c: DESIGN.md section 12.5)
- **Edit User** also changes the username (validated, unique) and the other attributes (a JSON object; reserved `qqq.io/` and `goauthentik.io/` keys are kept and cannot be set; blank leaves them as they are; editing attributes needs `authentik.audit.read`, which is also what shows them on the form). **Edit Group** edits other attributes the same way.
- **Delete User** and **Delete Group** record actions behind the new `DELETE` feature (off by default; needs Authentik's `delete_user` / `delete_group`). Typed confirmation; never yourself, a protected account, a superuser group or the last identity administrator; a group must have no members and no child groups first; the group's `authentik_policy` row goes with it. Deactivate stays the default for people.
### Added (policy editor, phase 5b: DESIGN.md section 12.4)
- **Edit Policy** record action on groups and on policies (feature `POLICIES`, permission `authentik.policies.manage`): a standard streamed-ETL action whose step shows the `AUTHENTIK_POLICY_EDITOR` widget (Next UI). Review parses the submitted document (fail closed), regenerates it in canonical form with the round-trip check, refuses new resources this app does not define, applies the subset rule to the permissions it adds, and lists what is added and removed. Result merge-patches `qqq.io/policy` into the group's current attributes (removed when empty). Optional second-person approval (`policyTwoPersonApproval`).
- **`PolicyCatalog`**: every app, table, process (hidden record actions included), report and protected widget a policy can grant, with the actions QQQ checks, grouped by app. Its permission names join the permission catalog.
- **`authentik_policy`** local table (one row per group; field-level audit is the change history; DDL in `docs/schema.sql`), in the nav under Access and as a child list on the group.
- Widget `authentikPolicyEditor` (type `AUTHENTIK_POLICY_EDITOR`): the catalog and the group's live document, with a flag when it was changed in Authentik since the last save in QQQ.
### Added (policies, phase 5a: DESIGN.md section 12, docs/POLICY-FORMAT.md)
- **Policy documents** (IAM-style statements) on Authentik groups: `PolicyParser` (fails closed: unknown spec or version, non-Allow effect, unknown action, bad resource or malformed JSON grant nothing), `PolicyGenerator` (canonical form with a round-trip check), `PolicyFormat` and the config `policySpec`, `policyVersion`, `policyAttribute`, `policyClaim`, `policyTwoPersonApproval`.
- **Runtime**: `AuthentikPolicyClaimMapper` (reads the `qqq_policy` claim from the ID token or access token; `applySecurityKeys` hook for record locks) and `PolicyPermissions.fromClaims` for a host's own customizer. `AuthentikGroupPermissionMapper` also adds the claim, and the group graph and `EffectivePermissionResolver` read each group's `qqq.io/policy`, so effective permissions, the subset rule and the last-admin check see policy grants. An invalid policy on a group is listed as malformed.
- **Blueprint example** for the scope mapping that puts the merged policies into the token, pinned by `PolicyClaimMappingIT` on Authentik 2026.8.3.
### Added (v1: full user lifecycle and group/permission management, phases 2-3)
- **Writes through the Authentik API**, behind the features `USERS`, `GROUPS`, `MEMBERSHIP`, `CREDENTIALS` and `SESSIONS` (read-only stays the default; `readOnly` is a hard kill switch). Every change goes through `AuthentikAdmin`: it plans against live Authentik data (the guardrails of DESIGN.md 5.4), audits first, calls Authentik, posts a `custom_qqq_admin_action` event naming the operator, and finishes the audit row. `AuthentikActionUtil.write` needs a `WriteAuthorization`, which only `AuthentikGuard.authorize` can mint.
- **Actions** (table and record actions with Review and Result steps; none in the nav): Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject.
- **Safety**: the subset (no escalation) rule evaluated from the operator's live groups; protected accounts and superuser groups; no self-edits; last-administrator protection; reasons; typed confirmations (and a typed count above 25 targets); a 100-target cap; stale checks by fingerprint (groups have no `last_updated`); attribute edits merge only the `qqq.io/*` keys into the current attributes; two-person approval for granting `authentik.*`.
- **Local tables** `authentik_admin_action` (append-only audit) and `authentik_approval_request` (DDL in `docs/schema.sql`), on the new `localBackendName`.
- **Read tables** `authentik_application`, `authentik_policy_binding`, `authentik_blueprint`, `authentik_role`, `authentik_token` (metadata only, never a key) and `authentik_invitation`. Sub-tables appear as child lists on their parents; the nav lists only the main tables.
- **Home**: the `authentikHome` widget (needs attention, people, groups and permissions, recent changes, connection).
- Config: `localBackendName` (replaces `mirrorBackendName`), `twoPersonApproval`, `schedulerName`, `linkPrefix`.
- Tests: an in-memory Authentik for the rules, process tests through `RunProcessAction`, and write contract tests on Authentik 2026.8.3 (`QbitWriteContractIT`, including the recovery email arriving in Mailpit).
### Security
- `bootstrapOperators` (emails) is replaced by `bootstrapOperatorSubjects` (env `AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS`): users can change their own email in Authentik, so an email must never confer identity permissions. A bootstrap operator is now matched on the token `sub` (email-looking subs ignored) or, with a verified email, on the single active Authentik user with that email whose pk or uuid is configured. Spoofed, duplicate or unverified emails are denied; entries containing `@` fail validation. The admin service uses the same rule.
### Fixed
- Count reported no distinct count, so a process Review showed no total; group lists now include member pks (member counts were 0).
- Spec: multi-value filters are repeated plain names (`type=a&type=b`), never `type[]=a` (which Authentik silently ignores); stale role-state, `check_access`, re-parenting-risk, private-repo and backend-name wording corrected (spec revision 4).
### Not in this release
- The Authentik webhook, the local mirror tables and reconcile, the access-matrix widget, snapshot export, RBAC role management (phase 4) and the Alerts producer integration. `MIRROR`, `WEBHOOK`, `ACCESS_MATRIX`, `SNAPSHOT_EXPORT` and `RBAC` are refused by config validation.
### Changed
- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md).
- **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
+16 -9
View File
@@ -1,10 +1,10 @@
# CLAUDE.md
# AGENTS.md / CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
Shared guidance for agents working in this repository. Keep AGENTS.md and CLAUDE.md 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).
**QBit Authentik**: a QQQ QBit to manage Authentik users, groups, memberships and group-granted QQQ permissions from a QQQ app. Authentik's database is the source of truth; the qbit reads live and writes through the Authentik API (never its DB). Design: `docs/DESIGN.md` (approved spec, QRun-IO/qqq#1065). Current line: 0.1 = the full user lifecycle and group/permission management (phases 0-3), read-only by default.
## Build Commands
@@ -12,6 +12,7 @@ This file provides guidance to Claude Code when working with code in this reposi
./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
./mvnw verify -DskipITs # compile, unit tests and JaCoCo; no contract ITs
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
@@ -29,24 +30,30 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
- `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.
- The generic table writes (`doInsert`/`doUpdate`/`doDelete`) are refused. Changes use `write(WriteAuthorization, ...)` or `writeAs(bearer, WriteAuthorization, ...)`, and only `AuthentikGuard.authorize(AdminAction)` mints a `WriteAuthorization` (an architecture test pins callers to `AuthentikAdmin` and its `AuthentikTokens` delegate).
- `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`).
- `security/AuthentikGuard` handles read permission checks, write authorization, the user scope, and protected-account detection. `security/IdentityPermission` is the `authentik.*` catalog (session form `<name>.hasAccess`).
- `admin/AuthentikAdmin` plans and performs every change (`AdminAction`, `AdminRequest`, `AdminPlan`): the guardrails of DESIGN.md 5.4 run against LIVE Authentik data and the operator's LIVE groups (never the session claim) at plan time and again at execute time; the audit row (`AdminActionAuditor`, table `authentik_admin_action`) is written BEFORE the call and the call is not made if that fails; then the change, then an Authentik `custom_qqq_admin_action` event. `AuthentikSafety` holds the pure rules (subset rule helpers, last-admin, fingerprints). Granting `authentik.*` queues an `authentik_approval_request` for a second person (`approve`/`reject`).
- `admin/AuthentikTokens` implements DESIGN section 13: personal calls use an action-scoped exchanged bearer; service-account calls use the qbit bearer. One-time keys never enter records, process values, audit or event context. `TokenKeyDelivery` refuses key-producing actions when core has no response-only secret channel.
- `processes/AuthentikProcesses`: every action is a standard streamed-ETL table or record action (details screen, Review = dry run, Result), never in the nav. The ETL defaults to full validation, so the Review screen already holds the dry-run lines. The preview step's record count is NOT carried to later steps (and a client count needs `setDistinctCount`), so steps count targets themselves.
- `app/`: `AuthentikHomeData`/`AuthentikHomeView` (the single home widget, HTML with no tables) and `AuthentikRelations` (joins, child-list widgets and sections so sub-tables appear on their parents, not in the nav).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `authentik.admin`.
## Invariants (do not break)
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
- 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.
- Only `AuthentikAdmin` and its `AuthentikTokens` delegate may call `AuthentikActionUtil.write` / `writeAs`. Every write needs a reason, passes the guard, and is audited first.
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
- The service account must never be a superuser and never hold `enable_group_superuser` or impersonate permissions; never token-key READ (view_token_key); add_token and set_token_key only for service-account creation and rotation (add_token cannot mint for others); `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
## Code Style
@@ -54,7 +61,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
## 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.
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` (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
+135 -36
View File
@@ -1,50 +1,89 @@
# QBit Authentik
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.
A QQQ QBit to manage [Authentik](https://goauthentik.io) identities from a QQQ app: **the full user lifecycle, and groups with the permissions they grant**. Authentik stays the source of truth. The qbit reads Authentik live and writes through the Authentik API, never its database. Changes made in the Authentik admin UI are equally valid, and the qbit never overwrites them blindly.
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).
Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065). Access tokens (PATs): [`docs/ACCESS-TOKENS.md`](docs/ACCESS-TOKENS.md). Status: [`docs/STATUS.md`](docs/STATUS.md). Phase 0 owner steps: [`docs/PHASE0-OWNER-CHECKLIST.md`](docs/PHASE0-OWNER-CHECKLIST.md). Table DDL: [`docs/schema.sql`](docs/schema.sql).
## Status
## What you get
`0.1.0-SNAPSHOT`, the **read-only** release line (phases 0 and 1 of DESIGN.md section 11):
| 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` |
| **Access tokens** | Create and rotate your personal access tokens, revoke (including bulk), extend expiry, and create service accounts. Enabled with any write feature; opt out with `tokensEnabled=false` or `AUTHENTIK_QBIT_TOKENS=false`. Keys are response-only and shown once; key-producing actions require QQQ 4.1.0-RC.7+ one-time output support. See [Access tokens](docs/ACCESS-TOKENS.md). |
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. Edit covers username, name, email, path and other attributes. Delete is a separate, explicit action behind the `DELETE` feature (off by default); deactivate stays the default. |
| **Groups and permissions** | Create, edit (parents, other attributes) and delete groups, add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, central health), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
Every write is refused by `AuthentikGuard` in this release.
Everything is a standard QQQ table, action and widget, so it works in any QQQ UI and over the QQQ API.
## 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"));
.withBaseUrl("https://auth.example.com") // host root; /api/v3 is appended
.withApiToken("${env.AUTHENTIK_API_TOKEN}") // service-account token, resolved by the qbit
.withAppKey("my-app-admin") // service account: qqq-my-app-admin-idadmin
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS, POLICIES)) // + DELETE for delete actions
.withLocalBackendName("rdbms") // audit trail and approval requests (see docs/schema.sql)
.withRecoveryEmailStage("my-recovery-email") // Authentik email stage (name or uuid)
.withInvitationFlow("my-enrollment"); // optional: enables invitations
new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
```
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
Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill switch that refuses every write whatever the features say.
Access is controlled by QQQ permissions:
### What it produces
| Permission (held as `<name>.hasAccess`) | Grants |
| | |
|---|---|
| `authentik.users.read` | the Identity app and its tables |
| `authentik.audit.read` | sensitive fields and tables |
| API backend | `authentikApi` |
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_policy` (with `POLICIES`), `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
| Child lists (on their parents, not in the nav) | `authentik_group_member`, `authentik_session`, `authentik_token` (metadata only, never a key), `authentik_event`, `authentik_policy_binding`, `authentik_role`, `authentik_blueprint` |
| App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report |
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group |
The full `authentik.*` set is in DESIGN.md section 5.2.
### Tables, starter data and empty screens
Nothing is seeded: there are no starter defaults to include in a changelog. Setup comes from `AuthentikQBitConfig` (URL, API token, features, read-only mode), and the Identity home's "Needs attention" card lists what is left. The tables are of two kinds:
- **API-backed (live):** `authentik_user`, `_group`, `_group_member` (virtual), `_session`, `_event`, `_application`, `_policy_binding`, `_blueprint`, `_role`, `_token`, `_invitation`. Every row is read from Authentik on each view and nothing is stored locally. An empty list means Authentik returned nothing to you: your role lacks the permission, the qbit's feature or invitation flow is not enabled, or Authentik has no such records. Read-only mode only blocks changes.
- **Local (the qbit's own records):** `authentik_admin_action` (audit trail), `authentik_approval_request`, `authentik_policy` and `authentik_token_scope`. They fill as people run actions, and are written only by the qbit.
Every table, and the app, ships help (`help/qbit-authentik.yaml`, the QQQ help-bundle format) that says what the table is for, how its rows get created and, for the live tables, what an empty list means. The Next UI shows it as the empty state. A host's own help on a table is never replaced, and a host's `tableNamePrefix` is applied to the bundle's table names. `AuthentikTablesGuardrailTest` fails when a table is added without runtime use, a documented reason it starts empty, and this help.
### Permissions
QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute `qqq.io/permissions` and read by `AuthentikGroupPermissionMapper`:
| Permission | Grants |
|---|---|
| `authentik.users.read` | the Identity app, home, users, groups, applications, bindings, roles, blueprints |
| `authentik.users.manage` | create, invite, edit, deactivate, reactivate users; cancel invitations |
| `authentik.groups.manage` | create and edit groups |
| `authentik.memberships.manage` | add and remove members (subset rule) |
| `authentik.permissions.manage` | edit a group's permissions and app access (subset rule) |
| `authentik.policies.manage` | edit a group's policy (subset rule) |
| `authentik.credentials.reset` | password reset email, reset MFA, revoke app passwords |
| `authentik.sessions.revoke` | revoke sessions |
| `authentik.audit.read` | sessions, tokens, events, the audit trail, sensitive fields |
| `authentik.approve` | approve or reject a second person's request |
| `authentik.admin` | the drift report |
| `authentik.roles.manage` | reserved (role management is phase 4) |
`authentik.*` grants all of them.
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
The editor discovers registered features and labels through core RC.6's `PermissionsHelper.getAllAvailablePermissions`. The build uses `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
### 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.
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, `view_token_key` or impersonate permissions, and holds user/group delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. Saving a policy uses `change_group`. Token management additionally needs `change_token`, `set_token_key` and `add_token` (Authentik requires `add_token` for `service_account` and `set_key`; for a non-superuser it can only create the caller's own token); never grant `view_token_key`. Personal token operations use an exchanged user bearer from the confidential token broker, never the qbit bearer. The `DELETE` feature also needs `delete_user` and `delete_group`. The contract fixtures include the token broker, claim mapping, detective rule and token write permissions.
## Adopting in another app
@@ -52,22 +91,21 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
| 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. |
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
| QQQ version | QQQ 4.1.0-RC.8 (released BOM, including the core health SPI). Java 21. |
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. |
| Tables | Create `authentik_admin_action`, `authentik_approval_request` and `authentik_token_scope` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. |
| Required config | `baseUrl`, `apiToken`, `appKey`. For writes: `features`, `localBackendName`, `recoveryEmailStage` (users and credentials). Optional: `invitationFlow`, `twoPersonApproval` (default on), `userScopePathStartsWith`, `protectedUsernames`, `applicationsInScope`, `linkPrefix` (default `/app`), `throttleRequestsPerSecond` (default 10), `bulkPacingMillis`. |
| Secrets / env | `apiToken` as a `${env.X}` reference; `bootstrapOperatorSubjects` (the lockout escape hatch) defaults to `${env.AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS}`: immutable ids only (the Authentik user pk or uuid, or the token `sub`), never emails, because users can change their own email. Empty it once real group grants exist. Keep secrets in the host's secret store. |
| Optional host wiring | ESB: publish the local tables with `EsbTableMetaData.ofOrWithNew(table).withPublication(...)`. Quick Search: register `authentik_user` and `authentik_group` with the Quick Search qbit. Alerts: see Known limitations. |
| Known limitations | Not in this release: the Authentik webhook, the local mirror tables and reconcile, the access-matrix widget, snapshot export, RBAC role management (phase 4), Alerts producer integration. Invitations add people but not their groups (add them to groups after they sign up). Group and user lists are read live (cached 30 s). |
| Upgrading | See `UPGRADE.md`. |
## Build
```bash
./mvnw test # unit tests (recorded 2026.8.3 fixtures, no network)
./mvnw test # unit tests (recorded 2026.8.3 fixtures and an in-memory Authentik, no network)
CI=true ./mvnw verify # + contract tests against Authentik 2026.8.3 in Docker (Testcontainers)
./mvnw verify -Dauthentik.test.image=ghcr.io/goauthentik/server:2026.10.0 # rehearse an upgrade
./mvnw verify -Dauthentik.fixtures.record=true # refresh unit fixtures
@@ -78,3 +116,64 @@ Contract tests start Authentik (server and worker), Postgres and Mailpit, and ta
## License
Apache 2.0
Access-token metadata is readable with `authentik.audit.read` or `authentik.tokens.manage`. Personal actions have no additional process permission; ownership is checked live. Managing service-account tokens needs `authentik.tokens.manage`, and creating a service account also needs `authentik.users.manage`. Configure `tokenBrokerClientId`, `tokenBrokerClientSecret` (environment references supported), `appClientId`, `appPublicUrl` and `authentikPublicUrl` for exchange and usage instructions. Scope fields remain unavailable until `tokenScopesEnabled=true`.
| Access-token setting | Default | Purpose |
|---|---|---|
| `tokenScopesEnabled` | `false` | Offer read-only, apps and tables fields after the signed claim mapping is installed. Names must exist and the operator must currently have read/hasAccess permission. |
| `tokenScopeClaim` | `qqq_token` | Signed JWT claim containing the Authentik token identifier. Claims are enforced even when offering new scopes is disabled. |
| `tokenUnusedDays` | `60` | Inactivity threshold for cleanup suggestions. With no login found, a known creation date and a threshold at most 90 days are required. |
| `tokenReminderSchedulerName` | First registered scheduler | Run `authentikTokenReminders` daily. Without a scheduler the hidden process is available for a host to invoke. Register the scheduler before producing the qbit. |
The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The qbit conservatively narrows session permissions for direct consumers and attaches core's `QCredentialScope` for framework enforcement. It does not add permissions.
The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`.
Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added.
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
## Health checks
The producer registers these central checks when Authentik is configured. All use
component `authentik`, category `IDENTITY`, and a 10-second timeout. They reuse the
API client and drift diagnostics, honor the runner deadline, and record only safe
version, expiry-day, and finding-code details.
| Name | Kind | Interval |
| --- | --- | --- |
| `authentik.api` | DEPENDENCY | 300 seconds |
| `authentik.api_token` | DEPENDENCY | 21600 seconds |
| `authentik.mapper` | DEPENDENCY | 21600 seconds |
| `authentik.token_broker` | DEPENDENCY | 300 seconds |
The broker check uses a client-credentials grant and discards the returned bearer;
it reports UNKNOWN when broker credentials are not configured. Token metadata must
identify one API token; ambiguous tokens or an unavailable expiry report UNKNOWN.
The Identity home links its central status to System Health. The configuration
`driftReport` widget remains available. Disable checks centrally with
`health_check.enabled`; no Authentik table fields are added.
These registrations use the core health SPI released in QQQ 4.1.0-RC.8; no
SNAPSHOT override or enforcer skip is needed.
## In-app help
The qbit ships `src/main/resources/help/qbit-authentik.yaml` and registers it with
QQQ's help bundle registry (QQQ 4.1.0-RC.7). Registration is metadata-only; core
skips startup synchronization when the host has no `helpContent` table. Inline
app and table help remains available without help storage.
To override wording, register a separate bundle with `layer: HOST`, its own bundle
ID and version, and entries with the same keys and roles. HOST entries take
precedence over QBIT entries; do not edit the dependency's resource. Keep deployment
names, navigation and authentication timing in the host bundle.
`AuthentikHelpBundleTest` enables every supported feature and uses
`HelpBundleAssertions` to require coverage and validate keys, summaries and live
references. Run it with `./mvnw test -Dtest=AuthentikHelpBundleTest`.
The bundle version is the literal project version; update it when releasing.
+26
View File
@@ -1,5 +1,31 @@
# Upgrading qbit-authentik
## Table help bundle (unreleased)
Nothing to do. The qbit now applies `help/qbit-authentik.yaml` to the Identity app and its tables at the end of `produce()`, filling only help slots the host has not set. There is no schema change and nothing to seed or include in a Liquibase changelog: the API-backed tables are live Authentik data, and the four local tables fill as people use the actions.
## QQQ 4.1.0-RC.6 baseline (#20 / F19)
Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.6`
and use Java 21. The historical `qqq-snapshot` profile and `qqq.snapshot.version`
property names remain, but the imported core version is a release, not a SNAPSHOT.
Core actions now enforce permissions and field security locks for non-HTTP callers
(QRun-IO/qqq#1061, #1062); callers must supply an appropriately authorized session.
The qbit keeps its guards, scope checks and sensitive-field stripping because they
also protect direct backend calls and implement Authentik-specific rules.
No qbit configuration, stored policy format or schema changes are introduced.
The build adopts `qbit-build-parent:2.1.0` with `qbit.guardrails.enforce=true`.
The expired #1136 test stand-in is removed: tests now register features through
`TableFeaturePermissionsMetaData` and discover them through the real
`PermissionsHelper.getAllAvailablePermissions`, as production hosts do. The
unused explicit permission-source overload in `PolicyCatalog` is removed.
Duplicate shared-base entries, replacement registrations and malformed suffixes
remain covered. Hosts should register feature suffixes, labels and table
applicability through the core metadata API; no synthetic catalog is needed.
Client-channel attribution and identity/credential channel restrictions remain
separate follow-up work.
## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release)
The qbit's permissions follow the same owner naming rule (2026-10-12) and now
+71
View File
@@ -0,0 +1,71 @@
# Using access tokens
An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6).
## Create a token
1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**.
2. Choose **Create token** and fill in:
- a description, for example "nightly export script";
- an expiry. It's required: at most 90 days for people, and 365 for service accounts;
- optionally, a **scope**: read only, and/or only some apps and tables. A scoped token can never do more than you can, and loses access as soon as you do.
3. Copy the key from the dialog. **It is shown once.** The app does not keep it, and nobody can show it again. If you lose it, rotate the token.
The dialog also shows ready-to-run examples for this app, with its real URLs.
## Use a token
Tokens are exchanged for a short-lived access token (a JWT) at the Authentik token endpoint. You then call the app with that JWT.
```sh
# 1. Exchange the token for a JWT (valid for the app's access-token lifetime)
JWT=$(curl -s -X POST "https://auth.example.com/application/o/token/" \
-d grant_type=client_credentials \
-d client_id="$APP_CLIENT_ID" \
-d username="$YOUR_USERNAME" \
-d password="$QQQ_TOKEN" \
-d scope="openid profile email" | jq -r .access_token)
# 2. Call the app
curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData"
```
- Keep the token in a secret store or an environment variable. Never commit it.
- When the JWT expires (the API answers 401), repeat step 1.
- Every exchange is recorded as a sign-in in Authentik. The app shows the token's **last used** time from those records.
## Manage your tokens
From **Preferences → Access tokens**:
| Action | What happens |
|---|---|
| **Rotate** | A new key replaces the old one. The description, expiry and scope stay the same. The old key stops working at once. |
| **Extend** | Moves the expiry. The limit counts from today. |
| **Revoke** | Deletes the token in Authentik. Anything using it stops working at once. |
When the host has configured a scheduler and the optional Alerts delivery adapter, the daily job sends owner reminders in the 7-day and 1-day expiry windows. Delivery channels (bell/email) follow the host’s Alerts configuration. Each window sends once; if first seen within one day, only the urgent window sends. Extending a token through the qbit resets its reminder windows. Without delivery support, the job logs and leaves the reminder pending.
## For administrators
People with `authentik.tokens.manage` see the **Access tokens** TABLE widget on the Identity home page, with a link to **all access tokens**. From there they can:
- filter by person, intent, expiring status or token identifier; view computed creation time, scope and last use (these computed columns cannot be filtered or sorted by the Authentik API);
- revoke tokens one at a time or in bulk, for example the tokens the home page lists as unused for 60 days, or as belonging to deactivated people;
- create a **service account** for an integration (**Users → Create Service Account**). The account gets one app password, shown once, and the groups you choose. Rotate and extend it from its token record.
The widget shows the top ten owners plus a total, age buckets (`<30d`, `30–90d`, `90–365d`, `>365d`, and unknown), unused tokens and tokens with deactivated owners. Cleanup rows include owner and token links; age buckets link to the explicit token identifiers. Creation dates are known only for tokens created by the qbit. External tokens can receive reminders without acquiring a scope or an invented creation date.
Last use comes from app-password login events over the last 90 days, cached for five minutes. The pinned Authentik schema has no date-range parameter for this endpoint, so the qbit pages newest-first to the window boundary. API-intent tokens show **not tracked** and never appear as unused. A token with no recorded login is suggested as unused only if its known creation date is older than `tokenUnusedDays` and that threshold is at most 90 days.
Create Token offers read-only, apps and tables scopes only when `tokenScopesEnabled` is enabled after installing the signed claim mapping. Enter one metadata name per line. Review checks existence and the operator’s current read/hasAccess permission and shows the scope. The claims mappers intersect permissions after resolving them; scopes never grant access the owner lacks. A deleted token receives no permissions even if its local scope row remains, subject to the 60-second verification cache.
Administrators can never create, rotate or extend a token for **another person**. Each person manages their own.
Accounts that can administer Authentik, the break-glass account and the app's own service account can't get tokens from the app at all. Use the Authentik admin UI for them if you really need one.
## Security notes
- A token can do what its owner can do, or less if it's scoped. Prefer a scoped token and a short expiry.
- Tokens for people are created as that person, through Authentik. The app's own service account cannot create a token for a person.
- Every create, rotate, extend and revoke is audited in the app (`authentik_admin_action`, with the channel it came from) and in Authentik's event log.
- The key exists only in Authentik and in the one response that showed it to you.
+534 -35
View File
@@ -1,6 +1,6 @@
# Authentik qbit: Design Spec
Status: Approved, revision 3 (phase 0 findings applied) · 2026-10-11 · Author: James Maes (drafted with Claude)
Status: Approved, revision 6 (adds §13 access tokens; revision 5 added §12 policies and full user/group CRUD; owner decisions of 2026-10-13) · 2026-10-13 · Author: James Maes (drafted with Claude)
Targets: QQQ 4.1.x, QQQ Next UI 1.0.x, Authentik 2026.8.x (researched against the live `auth.kof22.com` schema, `info.version: 2026.8.3`), new repo `QRun-IO/qbit-authentik`
## 0. Summary
@@ -24,7 +24,7 @@ Seven design rules shape everything below:
- A group's QQQ permissions live in the group attribute `qqq.io/permissions`.
- The reusable `AuthentikGroupPermissionMapper` turns a user's groups, including inherited parents, into QQQ session permissions.
- Who can operate the qbit is decided by those same permissions (`authentik.*`). No operator group is hard-coded.
4. **The qbit checks permissions itself.** QQQ core actions do not enforce permissions (QRun-IO/qqq#1061), and `FieldSecurityLock` is not enforced (#1062). An `AuthentikGuard` in the backend action layer checks every read and write.
4. **The qbit checks permissions itself.** The original core gaps (QRun-IO/qqq#1061, #1062) are fixed in the RC.5 baseline. Direct backend calls and Authentik-specific rules still require local checks. An `AuthentikGuard` in the backend action layer checks every read and write.
5. **No escalation.**
- Operators can only grant permissions they hold themselves, whether through memberships or permission attributes.
- Operators cannot edit themselves.
@@ -33,11 +33,14 @@ Seven design rules shape everything below:
- Authentik also enforces a hard floor: the service account cannot touch superuser groups, token keys or impersonation.
6. **Credentials never pass through QQQ.**
- New users and password resets go through Authentik's own emails (enrollment or recovery).
- QQQ never sets passwords, never shows recovery links and never displays token keys.
- QQQ never sets passwords and never shows recovery links.
- Token keys are the one exception (§13): a new or rotated access-token key is shown **once**, in the response that created it, and is never stored, logged or audited in QQQ.
7. **Every change is audited on both sides.** QQQ writes a local append-only `authentik_admin_action` row. Authentik receives a `custom_qqq_admin_action` event naming the human operator.
### 0.1 Owner decisions (2026-10-11)
**Owner decisions (2026-10-12): all recommendations accepted.** Every open question in this spec (§11.2, and the options marked *recommended* anywhere) is decided as the spec recommends, and the design is approved to build in phase order; adjustments follow once it can be seen working. The earlier decisions are below.
| # | Decision |
|---|---|
| 1 | **Source of truth.** Authentik's database. Users, groups, memberships, roles and permissions can be edited from **both** QQQ and the Authentik admin UI. Blueprints cover infrastructure plus `state: created` bootstrap objects only, with no member lists. A nightly read-only export to Git is optional (for audit and DR, not authoritative). |
@@ -47,6 +50,36 @@ Seven design rules shape everything below:
| 5–16 | The recommendations in the first revision are accepted: recovery email only, MFA view and reset, typed confirmation plus reason with two-person approval for role changes, read-through plus webhook→ESB plus mirror, Authentik-specific with neutral names, the mapper, list-and-revoke for service accounts, new repo, cluster-gitops housekeeping, no Enterprise features, and the retention defaults. Decision 11 (attribute-driven permissions) moves **into v1**, because decision 3 depends on it. |
| §11.2 | All six smaller questions take the recommended answer: snapshot repo `KofTwentyTwo/identity-snapshots` (private); two-person approval **on** for `authentik.*` grants, with the Authentik UI as break-glass while James is the only identity admin; a new `kof22-breakglass` superuser; all four seed groups; service accounts listed but only deactivate/revoke; `kof-website-drafters` created with **no** app access until the owner decides (one attribute later). |
**Owner decisions (2026-10-13): policies and full CRUD.** The owner asked for create, edit, delete and query of users and groups, and for IAM-style permission statements with a UI to create and edit them (§12). Decided:
| # | Decision |
|---|---|
| 17 | **Policies are edited in QQQ and stored on Authentik groups.** The qbit's screens are the editor; each policy is a QQQ record (`authentik_policy`) with change history. Saving writes the policy document into the group's attributes through the Authentik API. The sign-in token carries the merged document as a claim, so Authentik is the source of truth at sign-in. |
| 18 | **Statement model: the same as the IAM-style documents already used with Authentik in another QQQ app.** `PolicySpec`, `PolicyVersion` (semver), `Statement[]`; `Effect` `Allow`; `Action` from `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`; `Resource` = QQQ table, process, app, report and widget permission names (`[A-Za-z0-9_]+`). Parsing fails closed. No `Deny`, wildcard resources, conditions or field-level rules yet. |
| 19 | **The format belongs to this qbit** (`docs/POLICY-FORMAT.md`). The spec name and version are config with a generic default (`qqq-iam`, `1.0.0`), so a host app can keep its existing spec name. No host-specific names in this repo. |
| 20 | **A policy editor** lists the app's apps, tables, processes, reports and widgets from metadata, grouped by app, with search, a per-resource action matrix (select all per row, per column and per app), a live read-only JSON preview, round-trip validation, and opens existing documents back into the matrix. Saving writes to Authentik. |
| 21 | **Delete is allowed as an explicit action** (supersedes decision 4's "no hard delete"): deactivate stays the default; *Delete User* and *Delete Group* are separate typed-confirmation actions behind the new `DELETE` feature (off by default), which also needs Authentik's `delete_user` / `delete_group`. |
| 22 | **Write permissions split as read / users / groups / policies**: `authentik.users.read`, `authentik.users.manage`, `authentik.groups.manage` (+ `authentik.memberships.manage`), and the new `authentik.policies.manage`. Two-person approval for policy saves is optional (`policyTwoPersonApproval`, off by default). |
**Owner decisions (2026-10-13): access tokens (revision 6).** The owner asked: "I want to manage tokens in app, like we want to do with users and groups and permissions - all through authentik still, but exposed in app to make it easier", then: "it should be on by default. if this is gonna be our PAT solution, it needs to be first class." Authentik tokens, through this qbit, are **the** personal access token (PAT) solution for QQQ apps that sign in with Authentik. Full design in §13.
| # | Decision |
|---|---|
| 23 | **Tokens are managed in QQQ, stored in Authentik.** The `authentik_token` table lists them (owner, identifier, intent, expiry, last used, description, scope). Record actions: *Create Token*, *Rotate Token*, *Revoke Token*, *Extend Expiry*, and *Create Service Account*. |
| 24 | **The key is shown exactly once**, in the response of the create or rotate, with copy and usage examples, and is never stored in QQQ: not in a table, the audit, logs, process state, ESB, notifications or the assistant (core `OneTimeSecret`, §13.6). |
| 25 | **Self-service.** Every signed-in user manages their **own** tokens in the Preferences section "Access tokens" (core Preferences contract, QRun-IO/qqq#1162), also reachable from the user menu. |
| 26 | **On by default** whenever the qbit runs with writes enabled (feature `TOKENS`). A host can opt out (`tokensEnabled=false`, kof22 env `AUTHENTIK_QBIT_TOKENS=false`). No opt-in flag. |
| 27 | **Guardrails** (§13.4): (a) no minting or rotating for privileged accounts, in the qbit and again in Authentik; (b) self-service is self-only, admins may also target service accounts, never another human; (c) expiry is required, at most 90 days for people and 365 for service accounts (configurable); (d) optional two-person approval for service-account tokens (off by default); (e) an Authentik notification rule fires if the qbit's service account ever touches a protected user's token; (f) everything audited with the client channel. |
| 28 | **Optional per-token scopes** (read only; specific apps and tables), narrower than the user, stored in `authentik_token_scope` and enforced by core scoped credentials (QRun-IO/qqq#1153, `QCredentialScope`) through a signed `qqq_token` claim (§13.5). |
| 29 | **First class:** usage instructions generated from the instance's real URLs; expiry reminders 7 days and 1 day before (bell and email); last used; an unused-token cleanup suggestion; admin views by user and by age with bulk revoke; the doc page [`ACCESS-TOKENS.md`](ACCESS-TOKENS.md). |
| 30 | **Owner-approved revision of the mechanism (2026-10-13, coordinator-approved under the owner's direction).** Authentik 2026.8.3 always creates a token for the *caller* unless the caller is a superuser (`TokenViewSet.perform_create`), so `add_token` cannot mint for anyone else (§13.2). The qbit therefore creates a person's token **as that person**, through RFC 8693 token exchange with a 2-minute API token from the `qqq-<appKey>-token-broker` provider (with a deny policy for protected users). Service accounts use `POST /core/users/service_account/` (create), `set_key` (rotate) and `PATCH` (extend). The qbit role gains `change_token`, `set_token_key` and `add_token`, keeps `delete_token` and `add_user`, and **never** holds `view_token_key`. `add_token` was found necessary by the contract tests (`service_account` needs `add_user` + `add_token`; `set_key` needs the POST object permission `add_token` + `set_token_key`) and is safe because a non-superuser's create is always the caller's (§13.9). Limitation: one app password per service account. |
**Owner decision (2026-10-14): `add_token` (option A).** The contract tests showed that Authentik 2026.8.3 requires `add_token` for `POST /core/users/service_account/` (with `add_user`) and for `POST /core/tokens/{id}/set_key/` (the POST object permission, with `set_token_key`).
| # | Decision |
|---|---|
| 31 | **Grant `add_token` to the qbit role, so service-account creation and rotation stay in the app.** It is safe: for a non-superuser, `POST /core/tokens/` always creates the token for the caller, so it can never mint a token for anyone else. `view_token_key` stays **not** granted. `TokenContractIT` pins 403 without `add_token` and 200/204 with it. The detective alarm (§13.4 e) also notifies `identity-admins` whenever the qbit's account creates a service account. The blueprint explains this next to the permission (cluster-gitops#103). |
### 0.2 Phase 0 findings (2026-10-11, verified on Authentik 2026.8.3)
The four open points of revision 2 were resolved against a disposable Authentik 2026.8.3 (Testcontainers contract tests in `QRun-IO/qbit-authentik`). Six further facts changed the design; each is pinned by a contract test, so an Authentik upgrade that changes it fails CI.
@@ -138,7 +171,7 @@ These starting objects use `state: created`. Authentik's blueprint importer skip
- later: Authentik RBAC roles
- **See** who has access and why:
- groups (hierarchy), effective QQQ permissions per user, roles
- application access (`check_access`) and policy bindings
- application access (computed by the qbit from `qqq.io/app-access` and the app's bindings, §0.2 iii) and policy bindings
- sessions, MFA devices (metadata only), tokens (metadata only), invitations, events
- **Work alongside the Authentik admin UI**, with no lost updates and no "wrong" place to make a change.
- **Integrate with the host app.**
@@ -170,7 +203,7 @@ These starting objects use `state: created`. Authentik's blueprint importer skip
**A. "Add Sam as a publisher."**
- James opens Users → *Create user* and enters name, email and group `kof-website-publishers`.
- The preview shows the effective QQQ permissions Sam will get (`cms.publish`, …) and the `check_access` result for `kof22-website-prod` ("will be able to sign in: yes").
- The preview shows the effective QQQ permissions Sam will get (`cms.publish`, …) and the computed access result for `kof22-website-prod` ("will be able to sign in: yes", from `qqq.io/app-access` and the app's bindings; not Authentik's `check_access`, §0.2 iii).
- James confirms. QQQ calls `POST /core/users/` and `add_user`, then `recovery_email`, so Sam sets his own password.
- The local audit row and the Authentik event are written, and the webhook refreshes the mirror.
@@ -202,10 +235,10 @@ Facts come from the live OpenAPI schema at `https://auth.kof22.com/api/v3/schema
| Sessions | `GET /core/authenticated_sessions/` (filter `user__username`), `DELETE …/{uuid}/`, `DELETE …/bulk_delete/?user_pks=` | Fields: `last_ip`, `last_user_agent`, `last_used`, `expires`, `geo_ip`, `asn`. Deactivating a user deletes their sessions (2026.8). |
| MFA devices | `GET /authenticators/admin/all/?user=<pk>`. Per type, CRUD on `/authenticators/admin/{totp,webauthn,static,duo,sms,email}/{id}/` | Device metadata only: name, type, confirmed, created, last_used. |
| Tokens | `GET/POST /core/tokens/`, `GET/PUT/PATCH/DELETE /core/tokens/{identifier}/`, `GET …/view_key/`, `POST …/set_key/` | Intents: `api`, `app_password`, `recovery`, `verification`. Keyed by identifier. `view_token_key` is a separate permission, and the qbit must never hold it. **Any user may create tokens for itself and read their keys** without `add_token` (§0.2 v). |
| Groups | `GET/POST /core/groups/`, `GET/PUT/PATCH/DELETE /core/groups/{uuid}/`, `POST …/add_user/` and `…/remove_user/` (`{pk}`) | **`parents` is many-to-many since 2025.12.** Groups inherit roles **and superuser status** from every ancestor. Names are unique (2025.12). Filters: `members_by_username[]`, `include_users`, `include_children`, `include_parents`, `include_inherited_roles`, `is_superuser`, `attributes`. Custom permissions: `add_user_to_group`, `remove_user_from_group`, `enable_group_superuser`, `disable_group_superuser`. |
| Groups | `GET/POST /core/groups/`, `GET/PUT/PATCH/DELETE /core/groups/{uuid}/`, `POST …/add_user/` and `…/remove_user/` (`{pk}`) | **`parents` is many-to-many since 2025.12.** Groups inherit roles **and superuser status** from every ancestor. Names are unique (2025.12). Filters: `members_by_username` (repeat the plain name for several values), `include_users`, `include_children`, `include_parents`, `include_inherited_roles`, `is_superuser`, `attributes`. Custom permissions: `add_user_to_group`, `remove_user_from_group`, `enable_group_superuser`, `disable_group_superuser`. |
| RBAC | `GET/POST /rbac/roles/`, `…/{uuid}/add_user/` and `…/remove_user/`, `GET /rbac/permissions/` (catalogue), `GET /rbac/permissions/assigned_by_roles/?model=&object_pk=`, `POST …/assigned_by_roles/{uuid}/assign/`, `PATCH …/unassign/`, `GET /rbac/permissions/roles/?uuid=`, `/rbac/initial_permissions/` | **RBAC has been fully role-based since 2025.12.** Roles are assigned to users directly or through groups; there are no direct user permissions. `assign` with `object_pk` grants an object permission; without it, a global one. Initial permissions automatically grant a role permissions on objects that its members create. |
| Apps and policies | `GET /core/applications/{slug}/`, `GET /core/applications/{slug}/check_access/?for_user=`, `GET/POST /policies/bindings/` (target = `pbm_uuid`, plus exactly one of policy/group/user), `/policies/all/`, `POST /policies/all/{uuid}/test/` | **`for_user` is honoured only for superusers** (silently ignored otherwise), the policy test endpoint returns 500 for non-superusers, and the app list shows a non-superuser only its own apps (§0.2 iii). Bindings can expire (2026.8). |
| Events | `GET /events/events/` (filters `action`, `actions[]`, `username`, `client_ip`, `context_model_app`, `context_model_name`, `context_model_pk`, `context_authorized_app`), `POST /events/events/` (custom `custom_*` actions), `/actions/`, `/volume/`, `/top_per_user/` | Relevant actions: `login`, `login_failed`, `logout`, `password_set`, `model_created/updated/deleted`, `impersonation_*`, `secret_view`, `invitation_used`, `user_write`, `suspicious_request`, `custom_`. |
| Events | `GET /events/events/` (filters `action`, `actions` (repeated plain name), `username`, `client_ip`, `context_model_app`, `context_model_name`, `context_model_pk`, `context_authorized_app`), `POST /events/events/` (custom `custom_*` actions), `/actions/`, `/volume/`, `/top_per_user/` | Relevant actions: `login`, `login_failed`, `logout`, `password_set`, `model_created/updated/deleted`, `impersonation_*`, `secret_view`, `invitation_used`, `user_write`, `suspicious_request`, `custom_`. |
| Notifications | `/events/transports/` (mode `webhook` with `webhook_mapping_body`/`_headers`), `/events/rules/` (needs `destination_group` or `destination_event_user`), `/policies/event_matcher/` (action, app, model, client_ip) | Delivery runs as Dramatiq tasks with 5 retries, at least once, with no idempotency key and no ordering guarantee. The default body has no event id or model, so a custom body mapping is required. |
| Invitations | `GET/POST /stages/invitation/invitations/` (`fixed_data`, `expires`, `single_use`, `flow`), `POST …/{uuid}/send_email/` | Needs an enrollment flow with an invitation stage, plus working email. |
| Blueprints | `GET /managed/blueprints/`, `POST …/{uuid}/apply/`, `GET …/available/` | Each instance has `status`, `last_applied`, `managed_models`. |
@@ -299,7 +332,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which
│ /qqq/v1/... (normal QQQ routes) /qqq/v1/authentik/* (widgets)
┌──────────────▼──────────────── qbit-authentik (Java) ────────────────────┐
│ AuthentikQBitProducer / AuthentikQBitConfig │
│ ├─ API backend "authentik" ── AuthentikActionUtil (BaseAPIActionUtil) │
│ ├─ API backend "authentikApi" ── AuthentikActionUtil (BaseAPIActionUtil) │
│ │ ├─ AuthentikGuard (QQQ permission, escalation, protection) │
│ │ ├─ FilterMapper (QQueryFilter → Authentik query params) │
│ │ └─ Throttle (token bucket, bulk pacing) │
@@ -314,7 +347,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which
│ Bearer token of service account "qqq-<appKey>-idadmin"
┌──────────────▼──────────────── Authentik (source of truth) ──────────────┐
│ DB: users, groups, memberships, attributes, roles ◄── Authentik admin UI│
│ role qqq-<appKey>-idadmin (bootstrap blueprint, state: created + perms) │
│ role qqq-<appKey>-idadmin (bootstrap blueprint, present, perms only) │
│ notification rule + event_matcher policy + webhook transport → QQQ │
│ blueprints: flows/stages/providers/apps/policies/branding (present) │
└───────────────────────────────────────────────────────────────────────────┘
@@ -328,14 +361,14 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which
|---|---|---|
| `baseUrl` | required | Authentik host root, e.g. `https://auth.kof22.com`. `/api/v3` is appended. This is separate from the OAuth2 issuer URL, as MHP learned. |
| `apiToken` | required | Usually `${env.AUTHENTIK_API_TOKEN}`. Interpolated by the qbit (because `APIBackendMetaData.enrich()` is disabled) and redacted in `toString()` |
| `tableNamePrefix` / namespace | `authentik` | Prefix for tables (joined with `_`, snake_case: `authentik_user`), processes and widgets (camelCase: `authentikAdmin`) |
| `features` | `{READ}` | A subset of `READ`, `USERS` (create, invite, edit, deactivate), `GROUPS` (create, edit, permissions and app-access attributes), `MEMBERSHIP`, `CREDENTIALS` (recovery email, MFA reset, app-password revoke), `SESSIONS`, `RBAC` (phase 4), `ACCESS_MATRIX`, `MIRROR`, `WEBHOOK`, `SNAPSHOT_EXPORT`. A feature that is off produces no metadata. |
| `tableNamePrefix` / namespace | `authentik` | Prefix for tables (joined with `_`: `authentik_user`), processes and widgets (camel-cased: `authentikResyncMirror`) |
| `features` | `{READ}` | A subset of `READ`, `USERS` (create, invite, edit, deactivate), `GROUPS` (create, edit, permissions and app-access attributes), `POLICIES` (§12), `DELETE` (§12.5), `MEMBERSHIP`, `CREDENTIALS` (recovery email, MFA reset, app-password revoke), `SESSIONS`, `RBAC` (phase 4), `ACCESS_MATRIX`, `MIRROR`, `WEBHOOK`, `SNAPSHOT_EXPORT`. A feature that is off produces no metadata. |
| `readOnly` | `false` | Hard kill switch. Every write is refused. |
| `appKey` | required | Identifies this QQQ app in attributes, audit and the app-access attribute, e.g. `kof22-admin` |
| `userScope` | `internal` + `external` users | Optional `path_startswith` and/or `groups_by_name` filter, to partition a shared Authentik between apps. Out-of-scope users are invisible and cannot be written. |
| `groupScope` | all non-superuser groups | Optional name prefix or attribute filter (`qqq.io/app: <appKey>`) for multi-app Authentiks |
| `protectedUsernames` | `[]` | Added to the built-in protected set: the service account, users with `qqq.io/protected: true`, and superusers (§5.4) |
| `bootstrapOperators` | `${env.AUTHENTIK_QBIT_BOOTSTRAP_OPERATORS}` (empty) | Emails that receive all `authentik.*` permissions even if the group attributes are wrong. A lockout escape hatch that logs a warning while in use; intended to be emptied after go-live. |
| `bootstrapOperatorSubjects` | `${env.AUTHENTIK_QBIT_BOOTSTRAP_SUBJECTS}` (empty) | **Immutable ids, never emails** (users can change their own email in Authentik, and emails are not unique). A person is a bootstrap operator when the token `sub` equals an entry (a `sub` that looks like an email is ignored; kof22's provider uses `sub_mode: user_email`), or when the token email is verified, exactly ONE active Authentik user has it, and that user's pk or uuid equals an entry. Find the value in Authentik admin: Directory, Users, the user's numeric id (pk) or uuid. Grants all `authentik.*` permissions even if the group attributes are wrong: a lockout escape hatch that logs a warning while in use. **Empty it once real group grants exist** (after `identity-admins` membership is verified). Entries containing `@` fail validation. |
| `permissionCatalog` | derived | The QQQ permission names that can be granted through `qqq.io/permissions`, taken from `PermissionsHelper.getAllAvailablePermissions` plus the `authentik.*` names. Unknown names are rejected. |
| `recoveryEmailStage` | required if `CREDENTIALS` or `USERS` | Email stage slug or uuid for `recovery_email`, e.g. `kof22-recovery-email` |
| `invitationFlow` | required for invites | Enrollment flow slug, e.g. `kof22-enrollment` |
@@ -343,7 +376,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which
| `applicationsInScope` | all visible | Application slugs shown in the access matrix and the app-access editor |
| `confirmations` | see §6.3 | Per-action confirmation level: `CONFIRM`, `TYPED`, `TWO_PERSON` |
| `throttle` | 10 req/s, burst 20; bulk pacing 100 ms | Self-imposed; authenticated calls are not throttled server-side |
| `cacheTtl` | 30 s lists, 60 s group attributes, 5 min `check_access` | Memoization TTLs |
| `cacheTtl` | 30 s lists, 60 s group attributes, 5 min computed app access | Memoization TTLs |
| `mirror` | backend name, 15-minute reconcile, 30-day inbox retention | Used if `MIRROR` is on |
| `webhookSecret` | `${env.AUTHENTIK_WEBHOOK_SECRET}` | Shared secret in a header set by the transport's header mapping |
| `esbDestinationName` | optional | If set, webhook events go through the ESB; otherwise they are processed inline |
@@ -364,7 +397,7 @@ MHP learned this the hard way. Blueprint drift caused incident APPS-1462, which
| Concern | Lives in | Edited from | Blueprint state |
|---|---|---|---|
| Users, groups, memberships, group parents, group attributes (`qqq.io/permissions`, `qqq.io/app-access`), user attributes | **Authentik DB** | QQQ qbit **and** Authentik admin UI | none (data); bootstrap seeds only, as `created` |
| Authentik RBAC roles and their assignments | Authentik DB | Authentik UI (v1); QQQ (phase 4) | the qbit's own role: `created`, with `permissions:` |
| Authentik RBAC roles and their assignments | Authentik DB | Authentik UI (v1); QQQ (phase 4) | the qbit's own role: `present`, `permissions` only (§4.3) |
| Flows, stages, prompts, email templates | Git | cluster-gitops PR | `present` |
| OAuth2/other providers, applications, scope mappings | Git | cluster-gitops PR | `present` |
| Policies and policy bindings (incl. the app sign-in policy) | Git | cluster-gitops PR | `present` |
@@ -515,7 +548,7 @@ Notes (resolved in phase 0, §0.2):
- **Global permissions** are the role's `attrs.permissions` (finding i). The role is `present` with nothing but `permissions`, so it is re-asserted additively on every apply and carries no users or groups. `roles:` on the `created` service account applies on first create only; the role's later permission changes still reach it.
- **Ordering on a fresh install** (finding iv): the leading `metaapplyblueprint` entry applies `kof22-oauth2-apps` first, inside the bootstrap's transaction. Verified on an empty 2026.8.3. If the infra instance did not exist yet, only the bootstrap fails, and it retries on the next discovery.
- **Object permission** on `kof22-recovery-email` (needed by `recovery_email`, phase 3) is applied through the bare `created` stage entry's `permissions:` on every run.
- **Seeding James's membership.** `groups:` on a `created` user only applies when the user is first created. James's account already exists, so his `identity-admins` membership is a one-time manual step in the Authentik UI (owner checklist item 10). `bootstrapOperators` covers the window before it's done.
- **Seeding James's membership.** `groups:` on a `created` user only applies when the user is first created. James's account already exists, so his `identity-admins` membership is a one-time manual step in the Authentik UI (owner checklist item 10). `bootstrapOperatorSubjects` covers the window before it's done.
- **Break-glass password.** The break-glass admin has no usable password from the blueprint. Its password is set once by hand and stored in 1Password. Its recovery path is `ak create_recovery_key` on the worker pod.
### 4.4 Migration from today's blueprint
@@ -589,14 +622,16 @@ The role `qqq-<appKey>-idadmin` is assigned directly to the service account (boo
| `authentik_core.add_user_to_group`, `remove_user_from_group` | Membership | 2 |
| `authentik_core.delete_authenticatedsession` | Revoke sessions | 3 |
| `authentik_core.delete_token` | Revoke app passwords | 3 |
| `authentik_core.change_token` | Extend a token's expiry (§13); `PATCH` always sends `intent`, `user` and `expires` | 6 |
| `authentik_core.set_token_key` | Rotate a **service account's** app password with a key the qbit generates (§13). Guarded by §13.4 (a) and the detective rule (e) | 6 |
| Device `delete_*` for TOTP, WebAuthn, static, email, SMS | MFA reset | 3 |
| `authentik_stages_invitation.add_invitation`, `change_invitation`, `delete_invitation` | Invites | 3 |
| `authentik_rbac.change_role`, `assign` (object, on non-qbit roles) | Role management | 4 |
**Never granted:**
- `enable_group_superuser` / `disable_group_superuser`. Authentik then refuses every route into superuser ancestry: membership (direct, bulk, by user or by group), `parents` under a superuser group or any descendant, creating a group under one, and flipping `is_superuser` (verified, §0.2 ii).
- `view_token_key`, `set_token_key`, `add_token` (MHP APPS-1462)
- `impersonate`, `delete_user`
- `view_token_key` (MHP APPS-1462). Since revision 6, `set_token_key` and `add_token` are granted for service-account creation and rotation only, behind §13.4; `add_token` cannot mint for others (§13.2, §13.9)
- `impersonate`; `delete_user` and `delete_group` unless the `DELETE` feature is on (§12.5)
- any `change_*` on flows, stages, providers, applications, policies, bindings, brands or blueprints
- any change on its own role. Role permissions are object permissions excluding `qqq-*` roles, and the guard also refuses.
@@ -624,6 +659,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost
| `authentik.groups.manage` | Create groups, rename them, set parents (within `groupScope`), edit non-reserved attributes |
| `authentik.memberships.manage` | Add and remove members (subject to the escalation rule) |
| `authentik.permissions.manage` | Edit a group's `qqq.io/permissions` and `qqq.io/app-access` (subject to the escalation rule) |
| `authentik.policies.manage` | Create and edit group policies (§12) through *Edit Policy*, subject to the escalation rule |
| `authentik.roles.manage` | Assign and unassign Authentik RBAC roles (phase 4) |
| `authentik.credentials.reset` | Send recovery email, reset MFA, revoke app passwords |
| `authentik.sessions.revoke` | Revoke sessions |
@@ -638,7 +674,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost
- `AuthentikActionUtil.doQuery/doGet/doInsert/doUpdate/doDelete` call `AuthentikGuard.check(table, op, session)` first. The guard checks the QQQ permission, the feature flag, `readOnly` and scope.
- A session with no user is refused, except `MirrorSync`'s internal read-only session.
- Every qbit process's first backend step re-checks `checkProcessPermissionThrowing` and the per-record rules (§5.4). It does not trust that the middleware ran.
- Field-level protection does not use `FieldSecurityLock` (#1062):
- Field-level protection retains explicit stripping for direct backend calls; core also enforces `FieldSecurityLock` in the RC.5 baseline (#1062):
- Sensitive columns are removed in `jsonObjectToRecord` without `authentik.audit.read`. These are `attributes` (except the `qqq.io/*` keys, shown under `users.read`), `last_ip`, `user_agent` and `geo_ip`.
- Filters on those fields are rejected for that session.
- Tables also declare `QPermissionRules`, so the UI hides what the user can't use.
@@ -671,7 +707,8 @@ This is weaker than revision 1's per-group object permissions. That is the cost
| Invite user | Yes (`USERS`) | Invitation (`fixed_data` email and name, `single_use`, expires in 7 days) + `send_email`, through the `kof22-enrollment` flow. Groups are applied by QQQ when the `invitation_used` webhook arrives. |
| Send password-reset email | Yes (`CREDENTIALS`) | `recovery_email` with `recoveryEmailStage`, `token_duration` 30 minutes |
| Show or copy recovery link, set password, set hash | **No** | Never called. A link given to the operator amounts to account takeover. |
| View or set token keys; create tokens | **No** | Not permitted for the service account; not exposed |
| Create, rotate, extend, revoke access tokens | Yes (`TOKENS`, §13) | A person's own tokens: as that person, through the token broker. Service accounts: `service_account`, `set_key`, `PATCH`. The key is shown once and never stored |
| Read token keys (`view_key`) as the service account | **No** | Never granted; a key is read only by its owner, in the owner's own exchange |
| Revoke sessions | Yes (`SESSIONS`) | `bulk_delete?user_pks=` / per-session `DELETE` |
| Reset MFA | Yes (`CREDENTIALS`) | Device list, then per-type `DELETE`, with typed confirmation and a reason |
| Revoke app passwords | Yes (`CREDENTIALS`) | `DELETE /core/tokens/{identifier}/`, intent `app_password` only |
@@ -682,17 +719,23 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace
1. It takes the user's group names from the `groups` claim. The current kof22 `profile` mapping emits **direct** groups only.
2. It resolves each group through `EffectivePermissionResolver`: group attributes plus all ancestors via `include_parents`, from the mirror or the API, cached 60 s. The result is the union of `qqq.io/permissions`, with `authentik.*` and other wildcard prefixes expanded against `permissionCatalog`.
3. It adds static fallbacks if configured, through `withGroupPermissions(...)` (useful in tests and dev), plus `bootstrapOperators`.
3. It adds static fallbacks if configured, through `withGroupPermissions(...)` (useful in tests and dev), plus `bootstrapOperatorSubjects`.
4. It sets QQQ session permissions, user, email and full name.
**Freshness.** Permissions are computed at session creation. The QQQ session is re-validated on each access-token refresh (1 hour in kof22), and the mapper runs again then. A webhook on a group or membership change also marks affected sessions in `qqq_user_session` for re-evaluation on their next request (phase 2). Phase 0 checks this is possible through the OAuth2 module's session store. If it isn't, the staleness is up to 1 hour, which is documented.
**Fallback.** If Authentik is unreachable at sign-in, the mapper uses the mirror. If the mirror is empty, it grants only what the static config provides. It never grants everything.
#### Automated sessions
Server-created sessions (scheduled reports and SSH-key users of the SFTP gateway) have no token. `customizeAutomatedSessionForUser` treats `userId` as the email idReference used by `user_email` mode and requires exactly one Authentik user with that email (trimmed, case-insensitive), who must be active. It resolves the API user's direct groups and their ancestors through the same permission resolver, static fallbacks and cut-over guard as sign-in, including the host permission source while the guard blocks. Service accounts follow the same rule. Bootstrap grants match only the resolved user's immutable pk or uuid; email verification is never assumed. Unknown, duplicate, inactive, unreadable or truncated lookups grant only empty-group static fallbacks. Existing session permissions are always preserved; an API failure never grants all permissions.
---
## 6. Data model
### 6.1 API-backed tables (backend `authentik`, `AuthentikActionUtil`)
**Naming (owner rule, 2026-10-12):** "table names should always be lowercase and use _, and qbits should always be prefixed with their name, ie, cdn_ etc". Every table, API-backed or local, is lowercase snake_case with the `authentik_` prefix (`authentik_user`, `authentik_admin_action`). Where QQQ separates the table name from the backend name, both are identical; possible-value sources use the table name. Permissions follow the same rule (owner, 2026-10-12): the former `identity.*` permissions are now `authentik.*` (`authentik.users.read`, `authentik.memberships.manage`, ...); the shorthand `users.read` etc. in the action tables means the `authentik.` name. Java classes stay CamelCase; field, process and widget names are unchanged. The one name that is not a table is the API backend, a connection rather than a table: it is `authentikApi` everywhere (diagram, README, code), camel-cased like the process and widget names. No version of qbit-authentik has been released yet, so this is a rename in the design and in the unreleased branch code (whose `applyPrefix` camel-joins `authentik` + name today), not a migration.
### 6.1 API-backed tables (backend `authentikApi`, `AuthentikActionUtil`)
| Table | Source | Key | Capabilities | Notes |
|---|---|---|---|---|
@@ -706,14 +749,14 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace
| `authentik_policy_binding` | `/policies/bindings/?target=` | uuid | Query | Read only. Shows the policy, group or user, plus negate, order and expiry. |
| `authentik_session` | `/core/authenticated_sessions/` | uuid | Query, delete (revoke) | Sensitive |
| `authentik_mfa_device` | `/authenticators/admin/all/?user=` | `type:id` | Query, delete (reset) | Metadata only. Query requires a `user` criterion. |
| `authentik_token` | `/core/tokens/?user__username=` | identifier | Query, delete (revoke) | **No key field.** Only `intent`, `expires`, `description`. |
| `authentik_token` | `/core/tokens/?user__username=` | identifier | Query; changed only by the token actions (§13) | **No key field.** Owner, intent, expires, description, managed, plus computed `lastUsed`, `scopeSummary`, `ownerType`, `isProtectedOwner`. |
| `authentik_event` | `/events/events/` | uuid | Query, get | Sensitive. Ordered `-created`. `context` is rendered as JSON. |
| `authentik_invitation` | `/stages/invitation/invitations/` | uuid | Query; insert via *Invite*; delete (cancel) | |
| `authentik_blueprint` | `/managed/blueprints/` | uuid | Query | Read only. Used by the drift report. |
**`FilterMapper`** maps QQQ criteria to Authentik query params:
- `EQUALS` → `field=`
- `IN` → repeated `field[]=` where supported (`groups_by_name`, `type`, `actions`)
- `IN` → the plain name repeated once per value, e.g. `type=a&type=b` (`groups_by_name`, `type`, `actions`). **Never the bracketed `field[]=a` form: Authentik silently ignores it and returns unfiltered results (§0.2 vi).**
- `GREATER_THAN` / `LESS_THAN` on date fields → `__gt` / `__lt`
- `IS_BLANK` → `__isnull=true`
- `CONTAINS` / `STARTS_WITH` on text fields → `search=` (or `path_startswith`)
@@ -735,6 +778,7 @@ Unsupported criteria (OR across fields, `NOT_EQUALS`, nested filters) are **reje
| Table | Purpose |
|---|---|
| `authentik_admin_action` | **Append-only audit and outbox** of every write the qbit attempts. Columns: id, correlationId, actionName, operator (QQQ user + Authentik username), reason, targetType, targetKey, targetLabel, beforeJson, afterJson, status (`PENDING_APPROVAL`, `RUNNING`, `SUCCEEDED`, `FAILED`, `REFUSED`), errorMessage, authentikEventPk, approver, timestamps. The row is inserted *before* the API call, following the sql-workbench `ExecutionAuditor` pattern; if that insert fails, the call is not made. Insert, update and delete capabilities are removed for everyone. |
| `authentik_policy` | One row per group with a policy (§12): groupPk (unique), groupName, policyJson (canonical pretty JSON), statementCount, permissionCount, savedBy, savedAt, authentikFingerprint. Field-level QQQ audit is the change history. Changed only by *Edit Policy*; generic insert, edit and delete are off. |
| `authentik_approval_request` | Two-person requests: action payload, requester, approver, 24 h expiry, approver ≠ requester. Used for RBAC (phase 4) and, by default, for permission edits that add `authentik.*` (§6.3). |
| `authentik_user_mirror` | `MIRROR` feature: pk, uuid, username, email, name, type, isActive, lastLogin, isSuperuser, isProtected, groupNames, effectivePermissions (text), mfaDeviceCount, failedLogins24h, refreshedAt. Unique on username and email. Web Users joins here (`qqq_user_session.user_id` = email in kof22). The mapper falls back to it. |
| `authentik_group_mirror` | pk, name, parentPks, attributes `qqq.io/*` only, memberCount, isSuperuser (inherited), refreshedAt. Feeds the resolver and the tree widget. |
@@ -745,7 +789,7 @@ Unsupported criteria (OR across fields, `NOT_EQUALS`, nested filters) are **reje
Each process:
- is bound with `withTableName(...)` and placed in `QMenu` slots (`isHidden(true)` for navigation)
- uses `StreamedETLWithFrontendProcess` with steps: **input** (targets and values; **reason** is required for writes), **preview** (server-computed per-target before → after, effective-permission deltas, `check_access` deltas, guard results, and skipped targets with reasons), **confirm** (per `confirmations`), **execute** (audit row → stale check → API call → Authentik custom event → cache and mirror invalidation), and **result** (per-record outcome with links to audit rows)
- uses `StreamedETLWithFrontendProcess` with steps: **input** (targets and values; **reason** is required for writes), **preview** (server-computed per-target before → after, effective-permission deltas, computed-access deltas, guard results, and skipped targets with reasons), **confirm** (per `confirmations`), **execute** (audit row → stale check → API call → Authentik custom event → cache and mirror invalidation), and **result** (per-record outcome with links to audit rows)
| Process | Table / slot | Records | Permission | Default confirmation | Authentik calls |
|---|---|---|---|---|---|
@@ -759,7 +803,7 @@ Each process:
| Create group | `authentik_group` query action | 0 | `authentik.groups.manage` (+ `permissions.manage` if permissions given) | CONFIRM | `POST /core/groups/` (name, parents, attributes) |
| Edit group | `authentik_group` view | 1 | `authentik.groups.manage` | CONFIRM | `PATCH` name, parents and non-reserved attributes (merge-patch) |
| Edit permissions and app access | `authentik_group` view | 1 | `authentik.permissions.manage` | CONFIRM; **TWO_PERSON** when adding any `authentik.*` permission | `PATCH attributes` (`qqq.io/permissions`, `qqq.io/app-access`), re-read first |
| Check access / Effective permissions | `authentik_user` view | 1 | `authentik.users.read` | none | `check_access` per in-scope app; the resolver |
| Check access / Effective permissions | `authentik_user` view | 1 | `authentik.users.read` | none | computed access per in-scope app (§5.1, not `check_access`); the resolver |
| Send password reset email | `authentik_user` | 1–25 | `authentik.credentials.reset` | CONFIRM | `recovery_email` |
| Reset MFA | `authentik_user` | 1 | `authentik.credentials.reset` | TYPED | per-device `DELETE` |
| Revoke app passwords | `authentik_user`, `authentik_token` | 1–25 | `authentik.credentials.reset` | TYPED | `DELETE /core/tokens/{identifier}/` |
@@ -844,20 +888,21 @@ The qbit produces an app `authentikAdmin` (label **"Identity"**, icon `badge`).
| Groups | `AUTHENTIK_GROUP_TREE` widget + generic list | Tree over `parents` (many-to-many; shared children marked). Badges: superuser (red, locked), has-permissions, app-access. Member counts. |
| Group detail | generic view + child widgets | Members (Add/Remove); **Permissions** (own + inherited, with an editor that offers only permissions the operator holds); **App access**; parents and children; roles (read); other attributes (`authentik.audit.read`) |
| Access matrix | `AUTHENTIK_ACCESS_MATRIX` widget | Users in scope × `applicationsInScope`, cells computed by the qbit (not `check_access`, §0.2 iii; cached 5 min, 25 rows per page) |
| Health | `AUTHENTIK_HEALTH` widget | API reachability and version, token expiry, blueprint instance status, webhook lag, last reconcile, possible blueprint reversions, `bootstrapOperators` in use |
| Health | `AUTHENTIK_HEALTH` widget | API reachability and version, token expiry, blueprint instance status, webhook lag, last reconcile, possible blueprint reversions, `bootstrapOperatorSubjects` in use |
| Audit | generic query on `authentik_admin_action` + `authentik_event` | Filter by operator, target, action, status, and source (QQQ vs Authentik UI) |
### 8.3 Admin-home widgets (standard widget types, from the mirror)
- Statistics: active users, inactive users, service accounts, users without MFA, failed logins in 24 h.
- Table: recent auth events.
- Table: "attention" items: admins without MFA, tokens expiring within 14 days (including the qbit's own token), blueprint status not `successful`, `bootstrapOperators` still set.
- Table: "attention" items: admins without MFA, tokens expiring within 14 days (including the qbit's own token), blueprint status not `successful`, `bootstrapOperatorSubjects` still set.
### 8.4 Frontend delivery
- Custom widget types `AUTHENTIK_GROUP_TREE`, `AUTHENTIK_ACCESS_MATRIX` and `AUTHENTIK_HEALTH` are added to `WidgetRenderer.tsx` as `React.lazy` cases, following the `SQL_WORKBENCH` and `ESB_OVERVIEW` pattern.
- `src/lib/api/authentik.ts` calls `/qqq/v1/authentik/{tree,matrix,health,permission-catalog}`, all guarded.
- A permission-picker field component for the group permissions editor.
- `AUTHENTIK_POLICY_EDITOR` (§12.4), lazy-loaded and seeded through the standard process widget route, so no qbit route is needed.
- Mocks in `src/mocks/fixtures/authentik.ts`; e2e in `tests/e2e/authentik.spec.ts`.
- Everything else uses the generic UI.
@@ -909,13 +954,14 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh
| Threat | Control |
|---|---|
| A compromised QQQ backend escalates in Authentik | The service account can't create superusers, touch superuser groups, read or mint tokens, impersonate, delete users, or change flows and policies (§5.1) |
| A compromised QQQ backend escalates in Authentik | The service account can't create superusers, touch superuser groups, read token keys, create tokens for others, impersonate, delete users, or change flows and policies (§5.1). Since revision 6 it can set a token's key (`set_token_key`, for service-account rotation): guard §13.4 (a) plus the detective rule (e) |
| A person's token is minted by someone else | Tokens for people are created only as that person, through token exchange (§13.3.1); Authentik enforces it |
| A QQQ operator escalates themselves or a friend | Subset rule, no self-edits, two-person approval for `authentik.*` grants, protected accounts |
| Lockout (last admin removed or deactivated) | Last-admin guard; break-glass superuser outside QQQ; `bootstrapOperators` |
| Lockout (last admin removed or deactivated) | Last-admin guard; break-glass superuser outside QQQ; `bootstrapOperatorSubjects` |
| A blueprint re-apply reverts QQQ edits | No `present` identity entries, no member lists (CI lint); bootstrap as `created` (verified, §4.2); reversion detector |
| Lost update between QQQ and the Authentik UI | Stale check at execute; attribute merge-patch after a fresh read |
| Account takeover through a recovery link | Links are never shown; email only |
| A non-HTTP caller bypasses permissions (#1061) | Guard in the backend util, plus ArchUnit |
| A direct backend caller bypasses core actions | Guard in the backend util, plus ArchUnit |
| A field leaks because `FieldSecurityLock` isn't enforced (#1062) | Sensitive fields stripped in the util |
| A forged webhook | Shared secret, constant-time compare; it only triggers re-reads |
| Stale session permissions after a demotion | Re-evaluation on token refresh plus webhook-triggered marking (§5.6); live groups used for guard checks |
@@ -958,7 +1004,7 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a
| **0. Foundations and migration** | See the task list below | Contract tests (b), (c), (d), (h) green; the kof22 migration done in staging; read-only token wired | 2 wk |
| **1. Read-only + permissions model** | Tables (users, groups, members, roles, applications, bindings, blueprints; sensitive tables behind `audit.read`). `EffectivePermissionResolver` + `AuthentikGroupPermissionMapper` (kof22 cut over, sign-in policy fallback removed). Group tree, Effective permissions and Access tabs. Health widget. Mirror with scheduled reconcile. Admin-home widgets. | kof22 Web Users shows groups, last login and MFA; kof22 permissions come from `qqq.io/permissions`; guard matrix green for reads | 2.5 wk |
| **2. Memberships and permission editing** | Add/remove (both sides, bulk), the subset rule, the last-admin guard, the group permissions and app-access editor with two-person approval for `authentik.*`, `authentik_admin_action` + Authentik custom events, webhook route + ESB trigger + inbox, session re-evaluation marking | Scenarios B and D end to end; safety matrix 100%; webhook latency < 10 s | 2.5 wk |
| **3. Full user lifecycle** | Create user, invite (+ enrollment flow in Git), edit, deactivate/reactivate, recovery email, revoke sessions, reset MFA, revoke app passwords, create and edit groups, cancel invitations; optional snapshot export | Scenarios A and C end to end; contract tests (a), (e), (f), (g) green | 3 wk |
| **3. Full user lifecycle** | Create user, invite (+ enrollment flow in Git), edit, deactivate/reactivate, recovery email, revoke sessions, reset MFA, revoke app passwords, create and edit groups, cancel invitations; optional snapshot export | Scenarios A and C end to end; contract tests (a), (e), (f) green; access computation pinned by `AccessCheckIT` and `SignInPolicyIT` (§10 g) | 3 wk |
| **4. RBAC (post-v1)** | Authentik role assignment with two-person approval, access-matrix polish, MHP migration off `AuthentikAdminApiClient` | MHP running on the qbit | 3 wk |
**v1 (phases 0–3) is about 10 weeks**, and phase 4 adds about 3 more.
@@ -969,7 +1015,7 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a
| # | Task | Who |
|---|---|---|
| 1 | Create repo `QRun-IO/qbit-authentik` from `qbit-template-extension`; copy this spec to `docs/DESIGN.md`; add `AGENTS.md`/`CLAUDE.md` | agent: **done** (repo private for now, see the owner checklist) |
| 1 | Create repo `QRun-IO/qbit-authentik` from `qbit-template-extension`; copy this spec to `docs/DESIGN.md`; add `AGENTS.md`/`CLAUDE.md` | agent: **done** (the repo is public) |
| 2 | `AuthentikQBitConfig` + producer skeleton; `AuthentikActionUtil` (bearer auth, page/page_size paging, FilterMapper, throttle, redaction); pinned `schema.yml` + drift test | agent |
| 3 | Testcontainers harness (Authentik 2026.8.3 + Postgres + Mailpit), fixture recorder; contract tests (b), (c), (d), (h) | agent |
| 4 | Verify on the container: (i) how to grant *global* role permissions declaratively in 2026.8.3; (ii) whether a `parents` change under a superuser group is refused without `enable_group_superuser`; (iii) the permission `check_access` needs; (iv) the `metaapplyblueprint` ordering. Then fix §4.3 and §5.1. | agent: **done**, §0.2 |
@@ -988,12 +1034,12 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a
| The service account holds global user/group change rights (decision 1), so its blast radius is wider than revision 1's | Hard Authentik floor (no superuser, tokens, impersonation or delete); `userScope`/`groupScope`; guard; audit on both sides; 90-day token |
| Someone reintroduces a `present` identity entry, and members get wiped | CI lint; contract test (d); reversion detector |
| A bootstrap entry error rolls back the infrastructure blueprint | Separate bootstrap file; `metaapplyblueprint` ordering; minimal attrs; health widget |
| A lockout through bad permission edits | Last-admin guard, break-glass, `bootstrapOperators`, two-person approval for `authentik.*` |
| A lockout through bad permission edits | Last-admin guard, break-glass, `bootstrapOperatorSubjects`, two-person approval for `authentik.*` |
| Session permissions go stale after a demotion | Token-refresh re-evaluation, webhook marking, live checks in the guard |
| `parents` re-parenting under a superuser group isn't refused by Authentik | Guard refusal; phase 0 check (ii) |
| The service account can lock administrators out (remove members from `authentik Admins`, deactivate break-glass, strip `qqq.io/protected`, §0.2 ii-b). Re-parenting under a superuser group **is** refused by Authentik (§0.2 ii) | Guard refusal of every write that targets a protected account or a superuser group; drift report; recovery through cluster access |
| Load on the shared Postgres | Throttle, caps, mirror |
| Webhook loss | Inbox, 15-minute reconcile, nightly sweep |
| QQQ core gaps (#1061, #1062) | Guard in the backend util, ArchUnit, field stripping |
| Direct backend calls outside core enforcement | Guard in the backend util, ArchUnit, field stripping |
| Token leakage | Redaction tests; no body logging on sensitive paths |
| An Authentik upgrade changes fields | Pinned schema, fixtures per version, version check |
@@ -1022,6 +1068,459 @@ The decisions taken on 2026-10-11 are in §0.1. The remaining questions are smal
---
## 12. Policies: IAM-style permission statements (revision 5)
### 12.1 What a policy is
A **policy** is a JSON document on an Authentik group that grants QQQ permissions to everyone in the group (and in its child groups). The format is defined in [`POLICY-FORMAT.md`](POLICY-FORMAT.md); in short:
```json
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.0.0",
"Statement": [
{ "Effect": "Allow", "Action": ["hasAccess"], "Resource": ["contentApp", "publishPage"] },
{ "Effect": "Allow", "Action": ["read", "edit"], "Resource": ["cms_page"] }
]
}
```
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` on a named resource = only the five standard actions: `hasAccess`, `read`, `insert`, `edit`, `delete`). Those are QQQ permission names, so a QQQ session that holds them passes QQQ's own permission checks. Policies cover the instance's tables, processes, apps, reports and widgets. Named permissions that are not `{resource}.{action}` (for example `authentik.users.manage`) stay in `qqq.io/permissions` (§5.6); individual policy resources cannot contain a dot. Full access includes these named permissions in session form.
Table feature actions are generic strings registered in the table's permission catalog: for example `analyze` (qbit-data-analysis), or `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration). `{"Effect":"Allow","Action":["analyze"],"Resource":["person"]}` grants `person.analyze`, checked with `session.hasPermission("person.analyze")`. A feature must exist for every resource in its statement. Unknown features reject the document. `Action: ["*"]` on a named resource never includes features, so those statements do not acquire new grants when a feature is registered.
**Full access.** `{"Effect":"Allow","Action":["*"],"Resource":["*"]}` grants every name from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, the qbit permission catalog (including hidden record actions), and all `IdentityPermission` session names. This includes table features, READ_WRITE `write`, custom permission names enumerated by core, and future resources. Expansion happens at permission computation, never by replacing the stored statement with today's list. `Resource "*"` requires exactly `Action ["*"]`; other combinations reject the whole document. Other statements are still validated. Canonical full access is exactly one wildcard statement; enumerating all current grants never invents it. See [Full access](POLICY-FORMAT.md#full-access) for exact inclusion rules.
**Fail closed.** Any of these grants **zero** permissions from that document and logs a warning:
- an unknown `PolicySpec` or an unsupported `PolicyVersion`
- a document that is not a JSON object, or a `Statement` that is not an array
- a statement whose `Effect` is not `Allow`
- an unknown action
- a resource that does not match `[A-Za-z0-9_]+`, except the full-access `*`
- any other parse error
This is stricter than the reference implementation in two places. The reference ignores unknown actions, while this qbit rejects the whole document. `Deny` is not supported yet, so a `Deny` statement also rejects the document; ignoring it would grant more than its author intended.
### 12.2 Where it is stored, and how it reaches the session
| Step | Where | Detail |
|---|---|---|
| Edit | QQQ, *Edit Policy* on a group (§12.4) | The editor builds the document; the server validates it and checks the round trip |
| Save | Authentik group attribute `qqq.io/policy` (config `policyAttribute`) | The compact JSON **string** (the reference format; the parser also reads a JSON object). Merge-patch of that one key into the group's current attributes, re-read immediately before the write (§5.4 rule 7). |
| History | QQQ table `authentik_policy`, one row per group | The canonical pretty JSON, counts, who saved it and when, and the fingerprint of what Authentik held. Field-level QQQ audit gives the change history; `authentik_admin_action` holds before/after of every save. |
| Sign-in | Authentik scope mapping (blueprint example in `POLICY-FORMAT.md`) | Merges the statements of all the user's groups, **including inherited parents** (`request.user.all_groups()`), into one document in the claim `qqq_policy` (config `policyClaim`) |
| Session | `AuthentikPolicyClaimMapper`, or `PolicyPermissions.fromClaims` in a host's own customizer | Reads the claim from the ID token, else the access-token payload; parses it fail-closed; adds the permissions to the QQQ session |
`AuthentikGroupPermissionMapper` (§5.6) also reads `qqq.io/policy` from the group graph. So the following all see policy grants:
- `EffectivePermissionResolver`
- the subset rule
- the effective-permissions view
- the last-admin check
An app that uses the API-backed mapper therefore gets policy grants even before the scope mapping is installed. The claim and the graph hold the same Authentik data, and the session gets their union.
**Record locks (extension point).** Row-level security keys are out of scope for now. `AuthentikPolicyClaimMapper` has a protected `applySecurityKeys(QSession, JSONObject claims)` hook, a no-op by default.
- A host overrides it to read its own explicit claims and set security key values.
- It is called after the identity is set and before the permissions.
- It must trust only explicit claims, never group names.
### 12.3 Config
| Setting | Default | Meaning |
|---|---|---|
| `policySpec` | `qqq-iam` | The `PolicySpec` this app accepts and writes. A host that already has documents keeps its own name. |
| `policyVersion` | `1.0.0` | The one supported `PolicyVersion` (exact match, like the reference) |
| `policyAttribute` | `qqq.io/policy` | Group attribute holding the document. A host with existing documents may point it at its own key. |
| `policyClaim` | `qqq_policy` | Token claim holding the merged document |
| `policyTwoPersonApproval` | `false` | When true, every policy save is queued for a second person (`authentik.approve`) |
The feature `POLICIES` turns on the editor, the `authentik_policy` table and the save action. It needs `GROUPS`. Reading policies at sign-in needs no feature.
### 12.4 The policy editor
*Edit Policy* is a record action on `authentik_group` and on `authentik_policy`, never in the nav. It is a standard streamed-ETL process with three steps.
**1. Edit** (frontend step). The step shows the widget `authentikPolicyEditor`, type `AUTHENTIK_POLICY_EDITOR`, a lazy-loaded Next UI widget. The server sends its catalog, current document and member preview data.
The **catalog** lists every app, table, process, report and protected widget in the QInstance. Each entry has its permission base name, label, kind, app, and the actions QQQ actually checks for it:
- `hasAccess` for apps, processes, reports and widgets
- `read`, `insert`, `edit` and `delete` for tables, limited by the table's capabilities and permission level
- Table features from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, matched by the table's effective permission base name plus `.`. Their labels come from `AvailablePermission.permissionType`; registered `TableFeaturePermissionsMetaData` features appear through this core API. The five standard suffixes are excluded.
The widget JSON keeps `actions` unchanged and adds a `features` array to each table resource, for example `"actions":["read","insert","edit","delete"],"features":[{"name":"analyze","label":"Analyze"},{"name":"comment","label":"Comment"}]`. Tables without features have `"features":[]`. Features also appear for `HAS_ACCESS` and `READ_WRITE` tables. Shared permission base names merge their features. Feature checkboxes must emit explicit action names.
The catalog also follows these rules:
- Hidden processes are included. Record actions are hidden from the nav but are still permission-checked.
- Objects that are in no app are listed under "Other".
- Tables that use `READ_WRITE` permissions are shown with the standard action `read` and any registered features, and a note about their single write permission. The generic matching rule also exposes their available `write` permission as an explicit named action; `*` does not grant it.
- Objects with a custom permission checker, or `NOT_PROTECTED`, are left out because a policy cannot grant them.
**Full-access UI contract (Next implementation in a separate PR).** The widget payload adds `fullAccess: boolean`, true only when the current valid document contains the full-access statement, false for an absent or invalid document. Next must show a prominent **Full access (all current and future resources)** checkbox initialized from this field. While checked, disable the permissions matrix and emit a document with the payload's `spec` and `version` and exactly `Statement: [{"Effect":"Allow","Action":["*"],"Resource":["*"]}]` through the existing `outputFieldName`. Do not enumerate the matrix into explicit permissions. Unchecking removes that statement and re-enables the matrix; the emitted document then contains only explicit selected grants. `fullAccess` is display state, not a separately trusted save input: the server derives authority from the submitted document. Member `basePermissions` already expands full-access policies from other groups; the UI must label full access as including future resources, not infer completeness from the matrix alone.
The **current document** is read live from the group in Authentik, the source of truth, together with the group's fingerprint for the stale check. If it differs from the last QQQ save, a notice says it was changed in Authentik.
**Preview for a member.** On editor load only, under `authentik.policies.manage`, the payload adds `members` (up to 200 active, in-scope members of this group or its descendants, sorted by username), `membersTruncated` (true when more eligible people exist), and `mapperEnabled` from config so the UI can say "not enforced at sign-in". Each member has `username`, `label` (`Full Name (username)`), `viaGroups` (sorted direct group names that are this group or its descendants), and `basePermissions` (sorted session permissions with only this group's policy document excluded). Named `qqq.io/permissions`, other groups' and ancestors' policies, wildcard expansion, static direct-group fallbacks and bootstrap-subject grants still count. The Next UI combines that base with the current and edited documents to preview effective permissions and additions/removals locally.
The screen works like this:
- Resources are grouped by app, with a search box.
- Each row has a checkbox per action, plus *all* for the row. Each action column and each app has *select all*.
- A read-only JSON preview updates live.
- Unknown resources in an opened document (for example a table that was removed) are listed, and kept until the operator removes them.
- The widget writes the generated document into the process value `policyJson`. Clients without the widget post `policyJson` directly.
**2. Review** (dry run). The server:
- parses the submitted document, failing closed
- regenerates it in canonical form and checks that parse(generate(x)) = x (the round-trip invariant)
- flags unknown resources
- shows the permissions added and removed, and how many members are affected
- applies the subset rule (§5.4 rule 4): the operator must hold every **added** permission; for full access they must hold its **entire current expansion** (their own effective full-access policy satisfies this). Review and audit summaries say **Full access (all current and future resources)**. Removing full access also preserves the last qualifying identity administrator.
Refusals name the reason.
**3. Result.** The qbit:
- writes the attribute (merge-patch)
- inserts or updates the audited `authentik_policy` row
- writes the `authentik_admin_action` row and the Authentik event
- clears the caches
Saving an empty matrix removes the attribute. Full access canonicalizes to one wildcard statement. Other documents retain the reference generator's canonical form:
- one statement per action, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`, followed by feature names sorted alphabetically
- resources sorted
- a resource with every action is still written as five statements, not `*`, so diffs stay readable
**Maintenance API.** Reviewed Java code in the host repository, typically a run-once job such as `KOF22_RUN_ONCE`, may call `AuthentikMaintenance.applyPolicy(apiBackendName, groupName, policyJson, operatorName, reason)`. This is a plain Java API with no registered process or route; hosts must not expose it over HTTP. It resolves exactly one group by name and uses the same audited Save Policy plan and execution, including stale, scope, superuser ancestry, read-only and feature checks. A temporary non-system session grants `authentik.policies.manage` plus the document's permissions (the complete current expansion for full access); its `maintenance:` or `run-once:` operator name and the required reason are recorded verbatim. The caller's context is restored. Equivalent policies return `NO_CHANGE` without a write or audit row; `policyTwoPersonApproval` queues approval instead of applying. `AuthentikMaintenance.fullAccessDocument(config)` generates the canonical compact full-access document for the host's spec/version. System sessions remain forbidden from writing directly.
### 12.5 Full user and group CRUD (revision 5 additions)
| Action | Table / slot | Permission | Feature | Confirmation | Authentik |
|---|---|---|---|---|---|
| Edit user (adds username and non-reserved attributes) | `authentik_user` record | `authentik.users.manage` | `USERS` | CONFIRM | `PATCH /core/users/{pk}/` (`username`, `name`, `email`, `path`; attributes merge-patch, `qqq.io/*` keys refused) |
| Delete user | `authentik_user` record | `authentik.users.manage` | `DELETE` | TYPED (username) | `DELETE /core/users/{pk}/` |
| Edit group (adds non-reserved attributes) | `authentik_group` record | `authentik.groups.manage` | `GROUPS` | CONFIRM | `PATCH /core/groups/{pk}/` |
| Delete group | `authentik_group` record | `authentik.groups.manage` | `DELETE` | TYPED (group name) | `DELETE /core/groups/{pk}/`. Refused while it has members or child groups. |
| Edit policy | `authentik_group`, `authentik_policy` records | `authentik.policies.manage` | `POLICIES` | CONFIRM (TWO_PERSON if configured) | `PATCH /core/groups/{pk}/` attributes |
The existing guardrails apply unchanged:
- protected accounts and superuser groups
- no self-edits (an operator cannot delete or rename themselves)
- last-admin protection (deleting one of the last identity admins is refused)
- a reason for every change
- audit before the call
Deactivate stays the default for people, and *Delete User* says so in its help text.
**Authentik permissions** the service account needs for these writes, on top of §5.1. All are global on the qbit's role:
| Permission | For |
|---|---|
| `authentik_core.change_group` | Saving a policy (already in §5.1 for phase 3) |
| `authentik_core.delete_user` | Delete user (only with `DELETE`) |
| `authentik_core.delete_group` | Delete group (only with `DELETE`) |
The token needs these only when writes are on. A read-only deployment (`readOnly`, or no write features) keeps the phase 1 read set.
### 12.8 Cut-over: making policies take effect safely
Saved policies do nothing until the host signs people in through the qbit mapper. The qbit makes that state visible, helps seed policies from today's access, and refuses to switch over in a way that would lock administrators out.
**Host SPI.** `AuthentikHostPermissionSource` returns what a user (or a group's members) gets *today* from the host, typically a hard-coded grant such as "admins get `adminPermissions()`". The host sets it with `withHostPermissionSource(...)`.
**Config.**
| Setting | Default | Meaning |
|---|---|---|
| `mapperEnabled` | `false` | The host's own switch (kof22: `AUTHENTIK_QBIT_MAPPER`). Off means "policies are saved but not enforced". A host that calls the mapper without setting it is treated as on. |
| `cutoverGuardGroups` | none | Groups whose members must lose no permission at the cut-over (kof22: `kof22-admins`) |
| `mapperOverride` | `false` | The owner's explicit override of a failing guard |
| `staticGroupPermissions` | none | Permissions always granted to a group's members (the floor), e.g. today's admin grant |
**Status** (`AccessComparison.state`, cached 60 s):
- `OFF`: the mapper is not in use.
- `ACTIVE`: the guard passed, or nothing is guarded.
- `OVERRIDDEN`: the owner overrode a failing guard.
- `BLOCKED`: the guard failed or could not reach Authentik.
The status is shown in three places:
- the *Policy enforcement* banner, first on group and policy records and on the Edit Policy step. When off it reads "Policies are saved but NOT enforced at sign-in on this instance (mapper off)".
- the home's Connection card ("Policies at sign-in")
- **Compare Access**
**Compare Access** is a read-only `TABLE` widget on the app home (`authentik.admin`). For every active person it compares two sets:
- **today**: the host source
- **with the mapper**: the person's groups' policies and `qqq.io/permissions` including ancestors, the static fallback of their direct groups, and all `authentik.*` for a bootstrap subject
It shows what is added and removed, and the unchanged count. Guarded people are marked, and the footer says whether the guard passes.
**Guard.** While the status is `BLOCKED`, the mapper does not apply policies. A sign-in gets:
- today's access from the host source
- the static fallbacks
- the bootstrap subjects
So switching the mapper on can never take access away from a guarded admin until Compare Access shows no removal for them, or the owner sets `mapperOverride`. The static fallback and the bootstrap subject (the owner's immutable Authentik pk) stay in force in every state, so a lockout is impossible.
**Generate Policy From Current Access** is a record action on groups that saves a policy. It takes what the group's members get today (`groupPermissions`):
- Permissions a policy can express are joined with the group's current policy, so nothing is removed, and become the document.
- Named permissions the app defines but a policy cannot express (dotted names, `write`) are merged into `qqq.io/permissions`.
- Anything else is listed as skipped.
Review shows the generated document, the diff and the subset rule before anything is saved. For the subset rule, the operator's own access today (host source plus static fallback) counts.
**Rollback** is one switch: set the host flag off (kof22: `AUTHENTIK_QBIT_MAPPER=false`). Sign-in returns to the host's own grant immediately. Saved policies stay in Authentik and in `authentik_policy`, unused.
### 12.6 Tests
- **Parser:** every fail-closed case (null, blank, not JSON, unknown spec, unsupported version, `Deny`, unknown action, bad resource, non-array fields), `*` expansion, and merging several statements.
- **Generator:** the canonical form, the round trip over generated permission sets, and rejection of anything that cannot round-trip.
- **Catalog:** apps, tables (capabilities, levels, custom base names), hidden processes, reports, widgets, and "Other".
- **Editor process:** review lines, the subset rule, the stale fingerprint, round-trip refusal, and a save that writes both the attribute and the `authentik_policy` row.
- **Runtime:** the claim from the ID token and from the access token, string and object claims, fail closed, a custom spec name, and the security-key hook.
- **CRUD:** edit user (username, attributes, reserved keys refused) and delete user and group with their refusals, on the in-memory Authentik.
- **Contract tests** on Authentik 2026.8.3: policy save, delete, and the scope-mapping example (`propertymappings/all/{uuid}/test/` returns a claim the parser accepts).
- **Permission gating:** each new action is refused without its permission or feature.
### 12.7 Delivery
| Phase | Scope |
|---|---|
| 5a | Policy model, parser, generator, runtime mapper, resolver integration, format doc and blueprint example |
| 5b | Catalog, `authentik_policy`, *Edit Policy* with save to Authentik; the Next UI widget `AUTHENTIK_POLICY_EDITOR` (separate PR to qqq-frontend-next) |
| 5c | The user and group CRUD additions (§12.5) |
---
## 13. Access tokens: QQQ's personal access tokens (revision 6)
Decisions 23–30 (§0.1). Authentik stays the source of truth: every token is an Authentik `Token` row. QQQ keeps only non-secret metadata about the tokens it created: the scope, who created it and when.
### 13.1 What a QQQ access token is
A QQQ access token is an Authentik **app password** (`intent: app_password`) owned by a person or a service account. A client uses it in two steps:
1. **Exchange it for a short-lived JWT** at the app's Authentik token endpoint: `POST {authentikPublicUrl}/application/o/token/` with:
- `grant_type=client_credentials`
- `client_id={app client id}`
- `username={owner username}`
- `password={token}`
- `scope=openid profile email`
No client secret: Authentik 2026.8.3 authenticates this grant by the username and app password, even for a confidential client (client authentication is required only for the code, refresh, device and token-exchange grants; pinned by `TokenContractIT`). Authentik checks that the owner is active, that the token exists with intent `app_password`, and that the app's policies let the owner in. It records a `login` event with `method: token` and the token's identifier.
2. **Call the app** (`/qqq/v1/...` and the published APIs) with `Authorization: Bearer {jwt}`. When the JWT expires (the provider's access-token validity), exchange the token again.
The qbit generates these instructions from the instance's real URLs (§13.7).
API-intent tokens (`intent: api`) call Authentik's own API, not the app. QQQ lists them and can revoke them, but does not create them, because an API token acts with all of its owner's Authentik permissions.
### 13.2 Why the qbit can't just call `POST /core/tokens/` (verified in the 2026.8.3 source)
- **Create belongs to the caller.** `TokenViewSet.perform_create` saves every new token with `user = request.user` unless the caller is a superuser, and `add_token` changes nothing. The qbit's service account is not a superuser and never will be (§5.1), so it **can't** create a token for anyone else.
- **PATCH has dangerous defaults.** `TokenSerializer.validate` runs `attrs.setdefault("intent", "api")` and `attrs.setdefault("user", request.user)`, and forces `expires` to the default for API tokens.
- A `PATCH` without `intent` turns an app password into an API token with the default expiry.
- A `PATCH` without `user` moves the token to the caller.
- So **every `PATCH` the qbit sends carries `intent`, `user` (the current owner's pk) and `expires`**, and the qbit re-reads the token afterwards (the race guard in §13.3).
- **Expiry is capped.** An app password's expiry is capped by `goauthentik.io/user/token-maximum-lifetime` (for example `days=90`) from `User.group_attributes`, which merges the **brand's** attributes, then the user's groups, then the user. Without it the cap is the tenant's default token duration, which is short. The blueprint sets the attribute on the brands (§13.9); the qbit sets `days=365` on each service account it creates.
- **Expired tokens linger.** They stay in the database until Authentik's cleanup task removes them, and the token-endpoint lookup (`Token.objects.filter(key=...)`) doesn't check `expires`. So the qbit never relies on an expired token being unusable. Revoke deletes the token.
### 13.3 How each action works
| Action | Where | Who | Authentik calls |
|---|---|---|---|
| *Create Token* (own) | Preferences "Access tokens"; `authentik_token` table action | Any signed-in user, for themselves | As the user, through token exchange (§13.3.1): `POST /core/tokens/` (`intent: app_password`, identifier `qqq-pat-{username}-{yyyymmdd}-{6 random}`, `description`, `expires`, `expiring: true`), then `GET /core/tokens/{id}/view_key/` (the owner may read their own new key) |
| *Rotate Token* (own) | same | The owner | As the user: create a new token with the same description, expiry and scope, read its key, then `DELETE` the old one. The new identifier replaces the old one in `authentik_token_scope`. If the delete fails, the result says so and the old token stays listed. |
| *Revoke Token* | Preferences (own); table record and bulk action (`authentik.tokens.manage`) | The owner, or an admin for any owner who isn't protected | `DELETE /core/tokens/{identifier}/`. Own tokens: as the user. Admin: as the service account, with `delete_token`. |
| *Extend Expiry* | Preferences (own); table record action | The owner; an admin for service accounts | Own tokens: as the user. Service accounts: as the qbit's service account, with `change_token`. Always `PATCH {intent, user, expires}`, then a re-read. |
| *Create Service Account* | `authentik_user` table action | `authentik.tokens.manage` + `authentik.users.manage` | `POST /core/users/service_account/` (`name`, `create_group: false`, `expiring: true`, `expires`). The response's `token` is the one-time key. Then set `goauthentik.io/user/token-maximum-lifetime: days=365` on the new account (its expiry cap, §13.9) and add it to the groups chosen in the policy editor, subject to the subset rule (§5.4). |
| *Rotate Token* (service account) | `authentik_token` record action | `authentik.tokens.manage` | The qbit generates a key (48 random bytes, URL-safe base64) and calls `POST /core/tokens/{identifier}/set_key/`. The identifier, expiry and scope don't change. |
**Limitation (decision 30).** A service account has exactly one app password: the one `service_account` creates. Only a superuser could add a second. To run two credentials side by side, create a second service account.
**Contract finding (2026-10-13, verified on 2026.8.3).** `UserViewSet.service_account` requires `add_user` **and** `add_token`, and `POST …/set_key/` requires `add_token` (Authentik's `ObjectPermissions` maps a detail POST to `add_*`) as well as `set_token_key`. Without `add_token` both return 403. The role therefore holds `add_token` (§13.9); `TokenContractIT` pins both behaviours.
**Race guard.** Before an extend or a rotate, the qbit reads the token (owner pk and intent), and it reads the token again after the `PATCH` or `set_key`. If the owner or the intent changed:
- the run fails;
- the audit row is `FAILED` with "token moved";
- an alert is raised (§13.8).
Authentik's `validate_user` refuses a different user, and sending `user` explicitly closes the `setdefault` path.
#### 13.3.1 Acting as the user: token exchange
**The broker.** The blueprint adds a confidential OAuth2 provider and application, `qqq-{appKey}-token-broker`:
- access-token validity `minutes=2`;
- property mappings: only the default `goauthentik.io/api` scope;
- `jwt_federation_providers`: the app's own sign-in provider;
- a **deny policy** bound to the application for superusers, members of `authentik Admins` and `identity-admins`, users with `qqq.io/protected`, and the qbit's own service account (§13.4 a).
**The exchange.** The qbit takes the signed-in user's current access JWT for the app. In kof22 this is the token the OAuth2 module stores in `qqq_user_session`; a host can supply another source with `withSubjectTokenSource(...)`. The qbit posts:
- `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`
- `client_id` and `client_secret` of the broker
- `subject_token={jwt}`
- `subject_token_type=urn:ietf:params:oauth:token-type:jwt`
- `scope=goauthentik.io/api`
**What Authentik does.** It verifies the JWT against the federated provider and applies the broker app's policies, so the deny policy runs inside Authentik. It then returns a 2-minute access token **for that user**. The qbit uses it for the token calls of that one action, then drops it; it is never stored or logged.
**Why this is safe.** The calls are made as the user, so Authentik itself enforces "own tokens only": a user can create, and read the key of, only their own tokens.
**Config.**
- `tokenBrokerClientId`: `${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}`
- `tokenBrokerClientSecret`: `${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}`
- `tokenEndpointUrl`: default `{baseUrl}/application/o/token/`
Without a broker, the self-service actions are hidden, and Review says why.
### 13.4 Guardrails (checked at Review and again at execute)
| | Rule | Where enforced |
|---|---|---|
| (a) | **No minting or rotating for privileged accounts.** Refused for: superusers, including by inheritance; members of `authentik Admins`, `identity-admins` and the configurable `tokenProtectedGroups`; the break-glass account and anything with `qqq.io/protected`; `protectedUsernames`; and the qbit's own service account. Hard-coded for the built-in classes, plus the configurable list. | The `AuthentikTokens` guard; the broker app's deny policy (self-service); for protected owners, `set_key` is refused before the call |
| (b) | **Self-service is self-only.** A person without `authentik.tokens.manage` may target only themselves. An admin may also target service accounts, but **never another human**; this covers create, rotate and extend. Revoke is the exception: for incident response, an admin may revoke any token whose owner isn't protected. | The qbit guard; Authentik itself for the as-user calls |
| (c) | **Expiry is required.** There's no non-expiring option. The maximum is `tokenMaxDaysUser` (default 90) for people and `tokenMaxDaysServiceAccount` (default 365) for service accounts. Extend counts from now. Authentik's group attribute is a second cap. | The qbit (Review refuses); Authentik (`token-maximum-lifetime`) |
| (d) | **Two-person approval** for service-account tokens (create service account, rotate): `tokenTwoPersonApproval`, default **off** while the team is one person. When on, the change is queued in `authentik_approval_request`, and the key is shown to the approver who applies it. | The qbit |
| (e) | **Detective control.** An Authentik notification rule and an expression policy fire whenever the qbit's service account creates or updates a token whose owner is protected, reads its key, or sets its key (`model_created`, `model_updated`, `secret_view` or `secret_rotate` on `authentik_core.token`). They notify `identity-admins` through Authentik notifications. The rule should never fire; if it does, guard (a) was bypassed. It also notifies `identity-admins` whenever the qbit's account creates a service account (decision 31). | The Authentik blueprint (§13.9). The qbit also raises an `authentik_token_guard_bypass` alert through the Alerts producer when its own re-read finds a protected owner. |
| (f) | **Everything is audited.** Before each call, every action writes an `authentik_admin_action` row with the client channel (`audit.clientChannel`, for example `web`, `api` or `token:{identifier}`), plus an Authentik `custom_qqq_admin_action` event. The assistant can't reach these tables or actions, because the assistant wall denies `authentik_*`. | The qbit; core audit |
### 13.5 Scopes
**What a scope is.** A token may be narrower than its owner: **read only**, and/or limited to a list of **apps** and **tables**. Advanced scopes can also list processes, reports, widgets and operationIds. No scope means the token can do everything its owner can.
**Where scopes are stored.** In the local table `authentik_token_scope`, one row per token the qbit created:
- `id`
- `tokenIdentifier` (unique)
- `ownerUsername`, `ownerPk`
- `readOnly`
- `apps`, `tables`, `processes`, `reports`, `widgets`, `operationIds` (TEXT, one per line)
- `createdBy`, `createdAt`, `rotatedAt`
- `lastReminder7d`, `lastReminder1d`
There is no key, and no hash of a key. Generic insert, edit and delete are off; only the token actions change the table. Record-level audit is on.
**Binding the JWT to its token.** A scope mapping on the app's provider (scope name `profile`) adds `qqq_token: {identifier}` to JWTs minted by `client_credentials` with an app password. It finds the app password the request presented, owned by the same user. Authentik signs the claim. It is bound to `profile` because the app needs the `groups` claim (also `profile`) to accept a token at all, so a client can't drop the claim and keep access.
**Enforcement.** The mapper reads `qqq_token`, loads the scope row (cached 60 s) and sets core's `QCredentialScope` (QRun-IO/qqq#1153) on the session. Core then:
- intersects the scope with the owner's live permissions in `PermissionsHelper`;
- checks `operationIds` in the UI API;
- records the channel `token:{identifier}`.
STAND-IN(core=QRun-IO/qqq#1153, removeBy=the RC that ships #1153, RC.7 at the earliest). Until then the mapper narrows the session's permission set itself:
- read only keeps only `.read` and `.hasAccess`;
- apps and tables keep only permissions whose base name belongs to a listed app or table.
**Fail closed.**
- Scoped tokens are offered only when the host declares the claim mapping installed (`tokenScopesEnabled`; kof22 sets it to `true` once the blueprint is applied).
- A JWT whose `qqq_token` names a token with no scope row is unscoped (the token was created outside the qbit).
- A `qqq_token` naming a token that no longer exists gets **no** permissions.
### 13.6 The key is shown once
**The type.** Core's `com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret` (`label`, `value`, `masked`, `usageText`) from QRun-IO/qqq#1162. Its `toString()` never includes the value.
**Preferences.**
- The provider's `runAction` returns the secret in `UserPreferenceActionResult.secrets`.
- The endpoint responds with `Cache-Control: no-store`.
- Next shows it in a show-once dialog with *Copy* and the usage text.
**Record actions.** These use core's one-time process output values: `RunBackendStepOutput.addOneTimeSecret(...)`, in the QRun-IO/qqq PR `feature/process-one-time-values`.
- The value travels only in the step's HTTP response.
- `ProcessState`, traces and lifecycle listeners (ESB) see only the masked form: `••••` plus the last 4 characters.
- If the step outlives the middleware's step timeout (3 s) and finishes as a job, core parks the secrets in memory (`OneTimeSecretHandoff`, 5 minutes, never in state) and returns them once, to the first status poll that sees the job complete. A poll that reaches another server instance or comes too late finds nothing: the key is lost by design, and the result says "Rotate the token to get a new key". (Found in the demo: a create through the broker can take longer than 3 s.)
**Where the key never goes.** The qbit never passes it to the audit row, the Authentik event context, `AdminResult.message`, notifications, logs or exceptions. `AuthentikRedactor` also masks any `key` or `token` JSON value in error text.
**The test.** A test runs every action, then searches for the key in every QQQ table, the captured log output, the stored process state and the published events (§13.11).
### 13.7 How to use it (generated)
The result screen, the Preferences dialog and `docs/ACCESS-TOKENS.md` show:
- the token endpoint (`{authentikPublicUrl}/application/o/token/`), the app's client id and the owner's username;
- a `curl` that gets a JWT;
- a `curl` to `{appBaseUrl}/qqq/v1/metaData`, and one to a published API (`{appBaseUrl}/api/{version}/...`) when the instance publishes one;
- the token's expiry and scope.
The URLs come from config (`appPublicUrl`, `authentikPublicUrl`, `appClientId`), defaulting to the OAuth2 module's metadata. The key appears in the usage text only inside the one-time response.
### 13.8 Reminders, last used and cleanup
**Last used.** Read from Authentik `login` events with `method: token` (`context.method_args.identifier`), through `/events/events/?action=login`, over a 90-day window, cached for 5 minutes. API tokens show "not tracked".
**Expiry reminders.** A daily scheduled job finds tokens expiring within 7 days and within 1 day, and notifies each owner (bell and email):
- through the Alerts producer SPI when qbit-alerts is installed;
- otherwise through core notifications, if present;
- otherwise not at all.
Reminders are deduplicated through `lastReminder7d` and `lastReminder1d`. Tokens created outside the qbit have no scope row, so they are reminded by owner and identifier.
**Cleanup suggestion.** The app home lists tokens not used for 60 days (`tokenUnusedDays`, configurable) and tokens of deactivated users. It links to the filtered list and the bulk *Revoke Token* action.
### 13.9 Authentik changes (cluster-gitops, kof22)
**The qbit role** `qqq-kof22-admin-idadmin`:
- add `authentik_core.change_token`, `authentik_core.set_token_key` and `authentik_core.add_token`;
- it already has `delete_token`, `view_token` and `add_user`;
- **not** `view_token_key`.
`add_token` is safe here: for a non-superuser, `POST /core/tokens/` always creates the token for the **caller**, so it can never mint a token for someone else. It is required by `service_account` and by `set_key` (the POST object permission).
**New Authentik objects:**
- the broker provider and application, with the deny policy (§13.3.1);
- the `qqq_token` scope mapping on the kof22 admin provider (§13.5);
- `goauthentik.io/user/token-maximum-lifetime: days=90` on the brands (people). Groups are `state: created` and can't take new attributes from a blueprint; the qbit sets `days=365` on service accounts it creates;
- the broker in its own blueprint file (`kof22-qqq-tokens.yaml`), so a problem there can't affect sign-in;
- the notification rule and policy (§13.4 e).
**Rollout (kof22).** Seed the Infisical key `/kof22-website-authentik-api/token-broker-client-secret` first, then merge the role-permissions gitops PR, then deploy the backend. `TOKENS` is on by default, so there is no flag PR. The ESO secrets are `AUTHENTIK_TOKEN_BROKER_CLIENT_ID` and `AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET` (Infisical `/kof22-website-authentik-api/`).
### 13.10 Permissions, tables and config
| QQQ permission | Grants |
|---|---|
| (none beyond signing in) | The "Access tokens" Preferences section, for one's own tokens |
| `authentik.tokens.manage` | The `authentik_token` table (all tokens); revoking any token whose owner isn't protected (single and bulk); service-account create, rotate and extend; the admin widgets |
| `authentik.audit.read` | Still reads `authentik_token` (unchanged) |
| Setting | Default | Meaning |
|---|---|---|
| `tokensEnabled` | `true` when any write feature is on | Opt-out (`AUTHENTIK_QBIT_TOKENS=false`) |
| `tokenMaxDaysUser` / `tokenMaxDaysServiceAccount` | 90 / 365 | Guardrail (c) |
| `serviceAccountTokenLifetime` | `days=365` | Authentik lifetime cap merged into each newly created service account's USER attributes |
| `tokenProtectedGroups` | `authentik Admins`, `identity-admins` | Added to the hard-coded classes of guardrail (a) |
| `tokenTwoPersonApproval` | `false` | Guardrail (d) |
| `tokenScopesEnabled` | `false` | Offer scopes once the claim mapping is installed |
| `tokenBrokerClientId` / `tokenBrokerClientSecret` / `tokenEndpointUrl` | env / env / `{baseUrl}/application/o/token/` | §13.3.1 |
| `appPublicUrl`, `authentikPublicUrl`, `appClientId` | from the OAuth2 module | §13.7 |
| `serviceAccountTokenLifetime` | `days=365` | Set on service accounts the qbit creates |
| `tokenUnusedDays` | 60 | The cleanup suggestion |
### 13.11 Tests
**In-memory Authentik.** `FakeAuthentik` is extended to model the 2026.8.3 behaviour of §13.2: caller-owned create, `setdefault` on `PATCH`, the lifetime cap, `service_account`, `set_key` and token exchange. Tests cover:
- create, rotate, revoke and extend, both for self and for service accounts;
- every guardrail refusal: the protected classes, another human, a missing or too-long expiry, no broker;
- the race guard;
- approval when (d) is on;
- scope rows, reminders and last used.
**The key is never persisted or logged.** After each action, the key is absent from every local table, the audit rows, the captured log, the stored `ProcessState`, the published ESB events and the Authentik event body the qbit posted.
**Contract tests on Authentik 2026.8.3** (Testcontainers):
- the §13.2 facts: a non-superuser create belongs to the caller, a `PATCH` without `intent` changes the token, and the lifetime cap applies;
- token exchange as a user through the broker;
- the deny policy refuses a protected user;
- `service_account`, `set_key` and `PATCH` for a service account;
- the `qqq_token` claim on a `client_credentials` JWT;
- the detective notification rule fires for a protected owner.
### 13.12 Delivery
| Phase | Scope |
|---|---|
| 6a | This spec and `docs/ACCESS-TOKENS.md` |
| 6b | The `TOKENS` feature, the `authentik_token` columns, `authentik_token_scope`, the token actions with guardrails, service accounts, the one-time key (core PR), tests and contract tests |
| 6c | Scopes and the `qqq_token` claim, last used, reminders, admin widgets and bulk revoke |
| 6d | The "Access tokens" Preferences provider (core #1162) and the user-menu entry (Next, by the Preferences agent) |
| 6e | The cluster-gitops role permissions and blueprint objects; kof22 adoption (draft) |
---
## Appendix A: Example API mapping (`authentik_user`)
| QQQ | Authentik |
+27 -3
View File
@@ -5,13 +5,13 @@ These are the steps only James can take: items 6, 7, 8 and 10 of DESIGN.md secti
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)
- Spec: QRun-IO/qqq#1065 (revision 4: phase 0 findings, review fixes, owner decisions of 2026-10-12)
## 0. Decisions surfaced by phase 0 (read first)
| # | Decision | Recommendation |
|---|---|---|
| D1 | **Repo visibility.** `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). |
| D1 | **Repo visibility.** Resolved: `QRun-IO/qbit-authentik` is public. | Check that CircleCI follows the repo (it does for PR CI); nothing else to do. |
| D2 | **Lockout risk.** Authentik stops the service account from *escalating*, but not from *locking out*. With the phase 2/3 permissions it can do all of the following (contract test `SuperuserFloorIT`): remove members from `authentik Admins`, deactivate `kof22-breakglass`, strip `qqq.io/protected`, and rename the superuser group. The qbit guard refuses all of these; Authentik does not. | Accept, with the mitigations in the spec (§5.1, revision 3). The real break-glass for a compromised QQQ is cluster access: `kubectl exec` into the Authentik worker and run `ak shell` to reactivate or re-add. Optionally, phase 2 grants `remove_user_from_group` per group (object permissions) instead of globally. |
| D3 | **Self-minted tokens.** Any authenticated user, including the service account, can create API tokens **for itself** without `add_token`, and read their keys. A compromised QQQ could therefore keep a second token alive after you rotate the first. | Accept. The phase 1 drift report flags service-account tokens beyond the expected one. Check that list after every rotation (item 8). |
| D4 | **Access matrix.** `check_access?for_user=` only works for superusers. For the service account it silently answers *for itself*. The policy-test endpoint returns HTTP 500 for non-superusers on 2026.8.3. | Accepted in the spec: the qbit computes app access itself from `qqq.io/app-access` and the app's bindings, and shows other expression policies as "not evaluated". |
@@ -77,6 +77,7 @@ Do these in order, in one sitting.
```yaml
qqq.io/app-access: ["kof22-website-prod"]
qqq.io/permissions:
- authentik.users.read
- esbView.hasAccess
- esbOperate.hasAccess
- esbDelete.hasAccess
@@ -90,7 +91,7 @@ Do these in order, in one sitting.
- quickSearchFailedEvent.write
- dataHealth.hasAccess
```
Keep the existing attributes of the group. Add only these two keys.
Keep the existing attributes of the group. Add only these two keys. `authentik.users.read` lets `kof22-admins` open the Identity app; the seed group `identity-admins` already carries `authentik.*` (all identity permissions) from the blueprint, so you get everything through it once you join it (step 1).
3. On group **`kof-website-publishers`** → *Attributes*: `qqq.io/app-access: ["kof22-website-prod"]`. This keeps the `kof-publisher-prod` service account's client-credentials tokens working once the name fallback is removed.
4. **`kof-website-drafters`**: leave it without `qqq.io/app-access` until you decide that drafters should sign in to the admin. Granting it later is that one attribute.
5. **Break-glass password**:
@@ -101,3 +102,26 @@ Do these in order, in one sitting.
## After items 6-10
Tell the agent "phase 0 owner steps done". Next come item 11 (the qbit in read-only mode on staging, behind `AUTHENTIK_QBIT_ENABLED`), then the phase 1 mapper cut-over, then removal of the sign-in name fallback.
## Item 12: turn on the full lifecycle (v1)
The qbit can now create and change users and groups, not just read. Do this only after items 6-11 are done and the read-only screens look right on staging. Nothing here is applied by an agent.
1. **Add the write permissions to the service account's role.** In the identity bootstrap blueprint, append these to `qqq-kof22-admin-idadmin`'s `permissions` list (they are re-asserted additively on every apply; the PR is prepared as a separate commit on cluster-gitops#72):
```
authentik_core.add_user authentik_core.change_user
authentik_core.reset_user_password authentik_core.add_group
authentik_core.change_group authentik_core.add_user_to_group
authentik_core.remove_user_from_group
authentik_core.view_token authentik_core.delete_token
authentik_core.delete_authenticatedsession
authentik_stages_authenticator_{totp,static,webauthn,email,sms,duo}.view_<kind>device
authentik_stages_authenticator_{totp,static,webauthn,email,sms,duo}.delete_<kind>device
```
Never add `enable_group_superuser`, `view_token_key`, `set_token_key`, `add_token`, `impersonate` or `delete_user`. Invitations are not used at kof22 yet (there is no enrollment flow), so the invitation permissions and `view_flow` are left out; add them with the enrollment flow.
2. **Check the brand's recovery flow.** The password reset email (also used by Create User) fails with "No recovery flow set." unless the brand that serves `auth.kof22.com` has a recovery flow. In Authentik admin, *System → Brands*, edit the brand and set *Recovery flow* to `kof22-recovery`. This is a one-time setting.
3. **Create the two local tables** (`authentik_admin_action`, `authentik_approval_request`) with the Liquibase change in the Website-Backend PR (see `docs/schema.sql` for the DDL it follows).
4. **Turn the features on** in the Website-Backend deployment (the draft PR defines the flags; the default stays read-only). Start with `AUTHENTIK_QBIT_WRITES=false`, check the Identity home, then switch writes on.
5. **Try it on a test user**: create one, add them to `kof-website-publishers`, deactivate them. Check that Authentik's *Events* shows `custom_qqq_admin_action` events naming you, and that *Identity → Admin Actions* shows the rows.
6. **Two-person approval.** Granting `authentik.*` to a group needs a second person with `authentik.approve`. While you are the only identity administrator, make those grants in the Authentik UI (the break-glass in the spec), or turn `twoPersonApproval` off until a second administrator exists.
+162
View File
@@ -0,0 +1,162 @@
# QQQ policy format
Version 1.0.0 of the policy documents that `qbit-authentik` reads and writes. The design is in [DESIGN.md §12](DESIGN.md#12-policies-iam-style-permission-statements-revision-5).
A **policy** grants QQQ permissions to the members of an Authentik group. It is stored on the group as an attribute. At sign-in, the policies of all the user's groups, including inherited parent groups, become QQQ session permissions.
## Document
```json
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.0.0",
"Statement": [
{
"Effect": "Allow",
"Action": ["hasAccess"],
"Resource": ["contentApp", "publishPage"]
},
{
"Effect": "Allow",
"Action": ["read", "insert", "edit"],
"Resource": ["cms_page", "cms_media"]
}
]
}
```
| Field | Type | Rule |
|---|---|---|
| `PolicySpec` | string | Must equal the app's configured spec name (`policySpec`, default `qqq-iam`) |
| `PolicyVersion` | string, semver | Must equal the supported version (`policyVersion`, default `1.0.0`) |
| `Statement` | array of statements | May be empty (grants nothing) |
| `Statement[].Effect` | string | `Allow` (the only effect in 1.0.0) |
| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`, or a table feature name registered for every listed resource |
| `Statement[].Resource` | array of strings | Either `*` with exactly `Action: ["*"]`, or a QQQ permission base name matching `[A-Za-z0-9_]+`: a table, process, app, report or widget name (or its custom `permissionBaseName`) |
## Meaning
Each statement grants `{resource}.{action}` for every resource and action it lists. `Action: ["*"]` on a named resource stands for the five standard actions only; it never includes table features. The full-access statement below is the explicit exception. The result is the union over all statements, and over all of the user's groups.
| Action | QQQ permission | Applies to |
|---|---|---|
| `hasAccess` | `{name}.hasAccess` | apps, processes, reports, widgets, and tables with `HAS_ACCESS` permissions |
| `read` | `{name}.read` | tables (query, get) |
| `insert` | `{name}.insert` | tables |
| `edit` | `{name}.edit` | tables |
| `delete` | `{name}.delete` | tables |
| registered feature (e.g. `analyze`, `comment`) | `{name}.{feature}` | tables that register that feature, including `HAS_ACCESS` and `READ_WRITE` tables |
Examples:
- `{"Action": ["read"], "Resource": ["cms_page"]}` grants `cms_page.read`.
- `{"Action": ["*"], "Resource": ["cms_page"]}` grants `cms_page.hasAccess`, `cms_page.read`, `cms_page.insert`, `cms_page.edit` and `cms_page.delete`.
- `{"Action": ["analyze", "comment"], "Resource": ["person"]}` grants `person.analyze` and `person.comment` when both features are registered for `person`.
Features are generic strings, discovered by matching `PermissionsHelper.getAllAvailablePermissions(qInstance)` entries to the table's effective permission base name. Examples include `analyze` from qbit-data-analysis and `comment`, `attach`, `tag`, `share`, `task`, `assign` from qbit-collaboration. The feature label comes from `AvailablePermission.permissionType`. For named resources every feature grant is explicit, so registering a new feature never broadens an existing `Action: ["*"]` statement on a named resource. This matching rule also exposes the available `write` permission of `READ_WRITE` tables as an explicit named action; it is not part of `*`.
A grant that QQQ never checks (for example `hasAccess` on a table with insert/edit/delete permissions) is harmless.
**Not in 1.0.0:** `Deny`, prefix resources, wildcard resources other than the full-access form below, conditions, and field-level rules. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots. Explicit named grants stay in the group attribute `qqq.io/permissions`; full access includes them in session form.
## Full access
The exact full-access statement is:
```json
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
```
It grants **Full access (all current and future resources)**. A complete document is:
```json
{
"PolicySpec": "qqq-iam",
"PolicyVersion": "1.0.0",
"Statement": [
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
]
}
```
Full access expands when permissions are computed (sign-in, effective-permission resolution, access comparison and member preview), using the current QQQ instance. The document and cached group graph retain a symbolic marker; no expanded permission list is stored in the document or cached for full access. Adding a table, process, app, report, widget or table feature therefore requires no policy edit. Existing signed-in sessions receive the new set when their permissions are computed again.
The expansion is the union of:
- **Every `AvailablePermission.name` returned by `PermissionsHelper.getAllAvailablePermissions(qInstance)`**, without limiting names to the editor's resource syntax. Core uses effective permission base names and permission levels: table `hasAccess`, `read`, `insert`, `edit`, `delete`, READ_WRITE `write`, registered table feature names, and process/app/report/protected-widget `hasAccess`. Names supplied by custom permission checkers are included when those checkers expose them through this API; arbitrary permissions that core does not enumerate are not inferred.
- **The qbit permission catalog**, including policy-catalog permissions for hidden tables and processes (core skips hidden tables/processes), plus any additional catalog names supplied to the resolver.
- **Host-declared permission names** from `AuthentikQBitConfig.withFullAccessHostPermissions(qInstance -> names)`, for each runtime whose API backend is in the instance. Use it for names that core cannot enumerate: custom permission checkers and named permissions such as `cdn.purge` or `email.send`. The function runs at every expansion, so names the host adds later are covered without a policy edit. Without it, full access does not grant those names.
- **Every `IdentityPermission` session name**, such as `authentik.users.manage.hasAccess`, `authentik.permissions.manage.hasAccess` and `authentik.policies.manage.hasAccess`. This covers the entire qbit identity catalog, even when a particular feature is not enabled.
This grants QQQ permissions, not Authentik superuser status. Existing scope, protected-account, audit, approval, last-admin and cut-over checks still apply.
Examples and boundaries:
- `{"Effect":"Allow","Action":["*"],"Resource":["*"]}` includes table features, READ_WRITE `write`, named identity permissions and future resources.
- `{"Effect":"Allow","Action":["*"],"Resource":["cms_page"]}` still grants only the five standard actions for `cms_page`; it does not grant features or `write`.
- `{"Effect":"Allow","Action":["read"],"Resource":["*"]}` is invalid. So is `Action: ["*", "read"]` with `Resource: ["*"]`. The error says that `Resource "*" requires Action ["*"]`.
- A full-access statement may coexist with other valid statements (or named resources in the same statement). Canonicalization drops the redundant grants. Every statement and resource is validated first: an invalid statement anywhere still rejects the entire document, even beside full access.
Saving a full-access document requires the operator to hold **every permission in its current expansion**, even when the target already has full access. An operator whose own effective policy is full access meets that check. Review and audit summaries use **Full access (all current and future resources)**. Removing full access cannot remove the last qualifying identity administrator.
## Parsing fails closed
A document grants **nothing**, and the parser logs a warning, when any of these is true:
- it is missing, blank, not JSON, or not a JSON object
- `PolicySpec` is missing or is not the configured name
- `PolicyVersion` is missing or is not the supported version
- `Statement` is present but not an array, or a statement is not an object
- a statement's `Effect` is not `Allow` (including `Deny`, which is not supported yet)
- `Action` or `Resource` is missing or not an array of strings
- an action is neither a standard action, `*`, nor a feature registered for every listed resource
- a resource does not match `[A-Za-z0-9_]+`, except `*` with exactly `Action: ["*"]`
There is no partial credit: one bad statement voids the whole document. When the qbit reads policies from the group graph, each group's document is parsed on its own, so one bad group voids only that group. In the merged token claim, one bad statement voids the claim.
## Canonical form
A document containing full access canonicalizes to exactly one full-access statement. A generated permission set produces full access only when it explicitly contains the internal full-access marker; enumerating all current permissions never creates future grants.
For other documents the qbit writes the existing canonical form:
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`, then feature names sorted alphabetically (existing documents retain their byte-for-byte canonical form)
- each statement lists its resources sorted
- no `*`
- stored on the group as a compact JSON **string**
Before every save, the qbit checks the round trip: parsing the generated document must give back exactly the permissions it was generated from. A document that fails this check is never written.
## Where it lives
| What | Default | Config |
|---|---|---|
| Group attribute holding a group's document | `qqq.io/policy` | `policyAttribute` |
| Token claim holding the merged document | `qqq_policy` | `policyClaim` |
| Spec name | `qqq-iam` | `policySpec` |
| Version | `1.0.0` | `policyVersion` |
An app that already stores documents of this shape under its own spec name, attribute and claim sets those four values and keeps its data unchanged. The parser accepts the attribute or claim as a JSON string or as a JSON object.
## Getting policies into the token
Authentik puts the merged document into the token through a scope mapping. Steps:
1. Apply the example blueprint, [`src/test/resources/blueprints/qqq-policy-claim.yaml`](../src/test/resources/blueprints/qqq-policy-claim.yaml). Edit its spec, version, attribute and claim name to match your config.
2. Add the mapping to the OAuth2 provider's `property_mappings`. It uses the scope `profile`, which every QQQ sign-in requests.
3. Keep `include_claims_in_id_token: true` on the provider (the default).
4. Register the customizer in your app:
- `AuthentikPolicyClaimMapper` on its own, or
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog accept standard actions and full access; `PolicyPermissions.fromClaims` uses the current `QContext` instance for full-access expansion when no catalog is supplied. Raw `PolicyParser` results are symbolic and must be resolved before entering a session. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the available-permission API from the pinned RC.6 core; registered `TableFeaturePermissionsMetaData` features appear in that API.
The mapping merges the statements of every group in `request.user.all_groups()` (direct groups and their ancestors) whose document has the right spec and version. It always emits a document, with an empty `Statement` array when the user has no policies.
The contract test `PolicyClaimMappingIT` runs this blueprint on Authentik 2026.8.3. It puts generated policies on a parent and a child group (one as a string, one as a JSON object), then runs the mapping through Authentik's property-mapping test endpoint for a member of the child group. It checks that the parser turns the claim into the union of the groups' permissions, that a document with another spec is skipped, and that a user without policies gets a valid empty document.
### Freshness
The claim is computed when the token is issued, so a policy change reaches a signed-in user at their next token refresh. In kof22 that is within 1 hour. To apply a change at once, revoke the user's sessions (*Revoke Sessions*).
## Record locks (not yet)
Row-level security keys are not part of the format. `AuthentikPolicyClaimMapper.applySecurityKeys(QSession, JSONObject)` is the extension point. A host overrides it to set security key values from its own explicit claims, and must never infer them from group names.
+42 -25
View File
@@ -2,41 +2,58 @@
The Authentik qbit's progress through DESIGN.md section 11, with links. Updated with each PR.
## v1 = phases 0-3 (the full user lifecycle and group/permission management)
| Phase | State |
|---|---|
| 0. Foundations and migration | agent items done; **owner items open** (checklist items 6-8, 10, then 12) |
| 1. Read-only plus the permissions model | done, except the mirror tables, reconcile and the access-matrix widget (see "Not built yet") |
| 2. Memberships and permission editing | done, except the webhook route, ESB trigger, inbox and session re-evaluation marking |
| 3. Full user lifecycle | done, except the optional snapshot export |
| 4. RBAC role management | post-v1, not started |
| 5. Policies and full CRUD (DESIGN.md section 12) | 5a policy model (#14), 5b policy editor (#15), 5c user and group CRUD additions; the Next UI widget is a separate qqq-frontend-next PR |
### Built in this line (0.1)
| Area | What |
|---|---|
| People | Create, invite, edit (username, name, email, path, other attributes), deactivate, reactivate, delete (feature `DELETE`), password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
| Groups | Create, edit (parents, other attributes), delete (feature `DELETE`), add and remove members (bulk), edit permissions and app access, edit the group policy (feature `POLICIES`) |
| Safety | Subset rule on live groups, protected accounts and superuser groups, no self-edits, last-admin protection, reasons, typed confirmations, bulk caps, stale checks, two-person approval for `authentik.*` |
| Audit | `authentik_admin_action` (before the call), Authentik `custom_qqq_admin_action` events, approval requests |
| Screens | Home dashboard, main tables in the nav, child lists on parents, every action as a table or record action |
| Tests | Unit tests on an in-memory Authentik (rules, processes, app, home), contract tests on Authentik 2026.8.3 |
### Not built yet (and why)
| Item | Note |
|---|---|
| Authentik webhook, ESB trigger, inbox, session re-evaluation marking | Needs the notification transport in cluster-gitops (inert today); reads are live (30 s cache) so screens are correct without it |
| Mirror tables and reconcile | For Web Users joins and widget counts; the home reads live |
| Access matrix widget, group tree widget | Needs Next UI custom widgets (separate frontend PRs) |
| Snapshot export | Optional in the spec |
| RBAC role management | Phase 4 |
| Alerts producer integration | Optional until the Alerts qbit SPI (QRun-IO/qqq#1090) lands |
| Quick Search for users and groups | They are API-backed (read-through), so they are searched with the table's own search box, which Authentik evaluates live |
| Invitations add groups | Groups for invited people are added after they sign up (needs the webhook) |
## Phase 0: foundations (agent items done; owner items open)
| # | Item | State | Where |
|---|---|---|---|
| 1 | Repo, DESIGN.md, CI, README, CLAUDE.md | done | this repo (`develop`); private for now, see owner checklist D1 |
| 1 | Repo, DESIGN.md, CI, README, CLAUDE.md | done | this repo (`develop`); public |
| 2 | Config, producer, `AuthentikActionUtil`, pinned schema + drift test | done | phase 0 PR |
| 3 | Testcontainers harness, fixture recorder, contract tests (b), (c), (d), (h) | done | 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 |
| 3 | Testcontainers harness, fixture recorder, contract tests (b), (c), (d), (h) | done | `src/test/java/.../contract` |
| 4 | Open points (i)-(iv) resolved | done | DESIGN.md section 0.2 |
| 5 | cluster-gitops PR (bootstrap, removals, sign-in policy, webhook, lint) | draft | KofTwentyTwo/cluster-gitops#72 |
| 6 | Staging rehearsal | **owner** | `docs/PHASE0-OWNER-CHECKLIST.md`, `docs/cluster-gitops-phase0/rehearse-phase0.sh` |
| 7 | Production apply | **owner** | checklist |
| 8 | Service-account token, Infisical, webhook secret | **owner** | checklist |
| 9 | Website-Backend wiring (ExternalSecret + env, qbit behind a disabled flag) | draft | KofTwentyTwo/cluster-gitops#73, Website-Backend draft PR |
| 9 | Website-Backend wiring (ExternalSecret + env, qbit behind a disabled flag) | draft | KofTwentyTwo/cluster-gitops#73, Website-Backend#94 |
| 10 | Seed attributes, break-glass password | **owner** | checklist |
| 11 | Read-only on staging | after 6-10 | flip `AUTHENTIK_QBIT_ENABLED` |
| 12 | Turn on the full lifecycle (role permissions, brand recovery flow, tables, flags) | after 11 | checklist item 12 |
## Phase 1: read-only MVP
## Contract and unit tests (Authentik 2026.8.3)
| 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).
`CI=true ./mvnw verify`. The numbers are filled in at the end of each PR; see the PR description.
+93
View File
@@ -0,0 +1,93 @@
-- The qbit's own tables (DESIGN.md section 6.2) for the host's local RDBMS backend.
-- Column names are the snake_case of the QQQ field names. PostgreSQL syntax; adapt the
-- types for another database. The tables are written only by the qbit (the generic
-- insert, update and delete capabilities are off); authentik_admin_action is append-only
-- in practice (rows move from RUNNING to a final status, and are never deleted).
-- Retention: the audit trail is kept 2 years (DESIGN.md section 9.4).
CREATE TABLE authentik_admin_action
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
correlation_id VARCHAR(64),
action_name VARCHAR(60) NOT NULL,
operator VARCHAR(255),
reason TEXT,
target_type VARCHAR(30),
target_key VARCHAR(100),
target_label VARCHAR(255),
before_text TEXT,
after_text TEXT,
status VARCHAR(30) NOT NULL,
error_message TEXT,
authentik_event_pk VARCHAR(64),
approver VARCHAR(255)
);
CREATE INDEX idx_authentik_admin_action_create_date ON authentik_admin_action (create_date);
CREATE INDEX idx_authentik_admin_action_target ON authentik_admin_action (target_type, target_key);
CREATE INDEX idx_authentik_admin_action_operator ON authentik_admin_action (operator);
CREATE TABLE authentik_approval_request
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
action_name VARCHAR(60) NOT NULL,
target_key VARCHAR(100),
target_label VARCHAR(255),
summary TEXT,
reason TEXT,
payload_json TEXT NOT NULL,
requester VARCHAR(255) NOT NULL,
approver VARCHAR(255),
status VARCHAR(30) NOT NULL,
expires_date TIMESTAMP WITH TIME ZONE,
decided_date TIMESTAMP WITH TIME ZONE,
error_message TEXT
);
CREATE INDEX idx_authentik_approval_request_status ON authentik_approval_request (status);
-- authentik_policy (DESIGN.md section 12): the QQQ record of each group's last policy save.
-- Authentik holds the live document (group attribute qqq.io/policy); field-level QQQ audit on
-- this table is the policy's change history.
CREATE TABLE authentik_policy
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
group_pk VARCHAR(64) NOT NULL,
group_name VARCHAR(255),
policy_json TEXT,
statement_count INTEGER,
permission_count INTEGER,
saved_by VARCHAR(255),
saved_date TIMESTAMP WITH TIME ZONE,
authentik_fingerprint VARCHAR(64),
CONSTRAINT uk_authentik_policy_group_pk UNIQUE (group_pk)
);
-- Token metadata only (DESIGN section 13.5). No key or key hash is stored.
CREATE TABLE authentik_token_scope
(
id SERIAL PRIMARY KEY,
token_identifier VARCHAR(255) NOT NULL,
owner_username VARCHAR(255),
owner_pk INTEGER,
read_only BOOLEAN,
apps TEXT,
tables TEXT,
processes TEXT,
reports TEXT,
widgets TEXT,
operation_ids TEXT,
created_by VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE,
rotated_at TIMESTAMP WITH TIME ZONE,
last_reminder_7d TIMESTAMP WITH TIME ZONE,
last_reminder_1d TIMESTAMP WITH TIME ZONE,
CONSTRAINT uk_authentik_token_scope_identifier UNIQUE (token_identifier)
);
+7 -3
View File
@@ -18,6 +18,8 @@
<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>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -36,7 +38,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -139,6 +141,7 @@
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-authentik.properties</include>
</includes>
</resource>
@@ -146,6 +149,7 @@
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-authentik.properties</exclude>
</excludes>
</resource>
@@ -237,7 +241,7 @@
<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 -->
<!-- qqq-bom-pom 4.1.0-RC.7 ahead of the parent 4.0.0 BOM, as in -->
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
@@ -245,7 +249,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -26,12 +26,21 @@ public enum AuthentikFeature
/** Read-only tables: users, groups, memberships, roles, applications, bindings, blueprints, sessions, events. */
READ,
/** Access tokens (DESIGN section 13). */
TOKENS,
/** Create, invite, edit, deactivate users (phase 3). */
USERS,
/** Create and edit groups, permissions and app-access attributes (phase 2/3). */
GROUPS,
/** Edit group policies (IAM-style statements) with the policy editor (DESIGN.md section 12). */
POLICIES,
/** Delete users and groups as an explicit action (DESIGN.md section 12.5). */
DELETE,
/** Add and remove members (phase 2). */
MEMBERSHIP,
File diff suppressed because it is too large. Load diff
@@ -21,11 +21,20 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.app.AuthentikRelations;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.health.AuthentikHealthChecks;
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
import com.kingsrook.qbits.authentik.processes.AuthentikProcesses;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -178,13 +187,49 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
output.addSelfToInstance(qInstance);
AuthentikHealthChecks.register(qInstance, qBitConfig);
addPossibleValueSources(qInstance);
AuthentikRelations.apply(qInstance, qBitConfig);
for(QProcessMetaData process : AuthentikProcesses.processes(qBitConfig))
{
qInstance.addProcess(process);
}
// Registration is metadata-only; core skips startup sync without a helpContent table.
HelpBundles.register(qInstance, "help/qbit-authentik.yaml");
AuthentikHelp.apply(qInstance, qBitConfig);
LOG.info("Authentik QBit produced", logPair("version", VERSION), logPair("appKey", qBitConfig.getAppKey()),
logPair("features", qBitConfig.getFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
logPair("features", qBitConfig.getEffectiveFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
return (new MetaDataProducerMultiOutput());
}
/*******************************************************************************
** Possible-value sources: the user and group tables (for the group pickers
** on the actions) and the local tables' status enums.
*******************************************************************************/
private void addPossibleValueSources(QInstance qInstance)
{
for(String spec : List.of(AuthentikTables.USER, AuthentikTables.GROUP))
{
String name = qBitConfig.applyTablePrefix(spec);
if(qInstance.getPossibleValueSource(name) == null)
{
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(name));
}
}
if(qBitConfig.getLocalBackendName() != null)
{
for(QPossibleValueSource source : AuthentikLocalTables.possibleValueSources())
{
qInstance.addPossibleValueSource(source);
}
}
}
/*******************************************************************************
** The artifact version, from the Maven-filtered qbit-authentik.properties.
*******************************************************************************/
@@ -23,7 +23,9 @@ 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.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
@@ -44,7 +46,13 @@ public class AuthentikRuntime
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
private final Memoization<String, String> responseCache;
private final Memoization<String, GroupGraph> groupGraphCache;
private final Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> tokenScopeCache = new Memoization<>(Duration.ofSeconds(60));
private final Memoization<String, Map<String, java.time.Instant>> tokenActivityCache = new Memoization<>(Duration.ofMinutes(5));
private volatile java.util.Set<String> permissionCatalog;
private volatile PolicyCatalog policyCatalog;
private volatile AccessComparison.State mapperState;
private volatile boolean mapperInvoked;
private com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink tokenAlerts = new com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts()::send;
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
@@ -74,6 +82,16 @@ public class AuthentikRuntime
/*******************************************************************************
** Every registered runtime (a snapshot).
*******************************************************************************/
public static java.util.List<AuthentikRuntime> all()
{
return (java.util.List.copyOf(BY_BACKEND.values()));
}
/*******************************************************************************
** Find the runtime for an API backend name.
*******************************************************************************/
@@ -125,6 +143,8 @@ public class AuthentikRuntime
public void invalidateCache()
{
responseCache.clear();
tokenScopeCache.clear();
tokenActivityCache.clear();
groupGraphCache.clear();
}
@@ -142,21 +162,48 @@ public class AuthentikRuntime
/*******************************************************************************
** QQQ permission names that qqq.io/permissions may grant: every permission
** the instance's metadata defines (PermissionsHelper), computed once.
** the instance's metadata defines (PermissionsHelper), computed per instance.
*******************************************************************************/
public java.util.Set<String> getPermissionCatalog()
{
PolicyCatalog currentPolicyCatalog = getPolicyCatalog();
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));
if(qInstance == null)
{
return (java.util.Set.of());
}
java.util.Set<String> catalog = new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
catalog.addAll(currentPolicyCatalog.permissionNames());
permissionCatalog = catalog;
}
return (permissionCatalog);
}
/*******************************************************************************
** What a policy can grant in the current instance (built lazily per instance: the
** instance is complete by the first request).
*******************************************************************************/
public PolicyCatalog getPolicyCatalog()
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(policyCatalog == null || policyCatalog.getQInstance() != qInstance)
{
if(qInstance == null)
{
return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance()));
}
policyCatalog = PolicyCatalog.of(qInstance);
permissionCatalog = null;
}
return (policyCatalog);
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
@@ -206,4 +253,81 @@ public class AuthentikRuntime
return (responseCache);
}
/*******************************************************************************
** The cached mapper state (DESIGN.md section 12.8), or null.
*******************************************************************************/
public AccessComparison.State getMapperState()
{
return (mapperState);
}
/*******************************************************************************
** Cache a mapper state.
*******************************************************************************/
public void setMapperState(AccessComparison.State mapperState)
{
this.mapperState = mapperState;
}
/*******************************************************************************
** Whether the host has run the qbit mapper at sign-in (counts as "enabled"
** for hosts that call the mapper without setting mapperEnabled).
*******************************************************************************/
public boolean isMapperInvoked()
{
return (mapperInvoked);
}
/*******************************************************************************
** Record that the mapper ran.
*******************************************************************************/
public void markMapperInvoked()
{
if(!mapperInvoked)
{
mapperInvoked = true;
mapperState = null;
}
}
/*******************************************************************************
** Token claims are cached independently of the general list TTL.
*******************************************************************************/
public Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> getTokenScopeCache()
{
return (tokenScopeCache);
}
/*******************************************************************************
** Login-event aggregation is shared by every token record in this runtime.
*******************************************************************************/
public Memoization<String, Map<String, java.time.Instant>> getTokenActivityCache()
{
return (tokenActivityCache);
}
/*******************************************************************************
** Optional Alerts delivery, replaceable by a host or a capturing test sink.
*******************************************************************************/
public com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink getTokenAlerts()
{
return (tokenAlerts);
}
/*******************************************************************************
** Install a sink for this runtime only.
*******************************************************************************/
public AuthentikRuntime withTokenAlerts(com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink sink)
{
tokenAlerts = java.util.Objects.requireNonNull(sink);
return (this);
}
}
@@ -0,0 +1,135 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
/*******************************************************************************
** Every change the qbit can make to Authentik (DESIGN.md section 6.3), with
** the QQQ permission and feature it needs and how strongly it is confirmed.
*******************************************************************************/
public enum AdminAction
{
// Null permission means self-service: AuthentikTokens verifies the live owner.
// Processes add no permission beyond their table; cross-owner actions require TOKENS_MANAGE.
CREATE_TOKEN("Create token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
ROTATE_TOKEN("Rotate token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
REVOKE_TOKEN("Revoke token", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
EXTEND_TOKEN("Extend expiry", "token", null, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
CREATE_SERVICE_ACCOUNT("Create service account", "user", IdentityPermission.TOKENS_MANAGE, AuthentikFeature.TOKENS, Confirmation.CONFIRM),
CREATE_USER("Create user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
INVITE_USER("Invite user", "invitation", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
EDIT_USER("Edit user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
DEACTIVATE_USER("Deactivate user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.TYPED),
REACTIVATE_USER("Reactivate user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
CANCEL_INVITATION("Cancel invitation", "invitation", IdentityPermission.USERS_MANAGE, AuthentikFeature.USERS, Confirmation.CONFIRM),
ADD_MEMBER("Add to group", "membership", IdentityPermission.MEMBERSHIPS_MANAGE, AuthentikFeature.MEMBERSHIP, Confirmation.CONFIRM),
REMOVE_MEMBER("Remove from group", "membership", IdentityPermission.MEMBERSHIPS_MANAGE, AuthentikFeature.MEMBERSHIP, Confirmation.CONFIRM),
CREATE_GROUP("Create group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_GROUP("Edit group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_PERMISSIONS("Edit permissions and app access", "group", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
SAVE_POLICY("Save policy", "group", IdentityPermission.POLICIES_MANAGE, AuthentikFeature.POLICIES, Confirmation.CONFIRM),
DELETE_USER("Delete user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
DELETE_GROUP("Delete group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
SEND_RECOVERY_EMAIL("Send password reset email", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.CONFIRM),
RESET_MFA("Reset MFA", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
REVOKE_APP_PASSWORDS("Revoke app passwords", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
REVOKE_SESSIONS("Revoke sessions", "user", IdentityPermission.SESSIONS_REVOKE, AuthentikFeature.SESSIONS, Confirmation.CONFIRM);
/** How strongly an action is confirmed (DESIGN.md section 6.3). */
public enum Confirmation
{
/** The Review screen is the confirmation. */
CONFIRM,
/** The operator also types the target's name (or the number of targets). */
TYPED
}
private final String label;
private final String targetType;
private final IdentityPermission permission;
private final AuthentikFeature feature;
private final Confirmation confirmation;
/*******************************************************************************
** Constructor.
*******************************************************************************/
AdminAction(String label, String targetType, IdentityPermission permission, AuthentikFeature feature, Confirmation confirmation)
{
this.label = label;
this.targetType = targetType;
this.permission = permission;
this.feature = feature;
this.confirmation = confirmation;
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Getter for targetType
*******************************************************************************/
public String getTargetType()
{
return (targetType);
}
/*******************************************************************************
** Getter for permission
*******************************************************************************/
public IdentityPermission getPermission()
{
return (permission);
}
/*******************************************************************************
** Getter for feature
*******************************************************************************/
public AuthentikFeature getFeature()
{
return (feature);
}
/*******************************************************************************
** Getter for confirmation
*******************************************************************************/
public Confirmation getConfirmation()
{
return (confirmation);
}
}
@@ -0,0 +1,114 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Writes the local, append-only audit row (DESIGN.md sections 6.2 and 9.2).
** The row is inserted BEFORE the Authentik call; if that insert fails the call
** is not made. After the call the same row moves to its final status. Rows are
** never deleted.
*******************************************************************************/
public class AdminActionAuditor
{
private final AuthentikQBitConfig config;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AdminActionAuditor(AuthentikQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** Insert the row for a plan before it runs (or is refused / queued) and
** return its id. Throws when the audit trail cannot be written, so the
** caller must not proceed.
*******************************************************************************/
public Serializable begin(AdminPlan plan, String correlationId, String operator, AuthentikLocalTables.ActionStatus status, String approver) throws QException
{
QRecord record = new QRecord()
.withValue("correlationId", correlationId)
.withValue("actionName", plan.getRequest().action().name())
.withValue("operator", operator)
.withValue("reason", plan.getRequest().reason())
.withValue("targetType", plan.getTargetType())
.withValue("targetKey", plan.getRequest().targetKey())
.withValue("targetLabel", plan.getTargetLabel())
.withValue("beforeText", plan.getBefore())
.withValue("afterText", plan.getAfter())
.withValue("status", status.name())
.withValue("approver", approver)
.withValue("errorMessage", status == AuthentikLocalTables.ActionStatus.REFUSED ? plan.getMessage() : null)
.withValue("createDate", Instant.now())
.withValue("modifyDate", Instant.now());
InsertInput insertInput = new InsertInput();
insertInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
insertInput.setRecords(List.of(record));
InsertOutput output = new InsertAction().execute(insertInput);
QRecord saved = output.getRecords().isEmpty() ? null : output.getRecords().get(0);
if(saved == null || saved.getValue("id") == null || !saved.getErrors().isEmpty())
{
throw (new QException("The audit row could not be written, so nothing was changed in Authentik."));
}
return (saved.getValue("id"));
}
/*******************************************************************************
** Move a row to its final status. A failure here is logged by the caller;
** the Authentik change has already happened.
*******************************************************************************/
public void finish(Serializable id, AuthentikLocalTables.ActionStatus status, String errorMessage, String eventPk, String approver) throws QException
{
QRecord update = new QRecord()
.withValue("id", id)
.withValue("status", status.name())
.withValue("errorMessage", errorMessage)
.withValue("authentikEventPk", eventPk)
.withValue("modifyDate", Instant.now());
if(approver != null)
{
update.withValue("approver", approver);
}
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
updateInput.setRecords(List.of(update));
new UpdateAction().execute(updateInput);
}
}
@@ -0,0 +1,235 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.util.ArrayList;
import java.util.List;
/*******************************************************************************
** What a request would do, computed by the guard rules before anything is
** sent: whether it may run, why not, the target, a before and after, and
** whether it needs a second person. The Review screen shows it; the Result
** step executes it (after computing it again against live data).
*******************************************************************************/
public class AdminPlan
{
/** The outcome of planning. */
public enum State
{
/** Will run. */
READY,
/** Already in the wanted state; nothing to do. */
NO_CHANGE,
/** Refused by a rule (the message says which). */
REFUSED,
/** Allowed, but needs a second person to approve first. */
NEEDS_APPROVAL
}
private final AdminRequest request;
private State state = State.READY;
private String message;
private String targetType;
private String targetLabel;
private String before;
private String after;
private final List<String> notes = new ArrayList<>();
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AdminPlan(AdminRequest request)
{
this.request = request;
this.targetType = request.action().getTargetType();
}
/*******************************************************************************
** Refuse, with the reason shown to the operator.
*******************************************************************************/
public AdminPlan refuse(String reason)
{
this.state = State.REFUSED;
this.message = reason;
return (this);
}
/*******************************************************************************
** Mark as already done.
*******************************************************************************/
public AdminPlan noChange(String why)
{
this.state = State.NO_CHANGE;
this.message = why;
return (this);
}
/*******************************************************************************
** Whether the plan will make a change when executed.
*******************************************************************************/
public boolean isRunnable()
{
return (state == State.READY);
}
/*******************************************************************************
** Getter for request
*******************************************************************************/
public AdminRequest getRequest()
{
return (request);
}
/*******************************************************************************
** Getter for state
*******************************************************************************/
public State getState()
{
return (state);
}
/*******************************************************************************
** Setter for state
*******************************************************************************/
public AdminPlan withState(State state)
{
this.state = state;
return (this);
}
/*******************************************************************************
** Getter for message
*******************************************************************************/
public String getMessage()
{
return (message);
}
/*******************************************************************************
** Fluent setter for message
*******************************************************************************/
public AdminPlan withMessage(String message)
{
this.message = message;
return (this);
}
/*******************************************************************************
** Getter for targetType
*******************************************************************************/
public String getTargetType()
{
return (targetType);
}
/*******************************************************************************
** Getter for targetLabel
*******************************************************************************/
public String getTargetLabel()
{
return (targetLabel);
}
/*******************************************************************************
** Fluent setter for targetLabel
*******************************************************************************/
public AdminPlan withTargetLabel(String targetLabel)
{
this.targetLabel = targetLabel;
return (this);
}
/*******************************************************************************
** Getter for before
*******************************************************************************/
public String getBefore()
{
return (before);
}
/*******************************************************************************
** Getter for after
*******************************************************************************/
public String getAfter()
{
return (after);
}
/*******************************************************************************
** Set the before and after text.
*******************************************************************************/
public AdminPlan withChange(String before, String after)
{
this.before = before;
this.after = after;
return (this);
}
/*******************************************************************************
** Notes for the operator (warnings and consequences).
*******************************************************************************/
public List<String> getNotes()
{
return (notes);
}
/*******************************************************************************
** A one-line description for review lines and audit.
*******************************************************************************/
public String describe()
{
return (request.action().getLabel() + ": " + (targetLabel == null ? request.targetKey() : targetLabel)
+ (before != null || after != null ? " (" + (before == null ? "" : before) + " -> " + (after == null ? "" : after) + ")" : ""));
}
}
@@ -0,0 +1,116 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import org.json.JSONObject;
/*******************************************************************************
** One requested change: the action, its target key (a user pk, a group uuid,
** "groupPk:userPk" for memberships, an invitation uuid, or blank when
** creating), its parameters, and the operator's reason. Serializable to JSON
** so a two-person request can be stored and applied later.
**
** @param action what to do
** @param targetKey the target (see above)
** @param params action parameters (strings and booleans)
** @param reason why (required for every write)
*******************************************************************************/
public record AdminRequest(AdminAction action, String targetKey, Map<String, Serializable> params, String reason)
{
/*******************************************************************************
** A request with no parameters.
*******************************************************************************/
public static AdminRequest of(AdminAction action, String targetKey, String reason)
{
return (new AdminRequest(action, targetKey, new LinkedHashMap<>(), reason));
}
/*******************************************************************************
** Fluent parameter setter (returns this request).
*******************************************************************************/
public AdminRequest with(String name, Serializable value)
{
params.put(name, value);
return (this);
}
/*******************************************************************************
** A parameter as trimmed text, or "" when absent.
*******************************************************************************/
public String str(String name)
{
Serializable value = params.get(name);
return (value == null ? "" : String.valueOf(value).trim());
}
/*******************************************************************************
** A parameter as a boolean (true for true or "true").
*******************************************************************************/
public boolean bool(String name)
{
Serializable value = params.get(name);
return (value instanceof Boolean b ? b : value != null && "true".equalsIgnoreCase(String.valueOf(value).trim()));
}
/*******************************************************************************
** To JSON (for a stored approval request).
*******************************************************************************/
public String toJson()
{
return (new JSONObject()
.put("action", action.name())
.put("targetKey", targetKey)
.put("reason", reason)
.put("params", new JSONObject(params)).toString());
}
/*******************************************************************************
** From JSON written by toJson.
*******************************************************************************/
public static AdminRequest fromJson(String json)
{
JSONObject object = new JSONObject(json);
Map<String, Serializable> params = new LinkedHashMap<>();
JSONObject stored = object.optJSONObject("params");
if(stored != null)
{
for(String key : stored.keySet())
{
Object value = stored.get(key);
params.put(key, value instanceof Boolean || value instanceof Number ? (Serializable) value : String.valueOf(value));
}
}
return (new AdminRequest(AdminAction.valueOf(object.getString("action")), object.optString("targetKey", ""), params, object.optString("reason", "")));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,309 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** The qbit's own tables (DESIGN.md section 6.2), standard visible QQQ tables on
** the host's local RDBMS backend, named authentik_* (lowercase snake_case).
** Both are written only by the qbit: the generic insert, update and delete
** capabilities are off for everyone, and the audit table is append-only.
**
** The host creates the SQL tables (see docs/schema.sql).
*******************************************************************************/
public final class AuthentikLocalTables
{
public static final String TOKEN_SCOPE = "token_scope";
public static final String ADMIN_ACTION = "admin_action";
public static final String APPROVAL_REQUEST = "approval_request";
public static final String POLICY = "policy";
public static final String PVS_ACTION_STATUS = "authentik_action_status";
public static final String PVS_APPROVAL_STATUS = "authentik_approval_status";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikLocalTables()
{
}
/*******************************************************************************
** Status of an audited action.
*******************************************************************************/
public enum ActionStatus implements PossibleValueEnum<String>
{
PENDING_APPROVAL("Waiting for approval"),
RUNNING("Running"),
SUCCEEDED("Succeeded"),
FAILED("Failed"),
REFUSED("Refused");
private final String label;
/*******************************************************************************
** Constructor.
*******************************************************************************/
ActionStatus(String label)
{
this.label = label;
}
/*******************************************************************************
** Getter for possibleValueId.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Getter for possibleValueLabel.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (label);
}
}
/*******************************************************************************
** Status of a two-person approval request.
*******************************************************************************/
public enum ApprovalStatus implements PossibleValueEnum<String>
{
PENDING("Waiting for approval"),
APPROVED("Approved and applied"),
REJECTED("Rejected"),
EXPIRED("Expired"),
FAILED("Approved but failed");
private final String label;
/*******************************************************************************
** Constructor.
*******************************************************************************/
ApprovalStatus(String label)
{
this.label = label;
}
/*******************************************************************************
** Getter for possibleValueId.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Getter for possibleValueLabel.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (label);
}
}
/*******************************************************************************
** The possible-value sources the local tables use.
*******************************************************************************/
public static List<QPossibleValueSource> possibleValueSources()
{
return (List.of(
QPossibleValueSource.newForEnum(PVS_ACTION_STATUS, ActionStatus.values()),
QPossibleValueSource.newForEnum(PVS_APPROVAL_STATUS, ApprovalStatus.values())));
}
/*******************************************************************************
** authentik_admin_action: append-only audit and outbox of every write the
** qbit attempts. The row is inserted BEFORE the API call.
*******************************************************************************/
public static QTableMetaData adminAction(AuthentikQBitConfig config)
{
return (base(config, ADMIN_ACTION, "Admin Actions", "history", IdentityPermission.AUDIT_READ)
.withRecordLabelFormatAndFields("%s %s", "actionName", "targetLabel")
.withField(ro("correlationId", QFieldType.STRING).withMaxLength(64))
.withField(ro("actionName", QFieldType.STRING).withMaxLength(60))
.withField(ro("operator", QFieldType.STRING).withMaxLength(255))
.withField(ro("reason", QFieldType.TEXT))
.withField(ro("targetType", QFieldType.STRING).withMaxLength(30))
.withField(ro("targetKey", QFieldType.STRING).withMaxLength(100))
.withField(ro("targetLabel", QFieldType.STRING).withMaxLength(255))
.withField(ro("beforeText", QFieldType.TEXT))
.withField(ro("afterText", QFieldType.TEXT))
.withField(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_ACTION_STATUS))
.withField(ro("errorMessage", QFieldType.TEXT))
.withField(ro("authentikEventPk", QFieldType.STRING).withMaxLength(64))
.withField(ro("approver", QFieldType.STRING).withMaxLength(255)));
}
/*******************************************************************************
** authentik_approval_request: two-person requests (a second person with
** authentik.approve applies or rejects the stored change).
*******************************************************************************/
public static QTableMetaData approvalRequest(AuthentikQBitConfig config)
{
return (base(config, APPROVAL_REQUEST, "Approval Requests", "how_to_reg", IdentityPermission.APPROVE)
.withRecordLabelFormatAndFields("%s %s", "actionName", "targetLabel")
.withField(ro("actionName", QFieldType.STRING).withMaxLength(60))
.withField(ro("targetKey", QFieldType.STRING).withMaxLength(100))
.withField(ro("targetLabel", QFieldType.STRING).withMaxLength(255))
.withField(ro("summary", QFieldType.TEXT))
.withField(ro("reason", QFieldType.TEXT))
.withField(ro("payloadJson", QFieldType.TEXT))
.withField(ro("requester", QFieldType.STRING).withMaxLength(255))
.withField(ro("approver", QFieldType.STRING).withMaxLength(255))
.withField(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_APPROVAL_STATUS))
.withField(ro("expiresDate", QFieldType.DATE_TIME))
.withField(ro("decidedDate", QFieldType.DATE_TIME))
.withField(ro("errorMessage", QFieldType.TEXT)));
}
/*******************************************************************************
** authentik_policy: one row per group with a policy (DESIGN.md section 12).
** Authentik holds the live document; this row is the QQQ record of the last
** save, and field-level audit is its change history. Written only by the
** Edit Policy action.
*******************************************************************************/
public static QTableMetaData policy(AuthentikQBitConfig config)
{
return (base(config, POLICY, "Policies", "policy", IdentityPermission.USERS_READ)
.withRecordLabelFormatAndFields("%s", "groupName")
.withUniqueKey(new UniqueKey("groupPk"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withField(ro("groupPk", QFieldType.STRING).withLabel("Group Id").withMaxLength(64))
.withField(ro("groupName", QFieldType.STRING).withLabel("Group").withMaxLength(255))
.withField(ro("policyJson", QFieldType.TEXT).withLabel("Policy Document"))
.withField(ro("statementCount", QFieldType.INTEGER).withLabel("Statements"))
.withField(ro("permissionCount", QFieldType.INTEGER).withLabel("Permissions"))
.withField(ro("savedBy", QFieldType.STRING).withLabel("Saved By").withMaxLength(255))
.withField(ro("savedDate", QFieldType.DATE_TIME).withLabel("Saved"))
.withField(ro("authentikFingerprint", QFieldType.STRING).withLabel("Authentik Fingerprint").withMaxLength(64)));
}
/*******************************************************************************
** Common shape: id, create and modify dates, no generic writes.
*******************************************************************************/
private static QTableMetaData base(AuthentikQBitConfig config, String name, String label, String icon, IdentityPermission permission)
{
return (new QTableMetaData()
.withName(config.applyTablePrefix(name))
.withLabel(label)
.withIcon(new QIcon(icon))
.withBackendName(config.getLocalBackendName())
.withPrimaryKeyField("id")
.withPermissionRules(permission.rules())
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_EXPORT)
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE)
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)));
}
/*******************************************************************************
** A read-only field.
*******************************************************************************/
private static QFieldMetaData ro(String name, QFieldType type)
{
return (new QFieldMetaData(name, type).withIsEditable(false));
}
/*******************************************************************************
** Non-secret scope metadata only (DESIGN section 13.5).
*******************************************************************************/
public static QTableMetaData tokenScope(AuthentikQBitConfig config)
{
QTableMetaData table = base(config, TOKEN_SCOPE, "Token Scopes", "key", IdentityPermission.TOKENS_MANAGE)
.withPermissionRules(com.kingsrook.qbits.authentik.security.TokenReadPermission.rules())
.withRecordLabelFormatAndFields("%s", "tokenIdentifier")
.withUniqueKey(new UniqueKey("tokenIdentifier"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
.withField(ro("tokenIdentifier", QFieldType.STRING).withMaxLength(255))
.withField(ro("ownerUsername", QFieldType.STRING).withMaxLength(255))
.withField(ro("ownerPk", QFieldType.INTEGER))
.withField(ro("readOnly", QFieldType.BOOLEAN))
.withField(ro("createdBy", QFieldType.STRING).withMaxLength(255));
table.getFields().remove("createDate");
table.getFields().remove("modifyDate");
for(String name : List.of("apps", "tables", "processes", "reports", "widgets", "operationIds"))
{
table.withField(ro(name, QFieldType.TEXT));
}
for(String name : List.of("createdAt", "rotatedAt"))
{
table.withField(ro(name, QFieldType.DATE_TIME));
}
////////////////////////////////////////////////////////////////////////
// explicit column names: a camel-to-snake mapping would give //
// last_reminder7d, while docs/schema.sql (and hosts) use _7d / _1d //
////////////////////////////////////////////////////////////////////////
table.withField(ro("lastReminder7d", QFieldType.DATE_TIME).withBackendName("last_reminder_7d"));
table.withField(ro("lastReminder1d", QFieldType.DATE_TIME).withBackendName("last_reminder_1d"));
return (table);
}
}
@@ -0,0 +1,173 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
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.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONObject;
/*******************************************************************************
** Audited policy maintenance for reviewed host Java code, typically a run-once
** job. This API deliberately creates its own authority; never expose it through
** an HTTP route or a user-invokable process. No route or process is registered.
*******************************************************************************/
public final class AuthentikMaintenance
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikMaintenance()
{
}
/*******************************************************************************
** Apply a policy to exactly one group by name through the normal Save Policy
** plan and execute path. Requires an initialized QContext with the host's
** QInstance. The caller's context is restored on success, refusal or failure.
**
** Runs as the host's maintenance service principal (config
** maintenancePrincipal, default "maintenance"; docs/SERVICE-IDENTITY.md in
** qqq): under OAuth2 a real token for its Authentik service account, so its
** authority is exactly what the owner granted that account (policies.manage
** and every grant the document applies - the subset rule still holds). No
** authority is created here. The audit operator is service:{principal};
** the job name (maintenance: or run-once:, nonblank suffix) prefixes the
** required reason. Existing scope, protection, readOnly, feature and stale
** checks still apply. Equivalent policies return NO_CHANGE without a write
** or an audit row. With policyTwoPersonApproval enabled, a change queues an
** approval instead of writing to Authentik; this API never bypasses
** second-person approval.
*******************************************************************************/
public static AuthentikAdmin.AdminResult applyPolicy(String apiBackendName, String groupName, String policyJson, String operatorName, String reason) throws QException
{
if(operatorName == null || !(operatorName.startsWith("maintenance:") || operatorName.startsWith("run-once:"))
|| operatorName.substring(operatorName.indexOf(':') + 1).isBlank())
{
throw (new QUserFacingException("The operator name must start with maintenance: or run-once: and name the job."));
}
if(!StringUtils.hasContent(reason))
{
throw (new QUserFacingException("A reason is required for every change."));
}
if(!StringUtils.hasContent(groupName))
{
throw (new QUserFacingException("A group name is required."));
}
CapturedContext caller = QContext.capture();
try
{
String principal = AuthentikRuntime.forBackend(apiBackendName).getConfig().getMaintenancePrincipal();
QContext.init(caller.qInstance(), ServiceIdentities.session(caller.qInstance(), principal));
AuthentikAdmin admin = AuthentikAdmin.forBackend(apiBackendName);
AuthentikRuntime runtime = admin.getUtil().getRuntime();
runtime.getGuard().authorize(AdminAction.SAVE_POLICY);
if(StringUtils.hasContent(policyJson))
{
PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
}
runtime.invalidateCache();
List<JSONObject> groups = admin.getUtil().getAllPages("/core/groups/?name=" + URLEncoder.encode(groupName, StandardCharsets.UTF_8)
+ "&include_users=true&include_parents=true&include_children=true", 2);
if(groups.size() != 1 || !groupName.equals(groups.get(0).optString("name")))
{
throw (new QUserFacingException("Expected exactly one Authentik group named " + groupName + "."));
}
JSONObject group = groups.get(0);
if(isServicePrincipalGroup(group))
{
////////////////////////////////////////////////////////////////////
// a principal never changes a service principal's grants (its own //
// or another's): only the owner does, in Identity //
////////////////////////////////////////////////////////////////////
throw (new QUserFacingException("Service principal groups are changed only by the owner in Identity."));
}
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), operatorName + ": " + reason)
.with(AuthentikAdmin.POLICY_DOCUMENT, policyJson)
.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
return (admin.execute(request));
}
catch(PolicyParseException e)
{
throw (new QUserFacingException("The policy is not valid: " + e.getMessage()));
}
finally
{
QContext.init(caller);
}
}
/*******************************************************************************
** A service principal role group: named service:..., or a child of
** qqq-service-accounts.
*******************************************************************************/
static boolean isServicePrincipalGroup(JSONObject group)
{
if(group.optString("name", "").startsWith("service:"))
{
return (true);
}
org.json.JSONArray parents = group.optJSONArray("parents_obj");
for(int i = 0; parents != null && i < parents.length(); i++)
{
if("qqq-service-accounts".equals(parents.optJSONObject(i) == null ? null : parents.getJSONObject(i).optString("name")))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** Canonical compact full-access document using the host's spec and version.
*******************************************************************************/
public static String fullAccessDocument(AuthentikQBitConfig config) throws QException
{
try
{
return (PolicyGenerator.toCompact(PolicyGenerator.generate(Set.of(PolicyPermissions.FULL_ACCESS), config.getPolicyFormat())));
}
catch(PolicyParseException e)
{
throw (new QException("Could not generate the full-access policy.", e));
}
}
}
@@ -0,0 +1,279 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** The pure rules of DESIGN.md section 5.4, separated from I/O so they can be
** tested exhaustively: the subset (no escalation) rule, last-admin
** protection, descendant checks and group fingerprints.
*******************************************************************************/
public final class AuthentikSafety
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikSafety()
{
}
/*******************************************************************************
** What a user is, for last-admin protection.
**
** @param pk Authentik user pk
** @param active whether the account is active
** @param human internal or external (not a service account)
** @param protectedAccount break-glass, superuser, service account
*******************************************************************************/
public record UserFacts(int pk, boolean active, boolean human, boolean protectedAccount)
{
}
/*******************************************************************************
** The permissions in needed that held does not contain (the subset rule:
** an operator may only grant what they hold).
*******************************************************************************/
public static Set<String> missing(Collection<String> needed, Collection<String> held)
{
Set<String> rs = new TreeSet<>(needed);
rs.removeAll(held);
return (rs);
}
/*******************************************************************************
** pks of the users who qualify as identity administrators in a graph: active,
** human, not protected, and holding both authentik.memberships.manage and
** authentik.permissions.manage through their groups (inherited included).
*******************************************************************************/
public static Set<Integer> qualifyingAdmins(GroupGraph graph, EffectivePermissionResolver resolver, Collection<UserFacts> users)
{
Set<Integer> rs = new LinkedHashSet<>();
for(UserFacts user : users)
{
if(!user.active() || !user.human() || user.protectedAccount())
{
continue;
}
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(graph.groupsOfUser(user.pk()));
if(EffectivePermissionResolver.holdsAll(resolution, List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE)))
{
rs.add(user.pk());
}
}
return (rs);
}
/*******************************************************************************
** Whether a change leaves the qbit without an administrator that it had
** before: some admin existed, and none does after. A state that already had
** none (for example a bootstrap window) is not made worse, so it never
** blocks.
*******************************************************************************/
public static boolean removesLastAdmin(Set<Integer> before, Set<Integer> after)
{
return (!before.isEmpty() && after.isEmpty());
}
/*******************************************************************************
** A copy of a graph with some nodes replaced (matched by pk), for
** simulating a change.
*******************************************************************************/
public static GroupGraph withNodes(GroupGraph graph, GroupGraph.Node... replacements)
{
GroupGraph copy = new GroupGraph();
graph.nodes().forEach(copy::add);
for(GroupGraph.Node node : replacements)
{
copy.add(node);
}
return (copy);
}
/*******************************************************************************
** A node without a member.
*******************************************************************************/
public static GroupGraph.Node withoutMember(GroupGraph.Node node, int userPk)
{
List<Integer> members = new ArrayList<>(node.memberPks());
members.remove(Integer.valueOf(userPk));
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed(), node.policyPermissions()));
}
/*******************************************************************************
** A node with other permissions and app access.
*******************************************************************************/
public static GroupGraph.Node withPermissions(GroupGraph.Node node, List<String> permissions, List<String> appAccess)
{
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed(), node.policyPermissions()));
}
/*******************************************************************************
** A node with other parents.
*******************************************************************************/
public static GroupGraph.Node withParents(GroupGraph.Node node, List<String> parentPks)
{
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed(), node.policyPermissions()));
}
/*******************************************************************************
** Every group below a group (children, their children, ...), cycle-safe.
*******************************************************************************/
public static Set<String> descendantPks(GroupGraph graph, String pk)
{
Set<String> rs = new HashSet<>();
for(GroupGraph.Node node : graph.nodes())
{
if(!node.pk().equals(pk) && graph.ancestorPks(node.pk()).contains(pk))
{
rs.add(node.pk());
}
}
return (rs);
}
/*******************************************************************************
** Split free text (new lines or commas) into distinct trimmed entries.
*******************************************************************************/
public static List<String> parseList(String text)
{
List<String> rs = new ArrayList<>();
if(text == null)
{
return (rs);
}
for(String part : text.split("[\\r\\n,;]+"))
{
String trimmed = part.trim();
if(!trimmed.isEmpty() && !rs.contains(trimmed))
{
rs.add(trimmed);
}
}
return (rs);
}
/*******************************************************************************
** A short fingerprint of a group's editable state: name, parents,
** qqq.io/* attributes and member count. Authentik groups have no
** last_updated (DESIGN.md section 0.2 vi), so this is how a change made in
** the Authentik UI after a preview is noticed.
*******************************************************************************/
public static String groupFingerprint(JSONObject group)
{
JSONObject attributes = group.optJSONObject("attributes");
Map<String, Object> qqq = new java.util.TreeMap<>();
if(attributes != null)
{
for(String key : attributes.keySet())
{
if(key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX))
{
qqq.put(key, String.valueOf(attributes.get(key)));
}
}
}
List<String> parents = new ArrayList<>();
JSONArray array = group.optJSONArray("parents");
for(int i = 0; array != null && i < array.length(); i++)
{
Object value = array.get(i);
parents.add(value instanceof JSONObject object ? object.optString("pk") : String.valueOf(value));
}
java.util.Collections.sort(parents);
int members = group.optJSONArray("users") == null ? -1 : group.getJSONArray("users").length();
return (sha(group.optString("name") + "|" + parents + "|" + qqq + "|" + members));
}
/*******************************************************************************
** A short fingerprint of a user's editable state (name, email, path, type,
** active, last_updated).
*******************************************************************************/
public static String userFingerprint(JSONObject user)
{
return (sha(user.optString("username") + "|" + user.optString("name") + "|" + user.optString("email") + "|" + user.optString("path") + "|"
+ user.optString("type") + "|" + user.optBoolean("is_active", true) + "|" + user.optString("last_updated")));
}
/*******************************************************************************
**
*******************************************************************************/
private static String sha(String text)
{
try
{
byte[] digest = MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for(int i = 0; i < 8; i++)
{
hex.append(String.format("%02x", digest[i]));
}
return (hex.toString());
}
catch(NoSuchAlgorithmException e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.logging.QLogger;
/*******************************************************************************
** Optional Alerts bridge. The qbit compiles on RC.2 without either Alerts API.
** No credentials enter this interface. Hosts and tests can supply a sink.
*******************************************************************************/
public class AuthentikTokenAlerts
{
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenAlerts.class);
/*******************************************************************************
** Safe notification envelope. A null recipient denotes the admin audience.
*******************************************************************************/
public record Notice(String key, String severity, String recipient, String message)
{
}
/*******************************************************************************
** Return true only when delivery was accepted, so absence never dedupes.
*******************************************************************************/
@FunctionalInterface
public interface Sink
{
/*******************************************************************************
** Accept a safe notification, returning false when delivery is unavailable.
*******************************************************************************/
boolean send(Notice notice);
}
/*******************************************************************************
** Constructor for the reflection adapter.
*******************************************************************************/
public AuthentikTokenAlerts()
{
}
/*******************************************************************************
** Guard incidents are raised through core QAlerts; owner reminders through
** core QAlerts.notify (qqq#1125, QQQ 4.1.0-RC.6+). Never another qbit's API.
*******************************************************************************/
public boolean send(Notice notice)
{
try
{
if("high".equals(notice.severity()))
{
QAlerts.raise(new AlertSignal("authentik.token.guard-bypass")
.withDedupKey(notice.key())
.withTitle("Access token guard bypass")
.withSummary(notice.message())
.withSeverity(AlertSeverity.CRITICAL));
return (true);
}
///////////////////////////////////////////////////////////////////
// owner reminders: core's notification SPI (bell and email via //
// whichever alerts producer the host installed; qqq#1125) //
///////////////////////////////////////////////////////////////////
QAlerts.notify(new NotificationSignal("Access token expiry reminder")
.withBody(notice.message())
.withSourceName("authentik")
.withSourceKey(notice.key())
.withAudience(NotificationAudience.user(notice.recipient())));
return (true);
}
catch(RuntimeException e)
{
///////////////////////////////////////////////////////////
// never fail the caller; never log the notice payload //
///////////////////////////////////////////////////////////
LOG.info("Access-token notification delivery failed; notification skipped.");
return (false);
}
}
}
@@ -0,0 +1,703 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker;
import com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
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.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Access-token actions (DESIGN section 13). All target checks run at Review and
** again at execution; credentials exist only in the action and one-time result.
*******************************************************************************/
public class AuthentikTokens
{
public static final String DELIVERY_UNAVAILABLE = "This QQQ version cannot show a key once; upgrade QQQ (4.1.0-RC.7+)";
public static final String TOKEN_MOVED = "Token moved: owner or intent changed after the update; investigate in Authentik.";
private static final QLogger LOG = QLogger.getLogger(AuthentikTokens.class);
private static final SecureRandom RANDOM = new SecureRandom();
private final AuthentikAdmin admin;
private final AuthentikActionUtil util;
private final AuthentikQBitConfig config;
private final TokenScopeStore scopes;
/*******************************************************************************
** Delegate with the existing admin's live operator and subset checks.
*******************************************************************************/
public AuthentikTokens(AuthentikAdmin admin)
{
this.admin = admin;
util = admin.getUtil();
config = util.getRuntime().getConfig();
scopes = new TokenScopeStore(config);
}
/*******************************************************************************
** Review all constraints, without exchanging or creating a credential.
*******************************************************************************/
public AdminPlan plan(AdminPlan plan) throws QException
{
AdminRequest request = plan.getRequest();
AdminAction action = request.action();
util.getRuntime().invalidateCache();
try
{
if(action == AdminAction.CREATE_SERVICE_ACCOUNT)
{
if(!request.str("name").matches("[A-Za-z0-9][A-Za-z0-9._-]{0,99}"))
{
return (plan.refuse("A service-account name (letters, numbers, dot, dash or underscore) is required."));
}
if(admin.userPkByUsername(request.str("name")) != null)
{
return (plan.refuse("That username already exists."));
}
JSONObject candidate = new JSONObject().put("username", request.str("name")).put("type", "service_account");
checkProtected(candidate);
expiry(request, true);
if(!TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
for(GroupGraph.Node group : serviceGroups(request))
{
String problem = admin.checkGroupGrantable(group);
if(problem != null)
{
return (plan.refuse(problem));
}
checkProtectedGroup(group);
}
plan.withTargetLabel(request.str("name"));
return (approval(plan));
}
JSONObject token = action == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
Integer operatorPk = admin.operator().pk();
int ownerPk = token == null ? (operatorPk == null ? -1 : operatorPk) : ownerPk(token);
if(action == AdminAction.CREATE_TOKEN && (!request.targetKey().isBlank() && !request.targetKey().equals(String.valueOf(operatorPk))))
{
return (plan.refuse("Create Token only creates a token for you."));
}
if(ownerPk < 0)
{
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
}
JSONObject owner = owner(ownerPk);
boolean own = Objects.equals(operatorPk, ownerPk);
boolean service = "service_account".equals(owner.optString("type"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if(service && action == AdminAction.CREATE_TOKEN)
{
return (plan.refuse("Service accounts have one app password. Use Rotate Token or Create Service Account."));
}
if(!own && !manager || service && !manager)
{
return (plan.refuse("You may only manage your own tokens; service accounts need authentik.tokens.manage."));
}
if(!own && !service && action != AdminAction.REVOKE_TOKEN)
{
return (plan.refuse("You cannot create, rotate or extend another person's token."));
}
checkProtected(owner);
plan.withTargetLabel(token == null ? owner.getString("username") : request.targetKey());
if(action != AdminAction.REVOKE_TOKEN && token != null && !"app_password".equals(token.optString("intent")))
{
return (plan.refuse("Only app-password tokens can be rotated or extended here. API tokens can be revoked."));
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.EXTEND_TOKEN)
{
expiry(request, service);
}
if(action == AdminAction.ROTATE_TOKEN)
{
checkExpiry(token, service);
}
if(action == AdminAction.CREATE_TOKEN)
{
if(request.str("description").isBlank())
{
return (plan.refuse("A description is required."));
}
if(!Boolean.TRUE.equals(config.getTokenScopesEnabled()) && TokenScopeStore.SCOPE_FIELDS.stream().anyMatch(request.params()::containsKey))
{
return (plan.refuse("Scopes are not enabled on this app yet."));
}
}
if(action == AdminAction.CREATE_TOKEN && Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.validate(request);
QRecord scope = new QRecord();
request.params().forEach(scope::setValue);
plan.withChange(null, TokenScopeStore.summary(scope));
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
{
if(!TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
}
if(own && !service)
{
if(blank(config.getTokenBrokerClientId()) || blank(config.getTokenBrokerClientSecret()))
{
return (plan.refuse("Self-service tokens need a configured Authentik token broker."));
}
if(config.getSubjectTokenSource() == null || blank(config.getSubjectTokenSource().subjectToken(QContext.getQSession())))
{
return (plan.refuse(OAuth2SubjectTokenSource.UNAVAILABLE));
}
}
return (service && action == AdminAction.ROTATE_TOKEN ? approval(plan) : plan);
}
catch(QUserFacingException e)
{
return (plan.refuse(e.getMessage()));
}
}
/*******************************************************************************
** Optional approval for new service accounts and service-account rotation.
*******************************************************************************/
private AdminPlan approval(AdminPlan plan)
{
if(Boolean.TRUE.equals(config.getTokenTwoPersonApproval()) && !plan.getRequest().bool("approved"))
{
plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("Service-account tokens require a second person's approval.");
}
return (plan);
}
/*******************************************************************************
** Resolve requested groups with the same subset rules used for membership.
*******************************************************************************/
private List<GroupGraph.Node> serviceGroups(AdminRequest request) throws QException
{
Set<String> names = new LinkedHashSet<>(AuthentikSafety.parseList(request.str("groups")));
GroupGraph graph = util.groupGraph();
List<GroupGraph.Node> groups = new ArrayList<>();
for(String name : names)
{
GroupGraph.Node node = graph.byName(name);
if(node == null)
{
throw (new QUserFacingException("Group not found: " + name));
}
groups.add(node);
}
return (groups);
}
/*******************************************************************************
** Required expiry measured from now, with separate human and service caps.
*******************************************************************************/
private Instant expiry(AdminRequest request, boolean service) throws QUserFacingException
{
int maximum = service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser();
try
{
int days = Integer.parseInt(request.str("expiresInDays"));
if(days >= 1 && days <= maximum)
{
return (Instant.now().plus(days, ChronoUnit.DAYS));
}
}
catch(NumberFormatException ignored)
{
// Missing, fractional and malformed values are all refused.
}
throw (new QUserFacingException("Expiry is required: expiresInDays must be between 1 and " + maximum + "."));
}
/*******************************************************************************
** Rotation preserves a valid existing expiry, never a non-expiring token.
*******************************************************************************/
private Instant checkExpiry(JSONObject token, boolean service) throws QUserFacingException
{
try
{
Instant expires = Instant.parse(token.getString("expires"));
if(token.optBoolean("expiring") && expires.isAfter(Instant.now())
&& !expires.isAfter(Instant.now().plus(service ? config.getTokenMaxDaysServiceAccount() : config.getTokenMaxDaysUser(), ChronoUnit.DAYS)))
{
return (expires);
}
}
catch(RuntimeException ignored)
{
// No expiry is never acceptable for a new credential.
}
throw (new QUserFacingException("Token expiry is missing, expired or exceeds the maximum lifetime."));
}
/*******************************************************************************
** Built-in protected groups cannot be removed by configuration.
*******************************************************************************/
private void checkProtectedGroup(GroupGraph.Node group) throws QException
{
Set<String> protectedNames = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
protectedNames.addAll(List.of("authentik Admins", "identity-admins"));
if(config.getTokenProtectedGroups() != null)
{
protectedNames.addAll(config.getTokenProtectedGroups());
}
for(GroupGraph.Node ancestor : util.groupGraph().selfAndAncestors(group.pk()))
{
if(ancestor.ownSuperuser() || protectedNames.contains(ancestor.name()))
{
throw (protectedAccount("member of " + ancestor.name()));
}
}
}
/*******************************************************************************
** Reuse account protection, adding inherited protected-group membership.
*******************************************************************************/
private void checkProtected(JSONObject owner) throws QException
{
AuthentikGuard.ProtectionResult protection = util.getRuntime().getGuard().protection(owner);
if(protection.isProtected())
{
throw (protectedAccount(protection.reason()));
}
JSONArray groups = owner.optJSONArray("groups_obj");
for(int i = 0; groups != null && i < groups.length(); i++)
{
GroupGraph.Node node = util.groupGraph().byPk(groups.getJSONObject(i).getString("pk"));
if(node == null)
{
throw (protectedAccount("group protection could not be verified"));
}
checkProtectedGroup(node);
}
}
/*******************************************************************************
** Consistent protected-owner refusal, including service-account set_key.
*******************************************************************************/
private static QUserFacingException protectedAccount(String reason)
{
return (new QUserFacingException("Protected account (" + reason + "): tokens for it are managed in the Authentik admin UI."));
}
/*******************************************************************************
** Fetch token metadata without retaining a bearer or key.
*******************************************************************************/
private JSONObject token(String identifier) throws QException
{
util.getRuntime().invalidateCache();
JSONObject token = util.getApiJsonOrNull(path(identifier));
if(token == null)
{
throw (new QUserFacingException("That token no longer exists."));
}
return (token);
}
/*******************************************************************************
** Token owner pk from the pinned schema.
*******************************************************************************/
private static int ownerPk(JSONObject token)
{
return (token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk"));
}
/*******************************************************************************
** Fetch live owner facts, refusing missing owners.
*******************************************************************************/
private JSONObject owner(int pk) throws QException
{
util.getRuntime().invalidateCache();
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
if(owner == null)
{
throw (new QUserFacingException("The token's owner no longer exists."));
}
return (owner);
}
/*******************************************************************************
** Validated token URL component.
*******************************************************************************/
private static String path(String identifier) throws QException
{
return ("/core/tokens/" + AuthentikAdmin.safeKey(identifier) + "/");
}
/*******************************************************************************
** Whether a resolved config string is absent.
*******************************************************************************/
private static boolean blank(String value)
{
return (value == null || value.isBlank());
}
/*******************************************************************************
** Execute only after auditing has begun; return the key outside audit data.
*******************************************************************************/
public AuthentikAdmin.AdminResult perform(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
if((request.action() == AdminAction.CREATE_TOKEN || request.action() == AdminAction.ROTATE_TOKEN || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
&& !TokenKeyDelivery.get().available())
{
throw (new QUserFacingException(DELIVERY_UNAVAILABLE));
}
if(request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
{
return (createServiceAccount(plan, auth));
}
JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before));
checkProtectedAfterRead(owner);
boolean service = "service_account".equals(owner.optString("type"));
boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if((!own || service) && !manager || !own && !service && request.action() != AdminAction.REVOKE_TOKEN)
{
throw (new QUserFacingException("Token ownership changed; review again."));
}
String bearer = own && !service ? new AuthentikTokenBroker().exchange(util, auth) : config.getResolvedApiToken();
switch(request.action())
{
case CREATE_TOKEN:
return (createPersonal(request, owner, expiry(request, false), null, bearer, auth));
case REVOKE_TOKEN:
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
scopes.delete(request.targetKey());
return (success("Token revoked."));
case EXTEND_TOKEN:
util.writeAs(bearer, auth, "PATCH", path(request.targetKey()), new JSONObject().put("intent", before.getString("intent"))
.put("user", owner.getInt("pk")).put("expires", expiry(request, service).toString()).put("expiring", true));
raceGuard(before, token(request.targetKey()));
scopes.resetReminders(request.targetKey());
return (success("Token expiry updated."));
case ROTATE_TOKEN:
if(!service)
{
return (createPersonal(request, owner, checkExpiry(before, false), before, bearer, auth));
}
String scope = scopeSummary(request.targetKey());
Instant expires = checkExpiry(before, true);
byte[] random = new byte[48];
RANDOM.nextBytes(random);
String key = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
util.writeAs(bearer, auth, "POST", path(request.targetKey()) + "set_key/", new JSONObject().put("key", key));
JSONObject after = token(request.targetKey());
raceGuard(before, after);
return (success("Service-account token rotated. The old key no longer works.")
.withSecret(oneTime(request.targetKey(), key, owner, expires, scope), () ->
{
throw (new QUserFacingException("The old key no longer works and the new key is lost. Rotate it in the Authentik admin UI."));
}));
default:
throw (new QException("Unsupported token action"));
}
}
/*******************************************************************************
** Guard owner and intent after every PATCH or set_key, with a safe warning.
*******************************************************************************/
private void raceGuard(JSONObject before, JSONObject after) throws QException
{
if(ownerPk(before) != ownerPk(after) || !before.getString("intent").equals(after.getString("intent")))
{
alertGuardBypass();
LOG.warn(TOKEN_MOVED);
throw (new QUserFacingException(TOKEN_MOVED));
}
checkProtectedAfterRead(owner(ownerPk(after)));
}
/*******************************************************************************
** Create as the operator, fetch their key, and optionally replace the old id.
** On partial failure discard the new credential; the old one stays usable.
*******************************************************************************/
private AuthentikAdmin.AdminResult createPersonal(AdminRequest request, JSONObject owner, Instant expires, JSONObject old,
String bearer, WriteAuthorization auth) throws QException
{
String identifier = identifier(owner.getString("username"));
String description = old == null ? request.str("description") : old.optString("description");
util.writeAs(bearer, auth, "POST", "/core/tokens/", new JSONObject().put("identifier", identifier).put("intent", "app_password")
.put("description", description).put("expiring", true).put("expires", expires.toString()));
try
{
JSONObject created = util.getAs(bearer, auth, path(identifier));
checkProtectedAfterRead(owner(ownerPk(created)));
if(ownerPk(created) != owner.getInt("pk") || !"app_password".equals(created.optString("intent")))
{
throw (new QUserFacingException(TOKEN_MOVED));
}
String key = util.getAs(bearer, auth, path(identifier) + "view_key/").getString("key");
Instant actualExpiry = checkExpiry(created, false);
if(old == null)
{
scopes.create(identifier, owner, request, admin.operator().email());
}
else
{
QRecord previous = scopes.find(request.targetKey());
if(previous == null)
{
scopes.create(identifier, owner, AdminRequest.of(request.action(), identifier, request.reason()), admin.operator().email());
}
else
{
scopes.move(request.targetKey(), identifier);
}
}
OneTimeKey secret = oneTime(identifier, key, owner, actualExpiry, scopeSummary(identifier));
if(old != null)
{
try
{
util.writeAs(bearer, auth, "DELETE", path(request.targetKey()), null);
}
catch(QException e)
{
scopes.move(identifier, request.targetKey());
throw (new QUserFacingException("Rotation failed: the old token could not be deleted and remains listed."));
}
}
return (success(old == null ? "Token created. The key is shown once." : "Token rotated. The old token was deleted.")
.withSecret(secret, () -> revokeIssued(identifier, bearer, auth)));
}
catch(Exception e)
{
revokeIssued(identifier, bearer, auth);
if(e instanceof QUserFacingException facing)
{
throw (facing);
}
throw (new QUserFacingException("The new token could not be completed and was revoked."));
}
}
/*******************************************************************************
** Create the single service-account app password and grant checked groups.
*******************************************************************************/
private AuthentikAdmin.AdminResult createServiceAccount(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
String identifier = "service-account-" + request.str("name") + "-password";
JSONObject created = util.write(auth, "POST", "/core/users/service_account/", new JSONObject().put("name", request.str("name"))
.put("create_group", false).put("expiring", true).put("expires", expiry(request, true).toString()));
try
{
Integer pk = created.has("user_pk") ? created.getInt("user_pk") : admin.userPkByUsername(created.optString("username", request.str("name")));
if(pk == null)
{
throw (new QException("The service account could not be found."));
}
JSONObject current = owner(pk);
JSONObject attributes = new JSONObject(current.optJSONObject("attributes") == null ? "{}" : current.getJSONObject("attributes").toString());
attributes.put("goauthentik.io/user/token-maximum-lifetime", config.getServiceAccountTokenLifetime());
util.write(auth, "PATCH", "/core/users/" + pk + "/", new JSONObject().put("attributes", attributes));
for(GroupGraph.Node group : serviceGroups(request))
{
checkProtectedGroup(group);
String problem = admin.checkGroupGrantable(group);
if(problem != null)
{
throw (new QUserFacingException(problem));
}
util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk));
}
JSONObject owner = owner(pk);
checkProtectedAfterRead(owner);
JSONObject after = token(identifier);
checkProtectedAfterRead(owner(ownerPk(after)));
scopes.create(identifier, owner, request, admin.operator().email());
return (success("Service account created. " + String.join(" ", plan.getNotes()))
.withSecret(oneTime(identifier, created.getString("token"), owner, checkExpiry(after, true), "Owner permissions"),
() -> revokeIssued(identifier, config.getResolvedApiToken(), auth)));
}
catch(Exception e)
{
revokeIssued(identifier, config.getResolvedApiToken(), auth);
throw (new QUserFacingException("The service account was created, but setup failed. Its token was revoked; finish setup in Authentik."));
}
}
/*******************************************************************************
** Emergency revocation for a newly created token whose key cannot be shown.
*******************************************************************************/
private void revokeIssued(String identifier, String bearer, WriteAuthorization auth) throws QException
{
try
{
util.writeAs(bearer, auth, "DELETE", path(identifier), null);
scopes.delete(identifier);
}
catch(QException e)
{
throw (new QUserFacingException("The new key could not be delivered and token revocation failed. Revoke it in the Authentik admin UI."));
}
}
/*******************************************************************************
** Scope summary contains metadata only.
*******************************************************************************/
private String scopeSummary(String identifier) throws QException
{
QRecord row = scopes.find(identifier);
List<String> parts = new ArrayList<>();
for(String field : TokenScopeStore.SCOPE_FIELDS)
{
if(row != null && row.getValue(field) != null)
{
parts.add(field + ": " + row.getValueString(field));
}
}
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
}
/*******************************************************************************
** Build the isolated one-time object, including its response-only usage text.
*******************************************************************************/
private OneTimeKey oneTime(String identifier, String key, JSONObject owner, Instant expires, String scope)
{
return (new OneTimeKey(identifier, key, TokenUsageText.build(config, owner.getString("username"), key, expires, scope)));
}
/*******************************************************************************
** Non-secret result text.
*******************************************************************************/
private static AuthentikAdmin.AdminResult success(String message)
{
return (new AuthentikAdmin.AdminResult(true, false, false, message, null));
}
/*******************************************************************************
** Collision-resistant identifier, independent of the secret value.
*******************************************************************************/
private static String identifier(String username)
{
String alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder suffix = new StringBuilder();
for(int i = 0; i < 6; i++)
{
suffix.append(alphabet.charAt(RANDOM.nextInt(alphabet.length())));
}
String slug = username.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-]+", "-");
return ("qqq-pat-" + slug + "-" + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC).format(Instant.now()) + "-" + suffix);
}
/*******************************************************************************
** A protected owner observed after Review or a write is a guard incident.
*******************************************************************************/
private void checkProtectedAfterRead(JSONObject owner) throws QException
{
try
{
checkProtected(owner);
}
catch(QUserFacingException e)
{
alertGuardBypass();
throw (e);
}
}
/*******************************************************************************
** Safe incident payload: no response bodies, credentials or owner details.
*******************************************************************************/
private void alertGuardBypass()
{
try
{
util.getRuntime().getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null,
"An access token changed owner or intent, or a protected owner was observed after Review. Investigate in Authentik."));
}
catch(RuntimeException e)
{
LOG.warn("Access-token guard alert delivery failed.");
}
}
}
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
/*******************************************************************************
** A response-only value. Deliberately not Serializable; never a process value.
*******************************************************************************/
public final class OneTimeKey
{
private final String label;
private final String value;
private final String usageText;
/*******************************************************************************
** Construct the one-time response.
*******************************************************************************/
public OneTimeKey(String label, String value, String usageText)
{
this.label = label;
this.value = value;
this.usageText = usageText;
}
/*******************************************************************************
** Getter for label.
*******************************************************************************/
public String label()
{
return (label);
}
/*******************************************************************************
** Getter for value.
*******************************************************************************/
public String value()
{
return (value);
}
/*******************************************************************************
** Getter for masked.
*******************************************************************************/
public String masked()
{
return ("\u2022\u2022\u2022\u2022" + value.substring(Math.max(0, value.length() - 4)));
}
/*******************************************************************************
** Getter for usageText.
*******************************************************************************/
public String usageText()
{
return (usageText);
}
/*******************************************************************************
** Never render the key or its usage text.
*******************************************************************************/
@Override
public String toString()
{
return ("OneTimeKey{label=" + label + ", value=[one-time]}" );
}
}
@@ -0,0 +1,192 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
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.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Non-secret bookkeeping. No method accepts a key or a bearer.
*******************************************************************************/
public class TokenScopeStore
{
public static final List<String> SCOPE_FIELDS = List.of("readOnly", "apps", "tables", "processes", "reports", "widgets", "operationIds");
private final AuthentikQBitConfig config;
/*******************************************************************************
** Bind to one qbit's local backend.
*******************************************************************************/
public TokenScopeStore(AuthentikQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** Local table name.
*******************************************************************************/
private String table()
{
return (config.applyTablePrefix(AuthentikLocalTables.TOKEN_SCOPE));
}
/*******************************************************************************
** Fetch the unique scope row as a trusted SYSTEM read (QueryInput default).
*******************************************************************************/
public QRecord find(String identifier) throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput().withTableName(table())
.withFilter(new QQueryFilter(new QFilterCriteria("tokenIdentifier", QCriteriaOperator.EQUALS, identifier)))).getRecords();
return (rows.isEmpty() ? null : rows.get(0));
}
/*******************************************************************************
** Record a new identifier, even when scopes are disabled.
*******************************************************************************/
public void create(String identifier, JSONObject owner, AdminRequest request, String operator) throws QException
{
QRecord row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
.withValue("ownerUsername", owner.getString("username")).withValue("createdBy", operator).withValue("createdAt", Instant.now());
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
for(String field : SCOPE_FIELDS)
{
if(request.params().containsKey(field))
{
row.setValue(field, request.params().get(field));
}
}
}
List<QRecord> rows = new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords();
check(rows);
}
/*******************************************************************************
** Keep all scope and reminder fields when rotating a personal credential.
*******************************************************************************/
public void move(String oldIdentifier, String newIdentifier) throws QException
{
QRecord row = find(oldIdentifier);
if(row != null)
{
row.setValue("tokenIdentifier", newIdentifier);
row.setValue("rotatedAt", Instant.now());
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
/*******************************************************************************
** Revoke removes only the corresponding metadata row.
*******************************************************************************/
public void delete(String identifier) throws QException
{
QRecord row = find(identifier);
if(row != null)
{
new DeleteAction().execute(new DeleteInput().withTableName(table()).withPrimaryKeys(List.of(row.getValue("id"))));
}
}
/*******************************************************************************
** Fail closed if local storage rejects the write.
*******************************************************************************/
private static void check(List<QRecord> rows) throws QException
{
if(rows.isEmpty() || !rows.get(0).getErrors().isEmpty())
{
throw (new QException("Token scope metadata could not be saved."));
}
}
/*******************************************************************************
** Human-readable, non-secret scope for lists and Review.
*******************************************************************************/
public static String summary(QRecord row)
{
java.util.ArrayList<String> parts = new java.util.ArrayList<>();
for(String field : SCOPE_FIELDS)
{
if(row != null && row.getValue(field) != null && !row.getValueString(field).isBlank())
{
parts.add(field + ": " + row.getValueString(field));
}
}
return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts));
}
/*******************************************************************************
** Persist dedupe metadata. External tokens get no invented creation date or
** restrictions; their token row remains unscoped.
*******************************************************************************/
public void reminded(String identifier, JSONObject owner, String field, Instant at) throws QException
{
QRecord row = find(identifier);
if(row == null)
{
row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk"))
.withValue("ownerUsername", owner.getString("username")).withValue(field, at);
check(new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
else
{
row.setValue(field, at);
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
/*******************************************************************************
** A new expiry starts a new set of reminder windows.
*******************************************************************************/
public void resetReminders(String identifier) throws QException
{
QRecord row = find(identifier);
if(row != null)
{
row.setValue("lastReminder7d", null);
row.setValue("lastReminder1d", null);
check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords());
}
}
}
@@ -0,0 +1,69 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.time.Instant;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
/*******************************************************************************
** Usage instructions held exclusively inside the one-time response.
*******************************************************************************/
public final class TokenUsageText
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TokenUsageText()
{
}
/*******************************************************************************
** Generate shell-quoted exchange and application requests from public URLs.
*******************************************************************************/
public static String build(AuthentikQBitConfig config, String username, String key, Instant expires, String scope)
{
String clientId = config.getAppClientId();
if(clientId == null && QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth)
{
clientId = oauth.getClientId();
}
String endpoint = config.getAuthentikPublicUrl().replaceAll("/+$", "") + "/application/o/token/";
String app = config.getAppPublicUrl() == null ? "{appPublicUrl}" : config.getAppPublicUrl().replaceAll("/+$", "");
return ("Expires: " + expires + "\nScope: " + scope + "\n\n"
+ "curl -X POST " + quote(endpoint) + " --data-urlencode 'grant_type=client_credentials'"
+ " --data-urlencode " + quote("client_id=" + (clientId == null ? "{appClientId}" : clientId))
+ " --data-urlencode " + quote("username=" + username) + " --data-urlencode " + quote("password=" + key)
+ " --data-urlencode 'scope=openid profile email'\n\n"
+ "curl -H 'Authorization: Bearer {jwt}' " + quote(app + "/qqq/v1/metaData"));
}
/*******************************************************************************
** Quote literal shell arguments, including usernames containing apostrophes.
*******************************************************************************/
private static String quote(String value)
{
return ("'" + value.replace("'", "'\"'\"'") + "'");
}
}
@@ -0,0 +1,557 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Everything the Identity home shows (DESIGN.md section 8.3), read live from
** Authentik and the qbit's own tables. Each section is loaded on its own, so a
** failure shows as "unavailable" for that card instead of hiding the page.
** Sections that expose sensitive data need authentik.audit.read.
*******************************************************************************/
public class AuthentikHomeData
{
private static final QLogger LOG = QLogger.getLogger(AuthentikHomeData.class);
private boolean peopleAvailable;
private int activePeople;
private int inactivePeople;
private int serviceAccounts;
private int neverSignedIn;
private int protectedAccounts;
private boolean groupsAvailable;
private int groups;
private int groupsWithPermissions;
private int groupsWithAppAccess;
private int superuserGroups;
private int emptyGroups;
private boolean approvalsAvailable;
private int pendingApprovals;
private boolean driftAvailable;
private int driftErrors;
private int driftWarnings;
private boolean mayAudit;
private boolean actionsAvailable;
private List<QRecord> recentActions = new ArrayList<>();
private List<String> attention = new ArrayList<>();
private String healthStatus = "UNKNOWN";
private Integer healthComponentId;
private AuthentikQBitConfig config;
/*******************************************************************************
** Load everything for the current session.
*******************************************************************************/
public static AuthentikHomeData load(AuthentikQBitConfig config) throws Exception
{
AuthentikHomeData data = new AuthentikHomeData();
data.config = config;
data.mayAudit = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.AUDIT_READ);
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName());
data.loadPeople(util);
data.loadGroups(util);
data.loadApprovals();
data.loadActions();
data.loadAttention(util);
data.loadHealth();
return (data);
}
/*******************************************************************************
**
*******************************************************************************/
private void loadPeople(AuthentikActionUtil util)
{
try
{
for(JSONObject user : util.allUsers())
{
String type = user.optString("type");
if("service_account".equals(type))
{
serviceAccounts++;
}
else if(user.optBoolean("is_active", true))
{
activePeople++;
}
else
{
inactivePeople++;
}
if(user.isNull("last_login") && !"service_account".equals(type) && user.optBoolean("is_active", true))
{
neverSignedIn++;
}
if(util.getRuntime().getGuard().protection(user).isProtected())
{
protectedAccounts++;
}
}
peopleAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: people unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
**
*******************************************************************************/
private void loadGroups(AuthentikActionUtil util)
{
try
{
GroupGraph graph = util.groupGraphWithMembers();
for(GroupGraph.Node node : graph.nodes())
{
groups++;
if(!node.permissions().isEmpty())
{
groupsWithPermissions++;
}
if(!node.appAccess().isEmpty())
{
groupsWithAppAccess++;
}
if(graph.isSuperuserInherited(node.pk()))
{
superuserGroups++;
}
if(node.memberPks().isEmpty())
{
emptyGroups++;
}
}
groupsAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: groups unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
**
*******************************************************************************/
private void loadApprovals()
{
if(!hasLocal() || !config.getTwoPersonApproval() || !config.hasFeature(AuthentikFeature.GROUPS))
{
return;
}
try
{
CountInput countInput = new CountInput();
countInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.APPROVAL_REQUEST));
countInput.setFilter(new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, AuthentikLocalTables.ApprovalStatus.PENDING.name())));
pendingApprovals = new CountAction().execute(countInput).getCount();
approvalsAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: approvals unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
** The latest admin actions (audit.read only).
*******************************************************************************/
private void loadActions()
{
if(!hasLocal() || !mayAudit)
{
return;
}
try
{
QueryInput queryInput = new QueryInput();
queryInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
queryInput.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false)).withLimit(8));
recentActions = new QueryAction().execute(queryInput).getRecords();
actionsAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: admin actions unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
/*******************************************************************************
** Configuration review and bootstrap operators still set. Health is central.
*******************************************************************************/
private void loadAttention(AuthentikActionUtil util)
{
if(!config.getResolvedBootstrapSubjects().isEmpty())
{
attention.add("bootstrapOperatorSubjects is still set: remove it once an identity administrator is set up in Authentik.");
}
if(Boolean.TRUE.equals(config.getReadOnly()))
{
attention.add("The Identity screens are in read-only mode: no change can be made from here.");
}
if(AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN))
{
try
{
for(DriftFinding finding : new AuthentikDriftReport(util, config).run())
{
if(finding.severity() == DriftFinding.Severity.ERROR)
{
driftErrors++;
}
else if(finding.severity() == DriftFinding.Severity.WARNING)
{
driftWarnings++;
}
}
driftAvailable = true;
}
catch(Exception e)
{
LOG.info("Identity home: drift report unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private boolean hasLocal()
{
return (config.getLocalBackendName() != null && QContext.getQInstance().getTable(config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION)) != null);
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
public AuthentikQBitConfig getConfig()
{
return (config);
}
/*******************************************************************************
** Read the central rollup using the current session's table permissions.
*******************************************************************************/
private void loadHealth()
{
if(QContext.getQInstance().getTable("health_component") == null)
{
return;
}
try
{
QueryInput query = new QueryInput();
query.setTableName("health_component");
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
query.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "authentik")).withLimit(1));
List<QRecord> records = new QueryAction().execute(query).getRecords();
if(!records.isEmpty())
{
healthStatus = com.kingsrook.qqq.backend.core.health.HealthStatus.valueOf(records.getFirst().getValueString("status")).name();
healthComponentId = records.getFirst().getValueInteger("id");
}
}
catch(Exception e)
{
LOG.debug("Identity home: central health unavailable");
}
}
/*******************************************************************************
** Getter for the central component status.
*******************************************************************************/
public String getHealthStatus()
{
return (healthStatus);
}
/*******************************************************************************
** Getter for the central component record link.
*******************************************************************************/
public Integer getHealthComponentId()
{
return (healthComponentId);
}
/*******************************************************************************
** Getter for peopleAvailable
*******************************************************************************/
public boolean isPeopleAvailable()
{
return (peopleAvailable);
}
/*******************************************************************************
** Getter for activePeople
*******************************************************************************/
public int getActivePeople()
{
return (activePeople);
}
/*******************************************************************************
** Getter for inactivePeople
*******************************************************************************/
public int getInactivePeople()
{
return (inactivePeople);
}
/*******************************************************************************
** Getter for serviceAccounts
*******************************************************************************/
public int getServiceAccounts()
{
return (serviceAccounts);
}
/*******************************************************************************
** Getter for neverSignedIn
*******************************************************************************/
public int getNeverSignedIn()
{
return (neverSignedIn);
}
/*******************************************************************************
** Getter for protectedAccounts
*******************************************************************************/
public int getProtectedAccounts()
{
return (protectedAccounts);
}
/*******************************************************************************
** Getter for groupsAvailable
*******************************************************************************/
public boolean isGroupsAvailable()
{
return (groupsAvailable);
}
/*******************************************************************************
** Getter for groups
*******************************************************************************/
public int getGroups()
{
return (groups);
}
/*******************************************************************************
** Getter for groupsWithPermissions
*******************************************************************************/
public int getGroupsWithPermissions()
{
return (groupsWithPermissions);
}
/*******************************************************************************
** Getter for groupsWithAppAccess
*******************************************************************************/
public int getGroupsWithAppAccess()
{
return (groupsWithAppAccess);
}
/*******************************************************************************
** Getter for superuserGroups
*******************************************************************************/
public int getSuperuserGroups()
{
return (superuserGroups);
}
/*******************************************************************************
** Getter for emptyGroups
*******************************************************************************/
public int getEmptyGroups()
{
return (emptyGroups);
}
/*******************************************************************************
** Getter for approvalsAvailable
*******************************************************************************/
public boolean isApprovalsAvailable()
{
return (approvalsAvailable);
}
/*******************************************************************************
** Getter for pendingApprovals
*******************************************************************************/
public int getPendingApprovals()
{
return (pendingApprovals);
}
/*******************************************************************************
** Getter for driftAvailable
*******************************************************************************/
public boolean isDriftAvailable()
{
return (driftAvailable);
}
/*******************************************************************************
** Getter for driftErrors
*******************************************************************************/
public int getDriftErrors()
{
return (driftErrors);
}
/*******************************************************************************
** Getter for driftWarnings
*******************************************************************************/
public int getDriftWarnings()
{
return (driftWarnings);
}
/*******************************************************************************
** Getter for mayAudit
*******************************************************************************/
public boolean isMayAudit()
{
return (mayAudit);
}
/*******************************************************************************
** Getter for actionsAvailable
*******************************************************************************/
public boolean isActionsAvailable()
{
return (actionsAvailable);
}
/*******************************************************************************
** Getter for recentActions
*******************************************************************************/
public List<QRecord> getRecentActions()
{
return (recentActions);
}
/*******************************************************************************
** Getter for attention
*******************************************************************************/
public List<String> getAttention()
{
return (attention);
}
}
@@ -0,0 +1,292 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Renders the Identity home as one responsive HTML block (no tables, so it
** stays readable at every width): cards stack to one column on narrow
** screens, long values wrap, every card has an honest empty or unavailable
** state, and it follows the app's light and dark theme (inherited colors).
*******************************************************************************/
public final class AuthentikHomeView
{
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr));gap:1rem;";
private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;";
private static final String LIST = "display:grid;grid-template-columns:minmax(8rem,auto) 1fr;gap:.25rem .75rem;margin:0 0 .75rem;font-size:.9rem;";
private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;font-size:.9rem;";
private static final String MUTED = "opacity:.75;";
private static final String WARN = "font-weight:600;";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHomeView()
{
}
/*******************************************************************************
** The whole home.
*******************************************************************************/
public static String render(AuthentikHomeData data)
{
AuthentikQBitConfig config = data.getConfig();
StringBuilder html = new StringBuilder("<div class=\"authentik-home\" style=\"" + GRID + "\">");
attention(html, data, config);
people(html, data, config);
groups(html, data, config);
activity(html, data, config);
health(html, data);
return (html.append("</div>").toString());
}
/*******************************************************************************
** Needs attention: the first thing a person should see.
*******************************************************************************/
private static void attention(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "Needs attention");
boolean any = false;
html.append("<ul style=\"").append(ITEMS).append("\">");
if(data.isApprovalsAvailable() && data.getPendingApprovals() > 0)
{
any = true;
html.append("<li style=\"").append(WARN).append("\">").append(data.getPendingApprovals()).append(" change(s) waiting for a second person to approve. ")
.append(link(config, AuthentikLocalTables.APPROVAL_REQUEST, "Open approval requests")).append("</li>");
}
if(data.isDriftAvailable() && data.getDriftErrors() + data.getDriftWarnings() > 0)
{
any = true;
html.append("<li>").append(data.getDriftErrors()).append(" drift error(s) and ").append(data.getDriftWarnings()).append(" warning(s) in the identity drift report below.</li>");
}
for(String line : data.getAttention())
{
any = true;
html.append("<li>").append(esc(line)).append("</li>");
}
if(data.isPeopleAvailable() && data.getNeverSignedIn() > 0)
{
any = true;
html.append("<li>").append(data.getNeverSignedIn()).append(" active person(s) have never signed in.</li>");
}
html.append("</ul>");
if(!any)
{
empty(html, "Nothing needs attention.");
}
close(html);
}
/*******************************************************************************
**
*******************************************************************************/
private static void people(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "People");
if(!data.isPeopleAvailable())
{
empty(html, "People are unavailable right now (Authentik could not be reached).");
}
else
{
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Active", String.valueOf(data.getActivePeople()));
row(html, "Inactive", String.valueOf(data.getInactivePeople()));
row(html, "Service accounts", String.valueOf(data.getServiceAccounts()));
row(html, "Protected", data.getProtectedAccounts() + " (cannot be changed from here)");
html.append("</dl>");
}
html.append(link(config, AuthentikTables.USER, "Users"));
close(html);
}
/*******************************************************************************
**
*******************************************************************************/
private static void groups(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "Groups and permissions");
if(!data.isGroupsAvailable())
{
empty(html, "Groups are unavailable right now (Authentik could not be reached).");
}
else
{
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Groups", String.valueOf(data.getGroups()));
row(html, "Grant QQQ permissions", String.valueOf(data.getGroupsWithPermissions()));
row(html, "Grant app sign-in", String.valueOf(data.getGroupsWithAppAccess()));
row(html, "Superuser (locked)", String.valueOf(data.getSuperuserGroups()));
row(html, "Empty", String.valueOf(data.getEmptyGroups()));
html.append("</dl>");
}
html.append(link(config, AuthentikTables.GROUP, "Groups"));
close(html);
}
/*******************************************************************************
** Recent changes made from here (audit.read only).
*******************************************************************************/
private static void activity(StringBuilder html, AuthentikHomeData data, AuthentikQBitConfig config)
{
open(html, "Recent changes");
if(!data.isMayAudit())
{
empty(html, "The change history needs the authentik.audit.read permission.");
}
else if(!data.isActionsAvailable())
{
empty(html, "The change history is unavailable right now.");
}
else if(data.getRecentActions().isEmpty())
{
empty(html, "No changes have been made from here yet.");
}
else
{
html.append("<ul style=\"").append(ITEMS).append("\">");
for(QRecord row : data.getRecentActions())
{
Instant created = ValueUtils.getValueAsInstant(row.getValue("createDate"));
html.append("<li><a href=\"").append(esc(prefix(config) + "/" + config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION) + "/" + row.getValue("id"))).append("\">")
.append(esc(row.getValueString("actionName"))).append("</a> ").append(esc(row.getValueString("targetLabel"))).append(", ")
.append(esc(row.getValueString("status"))).append(created == null ? "" : ", " + WHEN.format(created)).append("</li>");
}
html.append("</ul>");
}
if(data.isMayAudit())
{
html.append(link(config, AuthentikLocalTables.ADMIN_ACTION, "All changes"));
}
close(html);
}
/*******************************************************************************
**
*******************************************************************************/
private static void health(StringBuilder html, AuthentikHomeData data)
{
open(html, "Health");
String target = prefix(data.getConfig()) + "/health_component" + (data.getHealthComponentId() == null ? "" : "/" + data.getHealthComponentId());
html.append("<p>Authentik: ").append(esc(data.getHealthStatus())).append(" - <a href=\"")
.append(esc(target)).append("\">System Health</a></p>");
close(html);
}
/*******************************************************************************
**
*******************************************************************************/
private static String prefix(AuthentikQBitConfig config)
{
return (config.getLinkPrefix() == null ? "" : config.getLinkPrefix());
}
/*******************************************************************************
**
*******************************************************************************/
private static String link(AuthentikQBitConfig config, String table, String text)
{
String name = AuthentikLocalTables.APPROVAL_REQUEST.equals(table) || AuthentikLocalTables.ADMIN_ACTION.equals(table)
? config.applyTablePrefix(table) : config.applyTablePrefix(table);
return ("<a href=\"" + esc(prefix(config) + "/" + name) + "\">" + esc(text) + "</a>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void open(StringBuilder html, String title)
{
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .5rem;font-size:1rem;\">").append(esc(title)).append("</h3>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void close(StringBuilder html)
{
html.append("</section>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void empty(StringBuilder html, String text)
{
html.append("<p style=\"").append(MUTED).append("\">").append(esc(text)).append("</p>");
}
/*******************************************************************************
**
*******************************************************************************/
private static void row(StringBuilder html, String label, String value)
{
html.append("<dt style=\"").append(MUTED).append("\">").append(esc(label)).append("</dt><dd style=\"margin:0;\">").append(esc(value)).append("</dd>");
}
/*******************************************************************************
** HTML-escape.
*******************************************************************************/
static String esc(String text)
{
if(text == null)
{
return ("");
}
return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"));
}
}
@@ -0,0 +1,74 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.metadata.AuthentikHomeWidgetProducer;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Holder of the Identity home widget renderer.
*******************************************************************************/
public final class AuthentikHomeWidget
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHomeWidget()
{
}
/*******************************************************************************
** Renders the home. QQQ checks the widget's permission rules (authentik.users.read);
** each card checks the finer permissions it needs itself.
*******************************************************************************/
public static class Renderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(AuthentikHomeWidgetProducer.BACKEND_NAME_KEY));
AuthentikQBitConfig config = AuthentikRuntime.forBackend(backendName).getConfig();
try
{
return (new RenderWidgetOutput(new RawHTML(null, AuthentikHomeView.render(AuthentikHomeData.load(config)))));
}
catch(QException e)
{
throw (e);
}
catch(Exception e)
{
throw (new QException("The Identity home could not be built: " + e.getMessage(), e));
}
}
}
}
@@ -0,0 +1,168 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
/*******************************************************************************
** Sub-tables appear on their parents' record screens as child lists, not in
** the nav (owner standard): a user shows its memberships, sessions, tokens and
** events; a group shows its members; an application shows its policy bindings;
** an approval request needs none. The joins are metadata only (the lists
** query the child table filtered by the parent's key), so they work on the
** API backend.
*******************************************************************************/
public final class AuthentikRelations
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikRelations()
{
}
/*******************************************************************************
** Add the joins, child-list widgets and record sections for the tables the
** config produced. Safe to call once per instance.
*******************************************************************************/
public static void apply(QInstance qInstance, AuthentikQBitConfig config)
{
String user = config.applyTablePrefix(AuthentikTables.USER);
String group = config.applyTablePrefix(AuthentikTables.GROUP);
String membership = config.applyTablePrefix(AuthentikTables.GROUP_MEMBER);
String session = config.applyTablePrefix(AuthentikTables.SESSION);
String token = config.applyTablePrefix(AuthentikTables.TOKEN);
String event = config.applyTablePrefix(AuthentikTables.EVENT);
String application = config.applyTablePrefix(AuthentikTables.APPLICATION);
String binding = config.applyTablePrefix(AuthentikTables.POLICY_BINDING);
List<QFieldSection> userSections = new ArrayList<>();
userSections.add(new QFieldSection("identity", "Identity", new QIcon("badge"), Tier.T1,
List.of("username", "name", "email", "isActive", "type", "isProtected", "isSuperuser", "path", "lastLogin", "dateJoined", "lastUpdated", "pk")));
userSections.add(new QFieldSection("access", "Access", new QIcon("admin_panel_settings"), Tier.T2,
List.of("groupNames", "roleNames", "effectivePermissions")));
relate(qInstance, userSections, "memberships", "Memberships", "group_add", user, membership, "pk", "userPk");
relate(qInstance, userSections, "sessions", "Sessions", "devices", user, session, "username", "username");
relate(qInstance, userSections, "tokens", "Tokens and app passwords", "key", user, token, "username", "username");
relate(qInstance, userSections, "events", "History", "history", user, event, "username", "username");
userSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("qqqAttributes", "passwordChangeDate", "attributes")));
applySections(qInstance, user, userSections);
List<QFieldSection> groupSections = new ArrayList<>();
groupSections.add(new QFieldSection("details", "Details", new QIcon("groups"), Tier.T1,
List.of("name", "isSuperuser", "isSuperuserInherited", "parentNames", "childNames", "memberCount", "roleNames", "pk")));
groupSections.add(new QFieldSection("permissions", "Permissions and app access", new QIcon("admin_panel_settings"), Tier.T2,
List.of("permissions", "effectivePermissions", "appAccess")));
relate(qInstance, groupSections, "members", "Members", "group", group, membership, "pk", "groupPk");
enforcement(qInstance, config, groupSections);
relate(qInstance, groupSections, "policy", "Policy", "policy", group, config.applyTablePrefix(AuthentikLocalTables.POLICY), "pk", "groupPk");
groupSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("attributes")));
applySections(qInstance, group, groupSections);
String policyTable = config.applyTablePrefix(AuthentikLocalTables.POLICY);
if(qInstance.getTable(policyTable) != null)
{
List<QFieldSection> policySections = new ArrayList<>();
enforcement(qInstance, config, policySections);
policySections.add(new QFieldSection("policy", "Policy", new QIcon("policy"), Tier.T1,
List.of("groupName", "statementCount", "permissionCount", "savedBy", "savedDate", "policyJson")));
policySections.add(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3, List.of("id", "groupPk", "authentikFingerprint", "createDate", "modifyDate")));
applySections(qInstance, policyTable, policySections);
}
List<QFieldSection> appSections = new ArrayList<>();
appSections.add(new QFieldSection("details", "Details", new QIcon("apps"), Tier.T1,
List.of("name", "slug", "group", "providerName", "policyEngineMode", "launchUrl", "publisher", "hiddenFromLibrary")));
relate(qInstance, appSections, "bindings", "Who can open it (policy bindings)", "rule", application, binding, "bindingTarget", "target");
applySections(qInstance, application, appSections);
}
/*******************************************************************************
** The policy enforcement banner as the first section (when produced).
*******************************************************************************/
private static void enforcement(QInstance qInstance, AuthentikQBitConfig config, List<QFieldSection> sections)
{
String widget = config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.ENFORCEMENT);
if(qInstance.getWidget(widget) != null)
{
sections.add(0, new QFieldSection("enforcement", new QIcon("verified_user"), Tier.T2).withLabel("Policy enforcement").withWidgetName(widget));
}
}
/*******************************************************************************
** One join plus its child-list widget and the section that shows it.
*******************************************************************************/
private static void relate(QInstance qInstance, List<QFieldSection> sections, String key, String label, String icon,
String parentTable, String childTable, String parentField, String childField)
{
if(qInstance.getTable(parentTable) == null || qInstance.getTable(childTable) == null)
{
return;
}
String joinName = parentTable + "_" + key;
QJoinMetaData join = new QJoinMetaData()
.withName(joinName)
.withLeftTable(parentTable)
.withRightTable(childTable)
.withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn(parentField, childField));
qInstance.addJoin(join);
QWidgetMetaData widget = ChildRecordListRenderer.widgetMetaDataBuilder(join).withLabel(label).withMaxRows(20).getWidgetMetaData();
qInstance.addWidget(widget);
sections.add(new QFieldSection(key, new QIcon(icon), Tier.T2).withLabel(label).withWidgetName(widget.getName()));
}
/*******************************************************************************
** Replace a table's sections (the enricher would otherwise skip the field
** sections once a widget section exists).
*******************************************************************************/
private static void applySections(QInstance qInstance, String tableName, List<QFieldSection> sections)
{
QTableMetaData table = qInstance.getTable(tableName);
if(table != null)
{
table.setSections(sections);
}
}
}
@@ -0,0 +1,180 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.app;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Standard TABLE widget: inventory, age and actionable cleanup suggestions.
** Computed fields never become API filters; links use owner or token keys.
*******************************************************************************/
public class AuthentikTokenWidget extends AbstractWidgetRenderer
{
/*******************************************************************************
** Check the permission here as well as in metadata (non-HTTP callers).
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backend = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get("backendName"));
return (new RenderWidgetOutput(data(AuthentikActionUtil.forBackend(backend), Instant.now())));
}
/*******************************************************************************
** Report complete inventory and explicit unknown ages. An API token's
** activity is untracked and never qualifies as unused.
*******************************************************************************/
public static TableData data(AuthentikActionUtil util, Instant now) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
{
throw (new QPermissionDeniedException("Access token widgets require authentik.tokens.manage."));
}
AuthentikQBitConfig config = util.getRuntime().getConfig();
List<JSONObject> tokens = util.getAllPages("/core/tokens/", Integer.MAX_VALUE);
Map<String, Instant> activity = AuthentikTokenActivity.lastUsed(util);
Map<String, List<String>> owners = new LinkedHashMap<>();
Map<String, List<String>> ages = new LinkedHashMap<>();
for(String bucket : List.of("<30d", "30-90d", "90-365d", ">365d", "unknown"))
{
ages.put(bucket, new ArrayList<>());
}
Map<Integer, JSONObject> users = new LinkedHashMap<>();
List<Map<String, Object>> cleanup = new ArrayList<>();
TokenScopeStore scopes = new TokenScopeStore(config);
for(JSONObject token : tokens)
{
String id = token.getString("identifier");
JSONObject owner = token.getJSONObject("user_obj");
String username = owner.getString("username");
owners.computeIfAbsent(username, k -> new ArrayList<>()).add(id);
QRecord scope = scopes.find(id);
Instant created = scope == null ? null : scope.getValueInstant("createdAt");
long days = created == null ? -1 : ChronoUnit.DAYS.between(created, now);
ages.get(days < 0 ? "unknown" : days < 30 ? "<30d" : days < 90 ? "30-90d" : days <= 365 ? "90-365d" : ">365d").add(id);
int pk = owner.getInt("pk");
if(!users.containsKey(pk))
{
users.put(pk, util.getApiJsonOrNull("/core/users/" + pk + "/"));
}
JSONObject live = users.get(pk);
String links = link(list(config, "username", List.of(username)), username) + " / " + link(list(config, "identifier", List.of(id)), id);
if(live != null && !live.optBoolean("is_active", true))
{
cleanup.add(row("Owner deactivated", 1, links));
}
Instant last = activity.get(id);
Instant cutoff = now.minus(config.getTokenUnusedDays(), ChronoUnit.DAYS);
// Without a creation time or an event we cannot prove age. A 90-day
// event window cannot prove inactivity beyond 90 days either.
boolean unused = last != null ? last.isBefore(cutoff)
: config.getTokenUnusedDays() <= 90 && created != null && created.isBefore(cutoff);
if("app_password".equals(token.optString("intent")) && unused)
{
cleanup.add(row("Unused for " + config.getTokenUnusedDays() + " days", 1, links));
}
}
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(row("Total", tokens.size(), link(list(config, null, List.of()), "All access tokens")));
owners.entrySet().stream().sorted(Comparator.<Map.Entry<String, List<String>>>comparingInt(e -> e.getValue().size()).reversed().thenComparing(Map.Entry::getKey))
.limit(10).forEach(e -> rows.add(row("Owner: " + e.getKey(), e.getValue().size(), link(list(config, "username", List.of(e.getKey())), "Tokens"))));
ages.forEach((bucket, ids) -> rows.add(row("Age: " + bucket, ids.size(), ids.isEmpty() ? "" : link(list(config, "identifier", ids), "Tokens"))));
rows.addAll(cleanup);
return (new TableData("Access tokens", List.of(
new TableData.Column("default", "Category", "category", "35%", "left"),
new TableData.Column("default", "Count", "count", "10%", "right"),
new TableData.Column("html", "Access tokens", "tokens", "55%", "left")), rows)
.withRowsPerPage(25).withLinkText("All access tokens / bulk revoke").withLinkURL(list(config, null, List.of())));
}
/*******************************************************************************
** A standard table row; only the link cell contains escaped anchor markup.
*******************************************************************************/
private static Map<String, Object> row(String category, int count, String tokens)
{
return (Map.of("category", category, "count", count, "tokens", tokens));
}
/*******************************************************************************
** Native list filter format shared by Material and Next. Primary-key IN is
** supported by the API adapter as individual GETs; no computed filtering.
*******************************************************************************/
private static String list(AuthentikQBitConfig config, String field, List<String> values)
{
String url = (config.getLinkPrefix() == null ? "" : config.getLinkPrefix()) + "/" + config.applyTablePrefix("token");
if(field != null)
{
JSONObject criteria = new JSONObject().put("fieldName", field).put("operator", values.size() == 1 ? "EQUALS" : "IN").put("values", values);
String filter = new JSONObject().put("booleanOperator", "AND").put("criteria", new JSONArray().put(criteria)).toString();
url += "?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8);
}
return (url);
}
/*******************************************************************************
** Escape both the link target and text; descriptions are never HTML.
*******************************************************************************/
private static String link(String url, String text)
{
return ("<a href=\"" + escape(url) + "\">" + escape(text) + "</a>");
}
/*******************************************************************************
** Minimal HTML escaping for a standard table link cell.
*******************************************************************************/
private static String escape(String value)
{
return (value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
}
@@ -22,6 +22,7 @@ import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
@@ -30,6 +31,8 @@ import com.kingsrook.qbits.authentik.AuthentikRuntime;
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.qbits.authentik.security.WriteAuthorization;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -61,13 +64,18 @@ import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.apache.http.Header;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.NameValuePair;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPatch;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.util.EntityUtils;
import org.json.JSONArray;
import org.json.JSONException;
@@ -108,6 +116,8 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/** Display value for derived fields when the group graph cannot be read. */
public static final String UNAVAILABLE = "Unavailable (Authentik groups could not be read)";
private java.time.Instant deadline;
private EffectivePermissionResolver resolverForCall;
@@ -130,6 +140,94 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** Bound this dedicated health client; ordinary action clients are unchanged.
*******************************************************************************/
public AuthentikActionUtil withHealthDeadline(java.time.Instant deadline) throws QException
{
runtime().getGuard().checkHealth();
this.deadline = java.util.Objects.requireNonNull(deadline);
remainingMillis();
return (this);
}
/*******************************************************************************
** Remaining health budget; zero must never become an unlimited HTTP timeout.
*******************************************************************************/
private int remainingMillis() throws QException
{
long remaining = deadline == null ? Integer.MAX_VALUE : java.time.Duration.between(java.time.Instant.now(), deadline).toMillis();
if(remaining <= 0 || (deadline != null && Thread.currentThread().isInterrupted()))
{
throw (new QException("Authentik health deadline exceeded"));
}
return ((int) Math.min(Integer.MAX_VALUE, remaining));
}
/*******************************************************************************
** Configured zero means unlimited normally, but health always has a deadline.
*******************************************************************************/
private static int healthTimeout(int remaining, int configured)
{
return (configured <= 0 ? remaining : Math.min(remaining, configured));
}
/*******************************************************************************
** Keep retry delays inside the health budget and preserve cancellation.
*******************************************************************************/
private void retrySleep(long millis) throws QException
{
if(deadline == null)
{
SleepUtils.sleep(millis, java.util.concurrent.TimeUnit.MILLISECONDS);
return;
}
if(millis >= remainingMillis())
{
throw (new QException("Authentik health deadline exceeded"));
}
try
{
Thread.sleep(millis);
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Authentik health check interrupted"));
}
}
/*******************************************************************************
** Read the service account API token metadata through the existing paged path.
*******************************************************************************/
public List<JSONObject> getServiceAccountApiTokens() throws QException
{
runtime().getGuard().checkHealth();
return (getAllPages("/core/tokens/?intent=api&user__username=" + java.net.URLEncoder.encode(
runtime().getConfig().getEffectiveServiceAccountUsername(), StandardCharsets.UTF_8), 50));
}
/*******************************************************************************
** Broker credentials use the same secret-safe request loop as token exchange.
*******************************************************************************/
JSONObject requestBrokerCredentials(HttpPost request) throws QException
{
runtime().getGuard().checkHealth();
return (brokerResponse(request));
}
/*******************************************************************************
** The runtime for the backend this util serves.
*******************************************************************************/
@@ -168,6 +266,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
if(spec.isVirtual())
{
output.setCount(memberships(spec, countInput.getFilter()).size());
output.setDistinctCount(output.getCount());
return (output);
}
@@ -175,11 +274,13 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
if(mapped.isKeyLookup())
{
output.setCount(fetchByKeys(table, spec, mapped.keyLookup(), null).size());
output.setDistinctCount(output.getCount());
return (output);
}
JSONObject page = getJson(listUrl(spec, mapped, 1, 1));
output.setCount(pagination(page).optInt("count", 0));
output.setDistinctCount(output.getCount());
return (output);
}
@@ -357,6 +458,141 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** Send a request that changes Authentik data. Needs the guard's
** authorization; every successful write drops the response and group caches.
** The path is relative to /api/v3. Returns the response body as JSON (an
** empty object for 204 / empty bodies); a missing target is an error.
**
** @param method POST, PATCH or DELETE
** @param body request body (null for none)
*******************************************************************************/
public JSONObject write(WriteAuthorization authorization, String method, String pathAndQuery, JSONObject body) throws QException
{
return (writeAs(token(), authorization, method, pathAndQuery, body));
}
/*******************************************************************************
** Explicit bearer for an authorized token action, through the same send seam.
*******************************************************************************/
public JSONObject writeAs(String bearer, WriteAuthorization authorization, String method, String pathAndQuery, JSONObject body) throws QException
{
if(authorization == null)
{
throw (new QPermissionDeniedException("Authentik data can only be changed through the guard."));
}
if(pathAndQuery == null || pathAndQuery.contains("..") || !pathAndQuery.startsWith("/"))
{
throw (new QException("Bad Authentik path"));
}
checkAuthorization(authorization);
String url = runtime().getConfig().getApiRoot() + pathAndQuery;
HttpRequestBase request = switch(method)
{
case "POST" -> new HttpPost(url);
case "PATCH" -> new HttpPatch(url);
case "DELETE" -> new HttpDelete(url);
default -> throw (new QException("Unsupported write method " + method));
};
if(body != null && request instanceof HttpEntityEnclosingRequest entityRequest)
{
entityRequest.setEntity(new StringEntity(body.toString(), ContentType.APPLICATION_JSON));
}
try
{
QHttpResponse response = execute(request, bearer, false);
if(response.getStatusCode() != null && response.getStatusCode() == 404)
{
throw (new QUserFacingException("Authentik no longer has that record (it may have been removed in the Authentik admin UI)."));
}
String content = response.getContent();
return (content == null || content.isBlank() ? new JSONObject() : new JSONObject(content));
}
catch(JSONException e)
{
return (new JSONObject());
}
finally
{
runtime().invalidateCache();
}
}
/*******************************************************************************
** Read token metadata or its new key as its owner; never use the read cache.
*******************************************************************************/
public JSONObject getAs(String bearer, WriteAuthorization auth, String path) throws QException
{
checkAuthorization(auth);
if(path == null || !path.startsWith("/core/tokens/") || path.contains("..") || path.contains("?"))
{
throw (new QException("Bad token path"));
}
QHttpResponse response = execute(new HttpGet(runtime().getConfig().getApiRoot() + path), bearer, false);
try
{
return (response.getStatusCode() == 404 ? null : new JSONObject(response.getContent()));
}
catch(JSONException e)
{
throw (new QUserFacingException("Authentik returned an invalid token response."));
}
}
/*******************************************************************************
** Exchange through the shared throttle/send seam with no service bearer.
*******************************************************************************/
public JSONObject exchangeToken(WriteAuthorization auth, HttpPost request) throws QException
{
checkAuthorization(auth);
return (brokerResponse(request));
}
/*******************************************************************************
** Parse a broker response without exposing its body.
*******************************************************************************/
private JSONObject brokerResponse(HttpPost request) throws QException
{
if(!request.getURI().toString().equals(runtime().getConfig().getTokenEndpointUrl()))
{
throw (new QException("Bad token endpoint"));
}
try
{
return (new JSONObject(execute(request, null, true).getContent()));
}
catch(JSONException e)
{
throw (new QUserFacingException("Authentik refused to act as you (invalid_response)"));
}
}
/*******************************************************************************
** Revalidate the authorization against the current session and config.
*******************************************************************************/
private void checkAuthorization(WriteAuthorization auth) throws QException
{
if(auth == null || !java.util.Objects.equals(auth.getOperator(), runtime().getGuard().operatorEmail()))
{
throw (new QPermissionDeniedException("Authentik data can only be changed through the guard."));
}
runtime().getGuard().authorize(com.kingsrook.qbits.authentik.admin.AdminAction.valueOf(auth.getAction()));
}
/*******************************************************************************
** Fetch a single Authentik object by key (null when it doesn't exist).
** No permission check: callers check first.
@@ -397,6 +633,26 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** GET an API path whose response is a bare JSON array (not paged), such as
** the MFA device list.
*******************************************************************************/
public JSONArray getApiArray(String pathAndQuery) throws QException
{
String url = runtime().getConfig().getApiRoot() + pathAndQuery;
String body = getBody(url, false);
try
{
return (new JSONArray(body));
}
catch(JSONException e)
{
throw (new QException("Authentik returned an unexpected response for " + pathOf(url)));
}
}
/*******************************************************************************
** Fetch every page of a list endpoint (relative path with query string,
** without page parameters). Bounded by maxObjects.
@@ -599,6 +855,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
try
{
QRecord record = new QRecord();
record.setTableName(tableNameOf(spec));
for(AuthentikFieldSpec field : spec.getFields())
{
if(field.isSensitive() && !maySeeSensitive)
@@ -648,6 +905,10 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
record.setDisplayValue("isSuperuserInherited", UNAVAILABLE);
}
}
else if(AuthentikTables.TOKEN.equals(spec.getName()))
{
AuthentikTokenActivity.enrich(this, record);
}
return (record);
}
catch(JSONException | IllegalArgumentException e)
@@ -665,7 +926,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public GroupGraph groupGraph() throws QException
{
AuthentikRuntime runtime = runtime();
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
if(cache)
{
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("all").map(m -> m.getResult());
@@ -680,7 +941,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
throw (new QUserFacingException("More than " + MAX_SWEEP_OBJECTS + " Authentik groups; the group graph is not supported at this size."));
}
GroupGraph graph = GroupGraph.fromGroups(groups);
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
if(cache)
{
runtime.getGroupGraphCache().storeResult("all", graph);
@@ -690,6 +951,131 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** The produced (prefixed) table name for a spec: records must carry it (the
** Next UI rejects a record response without a tableName).
*******************************************************************************/
private String tableNameOf(AuthentikTableSpec spec)
{
return (runtime().getConfig().applyTablePrefix(spec.getName()));
}
/*******************************************************************************
** The runtime of the backend this util serves (config, guard, caches).
*******************************************************************************/
public AuthentikRuntime getRuntime()
{
return (runtime());
}
/*******************************************************************************
** The group graph WITH member pks (one sweep; cached like the plain graph
** under a separate key). Used by safety checks that need to know who is in
** which group (last-admin protection).
*******************************************************************************/
public GroupGraph groupGraphWithMembers() throws QException
{
AuthentikRuntime runtime = runtime();
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
if(cache)
{
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("members").map(m -> m.getResult());
if(cached.isPresent())
{
return (cached.get());
}
}
List<JSONObject> groups = getAllPages("/core/groups/?include_users=true&include_parents=false&include_children=false&ordering=name", MAX_SWEEP_OBJECTS);
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
if(cache)
{
runtime.getGroupGraphCache().storeResult("members", graph);
}
return (graph);
}
/*******************************************************************************
** Every user the qbit may see (default types, user scope applied), without
** group objects. Bounded sweep.
*******************************************************************************/
public List<JSONObject> allUsers() throws QException
{
try
{
URIBuilder builder = new URIBuilder("/core/users/");
boolean groupScope = runtime().getConfig().getUserScopeGroupNames() != null && !runtime().getConfig().getUserScopeGroupNames().isEmpty();
builder.addParameter("include_groups", String.valueOf(groupScope));
builder.addParameter("include_roles", "false");
addUserScope(builder, List.of());
List<JSONObject> rs = new ArrayList<>();
for(JSONObject user : getAllPages(builder.build().toString(), MAX_SWEEP_OBJECTS))
{
if(runtime().getGuard().isUserInScope(user))
{
rs.add(user);
}
}
return (rs);
}
catch(java.net.URISyntaxException e)
{
throw (new QException("Bad Authentik URL", e));
}
}
/*******************************************************************************
** A bounded, username-ordered policy-preview lookup. Membership, active
** status and user scope are evaluated by Authentik before the limit.
** This read needs only the editor permission, not the users table permission.
*******************************************************************************/
public List<JSONObject> policyPreviewUsers(Collection<String> groupPks, int maxObjects) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.POLICIES_MANAGE))
{
throw (new QPermissionDeniedException("You do not have permission to preview policies (authentik.policies.manage)."));
}
if(groupPks.isEmpty())
{
return (List.of());
}
try
{
URIBuilder builder = new URIBuilder("/core/users/");
builder.addParameter("include_groups", "true");
builder.addParameter("include_roles", "false");
builder.addParameter("is_active", "true");
builder.addParameter("ordering", "username");
for(String pk : groupPks)
{
builder.addParameter("groups_by_pk", pk);
}
addUserScope(builder, List.of());
List<JSONObject> users = new ArrayList<>();
for(JSONObject user : getAllPages(builder.build().toString(), maxObjects))
{
if(runtime().getGuard().isUserInScope(user) && user.optBoolean("is_active", false))
{
users.add(user);
}
}
return (users);
}
catch(java.net.URISyntaxException e)
{
throw (new QException("Bad Authentik URL", e));
}
}
/*******************************************************************************
** A resolver over the current group graph and the instance's permissions.
*******************************************************************************/
@@ -1016,7 +1402,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
private String getBody(String url, boolean nullOn404) throws QException
{
AuthentikRuntime runtime = runtime();
if(runtime.isCacheEnabled())
if(deadline == null && runtime.isCacheEnabled())
{
Optional<String> cached = runtime.getResponseCache().getMemoizedResult(url).map(m -> m.getResult());
if(cached.isPresent())
@@ -1036,7 +1422,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
String body = response.getContent();
if(runtime.isCacheEnabled() && body != null)
if(deadline == null && runtime.isCacheEnabled() && body != null)
{
runtime.getResponseCache().storeResult(url, body);
}
@@ -1067,13 +1453,22 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
** error mapping, logging. Returns 2xx and 404 responses.
*******************************************************************************/
public QHttpResponse execute(HttpRequestBase request) throws QException
{
return (execute(request, token(), false));
}
/*******************************************************************************
** Shared request loop; exchange responses and all token traffic stay secret.
*******************************************************************************/
private QHttpResponse execute(HttpRequestBase request, String token, boolean exchange) throws QException
{
String path = pathOf(request.getURI().toString());
int attempts = 0;
int backoffMs = INITIAL_BACKOFF_MS;
String token = token();
if(!StringUtils.hasContent(token))
if(!exchange && !StringUtils.hasContent(token))
{
throw (new QException("The Authentik API token is not configured (apiToken resolved to an empty value)."));
}
@@ -1081,12 +1476,28 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
while(true)
{
attempts++;
runtime().getThrottle().acquire();
request.setHeader("Authorization", "Bearer " + token);
remainingMillis();
if(deadline == null)
{
runtime().getThrottle().acquire();
}
else
{
runtime().getThrottle().acquire(deadline);
int remaining = remainingMillis();
request.setConfig(RequestConfig.custom()
.setConnectTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
.setConnectionRequestTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
.setSocketTimeout(healthTimeout(remaining, runtime().getConfig().getSocketTimeoutMillis())).build());
}
if(!exchange)
{
request.setHeader("Authorization", "Bearer " + token);
}
request.setHeader("Accept", "application/json");
if(request instanceof HttpEntityEnclosingRequest)
{
request.setHeader("Content-Type", "application/json");
request.setHeader("Content-Type", exchange ? "application/x-www-form-urlencoded" : "application/json");
}
long start = System.currentTimeMillis();
@@ -1094,14 +1505,15 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
try
{
response = send(request);
remainingMillis();
}
catch(Exception e)
{
String message = AuthentikRedactor.redactText(e.getMessage(), token);
String message = exchange || AuthentikRedactor.isSensitivePath(path) ? "Sensitive request failed" : AuthentikRedactor.redactText(e.getMessage(), token);
LOG.warn("Authentik API call failed", logPair("method", request.getMethod()), logPair("path", path), logPair("error", message));
if(attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(backoffMs);
backoffMs *= 2;
continue;
}
@@ -1110,18 +1522,24 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
int status = response.getStatusCode() == null ? 0 : response.getStatusCode();
LOG.info("Authentik API call", logPair("method", request.getMethod()), logPair("path", path), logPair("status", status), logPair("millis", System.currentTimeMillis() - start));
logOutboundCall(request, path, response, token);
checkVersionOnce(status);
if(!exchange)
{
logOutboundCall(request, path, response, token);
}
if(deadline == null)
{
checkVersionOnce(status);
}
if(status == 429 && attempts <= MAX_RETRIES)
{
SleepUtils.sleep(retryAfterMillis(response, backoffMs), java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(retryAfterMillis(response, backoffMs));
backoffMs *= 2;
continue;
}
if(status >= 500 && attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(backoffMs);
backoffMs *= 2;
continue;
}
@@ -1130,6 +1548,23 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
return (response);
}
if(exchange)
{
String error = "exchange_failed";
try
{
String code = new JSONObject(response.getContent()).optString("error");
if(List.of("invalid_grant", "access_denied", "invalid_client", "invalid_request", "unsupported_grant_type", "invalid_scope").contains(code))
{
error = code;
}
}
catch(JSONException ignored)
{
// Never echo an untrusted broker error body.
}
throw (new BrokerRefusedException(error));
}
throw (mapError(request, path, status, response.getContent(), token));
}
}
@@ -1276,7 +1711,11 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
String requestBody = null;
if(request instanceof HttpEntityEnclosingRequest entityRequest && entityRequest.getEntity() != null)
if("POST".equals(request.getMethod()) && path.endsWith("/core/users/"))
{
path = path + " [body omitted: user create]";
}
else if(request instanceof HttpEntityEnclosingRequest entityRequest && entityRequest.getEntity() != null)
{
requestBody = EntityUtils.toString(entityRequest.getEntity(), StandardCharsets.UTF_8);
}
@@ -58,17 +58,23 @@ public final class AuthentikApiSurface
{
List<Operation> rs = new ArrayList<>();
rs.add(new Operation("get", "/admin/version/", Set.of()));
rs.add(new Operation("post", "/core/tokens/", Set.of()));
rs.add(new Operation("patch", "/core/tokens/{key}/", Set.of()));
rs.add(new Operation("delete", "/core/tokens/{key}/", Set.of()));
rs.add(new Operation("get", "/core/tokens/{key}/view_key/", Set.of()));
rs.add(new Operation("post", "/core/tokens/{key}/set_key/", Set.of()));
rs.add(new Operation("post", "/core/users/service_account/", 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/", Set.of("username", "include_groups", "include_roles", "attributes", "type", "path_startswith", "groups_by_name", "groups_by_pk", "is_active", "ordering", "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")));
rs.add(new Operation("get", "/events/events/", Set.of("username", "action", "ordering", "page", "page_size")));
return (rs);
}
@@ -37,10 +37,10 @@ public final class AuthentikRedactor
/** 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");
"/application/o/token/", "/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);
"(\"(?:key|token|access_token|subject_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._~+/=-]+");
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Supplies the current operator's subject JWT for one token action.
*******************************************************************************/
@FunctionalInterface
public interface AuthentikSubjectTokenSource
{
/*******************************************************************************
** Return a subject JWT without retaining or logging it.
*******************************************************************************/
String subjectToken(QSession session) throws QException;
}
@@ -41,6 +41,7 @@ public class AuthentikTableSpec
private final Map<String, AuthentikFieldSpec> fields = new LinkedHashMap<>();
private final List<NameValuePair> defaultListParams = new ArrayList<>();
private final List<NameValuePair> defaultGetParams = new ArrayList<>();
private IdentityPermission alternativeReadPermission;
private IdentityPermission readPermission = IdentityPermission.USERS_READ;
private String defaultOrdering;
private List<String> recordLabelFields = new ArrayList<>();
@@ -298,4 +299,34 @@ public class AuthentikTableSpec
return (requiredFilterField);
}
/*******************************************************************************
** Add a second permission that also permits reads.
*******************************************************************************/
public AuthentikTableSpec withAlternativeReadPermission(IdentityPermission permission)
{
alternativeReadPermission = permission;
return (this);
}
/*******************************************************************************
** Getter for the optional alternative permission.
*******************************************************************************/
public IdentityPermission getAlternativeReadPermission()
{
return (alternativeReadPermission);
}
/*******************************************************************************
** Token tables need OR rules in core as well as in the API guard.
*******************************************************************************/
public com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules readRules()
{
return (alternativeReadPermission == null ? readPermission.rules() : com.kingsrook.qbits.authentik.security.TokenReadPermission.rules());
}
}
@@ -49,6 +49,12 @@ public final class AuthentikTables
public static final String GROUP_MEMBER = "groupMember";
public static final String SESSION = "session";
public static final String EVENT = "event";
public static final String APPLICATION = "application";
public static final String POLICY_BINDING = "policyBinding";
public static final String BLUEPRINT = "blueprint";
public static final String ROLE = "role";
public static final String TOKEN = "token";
public static final String INVITATION = "invitation";
/*******************************************************************************
** Table names produced before the owner naming rule (2026-10-12), with the
@@ -78,6 +84,12 @@ public final class AuthentikTables
register(groupMembers());
register(sessions());
register(events());
register(applications());
register(policyBindings());
register(blueprints());
register(roles());
register(tokens());
register(invitations());
}
@@ -181,10 +193,10 @@ public final class AuthentikTables
static AuthentikTableSpec groups()
{
AuthentikTableSpec spec = new AuthentikTableSpec(GROUP, "Groups", "groups", "/core/groups/", "pk")
.withDefaultListParam("include_users", "false")
.withDefaultListParam("include_users", "true")
.withDefaultListParam("include_parents", "true")
.withDefaultListParam("include_children", "true")
.withDefaultGetParam("include_users", "false")
.withDefaultGetParam("include_users", "true")
.withDefaultGetParam("include_parents", "true")
.withDefaultGetParam("include_children", "true")
.withDefaultOrdering("name")
@@ -303,4 +315,136 @@ public final class AuthentikTables
return (spec);
}
/*******************************************************************************
** /core/applications/ (slug). Read only. A non-superuser caller (the service
** account) sees only applications it can itself access, so the qbit never
** relies on this list for access decisions (DESIGN.md 0.2 iii).
*******************************************************************************/
static AuthentikTableSpec applications()
{
AuthentikTableSpec spec = new AuthentikTableSpec(APPLICATION, "Applications", "apps", "/core/applications/", "slug")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("slug", "Slug", QFieldType.STRING, string("slug")).withFilter(QCriteriaOperator.EQUALS, "slug"));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("group", "Group", QFieldType.STRING, string("group")).withFilter(QCriteriaOperator.EQUALS, "group"));
spec.withField(new AuthentikFieldSpec("providerName", "Provider", QFieldType.STRING, at("provider_obj", "name")));
spec.withField(new AuthentikFieldSpec("policyEngineMode", "Policy Engine Mode", QFieldType.STRING, string("policy_engine_mode")));
spec.withField(new AuthentikFieldSpec("launchUrl", "Launch URL", QFieldType.STRING, string("launch_url")));
spec.withField(new AuthentikFieldSpec("publisher", "Publisher", QFieldType.STRING, string("meta_publisher")).withFilter(QCriteriaOperator.EQUALS, "meta_publisher"));
spec.withField(new AuthentikFieldSpec("hiddenFromLibrary", "Hidden", QFieldType.BOOLEAN, bool("meta_hide")));
spec.withField(new AuthentikFieldSpec("bindingTarget", "Binding Target", QFieldType.STRING, string("pbm_uuid")).withHidden());
return (spec);
}
/*******************************************************************************
** /policies/bindings/ (uuid): who or what an application's access policy
** binds. Read only.
*******************************************************************************/
static AuthentikTableSpec policyBindings()
{
AuthentikTableSpec spec = new AuthentikTableSpec(POLICY_BINDING, "Policy Bindings", "rule", "/policies/bindings/", "pk")
.withDefaultOrdering("order")
.withRecordLabelFields("pk");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("target", "Target Id", QFieldType.STRING, string("target")).withFilter(QCriteriaOperator.EQUALS, "target").withHidden());
spec.withField(new AuthentikFieldSpec("policyName", "Policy", QFieldType.STRING, at("policy_obj", "name")));
spec.withField(new AuthentikFieldSpec("groupName", "Group", QFieldType.STRING, at("group_obj", "name")));
spec.withField(new AuthentikFieldSpec("username", "User", QFieldType.STRING, at("user_obj", "username")));
spec.withField(new AuthentikFieldSpec("negate", "Negate", QFieldType.BOOLEAN, bool("negate")));
spec.withField(new AuthentikFieldSpec("enabled", "Enabled", QFieldType.BOOLEAN, bool("enabled")).withFilter(QCriteriaOperator.EQUALS, "enabled"));
spec.withField(new AuthentikFieldSpec("order", "Order", QFieldType.INTEGER, integer("order")).withFilter(QCriteriaOperator.EQUALS, "order").withOrdering("order"));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
return (spec);
}
/*******************************************************************************
** /managed/blueprints/ (uuid). Read only; the drift report uses it too.
*******************************************************************************/
static AuthentikTableSpec blueprints()
{
AuthentikTableSpec spec = new AuthentikTableSpec(BLUEPRINT, "Blueprints", "description", "/managed/blueprints/", "pk")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("path", "Path", QFieldType.STRING, string("path")).withFilter(QCriteriaOperator.EQUALS, "path"));
spec.withField(new AuthentikFieldSpec("status", "Status", QFieldType.STRING, string("status")));
spec.withField(new AuthentikFieldSpec("enabled", "Enabled", QFieldType.BOOLEAN, bool("enabled")));
spec.withField(new AuthentikFieldSpec("lastApplied", "Last Applied", QFieldType.DATE_TIME, instant("last_applied")));
return (spec);
}
/*******************************************************************************
** /rbac/roles/ (uuid). Read only in v1 (role management is phase 4);
** system-managed roles are hidden.
*******************************************************************************/
static AuthentikTableSpec roles()
{
AuthentikTableSpec spec = new AuthentikTableSpec(ROLE, "Roles", "admin_panel_settings", "/rbac/roles/", "pk")
.withDefaultListParam("managed__isnull", "true")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
return (spec);
}
/*******************************************************************************
** /core/tokens/ (identifier). Metadata only: there is NO key field, and the
** service account never reads token keys. Auditors and token managers may read.
*******************************************************************************/
static AuthentikTableSpec tokens()
{
AuthentikTableSpec spec = new AuthentikTableSpec(TOKEN, "Tokens", "key", "/core/tokens/", "identifier")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withAlternativeReadPermission(IdentityPermission.TOKENS_MANAGE)
.withDefaultOrdering("identifier")
.withRecordLabelFields("identifier");
spec.withField(new AuthentikFieldSpec("identifier", "Identifier", QFieldType.STRING, string("identifier")).withSearchable().withOrdering("identifier"));
spec.withField(new AuthentikFieldSpec("intent", "Intent", QFieldType.STRING, string("intent")).withFilter(QCriteriaOperator.EQUALS, "intent"));
spec.withField(new AuthentikFieldSpec("username", "User", QFieldType.STRING, at("user_obj", "username")).withFilter(QCriteriaOperator.EQUALS, "user__username"));
spec.withField(new AuthentikFieldSpec("description", "Description", QFieldType.STRING, string("description")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("expiring", "Expiring", QFieldType.BOOLEAN, bool("expiring")).withFilter(QCriteriaOperator.EQUALS, "expiring"));
spec.withField(new AuthentikFieldSpec("ownerPk", "Owner Id", QFieldType.INTEGER, intAt("user_obj", "pk")));
spec.withField(new AuthentikFieldSpec("ownerType", "Owner Type", QFieldType.STRING, at("user_obj", "type")));
spec.withField(new AuthentikFieldSpec("ownerName", "Owner", QFieldType.STRING, at("user_obj", "name")));
spec.withField(new AuthentikFieldSpec("managed", "Managed", QFieldType.STRING, string("managed")));
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, o -> null));
spec.withField(new AuthentikFieldSpec("lastUsedNote", "Last Used Note", QFieldType.STRING, o -> null));
spec.withField(new AuthentikFieldSpec("createdAt", "Created", QFieldType.DATE_TIME, o -> null));
spec.withField(new AuthentikFieldSpec("scopeSummary", "Scope", QFieldType.STRING, o -> null));
return (spec);
}
/*******************************************************************************
** /stages/invitation/invitations/ (uuid). Created by the Invite User action;
** cancelled by Cancel Invitation.
*******************************************************************************/
static AuthentikTableSpec invitations()
{
AuthentikTableSpec spec = new AuthentikTableSpec(INVITATION, "Invitations", "mail", "/stages/invitation/invitations/", "pk")
.withDefaultOrdering("name")
.withRecordLabelFields("name");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")).withSearchable().withOrdering("name"));
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, at("fixed_data", "email")));
spec.withField(new AuthentikFieldSpec("singleUse", "Single Use", QFieldType.BOOLEAN, bool("single_use")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("flowSlug", "Flow", QFieldType.STRING, at("flow_obj", "slug")));
spec.withField(new AuthentikFieldSpec("createdBy", "Created By", QFieldType.STRING, at("created_by", "username")));
return (spec);
}
}
@@ -0,0 +1,112 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Non-secret local metadata and the most recent app-password login per token.
*******************************************************************************/
public final class AuthentikTokenActivity
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikTokenActivity()
{
}
/*******************************************************************************
** The pinned schema has no created__gt. Walk descending pages until the
** 90-day boundary instead of sending a silently ignored query parameter.
*******************************************************************************/
public static Map<String, Instant> lastUsed(AuthentikActionUtil util) throws QException
{
return (util.getRuntime().getTokenActivityCache().getResultThrowing("login", key ->
{
Map<String, Instant> latest = new LinkedHashMap<>();
Instant cutoff = Instant.now().minus(90, ChronoUnit.DAYS);
for(int page = 1; ; page++)
{
String path = "/events/events/?action=login&ordering=-created&page=" + page + "&page_size=100";
util.getRuntime().getResponseCache().clearKey(util.getRuntime().getConfig().getApiRoot() + path);
JSONObject response = util.getApiJson(path);
JSONArray events = response.getJSONArray("results");
for(int i = 0; i < events.length(); i++)
{
JSONObject event = events.getJSONObject(i);
Instant at = Instant.parse(event.getString("created"));
if(at.isBefore(cutoff))
{
return (Map.copyOf(latest));
}
JSONObject context = event.optJSONObject("context");
if("login".equals(event.optString("action")) && context != null && "token".equals(context.optString("method")))
{
JSONObject args = context.optJSONObject("method_args");
String identifier = args == null ? "" : args.optString("identifier");
if(!identifier.isBlank())
{
latest.merge(identifier, at, (a, b) -> a.isAfter(b) ? a : b);
}
}
}
if(events.isEmpty() || page >= response.getJSONObject("pagination").getInt("total_pages"))
{
return (Map.copyOf(latest));
}
}
}).orElseThrow());
}
/*******************************************************************************
** Enrich only token records, never leaking token keys into the table.
*******************************************************************************/
public static void enrich(AuthentikActionUtil util, QRecord record) throws QException
{
var config = util.getRuntime().getConfig();
QRecord scope = QContext.getQInstance().getTable(config.applyTablePrefix("token_scope")) == null ? null
: new TokenScopeStore(config).find(record.getValueString("identifier"));
record.setValue("createdAt", scope == null ? null : scope.getValue("createdAt"));
record.setValue("scopeSummary", TokenScopeStore.summary(scope));
if("api".equals(record.getValueString("intent")))
{
record.setValue("lastUsedNote", "not tracked");
record.setDisplayValue("lastUsed", "not tracked");
}
else
{
record.setValue("lastUsed", lastUsed(util).get(record.getValueString("identifier")));
record.setValue("lastUsedNote", record.getValue("lastUsed") == null ? "No login in the last 90 days" : null);
}
}
}
@@ -0,0 +1,101 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.nio.charset.StandardCharsets;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
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 org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.json.JSONObject;
/*******************************************************************************
** RFC 8693 exchange for a two-minute API bearer, never cached or logged.
*******************************************************************************/
public class AuthentikTokenBroker
{
/*******************************************************************************
** Exchange the operator's JWT through the configured protected-user broker.
*******************************************************************************/
public String exchange(AuthentikActionUtil util, WriteAuthorization auth) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
String subject = config.getSubjectTokenSource().subjectToken(QContext.getQSession());
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
request.setEntity(new UrlEncodedFormEntity(List.of(
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret()),
new BasicNameValuePair("subject_token", subject),
new BasicNameValuePair("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"),
new BasicNameValuePair("scope", "goauthentik.io/api")), StandardCharsets.UTF_8));
JSONObject result = util.exchangeToken(auth, request);
String bearer = result.optString("access_token", "");
if(bearer.isBlank())
{
throw (new QUserFacingException("Authentik refused to act as you (invalid_response)"));
}
return (bearer);
}
/*******************************************************************************
** Prove the broker's client credentials with the grant it actually uses
** (token exchange), without a subject token, so nothing is issued and no
** user data is sent. Authentik checks the grant type, then the client
** secret, then the subject: invalid_request (missing subject) means both
** passed; invalid_client means a bad secret; invalid_grant means the
** provider does not allow token exchange.
*******************************************************************************/
public void probeCredentials(AuthentikActionUtil util) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
request.setEntity(new UrlEncodedFormEntity(List.of(
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret())), StandardCharsets.UTF_8));
String code;
try
{
util.requestBrokerCredentials(request);
code = "issued";
}
catch(BrokerRefusedException e)
{
code = e.getErrorCode();
}
switch(code)
{
case "invalid_request" ->
{
return;
}
case "invalid_client" -> throw (new QUserFacingException("Authentik rejected the token broker's client credentials"));
case "invalid_grant", "unsupported_grant_type" -> throw (new QUserFacingException("The token broker's Authentik provider does not allow token exchange"));
default -> throw (new QUserFacingException("Authentik answered the token broker probe unexpectedly (" + code + ")"));
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
/*******************************************************************************
** Authentik's token endpoint refused a broker request. Carries only the
** standard OAuth2 error code (never the untrusted response body).
*******************************************************************************/
public class BrokerRefusedException extends QUserFacingException
{
private final String errorCode;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public BrokerRefusedException(String errorCode)
{
super("Authentik refused to act as you (" + errorCode + ")");
this.errorCode = errorCode;
}
/*******************************************************************************
** The OAuth2 error code, or exchange_failed when it was not a standard one.
*******************************************************************************/
public String getErrorCode()
{
return (errorCode);
}
}
@@ -0,0 +1,75 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Reads the OAuth2 module's user-session row (uuid and accessToken in RC.2).
** No memoization: the credential lives only for the current action.
*******************************************************************************/
public class OAuth2SubjectTokenSource implements AuthentikSubjectTokenSource
{
public static final String UNAVAILABLE = "Self-service tokens need a signed-in Authentik session.";
/*******************************************************************************
** Look up the exact current session, including its hidden access token.
*******************************************************************************/
@Override
public String subjectToken(QSession session) throws QException
{
if(session != null && session == QContext.getQSession() && !(session instanceof QSystemUserSession) && session.getUser() != null && session.getUuid() != null && QContext.getQInstance() != null
&& QContext.getQInstance().getAuthentication() instanceof OAuth2AuthenticationMetaData oauth
&& oauth.getUserSessionTableName() != null)
{
QRecord row;
QSession previous = QContext.getQSession();
try
{
// Core uses a system read here too, avoiding recursive OAuth validation.
// The UUID is taken only from the already authorized current session.
QContext.setQSession(new QSystemUserSession());
row = new GetAction().executeForRecord(new GetInput(oauth.getUserSessionTableName())
.withUniqueKey(Map.of("uuid", session.getUuid())).withShouldMaskPasswords(false).withShouldOmitHiddenFields(false));
}
catch(QException e)
{
throw (new QUserFacingException(UNAVAILABLE));
}
finally
{
QContext.setQSession(previous);
}
if(row != null && row.getValueString("accessToken") != null && !row.getValueString("accessToken").isBlank())
{
return (row.getValueString("accessToken"));
}
}
throw (new QUserFacingException(UNAVAILABLE));
}
}
@@ -90,6 +90,30 @@ public class TokenBucketThrottle
/*******************************************************************************
** Reserve a token only when the health deadline permits the throttle delay.
*******************************************************************************/
public void acquire(java.time.Instant deadline) throws com.kingsrook.qqq.backend.core.exceptions.QException
{
long waitNanos;
synchronized(this)
{
refill();
waitNanos = (long) Math.ceil(Math.max(0, 1 - tokens) / tokensPerNano);
if(Thread.currentThread().isInterrupted() || waitNanos >= java.time.Duration.between(java.time.Instant.now(), deadline).toNanos())
{
throw (new com.kingsrook.qqq.backend.core.exceptions.QException("Authentik health deadline exceeded"));
}
tokens -= 1;
}
if(waitNanos > 0)
{
nanoSleeper.accept(waitNanos);
}
}
/*******************************************************************************
** Tokens currently available (may be negative while callers are waiting).
*******************************************************************************/
@@ -22,6 +22,7 @@ import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
@@ -32,9 +33,9 @@ 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;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
@@ -61,6 +62,9 @@ public class AuthentikDriftReport
{
private static final QLogger LOG = QLogger.getLogger(AuthentikDriftReport.class);
private static final Pattern SAFE_PERMISSION = Pattern.compile("[A-Za-z0-9_.*-]{1,120}");
private static final Pattern SAFE_ATTRIBUTE = Pattern.compile("[A-Za-z0-9_./-]{1,60}");
private final AuthentikActionUtil client;
private final AuthentikQBitConfig config;
private final AuthentikGuard guard;
@@ -98,6 +102,19 @@ public class AuthentikDriftReport
/*******************************************************************************
** Reuse only mapper attributes and service-account lockout diagnostics.
*******************************************************************************/
public List<DriftFinding> runMapperChecks()
{
List<DriftFinding> findings = new ArrayList<>();
runCheck(findings, "group attributes", this::checkGroupAttributes);
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
return (findings);
}
/*******************************************************************************
** A check.
*******************************************************************************/
@@ -304,7 +321,7 @@ public class AuthentikDriftReport
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);
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat(), client.getRuntime().getPolicyCatalog());
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
@@ -345,7 +362,7 @@ public class AuthentikDriftReport
if(admins.isEmpty())
{
String bootstrap = config.getResolvedBootstrapOperators().isEmpty() ? "" : " bootstrapOperators is set, which covers sign-in for now.";
String bootstrap = config.getResolvedBootstrapSubjects().isEmpty() ? "" : " bootstrapOperatorSubjects is set, which covers sign-in for now.";
findings.add(DriftFinding.error("identity admins", "-", "Nobody can administer identities from QQQ: no active, unprotected human user holds authentik.memberships.manage and authentik.permissions.manage. Add an administrator to a group carrying authentik.* (e.g. identity-admins) in the Authentik UI." + bootstrap));
}
else if(admins.size() == 1)
@@ -360,6 +377,19 @@ public class AuthentikDriftReport
/*******************************************************************************
** The attribute name of a GroupGraph malformed entry ("key: detail"), never
** the detail, which can echo whatever a person typed.
*******************************************************************************/
static String attributeOf(String malformedEntry)
{
int colon = malformedEntry == null ? -1 : malformedEntry.indexOf(": ");
String key = colon < 0 ? "" : malformedEntry.substring(0, colon);
return (SAFE_ATTRIBUTE.matcher(key).matches() ? key : "attribute");
}
/*******************************************************************************
** 6
*******************************************************************************/
@@ -375,14 +405,16 @@ public class AuthentikDriftReport
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."));
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.")
.withReason("malformed " + attributeOf(bad)));
}
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)."));
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard; a name the app checks only in code, not in its metadata, is not in the catalog).")
.withReason("grants nothing: " + (SAFE_PERMISSION.matcher(permission).matches() ? permission : "(unprintable name)")));
}
}
for(String app : node.appAccess())
@@ -396,7 +428,8 @@ public class AuthentikDriftReport
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."));
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.")
.withReason("superuser group carries qqq.io/permissions"));
}
}
if(issues == 0)
@@ -426,7 +459,7 @@ public class AuthentikDriftReport
continue;
}
String modelName = model.optString("model_name");
String pk = model.optString("pk");
String pk = dashed(model.optString("pk"));
boolean sensitive = false;
if(!"user".equals(modelName) && !"group".equals(modelName))
{
@@ -458,4 +491,19 @@ public class AuthentikDriftReport
}
}
/*******************************************************************************
** Authentik events carry group pks as 32 hex characters without dashes;
** the graph is keyed by the dashed uuid.
*******************************************************************************/
static String dashed(String pk)
{
if(pk != null && pk.matches("[0-9a-fA-F]{32}"))
{
return (pk.substring(0, 8) + "-" + pk.substring(8, 12) + "-" + pk.substring(12, 16) + "-" + pk.substring(16, 20) + "-" + pk.substring(20));
}
return (pk);
}
}
@@ -25,10 +25,33 @@ package com.kingsrook.qbits.authentik.drift;
** @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
** @param reason a short, safe summary for health outcomes (no attribute
** values a person typed beyond validated permission names);
** null when the finding has none
*******************************************************************************/
public record DriftFinding(Severity severity, String check, String subject, String message)
public record DriftFinding(Severity severity, String check, String subject, String message, String reason)
{
/*******************************************************************************
** A finding without a health reason.
*******************************************************************************/
public DriftFinding(Severity severity, String check, String subject, String message)
{
this(severity, check, subject, message, null);
}
/*******************************************************************************
** This finding with a short, safe reason for health outcomes.
*******************************************************************************/
public DriftFinding withReason(String reason)
{
return (new DriftFinding(severity, check, subject, message, reason));
}
/*******************************************************************************
** Severity, most severe first.
*******************************************************************************/
@@ -0,0 +1,260 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.health;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.TreeMap;
import java.util.TreeSet;
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.AuthentikTokenBroker;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthDetails;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONObject;
/*******************************************************************************
** Central identity checks reuse the qbit client and configuration diagnostics.
*******************************************************************************/
public final class AuthentikHealthChecks
{
private static final String GROUP_ATTRIBUTES = "group attributes";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHealthChecks()
{
}
/*******************************************************************************
** Called only after the producer has validated and registered the runtime.
*******************************************************************************/
public static void register(QInstance instance, AuthentikQBitConfig config)
{
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
for(String name : List.of("api", "api_token", "mapper", "token_broker"))
{
registry.withCheck(new HealthCheckMetaData().withName("authentik." + name).withLabel("Authentik " + name)
.withComponent("authentik").withCategory(HealthCategory.IDENTITY).withKind(HealthCheckKind.DEPENDENCY)
.withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : 21600)
.withTimeoutMs(10000).withCheckInstance(input ->
{
try
{
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName()).withHealthDeadline(input.getDeadline());
HealthCheckOutcome outcome;
switch(name)
{
case "api" -> outcome = api(util, config);
case "api_token" -> outcome = apiToken(util, Instant.now());
case "mapper" -> outcome = mapper(util, config);
default -> outcome = tokenBroker(util, config);
}
return (Instant.now().isBefore(input.getDeadline()) && !Thread.currentThread().isInterrupted()
? outcome : HealthCheckOutcome.down("Authentik health deadline exceeded"));
}
catch(Exception e)
{
// Never copy exception messages, URLs or credential values into outcomes.
return (HealthCheckOutcome.down("Authentik health check failed"));
}
}));
}
}
/*******************************************************************************
** Version compatibility follows the existing drift report's prefix rule.
*******************************************************************************/
private static HealthCheckOutcome api(AuthentikActionUtil util, AuthentikQBitConfig config)
{
String version = util.fetchServerVersion().orElse("");
if(!version.matches("[0-9]{1,4}(\\.[0-9]{1,4}){1,3}"))
{
return (HealthCheckOutcome.down("Authentik server version unavailable"));
}
String expected = config.getExpectedServerVersion();
boolean matches = !StringUtils.hasContent(expected) || version.equals(expected) || version.startsWith(expected + ".");
return ((matches ? HealthCheckOutcome.up("Authentik API reachable") : HealthCheckOutcome.degraded("Authentik server version differs"))
.withDetails(new HealthDetails().withValue("serverVersion", version)));
}
/*******************************************************************************
** The service account should have one API token; never read its key.
*******************************************************************************/
private static HealthCheckOutcome apiToken(AuthentikActionUtil util, Instant now) throws QException
{
List<JSONObject> tokens = util.getServiceAccountApiTokens();
if(tokens.isEmpty())
{
return (HealthCheckOutcome.down("API token missing"));
}
if(tokens.size() != 1)
{
return (HealthCheckOutcome.unknown("API token cannot be identified uniquely"));
}
JSONObject token = tokens.getFirst();
if(!token.optBoolean("expiring", true))
{
return (HealthCheckOutcome.up("API token present without expiry"));
}
Instant expires;
try
{
expires = AuthentikJson.parseInstant(token.optString("expires", null));
}
catch(java.time.format.DateTimeParseException e)
{
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
}
if(expires == null)
{
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
}
HealthCheckOutcome outcome = !expires.isAfter(now) ? HealthCheckOutcome.down("API token expired")
: expires.isBefore(now.plus(14, ChronoUnit.DAYS)) ? HealthCheckOutcome.degraded("API token expires within 14 days")
: HealthCheckOutcome.up("API token present");
return (outcome.withDetails(new HealthDetails().withValue("daysToExpiry", ChronoUnit.DAYS.between(now, expires))));
}
/*******************************************************************************
** Findings carry fixed codes, plus the flagged group names and each group
** finding's safe reason (DriftFinding.reason), so a degraded mapper says
** what to fix. Service-account activity subjects (people) stay private.
*******************************************************************************/
static HealthCheckOutcome mapper(AuthentikActionUtil util, AuthentikQBitConfig config)
{
return (mapperOutcome(new AuthentikDriftReport(util, config).runMapperChecks()));
}
/*******************************************************************************
** The mapper outcome for a set of drift findings.
*******************************************************************************/
static HealthCheckOutcome mapperOutcome(List<DriftFinding> findings)
{
HealthStatus status = HealthStatus.UP;
TreeSet<String> codes = new TreeSet<>();
TreeMap<String, TreeMap<String, TreeSet<String>>> byGroup = new TreeMap<>();
TreeSet<String> other = new TreeSet<>();
for(DriftFinding finding : findings)
{
if(finding.severity() != DriftFinding.Severity.ERROR && finding.severity() != DriftFinding.Severity.WARNING)
{
continue;
}
boolean groupCheck = GROUP_ATTRIBUTES.equals(finding.check());
codes.add((groupCheck ? "group_attributes" : "service_account_activity") + "." + finding.severity().name().toLowerCase(Locale.ROOT));
status = finding.severity() == DriftFinding.Severity.ERROR || status == HealthStatus.DOWN ? HealthStatus.DOWN : HealthStatus.DEGRADED;
if(groupCheck && finding.reason() != null)
{
String reason = finding.reason();
int colon = reason.indexOf(": ");
TreeSet<String> values = byGroup.computeIfAbsent(safeName(finding.subject()), k -> new TreeMap<>())
.computeIfAbsent(colon < 0 ? reason : reason.substring(0, colon), k -> new TreeSet<>());
if(colon >= 0)
{
values.add(reason.substring(colon + 2));
}
}
else
{
other.add(groupCheck ? "group attributes check could not run" : "service-account activity (see the identity drift report)");
}
}
if(status == HealthStatus.UP)
{
return (HealthCheckOutcome.up("Mapper checks passed").withDetails(new HealthDetails().withValue("findingCodes", "")));
}
List<String> parts = new ArrayList<>();
byGroup.forEach((group, reasons) ->
{
List<String> texts = new ArrayList<>();
reasons.forEach((label, values) -> texts.add(values.isEmpty() ? label : label + ": " + String.join(", ", values)));
parts.add(group + " (" + String.join("; ", texts) + ")");
});
parts.addAll(other);
String summary = String.join("; ", parts);
return (new HealthCheckOutcome().withStatus(status).withMessage(truncate("Mapper drift: " + summary, 500))
.withDetails(new HealthDetails()
.withValue("findingCodes", String.join(",", codes))
.withValue("flaggedGroups", truncate(String.join(", ", byGroup.keySet()), 500))
.withValue("summary", truncate(summary, 500))));
}
/*******************************************************************************
** A group name fit for a health message: printable and bounded.
*******************************************************************************/
private static String safeName(String name)
{
String printable = name == null ? "" : name.replaceAll("\\p{Cntrl}", "").trim();
return (printable.isEmpty() ? "(unnamed group)" : truncate(printable, 100));
}
/*******************************************************************************
** Cut a text to a length, marking the cut.
*******************************************************************************/
private static String truncate(String text, int max)
{
return (text.length() <= max ? text : text.substring(0, max - 3) + "...");
}
/*******************************************************************************
** Authenticate through the existing broker transport, retaining no bearer.
*******************************************************************************/
private static HealthCheckOutcome tokenBroker(AuthentikActionUtil util, AuthentikQBitConfig config) throws QException
{
if(!StringUtils.hasContent(config.getTokenBrokerClientId()) || !StringUtils.hasContent(config.getTokenBrokerClientSecret()))
{
return (HealthCheckOutcome.unknown("not configured"));
}
new AuthentikTokenBroker().probeCredentials(util);
return (HealthCheckOutcome.up("Token broker authenticated"));
}
}
@@ -83,7 +83,7 @@ public abstract class AbstractAuthentikTableProducer extends QBitComponentMetaDa
.withBackendName(config.getApiBackendName())
.withPrimaryKeyField(spec.getKeyFieldName())
.withBackendDetails(new APITableBackendDetails().withTablePath(spec.getPath()))
.withPermissionRules(spec.getReadPermission().rules())
.withPermissionRules(spec.readRules())
.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);
@@ -0,0 +1,65 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** authentik_admin_action: the append-only audit trail of every change made from QQQ.
*******************************************************************************/
public class AuthentikAdminActionTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.adminAction(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -18,12 +18,17 @@ package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
@@ -59,26 +64,55 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
MetaDataProducerMultiOutput produced = QBitProductionContext.peekMetaDataProducerMultiOutput();
if(produced != null)
{
for(String specName : AuthentikTables.all().keySet())
///////////////////////////////////////////////////////////////////////////
// the nav lists only the main tables; memberships, sessions, tokens, //
// events, bindings, roles and blueprints are reached from their parent //
// records as child lists //
///////////////////////////////////////////////////////////////////////////
addSection(app, produced, config, "people", "People", "person", AuthentikTables.USER, AuthentikTables.INVITATION);
addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikLocalTables.POLICY, AuthentikTables.APPLICATION);
addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION);
List<String> widgets = new ArrayList<>();
for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikTokenWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME),
config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.COMPARE)))
{
QTableMetaData table = produced.get(QTableMetaData.class, config.applyTablePrefix(specName));
if(table != null)
if(produced.get(QWidgetMetaData.class, widgetName) != null)
{
app.withChild(table);
widgets.add(widgetName);
}
}
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));
}
app.withWidgets(widgets);
}
return (app);
}
/*******************************************************************************
** Add a nav section with the main tables that were produced.
*******************************************************************************/
private static void addSection(QAppMetaData app, MetaDataProducerMultiOutput produced, AuthentikQBitConfig config, String name, String label, String icon, String... specNames)
{
List<String> names = new ArrayList<>();
for(String specName : specNames)
{
String tableName = config.applyTablePrefix(specName);
QTableMetaData table = produced.get(QTableMetaData.class, tableName);
if(table != null)
{
app.withChild(table);
names.add(tableName);
}
}
if(!names.isEmpty())
{
app.withSection(new QAppSection().withName(name).withLabel(label).withIcon(new QIcon(icon)).withTables(names));
}
}
/*******************************************************************************
** Last, so the tables exist in the output.
*******************************************************************************/
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentik_application: /core/applications/.
*******************************************************************************/
public class AuthentikApplicationTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.APPLICATION);
}
}
@@ -0,0 +1,65 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** authentik_approval_request: changes waiting for a second person.
*******************************************************************************/
public class AuthentikApprovalRequestTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.approvalRequest(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentik_blueprint: /managed/blueprints/.
*******************************************************************************/
public class AuthentikBlueprintTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.BLUEPRINT);
}
}
@@ -39,7 +39,7 @@ 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).
** for direct renderer calls as well as calls through core RenderWidgetAction.
*******************************************************************************/
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
{
@@ -0,0 +1,159 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
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.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-authentik.yaml (the QQQ help-bundle format) to the Identity
** app and its authentik_* tables: what each table is for and how its rows get
** created (for the API-backed tables: that they are read live, and what an
** empty list means), shown on the table screen and when it is empty.
**
** The bundle's keys use the default table prefix; a host's own tableNamePrefix
** is applied to them, so renamed tables get the same text.
** Inline fallback for hosts without help storage; core owns bundle validation
** and the producer also registers the bundle for startup synchronization.
*******************************************************************************/
public final class AuthentikHelp
{
public static final String RESOURCE = "/help/qbit-authentik.yaml";
public static final String SLOT = "help";
private static final String DEFAULT_TABLE_PREFIX = "authentik_";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
Map<String, JsonNode> entries = new LinkedHashMap<>();
var bundle = HelpBundles.load(RESOURCE.substring(1), AuthentikHelp.class.getClassLoader());
ObjectMapper mapper = new ObjectMapper();
for(var entry : bundle.getEntries())
{
entries.put(entry.getKey(), mapper.valueToTree(entry));
}
return (entries);
}
/*******************************************************************************
** Apply the help to the app and every table the qbit produced. A host's own
** help on a table or the app is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance qInstance, AuthentikQBitConfig config) throws QException
{
for(var bundle : HelpBundles.of(qInstance).getBundles())
{
if("qbit-authentik".equals(bundle.getId()))
{
for(var entry : bundle.getEntries())
{
entry.setKey(configuredNames(entry.getKey(), config));
entry.setSummary(configuredNames(entry.getSummary(), config));
entry.setContent(configuredNames(entry.getContent(), config));
}
}
}
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
{
String key = entry.getKey();
if(key.startsWith("table:") && key.endsWith(";slot:help"))
{
String tableKey = key.substring("table:".length(), key.indexOf(';'));
String unprefixed = tableKey.substring(DEFAULT_TABLE_PREFIX.length());
QTableMetaData table = qInstance.getTable(config.applyTablePrefix(unprefixed));
if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
else if(key.startsWith("app:") && key.endsWith(";slot:help"))
{
QAppMetaData app = qInstance.getApp(config.applyPrefix(AuthentikAppMetaDataProducer.APP_NAME));
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry, AuthentikQBitConfig config)
{
return (new QHelpContent().withContentAsMarkdown(configuredNames(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip(), config)));
}
/*******************************************************************************
** Follow the host prefix in keys and live references; status enums are fixed.
*******************************************************************************/
private static String configuredNames(String text, AuthentikQBitConfig config)
{
if(text == null)
{
return (null);
}
return (Pattern.compile("authentik_[a-zA-Z0-9_]+|authentik[A-Z][a-zA-Z0-9]*").matcher(text).replaceAll(match ->
{
String name = match.group();
if(name.equals("authentik_action_status") || name.equals("authentik_approval_status"))
{
return (name);
}
if(name.startsWith(DEFAULT_TABLE_PREFIX))
{
return (config.applyTablePrefix(name.substring(DEFAULT_TABLE_PREFIX.length())));
}
return (config.applyPrefix(name.substring("authentik".length())));
}));
}
}
@@ -0,0 +1,71 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.app.AuthentikHomeWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The Identity home widget: the single dashboard of this qbit (what needs
** attention, people, groups, recent changes). Other screens only link to it.
*******************************************************************************/
public class AuthentikHomeWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
public static final String NAME = "home";
public static final String BACKEND_NAME_KEY = "backendName";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(NAME))
.withLabel("Identity")
.withType(WidgetType.HTML.getType())
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(AuthentikHomeWidget.Renderer.class))
.withPermissionRules(IdentityPermission.USERS_READ.rules())
.withDefaultValue(BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (450);
}
}
@@ -0,0 +1,48 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentik_invitation: /stages/invitation/invitations/.
*******************************************************************************/
public class AuthentikInvitationTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
** Only when invitations are configured (an enrollment flow).
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (super.isEnabled() && getQBitConfig().getInvitationFlow() != null && !getQBitConfig().getInvitationFlow().isBlank());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.INVITATION);
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentik_policy_binding: /policies/bindings/.
*******************************************************************************/
public class AuthentikPolicyBindingTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.POLICY_BINDING);
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.policy.PolicyCutoverWidgets;
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;
/*******************************************************************************
** Compare Access (DESIGN.md section 12.8): today's access against what the policies would grant, per active person. Read only.
*******************************************************************************/
public class AuthentikPolicyCompareWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyCutoverWidgets.COMPARE))
.withLabel("Compare Access")
.withType(WidgetType.TABLE.getType())
.withGridColumns(12)
.withIsCard(true)
.withCodeReference(new QCodeReference(PolicyCutoverWidgets.CompareRenderer.class))
.withPermissionRules(IdentityPermission.ADMIN.rules())
.withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (470);
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
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.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The policy editor widget (DESIGN.md section 12.4), shown on the Edit Policy
** step. Gated by authentik.policies.manage.
*******************************************************************************/
public class AuthentikPolicyEditorWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyEditorWidget.NAME))
.withLabel("Policy")
.withType(PolicyEditorWidget.TYPE)
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(PolicyEditorWidget.Renderer.class))
.withPermissionRules(IdentityPermission.POLICIES_MANAGE.rules())
.withDefaultValue(PolicyEditorWidget.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (460);
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.policy.PolicyCutoverWidgets;
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 policy enforcement banner (DESIGN.md section 12.8): whether saved policies take effect at sign-in on this instance.
*******************************************************************************/
public class AuthentikPolicyEnforcementWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyCutoverWidgets.ENFORCEMENT))
.withLabel("Policy Enforcement")
.withType(WidgetType.HTML.getType())
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(PolicyCutoverWidgets.EnforcementRenderer.class))
.withPermissionRules(IdentityPermission.USERS_READ.rules())
.withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (465);
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** authentik_policy: the QQQ record (with change history) of each group policy.
*******************************************************************************/
public class AuthentikPolicyTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend and the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.policy(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentik_role: /rbac/roles/.
*******************************************************************************/
public class AuthentikRoleTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.ROLE);
}
}
@@ -0,0 +1,72 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
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.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
/*******************************************************************************
** Daily background task, hidden from navigation; no scheduler dependency.
*******************************************************************************/
public class AuthentikTokenReminderProcessProducer extends QBitComponentMetaDataProducer<QProcessMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Reminders require local dedupe storage and the TOKENS feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
** Use the configured scheduler or the instance's first registered scheduler.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance instance)
{
AuthentikQBitConfig config = getQBitConfig();
QProcessMetaData process = new QProcessMetaData().withName(config.applyPrefix("tokenReminders"))
.withLabel("Access token reminders").withIsHidden(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules())
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(AuthentikTokenReminders.class))
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("backendName", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING)
.withDefaultValue(config.getApiBackendName()))));
String scheduler = config.getTokenReminderSchedulerName();
if(scheduler == null && !instance.getSchedulers().isEmpty())
{
scheduler = instance.getSchedulers().keySet().iterator().next();
}
if(scheduler != null)
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(scheduler).withRepeatSeconds(86400).withInitialDelaySeconds(3600));
}
return (process);
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Token scope metadata, with no credential material.
*******************************************************************************/
public class AuthentikTokenScopeTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend and the TOKENS feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.tokenScope(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentik_token: /core/tokens/ (metadata only, never a key).
*******************************************************************************/
public class AuthentikTokenTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.TOKEN);
}
}
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
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;
/*******************************************************************************
** Access token administration uses a standard TABLE widget (guardrail G5).
*******************************************************************************/
public class AuthentikTokenWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
public static final String NAME = "accessTokens";
/*******************************************************************************
** Token inventory needs the local metadata table.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS));
}
/*******************************************************************************
** Register before the app, alongside the existing home widgets.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (500);
}
/*******************************************************************************
** Permission is checked again in the renderer.
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance instance)
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData().withName(config.applyPrefix(NAME)).withLabel("Access tokens").withType(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType())
.withGridColumns(12).withIsCard(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules())
.withCodeReference(new QCodeReference(AuthentikTokenWidget.class)).withDefaultValue("backendName", config.getApiBackendName()));
}
}
@@ -0,0 +1,266 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.permissions;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The cut-over from a host's current permission source to Authentik group
** policies (DESIGN.md section 12.8): for every active person, what they get
** today (the host source) against what the mapper would grant (their groups'
** policies and permissions, the static fallback and bootstrap subjects), and
** the guard that keeps the mapper inactive while a member of a guarded group
** would lose a permission.
*******************************************************************************/
public final class AccessComparison
{
private static final QLogger LOG = QLogger.getLogger(AccessComparison.class);
/** How long a computed mapper state is reused (sign-ins read it). */
public static final Duration STATE_TTL = Duration.ofSeconds(60);
/** Banner shown wherever policies are edited while they are not enforced. */
public static final String NOT_ENFORCED = "Policies are saved but NOT enforced at sign-in on this instance (mapper off).";
/*******************************************************************************
** One person.
*******************************************************************************/
public record Row(int pk, String username, String email, List<String> groups, Set<String> before, Set<String> after,
Set<String> added, Set<String> removed, boolean guarded)
{
}
/*******************************************************************************
** Whether the mapper is enforcing policies, and why not.
*******************************************************************************/
public enum Status
{
/** The host does not use the mapper: policies are saved, not enforced. */
OFF,
/** Enforced; the guard passed (or there is nothing to guard). */
ACTIVE,
/** Enforced because the owner overrode a failing guard. */
OVERRIDDEN,
/** Not enforced: the guard failed (or could not run); sign-in uses the host source. */
BLOCKED
}
/*******************************************************************************
** A mapper state with a plain message.
*******************************************************************************/
public record State(Status status, String message, Instant checkedAt)
{
/*******************************************************************************
** Whether sign-in permissions come from the policies.
*******************************************************************************/
public boolean enforced()
{
return (status == Status.ACTIVE || status == Status.OVERRIDDEN);
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AccessComparison()
{
}
/*******************************************************************************
** Compare every active person (internal and external users).
*******************************************************************************/
public static List<Row> run(AuthentikQBitConfig config, AuthentikActionUtil util, Collection<String> catalog) throws QException
{
GroupGraph graph = util.groupGraphWithMembers();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, catalog);
AuthentikHostPermissionSource source = config.getHostPermissionSource();
List<Row> rows = new ArrayList<>();
for(JSONObject user : util.allUsers())
{
String type = user.optString("type");
if(!user.optBoolean("is_active", true) || !("internal".equals(type) || "external".equals(type)))
{
continue;
}
int pk = user.optInt("pk");
List<String> groupPks = graph.groupsOfUser(pk);
List<String> names = new ArrayList<>();
for(String groupPk : groupPks)
{
names.add(graph.byPk(groupPk).name());
}
String email = user.optString("email", null);
Set<String> before = new TreeSet<>(source == null ? Set.of() : source.userPermissions(email, names));
Set<String> after = mapperPermissions(config, resolver, groupPks, names, String.valueOf(pk), user.optString("uuid"));
Set<String> added = new TreeSet<>(after);
added.removeAll(before);
Set<String> removed = new TreeSet<>(before);
removed.removeAll(after);
boolean guarded = names.stream().anyMatch(n -> config.getCutoverGuardGroups() != null && config.getCutoverGuardGroups().contains(n));
rows.add(new Row(pk, user.optString("username"), email, names, before, after, added, removed, guarded));
}
return (rows);
}
/*******************************************************************************
** What the mapper grants for a person's direct groups: the groups' (and
** ancestors') qqq.io/permissions and policies, the static fallback of the
** direct groups, and every authentik.* permission for a bootstrap subject.
*******************************************************************************/
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid)
{
return (mapperPermissions(config, resolver, groupPks, directGroupNames, userPk, userUuid, null));
}
/*******************************************************************************
** Preview the mapper with one group's policy removed, preserving the
** static floor and immutable bootstrap-subject grants.
*******************************************************************************/
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid, String excludePolicyOfGroupPk)
{
Set<String> rs = new TreeSet<>(resolver.resolveGroups(groupPks, excludePolicyOfGroupPk).sessionPermissions());
rs.addAll(staticPermissions(config, directGroupNames));
Set<String> subjects = config.getResolvedBootstrapSubjects();
if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid)))
{
for(IdentityPermission permission : IdentityPermission.values())
{
rs.add(permission.getSessionPermission());
}
}
return (rs);
}
/*******************************************************************************
** The static fallback permissions of some direct groups.
*******************************************************************************/
public static Set<String> staticPermissions(AuthentikQBitConfig config, Collection<String> directGroupNames)
{
Set<String> rs = new TreeSet<>();
if(config.getStaticGroupPermissions() != null)
{
for(String name : directGroupNames)
{
rs.addAll(config.getStaticGroupPermissions().getOrDefault(name, List.of()));
}
}
return (rs);
}
/*******************************************************************************
** The people in guarded groups who would lose a permission (the guard fails
** when this is not empty).
*******************************************************************************/
public static List<Row> guardFailures(List<Row> rows)
{
return (rows.stream().filter(r -> r.guarded() && !r.removed().isEmpty()).toList());
}
/*******************************************************************************
** The mapper state of a backend (cached for STATE_TTL; sign-ins read it).
*******************************************************************************/
public static State state(AuthentikRuntime runtime, AuthentikActionUtil util)
{
State cached = runtime.getMapperState();
if(cached != null && cached.checkedAt().plus(STATE_TTL).isAfter(Instant.now()))
{
return (cached);
}
State state = compute(runtime, util);
runtime.setMapperState(state);
return (state);
}
/*******************************************************************************
** Compute the mapper state now.
*******************************************************************************/
static State compute(AuthentikRuntime runtime, AuthentikActionUtil util)
{
AuthentikQBitConfig config = runtime.getConfig();
if(!Boolean.TRUE.equals(config.getMapperEnabled()) && !runtime.isMapperInvoked())
{
return (new State(Status.OFF, NOT_ENFORCED, Instant.now()));
}
if(config.getCutoverGuardGroups() == null || config.getCutoverGuardGroups().isEmpty())
{
return (new State(Status.ACTIVE, "Policies are enforced at sign-in.", Instant.now()));
}
if(Boolean.TRUE.equals(config.getMapperOverride()))
{
return (new State(Status.OVERRIDDEN, "Policies are enforced at sign-in; the cut-over guard is overridden by the owner.", Instant.now()));
}
try
{
List<Row> failures = guardFailures(run(config, util, runtime.getPermissionCatalog()));
if(failures.isEmpty())
{
return (new State(Status.ACTIVE, "Policies are enforced at sign-in; the cut-over guard passed (no member of "
+ String.join(", ", config.getCutoverGuardGroups()) + " loses a permission).", Instant.now()));
}
List<String> names = failures.stream().map(Row::username).limit(5).toList();
return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard found " + failures.size() + " member(s) of "
+ String.join(", ", config.getCutoverGuardGroups()) + " who would lose permissions (" + String.join(", ", names)
+ "). Sign-in keeps today's access until the policies cover it, or the owner overrides the guard.", Instant.now()));
}
catch(Exception e)
{
LOG.warn("The cut-over guard could not run; the mapper stays inactive", logPair("error", e.getMessage()));
return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard could not check Authentik ("
+ e.getMessage() + "). Sign-in keeps today's access.", Instant.now()));
}
}
}
@@ -17,6 +17,9 @@
package com.kingsrook.qbits.authentik.permissions;
import java.io.Serializable;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
@@ -27,6 +30,10 @@ import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.security.BootstrapOperators;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -49,7 +56,7 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** 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.
** 3. Static fallbacks (withGroupPermissions) and bootstrapOperatorSubjects are added.
** 4. If Authentik cannot be read, only the static fallbacks (and bootstrap
** operators) apply: it never grants everything.
**
@@ -101,22 +108,204 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
return;
}
List<String> groups = groupNames(claims);
String email = claims.optString("email", "").trim().toLowerCase();
String email = claims.optString("email", "").trim();
String sub = claims.optString("sub", "").trim();
boolean verified = claims.optBoolean("email_verified", false);
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, email));
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, sub, email, verified));
//////////////////////////////////////////////////////////////////////////
// the policy claim (docs/POLICY-FORMAT.md) carries the same group data //
// as the graph read above; the union also covers an unreachable API //
//////////////////////////////////////////////////////////////////////////
AuthentikRuntime runtime = runtime();
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format,
qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance)));
if(runtime != null)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups));
////////////////////////////////////////////////////////////////////////
// the cut-over guard (DESIGN.md section 12.8): while it blocks, the //
// session gets today's access from the host plus the floor (static //
// fallbacks, bootstrap subjects), never the policies alone //
////////////////////////////////////////////////////////////////////////
runtime.markMapperInvoked();
AccessComparison.State state;
try
{
state = AccessComparison.state(runtime, client(qInstance));
}
catch(Exception e)
{
state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now());
}
if(!state.enforced())
{
Set<String> floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups));
for(String group : groups)
{
floor.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
}
if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified))
{
for(IdentityPermission permission : IdentityPermission.values())
{
floor.add(permission.getSessionPermission());
}
}
AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource();
if(source != null)
{
floor.addAll(source.userPermissions(email, groups));
}
LOG.warn("Policies are not enforced at this sign-in; granting today's access", logPair("reason", state.message()));
permissions = floor;
}
}
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims);
}
/*******************************************************************************
** The permissions for a user's group names and email.
** Automated sessions have no claims. Resolve their email idReference to one
** active API user before trusting any groups or immutable bootstrap id.
*******************************************************************************/
public Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String email)
@Override
public void customizeAutomatedSessionForUser(QInstance qInstance, QSession session, Serializable userId)
{
AuthentikRuntime runtime = runtime();
Set<String> permissions = new TreeSet<>();
String email = userId == null ? "" : userId.toString().trim();
try
{
JSONObject found = null;
int matches = 0;
List<JSONObject> users = email.isEmpty() ? List.of() : client(qInstance).getAllPages(
"/core/users/?email=" + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&include_groups=true&include_roles=false", 10);
for(JSONObject user : users)
{
if(email.equalsIgnoreCase(user.optString("email", "").trim()))
{
found = user;
matches++;
}
}
// A bounded, possibly truncated lookup cannot prove uniqueness.
if(users.size() < 10 && matches == 1 && found.optBoolean("is_active", false))
{
List<String> groups = new ArrayList<>();
JSONArray groupObjects = found.optJSONArray("groups_obj");
if(groupObjects != null)
{
for(int i = 0; i < groupObjects.length(); i++)
{
groups.add(groupObjects.getJSONObject(i).getString("name"));
}
}
else if(found.opt("groups") instanceof JSONArray groupPks && !groupPks.isEmpty())
{
GroupGraph graph = client(qInstance).groupGraph();
for(int i = 0; i < groupPks.length(); i++)
{
GroupGraph.Node group = graph.byPk(groupPks.getString(i));
if(group != null)
{
groups.add(group.name());
}
}
}
String pk = found.optString("pk", "");
String sub = runtime != null && runtime.getConfig().getResolvedBootstrapSubjects().contains(pk) ? pk : found.optString("uuid", pk);
String userEmail = found.getString("email").trim();
boolean verified = found.optBoolean("email_verified", false);
permissions.addAll(resolve(qInstance, groups, sub, userEmail, verified));
if(runtime != null)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups));
permissions = automatedSessionPermissions(qInstance, runtime, permissions, groups, sub, userEmail, verified);
}
}
else
{
LOG.info("Automated session has no unique active Authentik user; granting only the static fallbacks");
}
}
catch(Exception e)
{
permissions.clear();
// Exception messages can contain the lookup email; log only the type.
LOG.warn("Authentik could not be read for an automated session; granting only the static fallbacks", logPair("errorType", e.getClass().getSimpleName()));
}
if(runtime != null)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), List.of()));
}
if(session.getPermissions() != null)
{
permissions.addAll(session.getPermissions());
}
session.setPermissions(permissions);
}
/*******************************************************************************
** Mirror the sign-in cut-over floor without changing the claims path.
*******************************************************************************/
private Set<String> automatedSessionPermissions(QInstance qInstance, AuthentikRuntime runtime, Set<String> permissions,
List<String> groups, String sub, String email, boolean verified)
{
runtime.markMapperInvoked();
AccessComparison.State state;
try
{
state = AccessComparison.state(runtime, client(qInstance));
}
catch(Exception e)
{
state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now());
}
if(state.enforced())
{
return (permissions);
}
Set<String> floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups));
for(String group : groups)
{
floor.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
}
if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified))
{
for(IdentityPermission permission : IdentityPermission.values())
{
floor.add(permission.getSessionPermission());
}
}
AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource();
if(source != null)
{
floor.addAll(source.userPermissions(email, groups));
}
LOG.warn("Policies are not enforced for this automated session; granting today's access", logPair("status", state.status()));
return (floor);
}
/*******************************************************************************
** The permissions for a user's group names and token identity (sub, email,
** email_verified). Bootstrap operators are matched on immutable ids only.
*******************************************************************************/
public Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
{
Set<String> rs = new TreeSet<>();
for(String group : groupNames)
@@ -125,9 +314,9 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
}
AuthentikRuntime runtime = runtime();
if(runtime != null && email != null && runtime.getConfig().getResolvedBootstrapOperators().contains(email))
if(runtime != null && !runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, emailVerified))
{
LOG.warn("bootstrapOperators grants all identity permissions; remove it once an identity administrator is set up in Authentik", logPair("email", email));
LOG.warn("bootstrapOperatorSubjects grants all identity permissions; empty it once an identity administrator is set up in Authentik", logPair("sub", sub));
for(IdentityPermission permission : IdentityPermission.values())
{
rs.add(permission.getSessionPermission());
@@ -138,7 +327,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
{
AuthentikActionUtil client = client(qInstance);
GroupGraph graph = client.groupGraph();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog());
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog(), qInstance);
List<String> pks = new ArrayList<>();
for(String name : groupNames)
{
@@ -164,6 +353,23 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
/*******************************************************************************
**
*******************************************************************************/
private boolean isBootstrap(QInstance qInstance, AuthentikRuntime runtime, String sub, String email, boolean emailVerified)
{
try
{
return (BootstrapOperators.matches(runtime.getConfig().getResolvedBootstrapSubjects(), client(qInstance), sub, email, emailVerified));
}
catch(Exception e)
{
return (false);
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.List;
import java.util.Set;
/*******************************************************************************
** SPI: the permissions a host grants TODAY, before Authentik group policies
** take over (DESIGN.md section 12.8). Typically a hard-coded grant such as
** "members of the admins group get adminPermissions()". The qbit uses it to
** - generate a group's policy from current access,
** - compare current access with what the mapper would grant, and
** - fall back to it while the cut-over guard keeps the mapper inactive.
*******************************************************************************/
public interface AuthentikHostPermissionSource
{
/*******************************************************************************
** The QQQ session permissions a user gets today.
**
** @param email the user's email (may be null)
** @param groupNames the user's direct Authentik group names
*******************************************************************************/
Set<String> userPermissions(String email, Collection<String> groupNames);
/*******************************************************************************
** The permissions members of a group get today because of that group. The
** default asks for a user in only that group.
*******************************************************************************/
default Set<String> groupPermissions(String groupName)
{
return (userPermissions(null, List.of(groupName)));
}
}
@@ -17,6 +17,7 @@
package com.kingsrook.qbits.authentik.permissions;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -24,14 +25,18 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Turns groups (and their ancestors) into QQQ permissions (DESIGN.md
** section 5.6). Pure: give it a GroupGraph and a permission catalog.
** section 5.6). Full access uses the explicit instance or current QContext.
**
** Rules:
** - A full-access policy marker expands to all current instance/catalog names.
** - 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
@@ -46,6 +51,7 @@ public class EffectivePermissionResolver
{
private final GroupGraph graph;
private final Set<String> catalog;
private final QInstance qInstance;
@@ -67,8 +73,19 @@ public class EffectivePermissionResolver
** null to pass non-identity names through unchecked
*******************************************************************************/
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog)
{
this(graph, catalog, null);
}
/*******************************************************************************
** Resolve against an explicit sign-in instance, or the current QContext.
*******************************************************************************/
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog, QInstance qInstance)
{
this.graph = graph;
this.qInstance = qInstance;
this.catalog = catalog == null ? null : new TreeSet<>(catalog);
if(this.catalog != null)
{
@@ -85,6 +102,17 @@ public class EffectivePermissionResolver
** Effective permissions of a set of groups (e.g. a user's direct groups).
*******************************************************************************/
public Resolution resolveGroups(Collection<String> groupPks)
{
return (resolveGroups(groupPks, null));
}
/*******************************************************************************
** Resolve normally, except for one group's policy document. Its named
** permissions and every other group's policy still contribute.
*******************************************************************************/
public Resolution resolveGroups(Collection<String> groupPks, String excludePolicyOfGroupPk)
{
Set<String> permissions = new TreeSet<>();
Map<String, Set<String>> grantedBy = new LinkedHashMap<>();
@@ -101,7 +129,12 @@ public class EffectivePermissionResolver
continue;
}
appAccess.addAll(node.appAccess());
for(String raw : node.permissions())
List<String> raws = new ArrayList<>(node.permissions());
if(!node.pk().equals(excludePolicyOfGroupPk))
{
raws.addAll(node.policyPermissions());
}
for(String raw : raws)
{
Set<String> expanded = expand(raw);
if(expanded.isEmpty())
@@ -143,6 +176,10 @@ public class EffectivePermissionResolver
return (rs);
}
String name = raw.trim();
if(name.equals(PolicyPermissions.FULL_ACCESS))
{
return (PolicyPermissions.allPermissions(qInstance == null ? QContext.getQInstance() : qInstance, catalog));
}
if(name.equals(IdentityPermission.WILDCARD))
{
@@ -27,6 +27,10 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -42,8 +46,16 @@ 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)
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed,
List<String> policyPermissions)
{
/*******************************************************************************
** A group without a policy document.
*******************************************************************************/
public Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
{
this(pk, name, ownSuperuser, parentPks, permissions, appAccess, memberPks, malformed, List.of());
}
}
@@ -59,9 +71,35 @@ public class GroupGraph
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups)
{
GroupGraph graph = new GroupGraph();
return (fromGroups(groups, PolicyFormat.DEFAULT));
}
/*******************************************************************************
** Build from Authentik group objects, reading each group's policy document
** (docs/POLICY-FORMAT.md) from the format's attribute. A document that does
** not parse grants nothing and is listed in the node's malformed entries.
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format)
{
return (fromGroups(groups, format, null));
}
/*******************************************************************************
** Read group policies with the catalog of registered table features.
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format, PolicyCatalog catalog)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
GroupGraph graph = new GroupGraph();
for(JSONObject group : groups)
{
JSONObject attributes = group.optJSONObject("attributes");
List<String> malformed = malformed(attributes);
List<String> policy = policyPermissions(attributes, f, malformed, catalog);
graph.add(new Node(
group.getString("pk"),
group.optString("name", ""),
@@ -70,7 +108,8 @@ public class GroupGraph
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"))));
malformed,
policy));
}
return (graph);
}
@@ -257,6 +296,30 @@ public class GroupGraph
/*******************************************************************************
** The permissions of a group's policy document; none (and a malformed entry)
** when it does not parse.
*******************************************************************************/
private static List<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed, PolicyCatalog catalog)
{
Object document = attributes == null ? null : attributes.opt(format.attribute());
if(document == null || JSONObject.NULL.equals(document) || "".equals(document))
{
return (List.of());
}
try
{
return (List.copyOf(PolicyParser.parseStrict(document, format, catalog).permissions()));
}
catch(PolicyParseException e)
{
malformed.add(format.attribute() + ": " + e.getMessage());
return (List.of());
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -0,0 +1,125 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Authentication customizer that adds the QQQ permissions granted by the
** policy claim of the sign-in token (docs/POLICY-FORMAT.md). It needs no
** Authentik API access: Authentik merges the user's group policies into the
** claim at sign-in (the scope mapping in the format doc).
**
** Order: applySecurityKeys (a no-op hook for hosts), then permissions. The
** permissions are added to whatever the session already holds. A missing or
** invalid claim adds nothing (fail closed).
*******************************************************************************/
public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomizerInterface
{
private static final QLogger LOG = QLogger.getLogger(AuthentikPolicyClaimMapper.class);
private PolicyFormat format = PolicyFormat.DEFAULT;
/*******************************************************************************
** Use these names (the qbit config's getPolicyFormat()) instead of the
** defaults.
*******************************************************************************/
public AuthentikPolicyClaimMapper withFormat(PolicyFormat format)
{
this.format = format == null ? PolicyFormat.DEFAULT : format;
return (this);
}
/*******************************************************************************
** Getter for format.
*******************************************************************************/
public PolicyFormat getFormat()
{
return (format);
}
/*******************************************************************************
** Add the permissions from the policy claim.
*******************************************************************************/
@Override
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
{
JSONObject claims = PolicyPermissions.claimsWithPolicy(context, format);
JSONObject any = claims;
if(any == null && context != null)
{
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ID_TOKEN_CONTEXT_KEY));
if(any == null)
{
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY));
}
}
if(any != null)
{
applySecurityKeys(qSession, any);
}
if(claims == null)
{
LOG.info("No policy claim in the token; no policy permissions added", logPair("claim", format.claim()));
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession,
context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY)));
return;
}
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format, qInstance == null ? null : PolicyCatalog.of(qInstance)));
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession,
context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY)));
}
/*******************************************************************************
** Extension point for record locks (security keys); a no-op here. A host
** overrides it to set security key values from its OWN explicit claims, and
** must never infer them from group names.
*******************************************************************************/
protected void applySecurityKeys(QSession qSession, JSONObject claims)
{
////////////////////////////////////////////////////////
// no record locks in policy format 1.0.0 (by design) //
////////////////////////////////////////////////////////
}
}
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.Optional;
/*******************************************************************************
** The five standard actions, in canonical statement order. Table features use
** their registered string names separately. Each standard action
** maps to the QQQ permission suffix of the same name ({resource}.{action}).
*******************************************************************************/
public enum PolicyAction
{
HAS_ACCESS("hasAccess"),
READ("read"),
INSERT("insert"),
EDIT("edit"),
DELETE("delete");
/** "All five actions" in a statement's Action array. */
public static final String WILDCARD = "*";
private final String jsonName;
/*******************************************************************************
** Constructor.
*******************************************************************************/
PolicyAction(String jsonName)
{
this.jsonName = jsonName;
}
/*******************************************************************************
** The name used in documents and as the QQQ permission suffix.
*******************************************************************************/
public String getJsonName()
{
return (jsonName);
}
/*******************************************************************************
** The QQQ permission this action grants on a resource.
*******************************************************************************/
public String permission(String resource)
{
return (resource + "." + jsonName);
}
/*******************************************************************************
** Look up by document name (case-sensitive, like QQQ permission names).
*******************************************************************************/
public static Optional<PolicyAction> fromJsonName(String name)
{
for(PolicyAction action : values())
{
if(action.jsonName.equals(name))
{
return (Optional.of(action));
}
}
return (Optional.empty());
}
}
@@ -0,0 +1,534 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
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.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** What a policy can grant in a QInstance (DESIGN.md section 12.4): every app,
** table, process, report and protected widget, with its permission base name
** (the policy resource) and the actions QQQ checks for it, grouped by app.
**
** Unlike PermissionsHelper.getAllAvailablePermissions, hidden tables and
** processes are included: record actions are hidden from the nav but still
** permission-checked. Objects with a custom permission checker or that are
** NOT_PROTECTED are left out (a policy cannot grant them).
*******************************************************************************/
public final class PolicyCatalog
{
/** Group label of objects that are in no app. */
public static final String OTHER = "Other";
/** Kinds of resources. */
public enum Kind
{
APP, TABLE, PROCESS, REPORT, WIDGET
}
/*******************************************************************************
** One grantable resource.
**
** @param name the policy resource (permission base name)
** @param label what operators see
** @param kind app, table, process, report or widget
** @param actions the actions QQQ checks, in canonical order
** @param features explicitly grantable table features, sorted by name
** @param note a plain note (e.g. why an action is missing), or null
*******************************************************************************/
public record Resource(String name, String label, Kind kind, List<PolicyAction> actions, List<Feature> features, String note)
{
/*******************************************************************************
** A resource with only standard actions.
*******************************************************************************/
public Resource(String name, String label, Kind kind, List<PolicyAction> actions, String note)
{
this(name, label, kind, actions, List.of(), note);
}
}
/*******************************************************************************
** A table feature's permission suffix and display label from QQQ.
*******************************************************************************/
public record Feature(String name, String label)
{
}
/*******************************************************************************
** The resources of one app (or of no app: OTHER).
*******************************************************************************/
public record Group(String app, String label, List<Resource> resources)
{
}
private final List<Group> groups;
private final Map<String, Resource> byName;
private final QInstance qInstance;
/*******************************************************************************
** Constructor.
*******************************************************************************/
private PolicyCatalog(List<Group> groups, QInstance qInstance)
{
this.qInstance = qInstance;
this.groups = groups;
this.byName = new LinkedHashMap<>();
for(Group group : groups)
{
for(Resource resource : group.resources())
{
byName.putIfAbsent(resource.name(), resource);
}
}
}
/*******************************************************************************
** Build the catalog of an instance.
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance)
{
Collection<AvailablePermission> availablePermissions = PermissionsHelper.getAllAvailablePermissions(qInstance);
Map<String, String> appOfObject = appsOfObjects(qInstance);
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
List<QAppMetaData> apps = new ArrayList<>(qInstance.getApps().values());
apps.sort(Comparator.comparing((QAppMetaData a) -> a.getSortOrder() == null ? 500 : a.getSortOrder()).thenComparing(a -> label(a.getLabel(), a.getName())));
for(QAppMetaData app : apps)
{
byApp.put(app.getName(), new LinkedHashMap<>());
}
byApp.put(null, new LinkedHashMap<>());
for(QAppMetaData app : apps)
{
add(byApp, app.getName(), qInstance, app, Kind.APP, label(app.getLabel(), app.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QTableMetaData> tables = new ArrayList<>(qInstance.getTables().values());
tables.sort(Comparator.comparing(t -> label(t.getLabel(), t.getName())));
for(QTableMetaData table : tables)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(table, qInstance);
Set<PolicyAction> actions = EnumSet.noneOf(PolicyAction.class);
String note = null;
if(rules.getLevel() == PermissionLevel.HAS_ACCESS_PERMISSION)
{
actions.add(PolicyAction.HAS_ACCESS);
}
else
{
QBackendMetaData backend = qInstance.getBackend(table.getBackendName());
if(table.isCapabilityEnabled(backend, Capability.TABLE_QUERY) || table.isCapabilityEnabled(backend, Capability.TABLE_GET))
{
actions.add(PolicyAction.READ);
}
if(rules.getLevel() == PermissionLevel.READ_WRITE_PERMISSIONS)
{
note = "This table uses one write permission instead of separate insert, edit and delete actions.";
}
else
{
if(table.isCapabilityEnabled(backend, Capability.TABLE_INSERT))
{
actions.add(PolicyAction.INSERT);
}
if(table.isCapabilityEnabled(backend, Capability.TABLE_UPDATE))
{
actions.add(PolicyAction.EDIT);
}
if(table.isCapabilityEnabled(backend, Capability.TABLE_DELETE))
{
actions.add(PolicyAction.DELETE);
}
}
}
String baseName = StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : table.getName();
Map<String, Feature> features = new TreeMap<>();
for(AvailablePermission permission : availablePermissions)
{
if(permission.getName().startsWith(baseName + "."))
{
String suffix = permission.getName().substring(baseName.length() + 1);
if(!suffix.isEmpty() && PolicyAction.fromJsonName(suffix).isEmpty())
{
features.putIfAbsent(suffix, new Feature(suffix, permission.getPermissionType()));
}
}
}
add(byApp, appOfObject.get("table:" + table.getName()), qInstance, table, Kind.TABLE, label(table.getLabel(), table.getName()), actions, features, note);
}
List<QProcessMetaData> processes = new ArrayList<>(qInstance.getProcesses().values());
processes.sort(Comparator.comparing(p -> label(p.getLabel(), p.getName())));
for(QProcessMetaData process : processes)
{
String app = appOfObject.get("process:" + process.getName());
if(app == null && process.getTableName() != null)
{
app = appOfObject.get("table:" + process.getTableName());
}
String label = label(process.getLabel(), process.getName());
if(process.getTableName() != null && qInstance.getTable(process.getTableName()) != null)
{
label = label + " (" + label(qInstance.getTable(process.getTableName()).getLabel(), process.getTableName()) + " action)";
}
add(byApp, app, qInstance, process, Kind.PROCESS, label, EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QReportMetaData> reports = new ArrayList<>(qInstance.getReports().values());
reports.sort(Comparator.comparing(r -> label(r.getLabel(), r.getName())));
for(QReportMetaData report : reports)
{
add(byApp, appOfObject.get("report:" + report.getName()), qInstance, report, Kind.REPORT, label(report.getLabel(), report.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QWidgetMetaDataInterface> widgets = new ArrayList<>(qInstance.getWidgets().values());
widgets.sort(Comparator.comparing(w -> label(w.getLabel(), w.getName())));
for(QWidgetMetaDataInterface widget : widgets)
{
add(byApp, appOfObject.get("widget:" + widget.getName()), qInstance, widget, Kind.WIDGET, label(widget.getLabel(), widget.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<Group> groups = new ArrayList<>();
for(Map.Entry<String, Map<String, ResourceDraft>> entry : byApp.entrySet())
{
if(entry.getValue().isEmpty())
{
continue;
}
String groupLabel = entry.getKey() == null ? OTHER : label(qInstance.getApp(entry.getKey()).getLabel(), entry.getKey());
List<Resource> resources = new ArrayList<>();
for(ResourceDraft draft : entry.getValue().values())
{
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), List.copyOf(draft.features.values()), draft.note));
}
groups.add(new Group(entry.getKey(), groupLabel, resources));
}
return (new PolicyCatalog(groups, qInstance));
}
/*******************************************************************************
** The source instance, used for live full-access expansion, not a snapshot.
*******************************************************************************/
public QInstance getQInstance()
{
return (qInstance);
}
/*******************************************************************************
** Accumulates one resource while building (several objects can share a
** permission base name).
*******************************************************************************/
private static class ResourceDraft
{
private final String name;
private final Kind kind;
private final Set<String> labels = new LinkedHashSet<>();
private final Set<PolicyAction> actions = new TreeSet<>();
private final Map<String, Feature> features = new TreeMap<>();
private String note;
/*******************************************************************************
** Constructor.
*******************************************************************************/
ResourceDraft(String name, Kind kind)
{
this.name = name;
this.kind = kind;
}
}
/*******************************************************************************
** Add an object under its app, unless a policy cannot grant it.
*******************************************************************************/
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
Set<PolicyAction> actions, String note)
{
add(byApp, app, qInstance, object, kind, label, actions, Map.of(), note);
}
/*******************************************************************************
** Add standard and feature actions, merging shared permission base names.
*******************************************************************************/
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
Set<PolicyAction> actions, Map<String, Feature> features, String note)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(object, qInstance);
if(rules == null || rules.getLevel() == null || rules.getLevel() == PermissionLevel.NOT_PROTECTED || rules.getCustomPermissionChecker() != null || (actions.isEmpty() && features.isEmpty()))
{
return;
}
String name = StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : nameOf(object);
if(!PolicyParser.RESOURCE_PATTERN.matcher(name).matches())
{
return;
}
////////////////////////////////////////////////////////////////////
// a base name shared with an object already listed (in any app) //
// joins that entry, so a resource appears once //
////////////////////////////////////////////////////////////////////
for(Map<String, ResourceDraft> resources : byApp.values())
{
ResourceDraft existing = resources.get(name);
if(existing != null)
{
existing.labels.add(label);
existing.actions.addAll(actions);
existing.features.putAll(features);
return;
}
}
ResourceDraft draft = new ResourceDraft(name, kind);
draft.labels.add(label);
draft.actions.addAll(actions);
draft.features.putAll(features);
draft.note = note;
byApp.computeIfAbsent(byApp.containsKey(app) ? app : null, k -> new LinkedHashMap<>()).put(name, draft);
}
/*******************************************************************************
** Which app directly lists each object ("table:x", "process:y", ...): app
** children (sections included) and app widgets. The first app wins.
*******************************************************************************/
private static Map<String, String> appsOfObjects(QInstance qInstance)
{
Map<String, String> rs = new LinkedHashMap<>();
for(QAppMetaData app : qInstance.getApps().values())
{
if(app.getChildren() != null)
{
for(QAppChildMetaData child : app.getChildren())
{
String type = child instanceof QTableMetaData ? "table" : child instanceof QProcessMetaData ? "process" : child instanceof QReportMetaData ? "report" : null;
if(type != null)
{
rs.putIfAbsent(type + ":" + child.getName(), app.getName());
}
}
}
if(app.getWidgets() != null)
{
for(String widget : app.getWidgets())
{
rs.putIfAbsent("widget:" + widget, app.getName());
}
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String nameOf(MetaDataWithPermissionRules object)
{
if(object instanceof QTableMetaData t)
{
return (t.getName());
}
if(object instanceof QProcessMetaData p)
{
return (p.getName());
}
if(object instanceof QAppMetaData a)
{
return (a.getName());
}
if(object instanceof QReportMetaData r)
{
return (r.getName());
}
if(object instanceof QWidgetMetaDataInterface w)
{
return (w.getName());
}
return ("");
}
/*******************************************************************************
**
*******************************************************************************/
private static String label(String label, String name)
{
return (StringUtils.hasContent(label) ? label : name);
}
/*******************************************************************************
** The groups, in app order, "Other" last.
*******************************************************************************/
public List<Group> getGroups()
{
return (groups);
}
/*******************************************************************************
** A resource by policy name, or null.
*******************************************************************************/
public Resource get(String name)
{
return (byName.get(name));
}
/*******************************************************************************
** Every QQQ permission a policy can grant here ({resource}.{action}).
*******************************************************************************/
public Set<String> permissionNames()
{
Set<String> rs = new TreeSet<>();
for(Resource resource : byName.values())
{
for(PolicyAction action : resource.actions())
{
rs.add(action.permission(resource.name()));
}
resource.features().forEach(feature -> rs.add(resource.name() + "." + feature.name()));
}
return (rs);
}
/*******************************************************************************
** The resources of a permission set this catalog does not know, or that
** grant an action the resource does not have (sorted, as permission names).
*******************************************************************************/
public Set<String> unknownPermissions(Set<String> permissions)
{
Set<String> known = permissionNames();
Set<String> rs = new TreeSet<>();
for(String permission : permissions)
{
if(!known.contains(permission) && !PolicyPermissions.FULL_ACCESS.equals(permission))
{
rs.add(permission);
}
}
return (rs);
}
/*******************************************************************************
** Whether this resource has an explicitly registered feature action.
*******************************************************************************/
public boolean hasFeature(String resourceName, String actionName)
{
Resource resource = get(resourceName);
return (resource != null && resource.features().stream().anyMatch(feature -> feature.name().equals(actionName)));
}
/*******************************************************************************
** As JSON for the editor widget.
*******************************************************************************/
public JSONArray toJson()
{
JSONArray rs = new JSONArray();
for(Group group : groups)
{
JSONArray resources = new JSONArray();
for(Resource resource : group.resources())
{
JSONArray actions = new JSONArray();
resource.actions().forEach(a -> actions.put(a.getJsonName()));
JSONObject json = new JSONObject()
.put("name", resource.name())
.put("label", resource.label())
.put("kind", resource.kind().name())
.put("actions", actions);
if(resource.kind() == Kind.TABLE || !resource.features().isEmpty())
{
JSONArray features = new JSONArray();
resource.features().forEach(feature -> features.put(new JSONObject().put("name", feature.name()).put("label", feature.label())));
json.put("features", features);
}
if(resource.note() != null)
{
json.put("note", resource.note());
}
resources.put(json);
}
rs.put(new JSONObject().put("app", group.app() == null ? JSONObject.NULL : group.app()).put("label", group.label()).put("resources", resources));
}
return (rs);
}
}
@@ -0,0 +1,173 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.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.permissions.AccessComparison;
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.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** The cut-over widgets (DESIGN.md section 12.8): the enforcement banner shown
** on group and policy screens and on Edit Policy, and the Compare Access
** report (read only).
*******************************************************************************/
public final class PolicyCutoverWidgets
{
/** Widget default value naming the qbit's API backend. */
public static final String BACKEND_NAME_KEY = "backendName";
/** Short name of the banner widget. */
public static final String ENFORCEMENT = "policyEnforcement";
/** Short name of the report widget. */
public static final String COMPARE = "compareAccess";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyCutoverWidgets()
{
}
/*******************************************************************************
** The mapper state for a widget's backend.
*******************************************************************************/
static AccessComparison.State state(RenderWidgetInput input) throws QException
{
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
return (AccessComparison.state(AuthentikRuntime.forBackend(backendName), AuthentikActionUtil.forBackend(backendName)));
}
/*******************************************************************************
** The banner as HTML (also used by the home's health card).
*******************************************************************************/
public static String bannerHtml(AccessComparison.State state)
{
String style = state.enforced()
? "border:1px solid #16a34a;background:rgba(22,163,74,.10);"
: "border:1px solid #d97706;background:rgba(217,119,6,.12);";
return ("<div role=\"status\" data-qqq-id=\"authentik-policy-enforcement\" data-status=\"" + state.status().name() + "\" style=\"" + style
+ "border-radius:.375rem;padding:.5rem .75rem;font-size:.875rem;line-height:1.4;color:inherit;\"><strong>"
+ (state.enforced() ? "Enforced. " : "Not enforced. ") + "</strong>" + escape(state.message()) + "</div>");
}
/*******************************************************************************
**
*******************************************************************************/
static String escape(String text)
{
return (text == null ? "" : text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"));
}
/*******************************************************************************
** The enforcement banner (authentik.users.read).
*******************************************************************************/
public static class EnforcementRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(new RawHTML(null, bannerHtml(state(input)))));
}
}
/*******************************************************************************
** Compare Access (authentik.admin): every active person's access today
** against what the mapper would grant. Read only.
*******************************************************************************/
public static class CompareRenderer 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 compare access."));
}
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
AuthentikRuntime runtime = AuthentikRuntime.forBackend(backendName);
List<AccessComparison.Row> rows = AccessComparison.run(runtime.getConfig(), AuthentikActionUtil.forBackend(backendName), runtime.getPermissionCatalog());
List<Map<String, Object>> data = new ArrayList<>();
int removedPeople = 0;
for(AccessComparison.Row row : rows)
{
Map<String, Object> map = new LinkedHashMap<>();
map.put("user", escape(row.username()) + (row.guarded() ? " (guarded)" : ""));
map.put("groups", escape(String.join(", ", row.groups())));
map.put("added", row.added().isEmpty() ? "-" : escape(String.join(", ", row.added())));
map.put("removed", row.removed().isEmpty() ? "-" : escape(String.join(", ", row.removed())));
map.put("unchanged", row.before().size() - row.removed().size());
data.add(map);
if(!row.removed().isEmpty())
{
removedPeople++;
}
}
List<TableData.Column> columns = List.of(
new TableData.Column("html", "User", "user", "15%", "left"),
new TableData.Column("html", "Groups", "groups", "20%", "left"),
new TableData.Column("html", "Added", "added", "27%", "left"),
new TableData.Column("html", "Removed", "removed", "28%", "left"),
new TableData.Column("default", "Unchanged", "unchanged", "10%", "right"));
List<AccessComparison.Row> failures = AccessComparison.guardFailures(rows);
String summary = rows.size() + " active people; " + removedPeople + " would lose permissions; guard "
+ (runtime.getConfig().getCutoverGuardGroups().isEmpty() ? "not configured" : failures.isEmpty() ? "passes" : "FAILS for " + failures.size())
+ (runtime.getConfig().getHostPermissionSource() == null ? ". No host permission source is configured, so today's access is shown as empty." : ".");
TableData table = new TableData("Compare Access (today vs policies)", columns, data)
.withRowsPerPage(25)
.withNoRowsFoundHTML("No active people.");
table.setFooterHTML("<p style=\"font-size:.875rem;\">" + escape(summary) + "</p>");
return (new RenderWidgetOutput(table));
}
}
}
@@ -0,0 +1,499 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
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.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.exceptions.QUserFacingException;
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.QWidgetData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** The policy editor widget (type AUTHENTIK_POLICY_EDITOR, DESIGN.md section
** 12.4). It is shown on the Edit Policy step; the Next UI widget of the same
** type draws the matrix and hands the generated document back to the step as
** the process value policyDocument. The data here: the catalog of what a
** policy can grant, the group's current document read live from Authentik,
** and the base permissions of members who inherit it.
*******************************************************************************/
public final class PolicyEditorWidget
{
/** The widget type the Next UI renders. */
public static final String TYPE = "AUTHENTIK_POLICY_EDITOR";
/** Short widget name (prefixed with the qbit namespace). */
public static final String NAME = "policyEditor";
/** Widget default value naming the qbit's API backend. */
public static final String BACKEND_NAME_KEY = "backendName";
/** Widget parameter (process value) with the group's pk. */
public static final String GROUP_PK_PARAM = "groupPk";
private static final int MAX_MEMBERS = 200;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyEditorWidget()
{
}
/*******************************************************************************
** The data for a group (or for none: an empty document).
*******************************************************************************/
public static Data load(AuthentikQBitConfig config, String groupPk) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.POLICIES_MANAGE))
{
throw (new QPermissionDeniedException("You do not have permission to edit policies (authentik.policies.manage)."));
}
AuthentikRuntime runtime = AuthentikRuntime.forBackend(config.getApiBackendName());
PolicyFormat format = config.getPolicyFormat();
Data data = new Data()
.withSpec(format.spec())
.withVersion(format.version())
.withCatalog(runtime.getPolicyCatalog().toJson().toList())
.withMapperEnabled(config.getMapperEnabled())
.withOutputFieldName(AuthentikAdmin.POLICY_DOCUMENT);
if(!StringUtils.hasContent(groupPk))
{
return (data);
}
if(!groupPk.matches("[A-Za-z0-9-]+"))
{
throw (new QUserFacingException("Not a group id: " + groupPk));
}
JSONObject group = AuthentikActionUtil.forBackend(config.getApiBackendName()).getApiJsonOrNull("/core/groups/" + groupPk + "/?include_users=false&include_parents=false&include_children=false");
if(group == null)
{
throw (new QUserFacingException("That group no longer exists."));
}
data.withGroupLabel(group.optString("name"));
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
if(stored != null && !JSONObject.NULL.equals(stored) && !"".equals(stored))
{
try
{
/////////////////////////////////////////////////////////////////
// canonical form of what is stored (unknown resources survive) //
/////////////////////////////////////////////////////////////////
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format, runtime.getPolicyCatalog())));
data.withFullAccess(PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).permissions().contains(PolicyPermissions.FULL_ACCESS));
}
catch(PolicyParseException e)
{
data.withDocumentProblem(e.getMessage());
}
}
QRecord row = PolicyStore.find(config, groupPk);
if(row != null)
{
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format, runtime.getPolicyCatalog())));
}
loadMembers(config, groupPk, data);
return (data);
}
/*******************************************************************************
** Members who inherit this policy, with only the edited document removed
** from their sign-in grants. Fetch one extra eligible member to detect truncation.
*******************************************************************************/
private static void loadMembers(AuthentikQBitConfig config, String groupPk, Data data) throws QException
{
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName());
GroupGraph graph = util.groupGraph();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, util.getRuntime().getPermissionCatalog());
Set<String> affectedGroups = graph.nodes().stream()
.filter(node -> node.pk().equals(groupPk) || graph.ancestorPks(node.pk()).contains(groupPk))
.map(GroupGraph.Node::pk).collect(Collectors.toSet());
List<JSONObject> users = new ArrayList<>(util.policyPreviewUsers(affectedGroups, MAX_MEMBERS + 1));
users.sort(Comparator.comparing(user -> user.optString("username")));
List<Map<String, Object>> members = new ArrayList<>();
for(JSONObject user : users)
{
List<String> groupPks = new ArrayList<>();
JSONArray groups = user.optJSONArray("groups_obj");
for(int i = 0; groups != null && i < groups.length(); i++)
{
String pk = groups.getJSONObject(i).getString("pk");
if(graph.byPk(pk) != null)
{
groupPks.add(pk);
}
}
List<String> viaGroups = groupPks.stream().filter(affectedGroups::contains).map(pk -> graph.byPk(pk).name()).sorted().toList();
if(viaGroups.isEmpty())
{
continue;
}
if(members.size() == MAX_MEMBERS)
{
data.withMembersTruncated(true);
break;
}
List<String> names = groupPks.stream().map(pk -> graph.byPk(pk).name()).toList();
Set<String> basePermissions = AccessComparison.mapperPermissions(config, resolver, groupPks, names,
String.valueOf(user.getInt("pk")), user.optString("uuid"), groupPk);
String username = user.optString("username");
members.add(Map.of("username", username, "label", user.optString("name") + " (" + username + ")",
"viaGroups", viaGroups, "basePermissions", basePermissions.stream().sorted().toList()));
}
data.withMembers(members);
}
/*******************************************************************************
** Renderer. QQQ checks the widget's permission rules
** (authentik.policies.manage) before calling it.
*******************************************************************************/
public static class Renderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
AuthentikQBitConfig config = AuthentikRuntime.forBackend(backendName).getConfig();
Map<String, String> params = input.getQueryParams();
return (new RenderWidgetOutput(load(config, params == null ? null : params.get(GROUP_PK_PARAM))));
}
}
/*******************************************************************************
** The widget's data (see the Next UI's PolicyEditorPayload).
*******************************************************************************/
public static class Data extends QWidgetData
{
private String spec;
private String version;
private String groupLabel;
private List<Object> catalog;
private String document;
private String documentProblem;
private Boolean changedInAuthentik = false;
private String outputFieldName;
private List<Map<String, Object>> members = List.of();
private Boolean membersTruncated = false;
private Boolean mapperEnabled;
private Boolean fullAccess = false;
/*******************************************************************************
** Whether the current valid document grants all current and future resources.
*******************************************************************************/
public Boolean getFullAccess()
{
return (fullAccess);
}
/*******************************************************************************
** Fluent setter for fullAccess.
*******************************************************************************/
public Data withFullAccess(Boolean fullAccess)
{
this.fullAccess = fullAccess;
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getType()
{
return (TYPE);
}
/*******************************************************************************
** Getter for spec
*******************************************************************************/
public String getSpec()
{
return (spec);
}
/*******************************************************************************
** Fluent setter for spec
*******************************************************************************/
public Data withSpec(String spec)
{
this.spec = spec;
return (this);
}
/*******************************************************************************
** Getter for version
*******************************************************************************/
public String getVersion()
{
return (version);
}
/*******************************************************************************
** Fluent setter for version
*******************************************************************************/
public Data withVersion(String version)
{
this.version = version;
return (this);
}
/*******************************************************************************
** Getter for groupLabel
*******************************************************************************/
public String getGroupLabel()
{
return (groupLabel);
}
/*******************************************************************************
** Fluent setter for groupLabel
*******************************************************************************/
public Data withGroupLabel(String groupLabel)
{
this.groupLabel = groupLabel;
return (this);
}
/*******************************************************************************
** Getter for catalog (groups of resources, as plain JSON values)
*******************************************************************************/
public List<Object> getCatalog()
{
return (catalog);
}
/*******************************************************************************
** Fluent setter for catalog
*******************************************************************************/
public Data withCatalog(List<Object> catalog)
{
this.catalog = catalog;
return (this);
}
/*******************************************************************************
** Getter for document (the group's current document, pretty; null for none)
*******************************************************************************/
public String getDocument()
{
return (document);
}
/*******************************************************************************
** Fluent setter for document
*******************************************************************************/
public Data withDocument(String document)
{
this.document = document;
return (this);
}
/*******************************************************************************
** Getter for documentProblem (why the stored document is invalid)
*******************************************************************************/
public String getDocumentProblem()
{
return (documentProblem);
}
/*******************************************************************************
** Fluent setter for documentProblem
*******************************************************************************/
public Data withDocumentProblem(String documentProblem)
{
this.documentProblem = documentProblem;
return (this);
}
/*******************************************************************************
** Getter for changedInAuthentik (differs from the last save in QQQ)
*******************************************************************************/
public Boolean getChangedInAuthentik()
{
return (changedInAuthentik);
}
/*******************************************************************************
** Fluent setter for changedInAuthentik
*******************************************************************************/
public Data withChangedInAuthentik(Boolean changedInAuthentik)
{
this.changedInAuthentik = changedInAuthentik;
return (this);
}
/*******************************************************************************
** Getter for outputFieldName (the process value the editor writes)
*******************************************************************************/
public String getOutputFieldName()
{
return (outputFieldName);
}
/*******************************************************************************
** Fluent setter for outputFieldName
*******************************************************************************/
public Data withOutputFieldName(String outputFieldName)
{
this.outputFieldName = outputFieldName;
return (this);
}
/*******************************************************************************
** Getter for members.
*******************************************************************************/
public List<Map<String, Object>> getMembers()
{
return (members);
}
/*******************************************************************************
** Fluent setter for members.
*******************************************************************************/
public Data withMembers(List<Map<String, Object>> members)
{
this.members = members;
return (this);
}
/*******************************************************************************
** Getter for membersTruncated.
*******************************************************************************/
public Boolean getMembersTruncated()
{
return (membersTruncated);
}
/*******************************************************************************
** Fluent setter for membersTruncated.
*******************************************************************************/
public Data withMembersTruncated(Boolean membersTruncated)
{
this.membersTruncated = membersTruncated;
return (this);
}
/*******************************************************************************
** Getter for mapperEnabled.
*******************************************************************************/
public Boolean getMapperEnabled()
{
return (mapperEnabled);
}
/*******************************************************************************
** Fluent setter for mapperEnabled.
*******************************************************************************/
public Data withMapperEnabled(Boolean mapperEnabled)
{
this.mapperEnabled = mapperEnabled;
return (this);
}
}
}
@@ -0,0 +1,85 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.regex.Pattern;
/*******************************************************************************
** The names a host uses for its policy documents (docs/POLICY-FORMAT.md): the
** PolicySpec it accepts, the one supported PolicyVersion, the Authentik group
** attribute that holds a group's document, and the token claim that holds the
** merged document.
**
** @param spec PolicySpec, e.g. "qqq-iam"
** @param version PolicyVersion (semver), e.g. "1.0.0"
** @param attribute group attribute key, e.g. "qqq.io/policy"
** @param claim token claim name, e.g. "qqq_policy"
*******************************************************************************/
public record PolicyFormat(String spec, String version, String attribute, String claim)
{
/** Default PolicySpec. */
public static final String DEFAULT_SPEC = "qqq-iam";
/** Default (and only) PolicyVersion of this qbit's format. */
public static final String DEFAULT_VERSION = "1.0.0";
/** Default group attribute. */
public static final String DEFAULT_ATTRIBUTE = "qqq.io/policy";
/** Default token claim. */
public static final String DEFAULT_CLAIM = "qqq_policy";
/** The generic defaults. */
public static final PolicyFormat DEFAULT = new PolicyFormat(DEFAULT_SPEC, DEFAULT_VERSION, DEFAULT_ATTRIBUTE, DEFAULT_CLAIM);
/** A valid spec name: letters, digits, dot, dash and underscore. */
public static final Pattern SPEC_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
/** Semver MAJOR.MINOR.PATCH. */
public static final Pattern VERSION_PATTERN = Pattern.compile("(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)");
/** A valid claim name. */
public static final Pattern CLAIM_PATTERN = Pattern.compile("[A-Za-z_][A-Za-z0-9_.-]{0,63}");
/*******************************************************************************
** Whether every name is well formed (used by the config's validate()).
*******************************************************************************/
public String problem()
{
if(spec == null || !SPEC_PATTERN.matcher(spec).matches())
{
return ("policySpec must be letters, digits, '.', '-' or '_': " + spec);
}
if(version == null || !VERSION_PATTERN.matcher(version).matches())
{
return ("policyVersion must be semver MAJOR.MINOR.PATCH: " + version);
}
if(attribute == null || attribute.isBlank())
{
return ("policyAttribute is required");
}
if(claim == null || !CLAIM_PATTERN.matcher(claim).matches())
{
return ("policyClaim must be a plain claim name: " + claim);
}
return (null);
}
}
@@ -0,0 +1,241 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Builds the canonical policy document for a set of QQQ permissions
** (docs/POLICY-FORMAT.md, "Canonical form"): one Allow statement per granted
** action in the order hasAccess, read, insert, edit, delete, then features
** sorted by name; resources sorted;
** full access is a single Action/Resource "*" statement. Every document is checked to round-trip through
** PolicyParser to exactly the permissions it came from; one that does not is
** never returned.
*******************************************************************************/
public final class PolicyGenerator
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyGenerator()
{
}
/*******************************************************************************
** The canonical document for these permissions ({resource}.{action} names).
**
** @throws PolicyParseException when a permission is not {resource}.{action}
** with a known action and a valid resource, or the document does not
** round-trip
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format) throws PolicyParseException
{
return (generate(permissions, format, null));
}
/*******************************************************************************
** Generate with the catalog of explicitly grantable table features.
*******************************************************************************/
public static JSONObject generate(Collection<String> permissions, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(permissions.contains(PolicyPermissions.FULL_ACCESS))
{
JSONObject document = new JSONObject().put("PolicySpec", f.spec()).put("PolicyVersion", f.version())
.put("Statement", new JSONArray().put(new JSONObject().put("Effect", PolicyParser.ALLOW)
.put("Action", new JSONArray().put("*")).put("Resource", new JSONArray().put("*"))));
if(!PolicyParser.parseStrict(toCompact(document), f, catalog).permissions().equals(Set.of(PolicyPermissions.FULL_ACCESS)))
{
throw (new PolicyParseException("The full-access document does not round-trip"));
}
return (document);
}
Map<String, Set<String>> byAction = new LinkedHashMap<>();
Map<String, Set<String>> byFeature = new TreeMap<>();
for(PolicyAction action : PolicyAction.values())
{
byAction.put(action.getJsonName(), new TreeSet<>());
}
Set<String> expected = new TreeSet<>();
for(String permission : permissions)
{
int dot = permission == null ? -1 : permission.lastIndexOf('.');
if(dot <= 0 || dot == permission.length() - 1)
{
throw (new PolicyParseException("\"" + permission + "\" is not {resource}.{action}"));
}
String resource = permission.substring(0, dot);
String name = permission.substring(dot + 1);
if(PolicyAction.fromJsonName(name).isEmpty() && (catalog == null || !catalog.hasFeature(resource, name)))
{
throw (new PolicyParseException("\"" + permission + "\": unknown action \"" + name + "\""));
}
if(!PolicyParser.RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException("\"" + permission + "\": resource must match [A-Za-z0-9_]+"));
}
Map<String, Set<String>> target = byAction.containsKey(name) ? byAction : byFeature;
target.computeIfAbsent(name, key -> new TreeSet<>()).add(resource);
expected.add(permission);
}
JSONArray statements = new JSONArray();
byAction.putAll(byFeature);
for(Map.Entry<String, Set<String>> entry : byAction.entrySet())
{
if(!entry.getValue().isEmpty())
{
statements.put(new JSONObject()
.put("Effect", PolicyParser.ALLOW)
.put("Action", new JSONArray().put(entry.getKey()))
.put("Resource", new JSONArray(entry.getValue())));
}
}
JSONObject document = new JSONObject()
.put("PolicySpec", f.spec())
.put("PolicyVersion", f.version())
.put("Statement", statements);
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f, catalog).permissions();
if(!parsed.equals(expected))
{
Set<String> missing = new TreeSet<>(expected);
missing.removeAll(parsed);
Set<String> extra = new TreeSet<>(parsed);
extra.removeAll(expected);
throw (new PolicyParseException("The generated document does not round-trip. Missing: " + missing + "; unexpected: " + extra));
}
return (document);
}
/*******************************************************************************
** Re-generate any valid document in canonical form (parse, then generate).
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format) throws PolicyParseException
{
return (canonical(document, format, null));
}
/*******************************************************************************
** Canonicalize a document using the registered table features.
*******************************************************************************/
public static JSONObject canonical(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
return (generate(PolicyParser.parseStrict(document, format, catalog).permissions(), format, catalog));
}
/*******************************************************************************
** Compact JSON in a fixed key order (PolicySpec, PolicyVersion, Statement;
** Effect, Action, Resource), as stored in the group attribute.
*******************************************************************************/
public static String toCompact(JSONObject document)
{
return (write(document, false));
}
/*******************************************************************************
** Pretty JSON (2-space indent, LF, trailing newline) in the fixed key order.
*******************************************************************************/
public static String toPretty(JSONObject document)
{
return (write(document, true));
}
/*******************************************************************************
** Write in the fixed key order (org.json does not keep insertion order).
*******************************************************************************/
private static String write(JSONObject document, boolean pretty)
{
String nl = pretty ? "\n" : "";
String i1 = pretty ? " " : "";
String i2 = pretty ? " " : "";
String i3 = pretty ? " " : "";
String i4 = pretty ? " " : "";
String sep = pretty ? ": " : ":";
StringBuilder sb = new StringBuilder("{").append(nl);
sb.append(i1).append(JSONObject.quote("PolicySpec")).append(sep).append(JSONObject.quote(document.optString("PolicySpec"))).append(",").append(nl);
sb.append(i1).append(JSONObject.quote("PolicyVersion")).append(sep).append(JSONObject.quote(document.optString("PolicyVersion"))).append(",").append(nl);
sb.append(i1).append(JSONObject.quote("Statement")).append(sep).append("[");
JSONArray statements = document.optJSONArray("Statement");
int count = statements == null ? 0 : statements.length();
for(int s = 0; s < count; s++)
{
JSONObject statement = statements.getJSONObject(s);
sb.append(s == 0 ? nl : "," + nl).append(i2).append("{").append(nl);
sb.append(i3).append(JSONObject.quote("Effect")).append(sep).append(JSONObject.quote(statement.optString("Effect"))).append(",").append(nl);
sb.append(i3).append(JSONObject.quote("Action")).append(sep).append(inline(statement.optJSONArray("Action"), pretty)).append(",").append(nl);
sb.append(i3).append(JSONObject.quote("Resource")).append(sep).append("[");
JSONArray resources = statement.optJSONArray("Resource");
int n = resources == null ? 0 : resources.length();
for(int r = 0; r < n; r++)
{
sb.append(r == 0 ? nl : "," + nl).append(i4).append(JSONObject.quote(resources.optString(r)));
}
sb.append(n == 0 ? "" : nl + i3).append("]").append(nl);
sb.append(i2).append("}");
}
sb.append(count == 0 ? "" : nl + i1).append("]").append(nl);
sb.append("}").append(nl);
return (sb.toString());
}
/*******************************************************************************
** A short string array on one line.
*******************************************************************************/
private static String inline(JSONArray array, boolean pretty)
{
StringBuilder sb = new StringBuilder("[");
int n = array == null ? 0 : array.length();
for(int i = 0; i < n; i++)
{
sb.append(i == 0 ? "" : (pretty ? ", " : ",")).append(JSONObject.quote(array.optString(i)));
}
return (sb.append("]").toString());
}
}
@@ -0,0 +1,35 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
/*******************************************************************************
** A policy document that cannot be accepted; the message names the problem
** in plain words (shown to operators in the editor's Review step).
*******************************************************************************/
public class PolicyParseException extends Exception
{
/*******************************************************************************
** Constructor.
*******************************************************************************/
public PolicyParseException(String message)
{
super(message);
}
}
@@ -0,0 +1,287 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Parses a policy document (docs/POLICY-FORMAT.md) into QQQ permission names.
**
** parse() FAILS CLOSED: any problem (unknown spec, unsupported version, a
** statement that is not Allow, an unknown action, a bad resource, malformed
** JSON, ...) grants zero permissions and logs a warning. parseStrict() throws
** a PolicyParseException naming the problem instead (for the editor).
*******************************************************************************/
public final class PolicyParser
{
private static final QLogger LOG = QLogger.getLogger(PolicyParser.class);
/** A valid resource: a QQQ permission base name. */
public static final Pattern RESOURCE_PATTERN = Pattern.compile("[A-Za-z0-9_]+");
/** The only effect in 1.0.0. */
public static final String ALLOW = "Allow";
/*******************************************************************************
** The result of a successful parse.
**
** @param permissions QQQ permission names, or the symbolic FULL_ACCESS marker
** @param resources every resource named, in document order
*******************************************************************************/
public record Parsed(Set<String> permissions, Set<String> resources)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyParser()
{
}
/*******************************************************************************
** The permissions a document grants, or none (with a warning) when it is
** missing or invalid in any way.
**
** @param document a JSONObject, a Map, or a JSON string (as stored in a group
** attribute or a token claim); null grants nothing
*******************************************************************************/
public static Set<String> parse(Object document, PolicyFormat format)
{
return (parse(document, format, null));
}
/*******************************************************************************
** Parse with the catalog of explicitly grantable table features; an invalid
** document still grants nothing.
*******************************************************************************/
public static Set<String> parse(Object document, PolicyFormat format, PolicyCatalog catalog)
{
if(document == null || JSONObject.NULL.equals(document))
{
return (Collections.emptySet());
}
try
{
return (parseStrict(document, format, catalog).permissions());
}
catch(PolicyParseException e)
{
LOG.warn("Policy document rejected; it grants no permissions", logPair("reason", e.getMessage()));
return (Collections.emptySet());
}
}
/*******************************************************************************
** Parse, throwing a PolicyParseException that names the first problem.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format) throws PolicyParseException
{
return (parseStrict(document, format, null));
}
/*******************************************************************************
** Parse standard actions and features registered for every listed resource.
** Without a catalog only standard actions and full access are accepted.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
JSONObject json = asObject(document);
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
Object spec = json.opt("PolicySpec");
if(!(spec instanceof String s) || !s.equals(f.spec()))
{
throw (new PolicyParseException("PolicySpec must be \"" + f.spec() + "\" (found " + describe(spec) + ")"));
}
Object version = json.opt("PolicyVersion");
if(!(version instanceof String v) || !v.equals(f.version()))
{
throw (new PolicyParseException("PolicyVersion must be \"" + f.version() + "\" (found " + describe(version) + ")"));
}
Set<String> permissions = new TreeSet<>();
Set<String> resources = new LinkedHashSet<>();
Object statements = json.opt("Statement");
if(statements == null)
{
return (new Parsed(permissions, resources));
}
if(!(statements instanceof JSONArray array))
{
throw (new PolicyParseException("Statement must be an array"));
}
for(int i = 0; i < array.length(); i++)
{
if(!(array.opt(i) instanceof JSONObject statement))
{
throw (new PolicyParseException("Statement " + (i + 1) + " must be an object"));
}
Object effect = statement.opt("Effect");
if(!ALLOW.equals(effect))
{
throw (new PolicyParseException("Statement " + (i + 1) + ": Effect must be \"Allow\" (found " + describe(effect) + "); Deny is not supported yet"));
}
Set<String> statementResources = strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource");
Set<String> statementActions = strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action");
if(statementResources.contains("*") && !statementActions.equals(Set.of("*")))
{
throw (new PolicyParseException("Statement " + (i + 1) + ": Resource \"*\" requires Action [\"*\"] (full access)"));
}
Set<String> actions = new LinkedHashSet<>();
for(String name : statementActions)
{
if(PolicyAction.WILDCARD.equals(name))
{
for(PolicyAction action : PolicyAction.values())
{
actions.add(action.getJsonName());
}
}
else
{
if(PolicyAction.fromJsonName(name).isEmpty()
&& (catalog == null || statementResources.isEmpty() || !statementResources.stream().allMatch(resource -> catalog.hasFeature(resource, name))))
{
throw (new PolicyParseException("Statement " + (i + 1) + ": unknown action \"" + name + "\""));
}
actions.add(name);
}
}
for(String resource : statementResources)
{
if(resource.equals("*"))
{
permissions.add(PolicyPermissions.FULL_ACCESS);
resources.add(resource);
continue;
}
if(!RESOURCE_PATTERN.matcher(resource).matches())
{
throw (new PolicyParseException("Statement " + (i + 1) + ": resource \"" + resource + "\" must match [A-Za-z0-9_]+"));
}
resources.add(resource);
for(String action : actions)
{
permissions.add(resource + "." + action);
}
}
}
return (new Parsed(permissions.contains(PolicyPermissions.FULL_ACCESS) ? Set.of(PolicyPermissions.FULL_ACCESS) : permissions, resources));
}
/*******************************************************************************
** The document as a JSON object.
*******************************************************************************/
static JSONObject asObject(Object document) throws PolicyParseException
{
if(document instanceof JSONObject object)
{
return (object);
}
if(document instanceof Map<?, ?> map)
{
return (new JSONObject(map));
}
if(document instanceof String text)
{
if(text.isBlank())
{
throw (new PolicyParseException("The document is empty"));
}
try
{
return (new JSONObject(text));
}
catch(JSONException e)
{
throw (new PolicyParseException("The document is not a JSON object"));
}
}
throw (new PolicyParseException("The document is not a JSON object"));
}
/*******************************************************************************
** An array of strings, or a PolicyParseException.
*******************************************************************************/
private static Set<String> strings(Object value, String what) throws PolicyParseException
{
if(!(value instanceof JSONArray array))
{
throw (new PolicyParseException(what + " must be an array of strings"));
}
Set<String> rs = new LinkedHashSet<>();
for(int i = 0; i < array.length(); i++)
{
if(!(array.opt(i) instanceof String text))
{
throw (new PolicyParseException(what + " must be an array of strings"));
}
rs.add(text);
}
return (rs);
}
/*******************************************************************************
** A short description of a found value for messages (never the whole value).
*******************************************************************************/
private static String describe(Object value)
{
if(value == null)
{
return ("nothing");
}
if(value instanceof String text)
{
return ("\"" + (text.length() > 40 ? text.substring(0, 40) + "..." : text) + "\"");
}
return (value.getClass().getSimpleName());
}
}
@@ -0,0 +1,182 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONObject;
/*******************************************************************************
** Helpers a host's own authentication customizer can call to turn the policy
** claim of a sign-in token into QQQ permissions (docs/POLICY-FORMAT.md).
*******************************************************************************/
public final class PolicyPermissions
{
/** Internal parse marker, never a session permission or persisted permission list. */
public static final String FULL_ACCESS = "<full-access>";
/** Operator-facing description of the wildcard statement. */
public static final String FULL_ACCESS_LABEL = "Full access (all current and future resources)";
/** Customizer context key of the ID token claims (QQQ OAuth2 module). */
public static final String ID_TOKEN_CONTEXT_KEY = "idToken";
/** Customizer context key of the access token claims. */
public static final String ACCESS_TOKEN_CONTEXT_KEY = "jwtPayloadJsonObject";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyPermissions()
{
}
/*******************************************************************************
** The permissions granted by the policy claim in these token claims; none
** when the claim is missing or invalid (fail closed).
*******************************************************************************/
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format)
{
return (fromClaims(claims, format, null));
}
/*******************************************************************************
** Read a policy claim with the catalog of registered table features.
*******************************************************************************/
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(claims == null || !claims.has(f.claim()))
{
return (Collections.emptySet());
}
Set<String> parsed = PolicyParser.parse(claims.opt(f.claim()), f, catalog);
return (parsed.contains(FULL_ACCESS)
? allPermissions(catalog == null ? QContext.getQInstance() : catalog.getQInstance(), Set.of()) : parsed);
}
/*******************************************************************************
** Expand full access at use time, including core names, the qbit catalog
** (hidden record actions included), and every identity session permission.
** No expanded set is cached, so instance additions and reloads are visible.
*******************************************************************************/
public static Set<String> allPermissions(QInstance qInstance, Collection<String> additional)
{
Set<String> permissions = new TreeSet<>();
if(additional != null)
{
permissions.addAll(additional);
}
if(qInstance != null)
{
PermissionsHelper.getAllAvailablePermissions(qInstance).forEach(permission -> permissions.add(permission.getName()));
permissions.addAll(PolicyCatalog.of(qInstance).permissionNames());
permissions.addAll(hostPermissions(qInstance));
}
for(IdentityPermission permission : IdentityPermission.values())
{
permissions.add(permission.getSessionPermission());
}
permissions.remove(FULL_ACCESS);
return (permissions);
}
/*******************************************************************************
** The host-declared permission names (config fullAccessHostPermissions) of
** every Authentik runtime whose API backend is in this instance.
*******************************************************************************/
static Set<String> hostPermissions(QInstance qInstance)
{
Set<String> rs = new TreeSet<>();
for(com.kingsrook.qbits.authentik.AuthentikRuntime runtime : com.kingsrook.qbits.authentik.AuthentikRuntime.all())
{
com.kingsrook.qbits.authentik.AuthentikQBitConfig config = runtime.getConfig();
if(config.getFullAccessHostPermissions() != null && config.getApiBackendName() != null && qInstance.getBackend(config.getApiBackendName()) != null)
{
java.util.Collection<String> names = config.getFullAccessHostPermissions().apply(qInstance);
if(names != null)
{
names.stream().filter(n -> n != null && !n.isBlank()).forEach(rs::add);
}
}
}
return (rs);
}
/*******************************************************************************
** The claims that carry the policy in a customizer context: the ID token's
** when it has the claim (Authentik puts scope-mapping claims there), else the
** access token's (the only ones on a session resume). Null when neither has
** the claim.
*******************************************************************************/
public static JSONObject claimsWithPolicy(Map<String, Object> context, PolicyFormat format)
{
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
if(context == null)
{
return (null);
}
for(String key : new String[] { ID_TOKEN_CONTEXT_KEY, ACCESS_TOKEN_CONTEXT_KEY })
{
JSONObject claims = asClaims(context.get(key));
if(claims != null && claims.has(f.claim()))
{
return (claims);
}
}
return (null);
}
/*******************************************************************************
** Claims as a JSONObject (QQQ passes a JSONObject; tests may pass a Map).
*******************************************************************************/
public static JSONObject asClaims(Object value)
{
if(value instanceof JSONObject object)
{
return (object);
}
if(value instanceof Map<?, ?> map)
{
return (new JSONObject(map));
}
return (null);
}
}
@@ -0,0 +1,189 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
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.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.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.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** The authentik_policy rows (DESIGN.md section 12.2): the QQQ record of each
** group's last policy save, plus the fingerprint used to tell when the policy
** was changed in Authentik since.
*******************************************************************************/
public final class PolicyStore
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyStore()
{
}
/*******************************************************************************
** A fingerprint of a group's policy attribute value: of its canonical form
** when it parses (so formatting differences do not count), else of the raw
** text. Null for no policy.
*******************************************************************************/
public static String fingerprint(Object attributeValue, PolicyFormat format)
{
return (fingerprint(attributeValue, format, null));
}
/*******************************************************************************
** Fingerprint including registered table features in the canonical form.
*******************************************************************************/
public static String fingerprint(Object attributeValue, PolicyFormat format, PolicyCatalog catalog)
{
if(attributeValue == null || JSONObject.NULL.equals(attributeValue) || "".equals(attributeValue))
{
return (null);
}
String text;
try
{
text = PolicyGenerator.toCompact(PolicyGenerator.canonical(attributeValue, format, catalog));
}
catch(PolicyParseException e)
{
text = String.valueOf(attributeValue);
}
try
{
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))));
}
catch(NoSuchAlgorithmException e)
{
throw (new IllegalStateException(e));
}
}
/*******************************************************************************
** The row of a group, or null (also null when the table is not set up).
*******************************************************************************/
public static QRecord find(AuthentikQBitConfig config, String groupPk) throws QException
{
String table = config.applyTablePrefix(AuthentikLocalTables.POLICY);
if(QContext.getQInstance() == null || QContext.getQInstance().getTable(table) == null)
{
return (null);
}
QueryInput queryInput = new QueryInput();
queryInput.setTableName(table);
queryInput.setFilter(new QQueryFilter(new QFilterCriteria("groupPk", QCriteriaOperator.EQUALS, groupPk)));
QueryOutput output = new QueryAction().execute(queryInput);
return (output.getRecords().isEmpty() ? null : output.getRecords().get(0));
}
/*******************************************************************************
** Remove the row of a deleted group (no-op without one).
*******************************************************************************/
public static void delete(AuthentikQBitConfig config, String groupPk) throws QException
{
QRecord existing = find(config, groupPk);
if(existing != null)
{
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.POLICY));
deleteInput.setPrimaryKeys(List.of(existing.getValue("id")));
new DeleteAction().execute(deleteInput);
}
}
/*******************************************************************************
** Insert or update the row of a group after a save to Authentik.
**
** @param document the canonical document that was saved (empty statements
** when the policy was removed)
*******************************************************************************/
public static void save(AuthentikQBitConfig config, String groupPk, String groupName, JSONObject document, int permissionCount, String savedBy, String fingerprint) throws QException
{
String table = config.applyTablePrefix(AuthentikLocalTables.POLICY);
QRecord existing = find(config, groupPk);
QRecord record = new QRecord()
.withValue("groupPk", groupPk)
.withValue("groupName", groupName)
.withValue("policyJson", PolicyGenerator.toPretty(document))
.withValue("statementCount", document.optJSONArray("Statement") == null ? 0 : document.getJSONArray("Statement").length())
.withValue("permissionCount", permissionCount)
.withValue("savedBy", savedBy)
.withValue("savedDate", Instant.now())
.withValue("authentikFingerprint", fingerprint)
.withValue("modifyDate", Instant.now());
if(existing == null)
{
record.setValue("createDate", Instant.now());
InsertInput insertInput = new InsertInput();
insertInput.setTableName(table);
insertInput.setRecords(List.of(record));
InsertOutput output = new InsertAction().execute(insertInput);
if(output.getRecords().isEmpty() || !output.getRecords().get(0).getErrors().isEmpty())
{
throw (new QException("The policy record could not be written: " + (output.getRecords().isEmpty() ? "no record" : output.getRecords().get(0).getErrors())));
}
}
else
{
record.setValue("id", existing.getValue("id"));
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(table);
updateInput.setRecords(List.of(record));
UpdateOutput output = new UpdateAction().execute(updateInput);
if(output.getRecords().isEmpty() || !output.getRecords().get(0).getErrors().isEmpty())
{
throw (new QException("The policy record could not be written: " + (output.getRecords().isEmpty() ? "no record" : output.getRecords().get(0).getErrors())));
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,105 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.processes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Daily app-password expiry reminders, deduplicated in local token metadata.
*******************************************************************************/
public class AuthentikTokenReminders implements BackendStep
{
/*******************************************************************************
** The scheduler invokes the same guarded path as manual process execution.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
run(AuthentikActionUtil.forBackend(input.getValueString("backendName")), Instant.now());
}
/*******************************************************************************
** Two non-overlapping windows avoid two reminders when first seen on day 1.
** Synchronization prevents overlapping local scheduled runs from duplicating.
*******************************************************************************/
public static void run(AuthentikActionUtil util, Instant now) throws QException
{
if(!(QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession)
&& !AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
{
throw (new QPermissionDeniedException("Token reminders require authentik.tokens.manage."));
}
AuthentikRuntime runtime = util.getRuntime();
synchronized(runtime)
{
TokenScopeStore store = new TokenScopeStore(runtime.getConfig());
for(JSONObject token : util.getAllPages("/core/tokens/?intent=app_password", Integer.MAX_VALUE))
{
if(!"app_password".equals(token.optString("intent")) || !token.optBoolean("expiring") || token.isNull("expires"))
{
continue;
}
Instant expires = Instant.parse(token.getString("expires"));
if(!expires.isAfter(now) || expires.isAfter(now.plus(7, ChronoUnit.DAYS)))
{
continue;
}
String field = expires.isAfter(now.plus(1, ChronoUnit.DAYS)) ? "lastReminder7d" : "lastReminder1d";
String id = token.getString("identifier");
QRecord scope = store.find(id);
Instant remindedAt = scope == null ? null : scope.getValueInstant(field);
Instant windowStart = expires.minus(field.equals("lastReminder7d") ? 7 : 1, ChronoUnit.DAYS);
if(remindedAt != null && !remindedAt.isBefore(windowStart))
{
continue;
}
int pk = token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk");
JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/");
if(owner == null || owner.optString("email").isBlank())
{
continue;
}
String message = "Your access token " + token.optString("description") + " (" + id + ") expires on " + expires
+ ". Extend or rotate it in Preferences > Access tokens.";
if(runtime.getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_expiry:" + id + ":" + field + ":" + expires,
"info", owner.getString("email"), message)))
{
store.reminded(id, owner, field, now);
}
}
}
}
}
@@ -0,0 +1,121 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.processes;
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
/*******************************************************************************
** Response-only delivery SPI. Hosts may install their core-compatible adapter.
*******************************************************************************/
@FunctionalInterface
public interface TokenKeyDelivery
{
/*******************************************************************************
** Deliver only into a response-only core channel, never process values.
*******************************************************************************/
void deliver(RunBackendStepOutput output, OneTimeKey key);
/*******************************************************************************
** Whether this implementation can show a key once.
*******************************************************************************/
default boolean available()
{
return (true);
}
/*******************************************************************************
** Current adapter; null restores the core response-only adapter.
*******************************************************************************/
static void set(TokenKeyDelivery delivery)
{
Holder.delivery = delivery == null ? new CoreDelivery() : delivery;
}
/*******************************************************************************
** Current adapter.
*******************************************************************************/
static TokenKeyDelivery get()
{
return (Holder.delivery);
}
/*******************************************************************************
** Fail closed if runtime delivery becomes unavailable after Review.
*******************************************************************************/
static boolean tryDeliver(RunBackendStepOutput output, OneTimeKey key)
{
try
{
TokenKeyDelivery delivery = get();
if(!delivery.available())
{
return (false);
}
delivery.deliver(output, key);
return (true);
}
catch(RuntimeException e)
{
return (false);
}
}
/*******************************************************************************
** Mutable holder, without putting a credential in static state.
*******************************************************************************/
final class Holder
{
private static volatile TokenKeyDelivery delivery = new CoreDelivery();
/*******************************************************************************
** Utility holder.
*******************************************************************************/
private Holder()
{
}
}
/*******************************************************************************
** Core response-only delivery; credentials never enter process values.
*******************************************************************************/
final class CoreDelivery implements TokenKeyDelivery
{
/*******************************************************************************
** Add to core's response-only channel.
*******************************************************************************/
@Override
public void deliver(RunBackendStepOutput output, OneTimeKey key)
{
output.addOneTimeSecret(new OneTimeSecret().withLabel(key.label()).withValue(key.value())
.withMasked(key.masked()).withUsageText(key.usageText()));
}
}
}
@@ -21,6 +21,7 @@ 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.admin.AdminAction;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
@@ -35,9 +36,9 @@ 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:
** The qbit's own enforcement point (DESIGN.md section 5.3). Core actions now
** enforce permissions (QRun-IO/qqq#1061); direct AuthentikActionUtil calls and
** Authentik-specific rules still need this guard:
** - reads need the table's authentik.* permission (system sessions may read,
** for the mirror sync);
** - sensitive fields need authentik.audit.read;
@@ -64,6 +65,21 @@ public class AuthentikGuard
/*******************************************************************************
** Health probes expose only safe outcomes, including to the central runner.
*******************************************************************************/
public void checkHealth() throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
requireUser(session);
if(!has(session, IdentityPermission.ADMIN) && (session.getPermissions() == null || !session.getPermissions().contains("health.read")))
{
throw (new QPermissionDeniedException("Health read permission is required."));
}
}
/*******************************************************************************
** Check that the current session may read a table.
*******************************************************************************/
@@ -79,7 +95,7 @@ public class AuthentikGuard
return;
}
requireUser(session);
if(!has(session, spec.getReadPermission()))
if(!has(session, spec.getReadPermission()) && !(spec.getAlternativeReadPermission() != null && has(session, spec.getAlternativeReadPermission())))
{
throw (new QPermissionDeniedException("You do not have permission to view " + spec.getLabel() + "."));
}
@@ -107,6 +123,53 @@ public class AuthentikGuard
/*******************************************************************************
** Authorize one change to Authentik for the current session: a signed-in
** user (never a system session), the action's feature on, not read-only,
** and the action's authentik.* permission. The returned authorization is
** what AuthentikActionUtil.write requires (DESIGN.md section 5.3).
*******************************************************************************/
public WriteAuthorization authorize(AdminAction action) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(session instanceof QSystemUserSession)
{
throw (new QPermissionDeniedException("System sessions may not change Authentik data."));
}
requireUser(session);
if(Boolean.TRUE.equals(config.getReadOnly()))
{
throw (new QPermissionDeniedException("The Identity screens are in read-only mode."));
}
if(!config.hasFeature(action.getFeature()))
{
throw (new QPermissionDeniedException(action.getLabel() + " is not enabled (feature " + action.getFeature() + ")."));
}
if(action.getPermission() != null && !has(session, action.getPermission()))
{
throw (new QPermissionDeniedException("You do not have permission to do this (" + action.getPermission().getShortName() + ")."));
}
if(action == AdminAction.CREATE_SERVICE_ACCOUNT && !has(session, IdentityPermission.USERS_MANAGE))
{
throw (new QPermissionDeniedException("Creating a service account also needs authentik.users.manage."));
}
return (new WriteAuthorization(action.name(), operatorEmail()));
}
/*******************************************************************************
** The current operator: the session user's id reference (an email in apps
** that sign in through Authentik), or null.
*******************************************************************************/
public String operatorEmail()
{
QSession session = QContext.getQSession();
return (session == null || session.getUser() == null ? null : session.getUser().getIdReference());
}
/*******************************************************************************
** Whether the current session may see and filter sensitive fields.
*******************************************************************************/
@@ -0,0 +1,437 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.admin.AdminRequest;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
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.logging.QLogger;
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.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.json.JSONObject;
/*******************************************************************************
** Signed token claims can only narrow the permissions computed by a host.
*******************************************************************************/
public final class AuthentikTokenScopes
{
private static final QLogger LOG = QLogger.getLogger(AuthentikTokenScopes.class);
/*******************************************************************************
** Distinguish unrestricted credentials from revoked credentials.
*******************************************************************************/
public enum Mode
{
SCOPED,
UNSCOPED,
DENY_ALL
}
/*******************************************************************************
** Immutable, non-secret credential restrictions, including advanced scopes.
*******************************************************************************/
public record TokenScope(String identifier, Mode mode, boolean readOnly, Set<String> apps, Set<String> tables,
Set<String> processes, Set<String> reports, Set<String> widgets, Set<String> operationIds)
{
/*******************************************************************************
** A missing row is unrestricted; a missing token is denied.
*******************************************************************************/
public static TokenScope marker(String identifier, Mode mode)
{
return (new TokenScope(identifier, mode, false, Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), Set.of()));
}
/*******************************************************************************
** Decode a local metadata row without retaining the mutable record.
*******************************************************************************/
public static TokenScope from(String identifier, QRecord row)
{
return (new TokenScope(identifier, Mode.SCOPED, Boolean.TRUE.equals(row.getValueBoolean("readOnly")),
names(row.getValueString("apps")), names(row.getValueString("tables")), names(row.getValueString("processes")),
names(row.getValueString("reports")), names(row.getValueString("widgets")), names(row.getValueString("operationIds"))));
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikTokenScopes()
{
}
/*******************************************************************************
** One name per line; immutable sets keep cached scopes safe to share.
*******************************************************************************/
public static Set<String> names(String value)
{
if(value == null || value.isBlank())
{
return (Set.of());
}
return (Set.copyOf(Arrays.stream(value.split("\\R")).map(String::trim).filter(s -> !s.isEmpty()).toList()));
}
/*******************************************************************************
** Resolve only qbit backends present in this instance. Cache by identifier
** within each runtime, so tenants never share a scope or token existence.
*******************************************************************************/
public static Optional<TokenScope> forClaims(QInstance instance, JSONObject claims) throws QException
{
if(claims == null)
{
return (Optional.empty());
}
if(instance != null)
{
for(var backend : instance.getBackends().values())
{
AuthentikRuntime runtime;
try
{
runtime = AuthentikRuntime.forBackend(backend.getName());
}
catch(IllegalStateException ignored)
{
continue;
}
String claim = runtime.getConfig().getTokenScopeClaim();
if(!claims.has(claim))
{
continue;
}
Object value = claims.opt(claim);
if(!(value instanceof String identifier) || !identifier.matches("[A-Za-z0-9._-]+"))
{
return (Optional.of(TokenScope.marker("invalid", Mode.DENY_ALL)));
}
return (runtime.getTokenScopeCache().getResultThrowing(identifier, id -> lookupAsSystem(instance, () ->
{
APIBackendMetaData api = (APIBackendMetaData) backend;
AuthentikActionUtil util = (AuthentikActionUtil) QCodeLoader.getAdHoc(BaseAPIActionUtil.class, api.getActionUtil());
util.setBackendMetaData(api);
// Check existence even when a local row remains after an external revoke.
// Do not let a longer general response TTL extend the 60-second
// revocation window of this dedicated cache.
runtime.getResponseCache().clearKey(runtime.getConfig().getApiRoot() + "/core/tokens/" + id + "/");
if(util.getApiJsonOrNull("/core/tokens/" + id + "/") == null)
{
return (TokenScope.marker(id, Mode.DENY_ALL));
}
if(instance.getTable(runtime.getConfig().applyTablePrefix("token_scope")) == null)
{
throw (new QException("Token scope metadata is unavailable."));
}
QRecord row = new TokenScopeStore(runtime.getConfig()).find(id);
return (row == null ? TokenScope.marker(id, Mode.UNSCOPED) : TokenScope.from(id, row));
})));
}
}
// A default token claim must never silently lose enforcement if the qbit is missing.
return (claims.has("qqq_token") ? Optional.of(TokenScope.marker(claims.optString("qqq_token"), Mode.DENY_ALL)) : Optional.empty());
}
/*******************************************************************************
** A lookup step that may throw.
*******************************************************************************/
@FunctionalInterface
private interface Lookup
{
/***************************************************************************
** Run the lookup.
***************************************************************************/
TokenScope get() throws Exception;
}
/*******************************************************************************
** The scope lookup runs while a session is still being built (no user yet),
** so it reads Authentik and the scope row as the internal system session,
** then restores the caller's context.
*******************************************************************************/
private static TokenScope lookupAsSystem(QInstance instance, Lookup lookup) throws QException
{
QInstance previousInstance = QContext.getQInstance();
com.kingsrook.qqq.backend.core.model.session.QSession previousSession = QContext.getQSession();
try
{
if(previousInstance == null)
{
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession());
}
else
{
QContext.setQSession(new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession());
}
return (lookup.get());
}
catch(QException e)
{
throw (e);
}
catch(Exception e)
{
throw (new QException("Token scope lookup failed", e));
}
finally
{
if(previousInstance == null)
{
QContext.clear();
}
else
{
QContext.setQSession(previousSession);
}
}
}
/*******************************************************************************
** Hosts with their own customizer call this AFTER computing permissions.
*******************************************************************************/
public static void apply(QSession session, JSONObject claims)
{
apply(QContext.getQInstance(), session, claims);
}
/*******************************************************************************
** Explicit instance variant for authentication, before QContext is installed.
*******************************************************************************/
public static void apply(QInstance instance, QSession session, JSONObject claims)
{
try
{
forClaims(instance, claims).ifPresent(scope -> narrow(instance, session, scope));
}
catch(Exception e)
{
session.setPermissions(new TreeSet<>());
LOG.warn("Token scope could not be verified; denying credential permissions.", e);
}
}
/*******************************************************************************
** Intersect permissions for direct session consumers and attach core scope.
*******************************************************************************/
public static void narrow(QSession session, TokenScope scope)
{
narrow(QContext.getQInstance(), session, scope);
}
/*******************************************************************************
** Keep conservative permission narrowing for direct session consumers, then
** attach core scope for framework enforcement. Management is not read-only.
*******************************************************************************/
private static void narrow(QInstance instance, QSession session, TokenScope scope)
{
Set<String> permissions = new TreeSet<>(session.getPermissions() == null ? Set.of() : session.getPermissions());
if(scope.mode() == Mode.DENY_ALL)
{
permissions.clear();
}
else if(scope.mode() == Mode.SCOPED)
{
if(scope.readOnly())
{
permissions.removeIf(p -> p.startsWith("authentik.") || !(p.endsWith(".read") || p.endsWith(".hasAccess")));
}
if(!scope.apps().isEmpty() || !scope.tables().isEmpty() || !scope.processes().isEmpty() || !scope.reports().isEmpty() || !scope.widgets().isEmpty())
{
Set<String> bases = new LinkedHashSet<>();
if(instance != null)
{
for(String name : scope.tables())
{
if(instance.getTable(name) != null)
{
bases.add(base(instance, instance.getTable(name), name));
}
}
// Policy-format names exclude dots; host permission aliases need
// direct metadata traversal as well as the policy catalog.
for(String appName : scope.apps())
{
var app = instance.getApp(appName);
if(app == null)
{
continue;
}
bases.add(base(instance, app, appName));
Set<String> appTables = new LinkedHashSet<>();
if(app.getChildren() != null)
{
for(var child : app.getChildren())
{
if(child instanceof MetaDataWithPermissionRules secured)
{
bases.add(base(instance, secured, child.getName()));
}
if(instance.getTable(child.getName()) != null)
{
appTables.add(child.getName());
}
}
}
for(var process : instance.getProcesses().values())
{
if(process.getTableName() != null && appTables.contains(process.getTableName()))
{
bases.add(base(instance, process, process.getName()));
}
}
if(app.getWidgets() != null)
{
for(String name : app.getWidgets())
{
if(instance.getWidget(name) != null)
{
bases.add(base(instance, instance.getWidget(name), name));
}
}
}
}
for(PolicyCatalog.Group group : PolicyCatalog.of(instance).getGroups())
{
for(PolicyCatalog.Resource resource : group.resources())
{
if((group.app() != null && scope.apps().contains(group.app())) || scope.processes().contains(resource.name())
|| scope.reports().contains(resource.name()) || scope.widgets().contains(resource.name()))
{
bases.add(resource.name());
}
}
}
}
permissions.removeIf(p -> p.lastIndexOf('.') < 0 || !bases.contains(p.substring(0, p.lastIndexOf('.'))));
}
}
session.setPermissions(permissions);
session.setValue("qqq.credentialScope", scope.identifier());
attachCoreScope(session, scope);
}
/*******************************************************************************
** Use the permission base, which may differ from a table's metadata name.
*******************************************************************************/
private static String base(QInstance instance, MetaDataWithPermissionRules object, String name)
{
String base = PermissionsHelper.getEffectivePermissionRules(object, instance).getPermissionBaseName();
return (base == null || base.isBlank() ? name : base);
}
/*******************************************************************************
** Validate against current operator permissions at Review and execution.
*******************************************************************************/
public static void validate(AdminRequest request) throws QUserFacingException
{
QInstance instance = QContext.getQInstance();
for(String field : List.of("apps", "tables"))
{
for(String name : names(request.str(field)))
{
MetaDataWithPermissionRules object = field.equals("apps") ? instance.getApp(name) : instance.getTable(name);
if(object == null)
{
throw (new QUserFacingException("Unknown " + field + " scope: " + name));
}
String base = base(instance, object, name);
QSession session = QContext.getQSession();
if(!session.hasPermission(base + ".hasAccess") && !session.hasPermission(base + ".read"))
{
throw (new QUserFacingException("You do not currently have read access to scope: " + name));
}
}
}
for(String advanced : List.of("processes", "reports", "widgets", "operationIds"))
{
if(!request.str(advanced).isBlank())
{
throw (new QUserFacingException("Advanced token scopes are not offered by Create Token."));
}
}
}
/*******************************************************************************
** An empty scope dimension means "not restricted" (null for core).
*******************************************************************************/
private static Set<String> nullIfEmpty(Set<String> set)
{
return (set == null || set.isEmpty() ? null : set);
}
/*******************************************************************************
** Attach the released core scope contract; a revoked token allows nothing.
*******************************************************************************/
private static void attachCoreScope(QSession session, TokenScope scope)
{
boolean denied = scope.mode() == Mode.DENY_ALL;
session.withCredentialScope(new QCredentialScope().withCredentialId(scope.identifier())
.withReadOnly(denied || scope.readOnly())
.withApps(denied ? Set.of() : nullIfEmpty(scope.apps()))
.withTables(denied ? Set.of() : nullIfEmpty(scope.tables()))
.withProcesses(denied ? Set.of() : nullIfEmpty(scope.processes()))
.withReports(denied ? Set.of() : nullIfEmpty(scope.reports()))
.withWidgets(denied ? Set.of() : nullIfEmpty(scope.widgets()))
.withOperationIds(denied ? Set.of() : nullIfEmpty(scope.operationIds())));
}
}
@@ -0,0 +1,109 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.security;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import org.json.JSONObject;
/*******************************************************************************
** Decides whether a person is a bootstrap operator (the lockout escape hatch).
** Users can change their own email in Authentik and emails are not unique, so
** an email is NEVER enough. Someone is a bootstrap operator only when:
** - the token sub is a configured subject (a hashed or opaque id; a sub that
** looks like an email is ignored, since Authentik's user_email sub mode makes
** it mutable); or
** - the email is verified, exactly ONE Authentik user has it, that user is
** active, and that user's pk or uuid is a configured subject.
** Anything else is denied.
*******************************************************************************/
public final class BootstrapOperators
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BootstrapOperators()
{
}
/*******************************************************************************
** Whether a token (sub, email, email_verified) is a bootstrap operator.
**
** @param subjects the configured immutable subjects
** @param client an Authentik client for the lookup
** @param sub the sub claim (may be null)
** @param email the email claim (may be null)
** @param emailVerified the email_verified claim
*******************************************************************************/
public static boolean matches(Set<String> subjects, AuthentikActionUtil client, String sub, String email, boolean emailVerified)
{
if(subjects == null || subjects.isEmpty())
{
return (false);
}
if(sub != null && !sub.isBlank() && !sub.contains("@") && subjects.contains(sub.trim()))
{
return (true);
}
if(!emailVerified || email == null || email.isBlank() || client == null)
{
return (false);
}
return (matchesUniqueUser(subjects, client, email.trim()));
}
/*******************************************************************************
** The email-based path without the verified claim (an admin session has no
** claims): still requires exactly one active user with that email whose pk
** or uuid is a configured subject.
*******************************************************************************/
public static boolean matchesUniqueUser(Set<String> subjects, AuthentikActionUtil client, String email)
{
if(subjects == null || subjects.isEmpty())
{
return (false);
}
try
{
JSONObject found = null;
int count = 0;
for(JSONObject user : client.getAllPages("/core/users/?email=" + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&include_groups=false&include_roles=false", 10))
{
if(email.equalsIgnoreCase(user.optString("email")))
{
found = user;
count++;
}
}
return (count == 1 && found.optBoolean("is_active", true)
&& (subjects.contains(String.valueOf(found.optInt("pk"))) || subjects.contains(found.optString("uuid", "-"))));
}
catch(Exception e)
{
return (false);
}
}
}
@@ -38,9 +38,11 @@ public enum IdentityPermission
GROUPS_MANAGE("authentik.groups.manage"),
MEMBERSHIPS_MANAGE("authentik.memberships.manage"),
PERMISSIONS_MANAGE("authentik.permissions.manage"),
POLICIES_MANAGE("authentik.policies.manage"),
ROLES_MANAGE("authentik.roles.manage"),
CREDENTIALS_RESET("authentik.credentials.reset"),
SESSIONS_REVOKE("authentik.sessions.revoke"),
TOKENS_MANAGE("authentik.tokens.manage"),
AUDIT_READ("authentik.audit.read"),
APPROVE("authentik.approve"),
ADMIN("authentik.admin");
@@ -0,0 +1,56 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
/*******************************************************************************
** Either auditors or token managers may read token metadata.
*******************************************************************************/
public class TokenReadPermission implements CustomPermissionChecker
{
/*******************************************************************************
** Shared metadata rules for the API and local token tables.
*******************************************************************************/
public static QPermissionRules rules()
{
return (IdentityPermission.AUDIT_READ.rules().withCustomPermissionChecker(new QCodeReference(TokenReadPermission.class)));
}
/*******************************************************************************
** Require either permission; core invokes this for table-level access.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.AUDIT_READ)
&& !AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE))
{
throw (new QPermissionDeniedException("Token metadata requires authentik.audit.read or authentik.tokens.manage."));
}
}
}
@@ -0,0 +1,62 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.security;
/*******************************************************************************
** Proof that {@link AuthentikGuard} authorized one write. AuthentikActionUtil
** refuses to send any request that changes Authentik data without one, and the
** constructor is package-private, so only the guard can mint it (DESIGN.md
** section 5.3: every write path goes through the guard).
*******************************************************************************/
public final class WriteAuthorization
{
private final String action;
private final String operator;
/*******************************************************************************
** Constructor (package-private: only AuthentikGuard authorizes).
*******************************************************************************/
WriteAuthorization(String action, String operator)
{
this.action = action;
this.operator = operator;
}
/*******************************************************************************
** Getter for action
*******************************************************************************/
public String getAction()
{
return (action);
}
/*******************************************************************************
** Getter for operator
*******************************************************************************/
public String getOperator()
{
return (operator);
}
}
+957
View File
@@ -0,0 +1,957 @@
bundle: qbit-authentik
version: ${project.version}
layer: QBIT
entries:
- key: app:authentikAdmin;slot:help
summary: Review Authentik accounts, groups, application access and identity administration.
content: |
Identity shows the accounts, groups and applications held in Authentik, the service that signs people in to this application. The directory data is read from Authentik with the host's configured caching, and Authentik stays the source of truth. Changes made in Authentik itself are equally valid, and nothing here overwrites them blindly.
**Read only by default.** Out of the box you can look but not change anything. The change actions (creating and editing users and groups, resetting passwords, revoking sessions, editing policies) appear only when writes are switched on for this site. When they are off, those buttons are absent, not just disabled. Deleting a user or group needs a second switch and is off unless it was turned on deliberately.
Some details, such as sessions, tokens, events and raw attributes, need the audit permission. You need the permission to read users to open this app.
- key: table:authentik_application;slot:help
summary: The apps that people sign in to through Authentik, and who is allowed to open them.
content: |
Each row is an application set up in Authentik. Open one to see its provider, how it is launched and, under **Who can open it (policy bindings)**, the rules that decide which groups or users may use it.
This list is read only. Applications are managed in Authentik, not here.
**How rows get created:** Define applications in Authentik; you can inspect their live state here. An empty list means no applications match your query and visible scope.
- key: table:authentik_application;section:details
summary: The application's name, address, provider and library settings.
- key: table:authentik_application;section:bindings
summary: The rules that allow or deny groups and users from opening this application.
- key: table:authentik_application;field:slug
summary: The application's short address name inside Authentik.
- key: table:authentik_application;field:name
summary: The application's display name in Authentik.
- key: table:authentik_application;field:group
summary: The heading Authentik lists this application under in its library; often empty.
- key: table:authentik_application;field:providerName
summary: The sign-in provider connected to this application, such as an OAuth2 or proxy provider.
- key: table:authentik_application;field:policyEngineMode
summary: How the application's access rules combine.
content: |
Authentik uses **any** (one passing rule is enough) or **all** (every rule must pass). See the rules under **Who can open it (policy bindings)**.
- key: table:authentik_application;field:launchUrl
summary: The address people are sent to when they open the application from Authentik.
- key: table:authentik_application;field:publisher
summary: The publisher name shown for the application in Authentik; often empty.
- key: table:authentik_application;field:hiddenFromLibrary
summary: Yes when the application is hidden from users' Authentik app library.
content: |
Hidden applications can still be opened directly.
- key: table:authentik_application;field:bindingTarget
summary: Internal link used to list this application's access rules.
- key: table:authentik_blueprint;slot:help
summary: The configuration files Authentik applies to set itself up, and whether each applied cleanly.
content: |
Blueprints are files that describe parts of Authentik, such as its groups and service accounts. Authentik applies them itself. This list shows each one's **{{label:authentik_blueprint.status}}**, whether it is **{{label:authentik_blueprint.enabled}}**, and when it was **{{label:authentik_blueprint.lastApplied}}**.
It is read only and for checking. A status that is not successful means something in Authentik's own setup needs attention.
**How rows get created:** Authentik discovers and applies blueprints; you can inspect their live status here. An empty list means no blueprint instances match your query.
- key: table:authentik_blueprint;field:pk
summary: Authentik's internal id for the blueprint.
- key: table:authentik_blueprint;field:name
summary: The blueprint's name.
- key: table:authentik_blueprint;field:path
summary: Where Authentik finds the blueprint file.
- key: table:authentik_blueprint;field:status
summary: Whether Authentik applied the blueprint successfully, as reported by Authentik.
- key: table:authentik_blueprint;field:enabled
summary: Whether Authentik applies this blueprint automatically.
- key: table:authentik_blueprint;field:lastApplied
summary: When Authentik last applied this blueprint.
- key: table:authentik_event;slot:help
summary: 'Authentik''s activity log: sign-ins, failed logins, changes and other security events, newest first.'
content: |
Use this to answer who did what and when: a login, a password reset, a group change, or something an admin did from this app. Each row names the **{{label:authentik_event.action}}**, who did it, from which **{{label:authentik_event.clientIp}}**, and what it touched. **{{label:authentik_event.context}}** holds the raw detail.
The list is read only and needs the audit permission. On a user, the **History** section shows just that person's events. Authentik deletes old events on its own schedule, shown in **{{label:authentik_event.expires}}**.
**How rows get created:** Authentik records security events; you can inspect the live event log here. An empty list means no events match your query.
- key: table:authentik_event;field:pk
summary: Authentik's internal id for the event.
- key: table:authentik_event;field:created
summary: When the event happened.
- key: table:authentik_event;field:action
summary: What happened, in Authentik's own terms, such as login, login failed, model created or model updated.
- key: table:authentik_event;field:username
summary: The account that did it, where known.
- key: table:authentik_event;field:userPk
summary: The Authentik number of the account that did it.
- key: table:authentik_event;field:app
summary: The part of Authentik that recorded the event.
- key: table:authentik_event;field:clientIp
summary: The network address the request came from.
- key: table:authentik_event;field:modelApp
summary: For changes, which part of Authentik owns the thing that changed.
- key: table:authentik_event;field:modelName
summary: For changes, the kind of thing that changed, such as user or group.
- key: table:authentik_event;field:modelPk
summary: For changes, the id of the thing that changed.
- key: table:authentik_event;field:modelLabel
summary: For changes, the name of the thing that changed.
- key: table:authentik_event;field:authorizedApp
summary: For sign-ins, the application the person was signing in to.
- key: table:authentik_event;field:context
summary: The raw details Authentik recorded with the event, as JSON text.
- key: table:authentik_event;field:expires
summary: When Authentik will delete this event from its log.
- key: table:authentik_group_member;slot:help
summary: Which people belong to which Authentik groups, one row per person and group.
content: |
This is the list behind the **Members** section on a group and the **Memberships** section on a user. It is read only. To change who is in a group you use the **{{label:authentikAddMembers}}**, **{{label:authentikRemoveMembers}}**, **{{label:authentikAddToGroup}}** or **{{label:authentikRemoveFromGroup}}** actions, which are present only when writes are enabled.
**How rows get created:** Membership rows are assembled live from Authentik. Use the membership actions to add or remove people. An empty list means no memberships match your query.
- key: table:authentik_group_member;field:groupName
summary: The group the person belongs to.
- key: table:authentik_group_member;field:username
summary: The member's sign-in username.
- key: table:authentik_group_member;field:name
summary: The member's full name.
- key: table:authentik_group_member;field:email
summary: The member's email address.
- key: table:authentik_group_member;field:isActive
summary: Whether the member's account can currently sign in.
- key: table:authentik_group_member;field:groupPk
summary: Authentik's internal id of the group.
- key: table:authentik_group_member;field:userPk
summary: Authentik's number for the member's account.
- key: table:authentik_group;slot:help
summary: The groups that decide what each person can do in this application and which applications they can open.
content: |
Access in this application comes from group membership. A group can grant specific application permissions and app sign-in, can sit under parent groups whose access it inherits, and can carry a written policy.
Open a group to see its members, the permissions it grants directly and the full set including inherited ones, and its policy.
You can inspect the details your permissions allow. These actions appear when their features and writes are enabled: **{{label:authentikCreateGroup}}**, **{{label:authentikEditGroup}}**, **{{label:authentikEditPermissions}}**, **Edit Policy**, **{{label:authentikGeneratePolicy}}**, **{{label:authentikAddMembers}}** and **{{label:authentikRemoveMembers}}**. Safeguards apply: you can only grant permissions you hold yourself, you cannot edit your own access, superuser groups cannot be managed, and granting identity administration rights needs a second person when two-person approval is enabled.
**How rows get created:** Create groups in Authentik or use Create Group when enabled. You read their live state from Authentik. An empty list means no groups match your query.
- key: table:authentik_group;section:enforcement
summary: Whether saved policies currently take effect when people sign in.
- key: table:authentik_group;section:details
summary: The group's name, parents, children, member count and superuser status.
- key: table:authentik_group;section:permissions
summary: The application permissions and app sign-in this group grants, directly and by inheritance.
- key: table:authentik_group;section:members
summary: The people in this group.
- key: table:authentik_group;section:policy
summary: The written access policy saved on this group, if any.
- key: table:authentik_group;section:attributes
summary: The raw extra settings stored on the group in Authentik; needs the audit permission.
- key: table:authentik_group;field:pk
summary: Authentik's internal id for the group.
- key: table:authentik_group;field:name
summary: The group's name.
- key: table:authentik_group;field:isSuperuser
summary: Yes when this group itself gives its members full superuser rights in Authentik.
content: |
Superuser groups cannot be managed from here.
- key: table:authentik_group;field:isSuperuserInherited
summary: Yes when this group, or any parent it inherits from, gives members superuser rights.
- key: table:authentik_group;field:parentNames
summary: The groups this group inherits access from.
- key: table:authentik_group;field:childNames
summary: The groups that inherit access from this one.
- key: table:authentik_group;field:memberCount
summary: How many people are directly in this group.
- key: table:authentik_group;field:memberUsername
summary: Filter for groups containing a given username; not shown.
- key: table:authentik_group;field:permissions
summary: The application permissions this group grants directly, one per line.
content: |
These come from the group's permissions setting in Authentik. A permission looks like a screen or action name followed by what is allowed, for example read or has access.
- key: table:authentik_group;field:effectivePermissions
summary: Everything this group grants once parent groups and the group's policy are included.
content: |
Use it to answer why a person has a permission. The permissions here are the full set for the group's members.
- key: table:authentik_group;field:appAccess
summary: The applications the group's members may sign in to, one per line.
- key: table:authentik_group;field:roleNames
summary: The Authentik roles attached to this group.
- key: table:authentik_group;field:numPk
summary: Authentik's numeric id for the group; used internally.
- key: table:authentik_group;field:parentPks
summary: The internal ids of the parent groups; used internally.
- key: table:authentik_group;field:attributes
summary: The raw attributes stored on the group in Authentik, as JSON text.
content: |
Needs the audit permission. Permissions, app access and policy are kept here, so edit them with the actions rather than by hand.
- key: table:authentik_policy_binding;slot:help
summary: The rules attached to an application that allow or deny specific groups or users from opening it.
content: |
Each binding points at a group or user (or a named policy) and says whether it grants or blocks access. They are evaluated in **{{label:authentik_policy_binding.order}}**, and the application's **Policy Engine Mode** decides how the results combine.
This list is read only and appears on an application under **Who can open it (policy bindings)**. Bindings are changed in Authentik.
**How rows get created:** Define application bindings in Authentik; you can inspect the live bindings here. An empty list means no bindings match your query.
- key: table:authentik_policy_binding;field:pk
summary: Authentik's internal id for the binding.
- key: table:authentik_policy_binding;field:target
summary: Internal id of the application the binding is attached to.
- key: table:authentik_policy_binding;field:policyName
summary: The named policy the binding uses, when it is a policy rather than a group or user.
- key: table:authentik_policy_binding;field:groupName
summary: The group this binding applies to, if it targets a group.
- key: table:authentik_policy_binding;field:username
summary: The user this binding applies to, if it targets a person.
- key: table:authentik_policy_binding;field:negate
summary: Yes when the rule is reversed, so it applies to everyone except the group or user named.
- key: table:authentik_policy_binding;field:enabled
summary: Whether the rule is currently in force.
- key: table:authentik_policy_binding;field:order
summary: The position of the rule; lower numbers are checked first.
- key: table:authentik_policy_binding;field:expires
summary: When the rule stops applying, if it has an end date.
- key: table:authentik_role;slot:help
summary: Authentik roles, which bundle Authentik's own admin rights and can be attached to groups.
content: |
A role controls what someone may do inside Authentik itself, not inside this application. Only roles created by people are listed; ones Authentik manages itself are left out.
This list is read only. Managing roles from here is not available.
**How rows get created:** Define roles in Authentik; you can inspect its live, unmanaged roles here. An empty list means no unmanaged roles match your query.
- key: table:authentik_role;field:pk
summary: Authentik's internal id for the role.
- key: table:authentik_role;field:name
summary: The role's name.
- key: table:authentik_session;slot:help
summary: The sign-in sessions currently open in Authentik, with where and when each was last used.
content: |
A session is a signed-in browser or device. Use this to see who is signed in, from which address, and whether a session looks unfamiliar. A person's sessions also appear under **{{label:authentik_session}}** on their user record.
The list is read only and needs the audit permission. The **{{label:authentikRevokeSessions}}** action, which signs a person out everywhere, appears only when writes are switched on.
**How rows get created:** Authentik creates sessions when people sign in; you can inspect them live here. An empty list means no sessions match your query.
- key: table:authentik_session;field:uuid
summary: Authentik's id for the session.
- key: table:authentik_session;field:userPk
summary: The Authentik id of the person this sign-in session belongs to.
- key: table:authentik_session;field:username
summary: The username of the person who owns this session.
- key: table:authentik_session;field:lastIp
summary: The network address this session was last seen from.
content: |
Only people with the audit permission can open the Sessions list.
- key: table:authentik_session;field:lastUserAgent
summary: The browser or device description sent with the session's most recent request.
- key: table:authentik_session;field:lastUsed
summary: When the session was last used.
- key: table:authentik_session;field:expires
summary: When the session ends by itself if it is not revoked first.
- key: table:authentik_session;field:current
summary: Yes when this is the session you are using right now.
- key: table:authentik_session;field:geoIp
summary: The approximate location Authentik worked out from the last IP address, when it has one.
- key: table:authentik_session;field:asn
summary: The network provider Authentik matched to the last IP address, when it has one.
- key: table:authentik_token;slot:help
summary: Details of Authentik tokens and app passwords. The secret key is never shown.
content: |
This list holds the details of access tokens and app passwords: who owns each one, what it is for and when it expires. The secret key is never stored or shown here.
It is read from Authentik, subject to the host's configured cache lifetime.
You need the audit permission (or the permission to manage tokens) to see it. You normally reach it from the **Tokens and app passwords** section on a person's record.
Token actions are available when the host enables token management and writes, and you have the required permissions.
**How rows get created:** Create tokens in Authentik or use Create Token when enabled. This list reads live token metadata, never keys. An empty list means no tokens match your query and visible scope.
- key: table:authentik_token;field:identifier
summary: The token's unique name in Authentik, not its secret key.
- key: table:authentik_token;field:intent
summary: What the token is for in Authentik, for example an API token or an app password.
- key: table:authentik_token;field:username
summary: The username of the person who owns the token.
- key: table:authentik_token;field:description
summary: The note whoever created the token gave it, to remember what it is used for.
- key: table:authentik_token;field:expires
summary: When the token stops working.
- key: table:authentik_token;field:expiring
summary: Yes when the token has an expiry date; No when it never expires.
- key: table:authentik_user;slot:help
summary: Review people and service accounts within your configured Authentik user scope.
content: |
**{{label:authentik_user}}** lists the accounts in Authentik. It is read live (with caching configured by your host), so changes made in the Authentik admin screens show up here too. Authentik stays the source of truth.
Open a person to see their identity, groups and roles, sessions, tokens and recent history.
**Changing people** (create, edit, deactivate and reactivate, add to group, send a password reset, reset MFA, revoke app passwords, revoke sessions) only appears when changes are switched on for this site. Otherwise the Identity screens are read-only and every change must be made in Authentik itself.
Some accounts are protected and cannot be changed from here: Authentik superusers, the service account that connects this site to Authentik, internal service accounts, and any account marked protected. You also cannot change your own account.
Every change needs a reason and is recorded under **{{label:authentik_admin_action}}**.
**How rows get created:** Create accounts in Authentik or use the enabled user actions. You read their live state from Authentik. An empty list means no accounts match your query and configured user scope.
- key: table:authentik_user;section:identity
summary: 'Who the person is: username, name, email, status, type and sign-in dates.'
- key: table:authentik_user;section:access
summary: The groups and roles the person belongs to and the permissions they add up to in this app.
- key: table:authentik_user;section:memberships
summary: The groups this person is a member of.
- key: table:authentik_user;section:sessions
summary: The person's current sign-in sessions. Needs the audit permission.
- key: table:authentik_user;section:tokens
summary: Details of the person's access tokens and app passwords, never the secret keys. Needs the audit permission.
- key: table:authentik_user;section:events
summary: Recent things Authentik recorded for this person, such as sign-ins and changes. Needs the audit permission.
- key: table:authentik_user;section:attributes
summary: Extra data stored on the person in Authentik, plus when their password last changed. Needs the audit permission.
- key: table:authentik_user;field:pk
summary: The number Authentik uses to identify this person.
- key: table:authentik_user;field:username
summary: "The name the person signs in with."
content: |
The name the person signs in with. Letters, digits and . _ @ + - only.
- key: table:authentik_user;field:name
summary: The person's full name as shown in Authentik.
- key: table:authentik_user;field:email
summary: The person's email address. Password reset emails are sent here.
- key: table:authentik_user;field:isActive
summary: Yes if the person can sign in; deactivated accounts keep their history but cannot sign in.
- key: table:authentik_user;field:type
summary: The kind of account in Authentik.
content: |
Values you can see here:
- **internal**: a normal member account.
- **external**: an account for someone outside the organisation.
- **service_account**: an account for a script or service rather than a person.
- key: table:authentik_user;field:isSuperuser
summary: Yes if the account is an Authentik superuser, directly or through a parent group. Not editable here.
- key: table:authentik_user;field:isProtected
summary: Yes if this account cannot be changed from this screen.
content: |
An account is protected when it is an Authentik superuser, the service account that connects this site to Authentik, an internal Authentik service account, listed as protected in the site's setup, or marked as protected in its attributes. Use the Authentik admin screens for these.
- key: table:authentik_user;field:groupNames
summary: The groups the person is directly a member of.
- key: table:authentik_user;field:roleNames
summary: The Authentik roles the person has through their groups.
- key: table:authentik_user;field:lastLogin
summary: When the person last signed in. Blank if they never have.
- key: table:authentik_user;field:dateJoined
summary: When the account was created in Authentik.
- key: table:authentik_user;field:lastUpdated
summary: When the account was last changed in Authentik.
- key: table:authentik_user;field:path
summary: The folder-like location of the account in Authentik, used to organise users.
- key: table:authentik_user;field:uuid
summary: The unique identifier Authentik uses for this account.
- key: table:authentik_user;field:qqqAttributes
summary: The Authentik attributes that start with qqq.io/, which this application reads for permissions and protection.
- key: table:authentik_user;field:effectivePermissions
summary: The permissions this person ends up with in this app, added up from all their groups and parent groups.
content: |
This list is worked out when you open the record. Policies on groups only count here when policy enforcement is on (see **Policy Enforcement** on a group).
- key: table:authentik_user;field:passwordChangeDate
summary: When the person last changed their password. Needs the audit permission.
- key: table:authentik_user;field:attributes
summary: All extra data stored on the person in Authentik, as JSON. Needs the audit permission.
content: |
**{{label:authentikEditUser}}** can change attributes that do not start with qqq.io/ or goauthentik.io/. Those two prefixes are managed elsewhere.
- key: table:authentik_admin_action;slot:help
summary: The audit trail of every change attempted from the Identity screens, successful or not.
content: |
**{{label:authentik_admin_action}}** records each attempt to change something in Authentik: who did it, what, to whom, why, and how it ended. A row is written before the call to Authentik is made, so failures and refusals are recorded too. Authentik's own event log also gets a matching entry naming the person who did it.
Use it to answer who changed someone's access and when, and to see the state before and after.
You need the audit permission to read it. Rows are written by the system only; you cannot add or edit them by hand.
**How rows get created:** Your administrative actions write audit records before making changes in Authentik. An empty list means no audit records match your query.
- key: table:authentik_admin_action;field:correlationId
summary: An id shared by every record of the same attempt, to match it with Authentik's own log.
- key: table:authentik_admin_action;field:actionName
summary: Which change was attempted, for example Edit User or Add To Group.
- key: table:authentik_admin_action;field:operator
summary: The person who did it, shown by their sign-in email.
- key: table:authentik_admin_action;field:reason
summary: The reason the person typed when they made the change.
- key: table:authentik_admin_action;field:targetType
summary: 'What kind of thing was changed: a user, group, membership or similar.'
- key: table:authentik_admin_action;field:targetKey
summary: The Authentik id of the thing that was changed.
- key: table:authentik_admin_action;field:targetLabel
summary: The name of the thing that was changed, such as a username or group name.
- key: table:authentik_admin_action;field:beforeText
summary: How the thing looked before the change.
- key: table:authentik_admin_action;field:afterText
summary: How the thing looked after the change.
- key: table:authentik_admin_action;field:status
summary: 'How the attempt ended: {{values:authentik_action_status}}.'
content: |
Values:
- **Waiting for approval**: needs a second person to approve it; nothing has been sent to Authentik.
- **Running**: in progress.
- **Succeeded**: done in Authentik.
- **Failed**: sent to Authentik but it did not work; see the error message.
- **Refused**: a safety rule or missing permission stopped it before anything was sent.
- key: table:authentik_admin_action;field:errorMessage
summary: Why the attempt failed or was refused. Blank when it succeeded.
- key: table:authentik_admin_action;field:authentikEventPk
summary: The id of the matching entry in Authentik's own event log, when one was written.
- key: table:authentik_admin_action;field:approver
summary: The second person who approved the change, when approval was needed.
- key: table:authentik_approval_request;slot:help
summary: Changes waiting for a second person to approve before they are sent to Authentik.
content: |
Some changes need two people, so one person cannot grant themselves power. When two-person approval is enabled, adding identity administration permissions requires approval. Your host can also require approval for policy changes. The first person's request waits here. Nothing has changed in Authentik yet.
A different person with the approve permission opens the request and runs **{{label:authentikApprove}}**, or **{{label:authentikReject}}**. You cannot approve your own request.
Requests expire after 24 hours; ask again if one has expired. When approved, the change is checked again against live data, so it can still fail if something changed in the meantime.
Waiting requests also show on the Identity home under **Needs attention**.
**How rows get created:** Changes requiring a second person create approval requests. An empty list means no requests match your query; completed requests remain recorded.
- key: table:authentik_approval_request;field:actionName
summary: Which change is waiting for approval.
- key: table:authentik_approval_request;field:targetKey
summary: The Authentik id of the thing the change applies to.
- key: table:authentik_approval_request;field:targetLabel
summary: The name of the person or group the change applies to.
- key: table:authentik_approval_request;field:summary
summary: A plain description of what will change if the request is approved.
- key: table:authentik_approval_request;field:reason
summary: The reason the requester gave for the change.
- key: table:authentik_approval_request;field:payloadJson
summary: The exact change that will be applied, saved so the approver applies what was requested.
- key: table:authentik_approval_request;field:requester
summary: The person who asked for the change, shown by their sign-in email.
- key: table:authentik_approval_request;field:approver
summary: The person who approved or rejected the request. Blank while it is still waiting.
- key: table:authentik_approval_request;field:status
summary: 'Where the request stands: waiting, approved, rejected, expired or failed.'
content: |
Values:
- **Waiting for approval**: nobody has decided yet.
- **Approved and applied**: a second person approved it and the change was made.
- **Rejected**: a second person said no; nothing changed.
- **Expired**: nobody decided within 24 hours.
- **Approved but failed**: it was approved but the change could not be made; see the error message.
- key: table:authentik_approval_request;field:expiresDate
summary: When the request lapses if nobody decides it. It is 24 hours after the request was made.
- key: table:authentik_approval_request;field:decidedDate
summary: When the request was approved, rejected or marked expired.
- key: table:authentik_approval_request;field:errorMessage
summary: Why an approved change failed, or the word expired. Blank otherwise.
- key: table:authentik_policy;slot:help
summary: 'The saved policy of each group: a list of what its members may do in this app.'
content: |
A **policy** is a document saved on a group in Authentik. It lists the apps, tables, actions and reports the group's members may use, and what they may do (open, read, add, edit, delete). Members of the group, and of groups below it, receive those permissions when they sign in.
This list shows the last policy saved from this application for each group, with who saved it and when. Authentik holds the live document.
Saved policies only take effect at sign-in when policy enforcement is on. The banner on this screen says whether it is. When signed-in users receive changed permissions depends on the host's authentication and session configuration.
Use **{{label:authentikEditPolicyRecord}}** to change one. It needs the policies permission, and you can only grant permissions you hold yourself.
**How rows get created:** Saving a group policy records its document and author here. Authentik holds the live policy. An empty list means no saved policy records match your query.
- key: table:authentik_policy;section:enforcement
summary: Whether saved policies are actually applied when people sign in.
- key: table:authentik_policy;section:policy
summary: The group, how big its policy is, who saved it and the policy document itself.
- key: table:authentik_policy;section:system
summary: Technical details kept by the system, including the check used to spot changes made in Authentik.
- key: table:authentik_policy;field:groupPk
summary: The Authentik id of the group this policy belongs to.
- key: table:authentik_policy;field:groupName
summary: The name of the group the policy is saved on.
- key: table:authentik_policy;field:policyJson
summary: The policy document as it was last saved, in JSON.
content: |
The document lists statements. Each statement allows some actions (open, read, insert, edit, delete) on named apps, tables, actions, reports or widgets. Use **{{label:authentikEditPolicyRecord}}** to change it with tick boxes and a live preview instead of editing the JSON by hand. A document that is not valid grants nothing.
- key: table:authentik_policy;field:statementCount
summary: How many statements the saved policy has.
- key: table:authentik_policy;field:permissionCount
summary: How many separate permissions the saved policy grants in total.
- key: table:authentik_policy;field:savedBy
summary: Who saved this policy last, shown by their sign-in email.
- key: table:authentik_policy;field:savedDate
summary: When this policy was last saved from this application.
- key: table:authentik_policy;field:authentikFingerprint
summary: A short code of the policy as saved, used to detect that someone changed it directly in Authentik.
content: |
When you open **{{label:authentikEditPolicyRecord}}**, the editor compares this code to what Authentik holds now. If they differ, someone changed the policy in Authentik, and you are asked to review it again instead of overwriting their change.
- key: process:authentikCreateUser;slot:help
summary: Creates a new person in Authentik and, by default, emails them a link to set their own password.
content: |
Only available when changes are switched on for this site. Needs the permission to manage users.
You never set or see a password. If **Email them a link to set their own password** is ticked (the default), Authentik sends the person a link to choose one.
- The username may use letters, digits and . _ @ + - (up to 150 characters), and must not already exist.
- The email must be valid and not already used by another person.
- **{{label:authentik_group}}** adds them to existing groups straight away. You can only pick groups whose permissions you hold yourself.
Review shows what will be created before anything is sent to Authentik. Every run needs a reason and is recorded under **{{label:authentik_admin_action}}**.
- key: process:authentikCreateUser;field:username
summary: "The unique sign-in name for the new person."
content: |
The sign-in name: letters, digits and . _ @ + - only, up to 150 characters, and not already taken.
- key: process:authentikCreateUser;field:name
summary: The person's full name.
- key: process:authentikCreateUser;field:email
summary: Their email address. The link to set a password is sent here, and it must not belong to another person.
- key: process:authentikCreateUser;field:groups
summary: Groups to add them to, one group name per line. Optional.
- key: process:authentikCreateUser;field:sendRecovery
summary: Tick to have Authentik email the person a link so they can set their own password. On by default.
- key: process:authentikCreateUser;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikEditUser;slot:help
summary: Changes a person's username, name, email, path or other attributes in Authentik.
content: |
Only available when changes are switched on for this site. Needs the permission to manage users.
You can edit one person at a time. The form opens filled in with their current details; change what you need. Review shows exactly what will change.
- Attributes starting with qqq.io/ or goauthentik.io/ are managed elsewhere and cannot be changed here. Editing the other attributes also needs the audit permission.
- If someone changed this person in Authentik after you opened the form, the change is stopped and you are asked to review it again, rather than overwriting their work.
- Protected accounts, and your own account, cannot be edited here.
- key: process:authentikEditUser;field:username
summary: The sign-in name. It must not already belong to another person.
- key: process:authentikEditUser;field:name
summary: The person's full name. It cannot be empty.
- key: process:authentikEditUser;field:email
summary: The person's email address. It must be valid and not used by another person.
- key: process:authentikEditUser;field:path
summary: The folder-like location of the account in Authentik. It cannot be empty.
- key: process:authentikEditUser;field:otherAttributes
summary: 'Extra attributes as a JSON object, for example {"department": "sales"}. Leave blank to keep them as they are.'
- key: process:authentikEditUser;field:expectedFingerprint
summary: Filled in automatically. Used to detect that the person was changed in Authentik while you had this form open.
- key: process:authentikEditUser;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikDeactivateUser;slot:help
summary: Stops selected people from signing in and ends their sessions. Nothing is deleted.
content: |
Only available when changes are switched on for this site. Needs the permission to manage users.
You can select up to 100 people. Deactivating signs them out immediately and keeps the account and its history. Use **{{label:authentikReactivateUser}}** to undo it.
To confirm, type the username. When you select several people, type the username of the first one. Tick **Also revoke their app passwords** to remove those too.
Protected accounts, service accounts that connect this site, superusers and your own account cannot be deactivated here. The last person able to manage memberships and permissions cannot be deactivated either: add another administrator first.
- key: process:authentikDeactivateUser;field:revokeAppPasswords
summary: Also deletes the person's app passwords. Off by default.
- key: process:authentikDeactivateUser;field:confirmText
summary: Type the person's username to confirm. With several people selected, type the first one's username.
- key: process:authentikDeactivateUser;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikReactivateUser;slot:help
summary: Lets selected deactivated people sign in again.
content: |
Only available when changes are switched on for this site. Needs the permission to manage users.
You can select up to 100 people. Their groups and history are as they were. Protected accounts and your own account cannot be changed from here.
- key: process:authentikReactivateUser;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikAddToGroup;slot:help
summary: Adds the selected people to one group.
content: |
Only available when changes are switched on for this site. Needs the permission to manage memberships.
Pick the group and give a reason. You can select up to 100 people. Above 25 people you must also type how many you selected.
You can only add people to a group whose permissions and app sign-in access you hold yourself. Superuser groups and the groups below them cannot be managed here, and protected accounts and your own account cannot be changed.
Review shows who will be added before anything is sent to Authentik. To add several people to a group from the group's own screen, use **{{label:authentikAddMembers}}**.
- key: process:authentikAddToGroup;field:group
summary: The group to add the selected people to.
- key: process:authentikAddToGroup;field:confirmText
summary: Type the number of people you selected. Only needed when you selected more than 25.
- key: process:authentikAddToGroup;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikRemoveFromGroup;slot:help
summary: Removes the selected people from one group.
content: |
Only available when changes are switched on for this site. Needs the permission to manage memberships.
Pick the group and give a reason. You can select up to 100 people. Above 25 people you must also type how many you selected.
The last person able to manage memberships and permissions cannot be removed: add another administrator first. Superuser groups cannot be managed here, and protected accounts and your own account cannot be changed.
Use **{{label:authentikRevokeSessions}}** to end Authentik sessions. Permission refresh timing depends on your host.
- key: process:authentikRemoveFromGroup;field:group
summary: The group to remove the selected people from.
- key: process:authentikRemoveFromGroup;field:confirmText
summary: Type the number of people you selected. Only needed when you selected more than 25.
- key: process:authentikRemoveFromGroup;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikSendPasswordReset;slot:help
summary: Has Authentik email each selected person a link to choose a new password.
content: |
Only available when changes are switched on for this site. Needs the permission to reset credentials.
You never see the link. The person must be active and have an email address, and the password email must be set up for this site, otherwise the run is refused for that person. You can select up to 100 people.
- key: process:authentikSendPasswordReset;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikResetMfa;slot:help
summary: Removes every multi-factor sign-in device for the selected people.
content: |
Only available when changes are switched on for this site. Needs the permission to reset credentials.
Use it when someone has lost their phone or authenticator. Whether they must enroll another device depends on your Authentik sign-in flow. Type the username to confirm. It is recorded under **{{label:authentik_admin_action}}**.
- key: process:authentikResetMfa;field:confirmText
summary: Type the person's username to confirm.
- key: process:authentikResetMfa;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikRevokeAppPasswords;slot:help
summary: Deletes the selected people's app passwords. API tokens are not touched.
content: |
Only available when changes are switched on for this site. Needs the permission to reset credentials.
App passwords are long-lived passwords people create for scripts and other apps. Use this when one may have leaked. Type the username to confirm. It does not sign anyone out; use **{{label:authentikRevokeSessions}}** for that.
- key: process:authentikRevokeAppPasswords;field:confirmText
summary: Type the person's username to confirm.
- key: process:authentikRevokeAppPasswords;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikRevokeSessions;slot:help
summary: Signs the selected people out everywhere.
content: |
Only available when changes are switched on for this site. Needs the permission to revoke sessions.
Use it if an account may be compromised or its Authentik sessions should end. The person can sign in again unless deactivated. Existing host sessions and permission refresh timing depend on your host.
- key: process:authentikRevokeSessions;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikCreateGroup;slot:help
summary: Creates a group in Authentik, with its parents, permissions and app sign-in access.
content: |
Only available when changes are switched on for this site. Needs the permission to manage groups.
A group's permissions apply to everyone in it and to the groups below it.
- **Parent groups** and group names must exist; a group name must not already exist.
- **QQQ permissions** and **App access** are optional and can be set later with **{{label:authentikEditPermissions}}**.
- You can only grant permissions you hold yourself. Granting identity administration permissions needs a second person when two-person approval is enabled.
- A group cannot be placed under a superuser group.
- key: process:authentikCreateGroup;field:name
summary: The new group's name. It must not already exist.
- key: process:authentikCreateGroup;field:parents
summary: Parent groups, one group name per line. Optional. Members of this group also get the parents' access.
- key: process:authentikCreateGroup;field:permissions
summary: Named permissions the group grants, one per line. You can only list permissions you hold yourself.
- key: process:authentikCreateGroup;field:appAccess
summary: Apps the group may sign in to, one app key per line. You can only list apps you can sign in to yourself.
- key: process:authentikCreateGroup;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikEditGroup;slot:help
summary: Renames a group, changes its parent groups or its other attributes.
content: |
Only available when changes are switched on for this site. Needs the permission to manage groups.
You can edit one group at a time. The form opens filled in with the current values.
- Parent groups: one name per line. Write none to clear them. A group cannot be put below itself or its own descendants, and not under a superuser group.
- Changes that would leave nobody able to manage memberships and permissions are refused.
- If someone changed the group in Authentik after you opened the form, you are asked to review it again instead of overwriting their change.
To change what the group grants, use **{{label:authentikEditPermissions}}** or **Edit Policy**.
- key: process:authentikEditGroup;field:name
summary: The group's name. It must not already belong to another group.
- key: process:authentikEditGroup;field:parents
summary: Parent groups, one group name per line. Write none to clear them.
- key: process:authentikEditGroup;field:otherAttributes
summary: Extra attributes as a JSON object. Leave blank to keep them as they are.
- key: process:authentikEditGroup;field:expectedFingerprint
summary: Filled in automatically. Used to detect that the group was changed in Authentik while you had this form open.
- key: process:authentikEditGroup;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikEditPermissions;slot:help
summary: Changes the named permissions and app sign-in access a group grants.
content: |
Only available when changes are switched on for this site. Needs the permission to manage permissions.
Both lists are one entry per line. They replace what the group has now, so Review shows what is added and removed.
- You can only add permissions you hold yourself, and give sign-in access only to apps you can sign in to yourself.
- When two-person approval is enabled, adding identity administration permissions requires approval, so the change waits under **{{label:authentik_approval_request}}**.
- Removing permissions is refused if nobody would be left able to manage memberships and permissions.
- Superuser groups cannot be managed here.
Permission refresh timing depends on your host. Use **{{label:authentikRevokeSessions}}** to end Authentik sessions.
- key: process:authentikEditPermissions;field:permissions
summary: Named permissions the group grants, one per line. Replaces the current list.
- key: process:authentikEditPermissions;field:appAccess
summary: Apps the group may sign in to, one app key per line. Replaces the current list.
- key: process:authentikEditPermissions;field:expectedFingerprint
summary: Filled in automatically. Used to detect that the group was changed in Authentik while you had this form open.
- key: process:authentikEditPermissions;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikEditPolicy;slot:help
summary: Opens the policy editor to choose what a group's members may do, then saves it to Authentik.
content: |
Only available when changes are switched on for this site. Needs the permission to manage policies.
Tick the apps, tables, actions and reports the group's members may use, and what they may do with each (open, read, insert, edit, delete). A live preview shows the policy document that will be saved. Members of the group, and of groups below it, receive these permissions when they sign in.
- You can only grant permissions you hold yourself.
- A document that is not valid grants nothing, and one that cannot be read back exactly is never saved.
- If someone changed the policy in Authentik after you opened it, you are asked to review it again instead of overwriting their change.
- The banner at the top says whether saved policies are enforced at sign-in. If not, saving still works but changes nothing yet.
Permission refresh timing depends on your host. Use **{{label:authentikRevokeSessions}}** to end Authentik sessions.
- key: process:authentikEditPolicy;field:groupPk
summary: Filled in automatically with the group you opened.
- key: process:authentikEditPolicy;field:expectedFingerprint
summary: Filled in automatically. Used to detect that the policy was changed in Authentik while you had this form open.
- key: process:authentikEditPolicy;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikGeneratePolicy;slot:help
summary: Builds a group's policy from the access its members get today, so nothing is lost when policies are switched on.
content: |
Only available when changes are switched on for this site. Needs the permission to manage policies.
The generated policy is joined with the group's current policy, so nothing is removed. Named permissions this app has that a policy cannot express stay on the group's permissions list, and anything that cannot be written is listed as skipped.
Review shows the generated document and what changes before anything is saved to Authentik. Use it once per group when moving from fixed access to policy-based access. See **Compare Access** to check the result.
- key: process:authentikGeneratePolicy;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikEditPolicyRecord;slot:help
summary: Opens the policy editor for this group's saved policy, then saves it to Authentik.
content: |
Works the same as **{{label:authentikEditPolicy}}** on a group. Only available when changes are switched on for this site. Needs the permission to manage policies.
Tick what members of the group, and of the groups below it, may do. A live preview shows the document that will be saved. You can only grant permissions you hold yourself, and you are asked to review again if someone changed the policy in Authentik meanwhile.
Saved policies only take effect at sign-in when enforcement is on; the banner says whether it is.
- key: process:authentikEditPolicyRecord;field:groupPk
summary: Filled in automatically with the group this policy belongs to.
- key: process:authentikEditPolicyRecord;field:expectedFingerprint
summary: Filled in automatically. Used to detect that the policy was changed in Authentik while you had this form open.
- key: process:authentikEditPolicyRecord;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikAddMembers;slot:help
summary: Adds people to the selected group by typing their usernames.
content: |
Only available when changes are switched on for this site. Needs the permission to manage memberships.
Enter one username per line. You can add up to 100 people at a time; above 25 you must also type how many. The group must grant only permissions and app access you hold yourself, and superuser groups cannot be managed here. Protected accounts and your own account cannot be changed.
Review shows who will be added before anything is sent to Authentik. See also **{{label:authentikAddToGroup}}**, which starts from selected people.
- key: process:authentikAddMembers;field:usernames
summary: The usernames to add, one per line.
- key: process:authentikAddMembers;field:confirmText
summary: Type the number of people listed. Only needed when there are more than 25.
- key: process:authentikAddMembers;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikRemoveMembers;slot:help
summary: Removes people from the selected group by typing their usernames.
content: |
Only available when changes are switched on for this site. Needs the permission to manage memberships.
Enter one username per line. You can remove up to 100 people at a time; above 25 you must also type how many. The last person able to manage memberships and permissions cannot be removed: add another administrator first. Superuser groups cannot be managed here.
Review shows who will be removed before anything is sent to Authentik. See also **{{label:authentikRemoveFromGroup}}**, which starts from selected people.
- key: process:authentikRemoveMembers;field:usernames
summary: The usernames to remove, one per line.
- key: process:authentikRemoveMembers;field:confirmText
summary: Type the number of people listed. Only needed when there are more than 25.
- key: process:authentikRemoveMembers;field:reason
summary: Why you are making this change. It is saved in the audit trail.
- key: process:authentikApprove;slot:help
summary: Applies a change a colleague asked for, after you check it.
content: |
Needs the approve permission. It only exists while two-person approval is on.
You must be a different person from the one who asked, and the request must still be waiting and under 24 hours old. The change is checked again against live data, under your own permissions, then sent to Authentik. If it can no longer be applied the request is marked as failed and nothing is changed.
The result is recorded under **{{label:authentik_admin_action}}** with both names.
- key: process:authentikReject;slot:help
summary: Turns down a colleague's request. Nothing is changed in Authentik.
content: |
Needs the approve permission, and you must be a different person from the one who asked. The request is marked as rejected and stays in the list as a record.
- key: widget:authentikHome;slot:label
summary: 'The Identity home: what needs attention, people and group counts, recent changes and the connection to Authentik.'
content: |
Needs attention shows waiting approval requests, drift report problems, a read-only notice, and an expiring Authentik connection token. Recent changes lists the latest 8 entries from Admin Actions and needs the audit permission.
- key: widget:authentikPolicyEditor;slot:label
summary: 'The editor for a group''s policy: tick boxes for apps, tables, actions and reports, with a live document preview.'
- key: widget:authentikPolicyEnforcement;slot:label
summary: A banner saying whether saved policies are enforced when people sign in.
content: |
Green and "Enforced" means saved policies decide people's permissions at sign-in. "Not enforced" means they are saved but ignored for now. The text says why, for example that enforcement is switched off, or that switching it on would take permissions away from current administrators.
- key: widget:authentikCompareAccess;slot:label
summary: Shows, for every active person, the permissions they have today next to what saved policies would give them.
content: |
Needs the admin permission. Added shows what policies would give them that they lack today, and Removed shows what they would lose. Use it before switching policy enforcement on. Read only.
- key: widget:authentikDriftReport;slot:label
summary: Health checks on the Authentik setup, with errors listed first.
content: |
Needs the admin permission. It checks the Authentik version, blueprints, the service account, a protected break-glass superuser, that at least one person can administer identities, group attributes, and the service account's activity. Each finding is OK, info, warning or error. A check that cannot run is reported as an error with an unknown result.
- key: widget:authentik_user_memberships;slot:label
summary: The groups this person belongs to.
- key: widget:authentik_user_sessions;slot:label
summary: The person's current sign-in sessions. Needs the audit permission.
- key: widget:authentik_user_tokens;slot:label
summary: Details of the person's access tokens and app passwords, never the keys. Needs the audit permission.
- key: widget:authentik_user_events;slot:label
summary: The latest events Authentik recorded for this person. Needs the audit permission.
- key: widget:authentik_group_members;slot:label
summary: The people who are members of this group.
- key: widget:authentik_group_policy;slot:label
summary: The saved policy for this group, with who saved it and when.
- key: widget:authentik_application_bindings;slot:label
summary: The groups and people Authentik allows to open this application.
- key: possibleValueSource:authentik_action_status;slot:help
summary: The outcomes an attempted change can have in the audit trail.
content: |
Values:
- **Waiting for approval**: a second person must approve it; nothing has been sent to Authentik.
- **Running**: in progress.
- **Succeeded**: the change was made.
- **Failed**: it was sent to Authentik but did not work.
- **Refused**: a safety rule or missing permission stopped it before anything was sent.
- key: possibleValueSource:authentik_approval_status;slot:help
summary: The states an approval request can be in.
content: |
Values:
- **Waiting for approval**: nobody has decided yet.
- **Approved and applied**: a second person approved it and the change was made.
- **Rejected**: a second person said no; nothing changed.
- **Expired**: nobody decided within 24 hours.
- **Approved but failed**: it was approved but the change could not be made.
- key: table:authentik_token;field:ownerPk
summary: 'The Authentik identifier of the token’s owner.'
- key: table:authentik_token;field:ownerType
summary: 'Whether the token belongs to a person or a service account.'
- key: table:authentik_token;field:ownerName
summary: 'The displayed name of the token’s owner.'
- key: table:authentik_token;field:managed
summary: 'The management marker reported for this token.'
- key: table:authentik_token;field:lastUsed
summary: 'The last usage time available for this token.'
- key: table:authentik_token;field:lastUsedNote
summary: 'An explanation of the available token usage information.'
- key: table:authentik_token;field:createdAt
summary: 'The creation time available for this token.'
- key: table:authentik_token;field:scopeSummary
summary: 'A readable summary of the restrictions associated with this token.'
- key: table:authentik_token_scope;slot:help
summary: 'Locally stored access restrictions and reminder history for an Authentik token.'
content: |
Use **{{label:authentik_token_scope}}** to inspect the restrictions associated with an access token. These records contain identifiers and scope metadata, not the credential itself. Read-only mode and selected resources can narrow the access already held by the token’s owner; they cannot grant additional permissions.
Scope records also track creation, rotation and expiry reminders. Review them alongside **Tokens** when investigating access. Token creation and rotation actions maintain this metadata, and an empty scope dimension means that dimension adds no restriction.
**How rows get created:** Token actions maintain local scope and reminder metadata here. An empty list means no token scope records match your query.
- key: table:authentik_token_scope;field:tokenIdentifier
summary: 'The identifier of the Authentik token these restrictions belong to.'
- key: table:authentik_token_scope;field:ownerUsername
summary: 'The username of the token’s owner.'
- key: table:authentik_token_scope;field:ownerPk
summary: 'The Authentik identifier of the token’s owner.'
- key: table:authentik_token_scope;field:readOnly
summary: 'Whether the token is restricted to read operations.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:createdBy
summary: 'Who created this token scope record.'
- key: table:authentik_token_scope;field:apps
summary: 'The applications included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:tables
summary: 'The tables included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:processes
summary: 'The processes included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:reports
summary: 'The reports included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:widgets
summary: 'The widgets included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:operationIds
summary: 'The individual operations included in this token’s recorded scope.'
content: |
Scopes narrow the token owner’s existing access; they cannot grant permissions the owner does not have. An empty dimension adds no restriction for that dimension. Resource restrictions and read-only mode are separate controls. Advanced process, report, widget and operation scopes are not offered by **Create Token**. Review **Tokens** and the scope summary when diagnosing unexpected access.
- key: table:authentik_token_scope;field:createdAt
summary: 'When this token scope record was created.'
- key: table:authentik_token_scope;field:rotatedAt
summary: 'When the token associated with this scope was last rotated.'
- key: table:authentik_token_scope;field:lastReminder7d
summary: 'When the seven-day expiry reminder was last recorded.'
- key: table:authentik_token_scope;field:lastReminder1d
summary: 'When the one-day expiry reminder was last recorded.'
- key: process:authentikTokenReminders;slot:help
summary: 'Check approaching token expiries and issue the configured reminders.'
content: |
This background action checks tokens approaching their expiry and records reminder timing in **{{label:authentik_token_scope}}**. Use it to understand when owners were warned to renew or replace credentials. Reminders do not extend expiry or rotate a key automatically. Review **Tokens** for the current expiry, then use **{{label:authentikExtendToken}}**, **Rotate Token** or **Revoke Token** as appropriate for the credential.
- key: process:authentikCreateToken;slot:help
summary: 'Create a personal access token with an expiry and optional access restrictions.'
content: |
Use **Create Token** to create an app-password token for your own account. Review the expiry and any available scope restrictions before submitting. Scopes can narrow your permissions but cannot grant new access. The credential is shown once after creation; keep it in your intended credential store. The **Tokens** list and **{{label:authentik_token_scope}}** retain metadata rather than a recoverable copy of that credential.
- key: process:authentikRotateToken;slot:help
summary: 'Replace a token’s credential and invalidate the previous credential.'
content: |
Use **Rotate Token** when a credential needs replacement. Review the selected token and its ownership before continuing. Supported app-password tokens can be rotated; protected accounts require management in Authentik. Rotation invalidates the old credential, so update the consumers that use it. The new credential is shown once. Review **Tokens** and the recorded scope after completion.
- key: process:authentikRevokeToken;slot:help
summary: 'Invalidate a selected token so it can no longer authenticate.'
content: |
Use **Revoke Token** when access is no longer needed or a token should stop working. Review the identifier and owner before submitting because any consumer relying on that token will lose access. Revocation also removes its local scope metadata. It does not delete the owner’s account. Review the resulting **Tokens** list to confirm the selected token is no longer available.
- key: process:authentikExtendToken;slot:help
summary: 'Update the expiry of an eligible app-password token.'
content: |
Use **{{label:authentikExtendToken}}** when an existing eligible credential should remain valid for a longer allowed period. Review the owner and requested expiry before submitting. The action is limited to supported app-password tokens and permitted ownership; protected accounts are managed in Authentik. Extending resets expiry reminders but does not rotate the credential. Use **Rotate Token** when you need a replacement key.
- key: process:authentikCreateServiceAccount;slot:help
summary: 'Create a service account with checked group access and an expiring app password.'
content: |
Use **Create Service Account** for an integration that needs its own identity. Review the account name, requested groups and expiry before proceeding. Group checks and protection rules still apply. The account receives an app-password credential that is shown once, so store it for the intended integration. Inspect the resulting account and **Tokens** after creation, and use **Rotate Token** for later credential replacement.
- key: widget:authentikAccessTokens;slot:label
summary: 'Access tokens and their ownership, expiry and available management actions.'
- key: table:authentik_invitation;slot:help
summary: Review enrollment invitations and their expiry dates in Authentik.
content: |
Use **{{label:authentik_invitation}}** to review invitations when your host has configured an enrollment flow. Check the email address and expiry before following up with someone. Use **{{label:authentikCancelInvitation}}** to withdraw an invitation.
**How rows get created:** **{{label:authentikInviteUser}}** creates a single-use invitation in Authentik and requests its delivery by email. You read invitations live from Authentik. An empty list means no invitations match your query.
- key: table:authentik_invitation;field:name
summary: The invitation name recorded in Authentik.
- key: table:authentik_invitation;field:email
summary: The email address supplied for the invited person.
- key: table:authentik_invitation;field:singleUse
summary: Whether the invitation can be redeemed only once.
- key: table:authentik_invitation;field:expires
summary: When the invitation stops being valid.
- key: table:authentik_invitation;field:flowSlug
summary: The enrollment flow used by this invitation.
- key: table:authentik_invitation;field:createdBy
summary: The username Authentik records as the invitation creator.
- key: process:authentikInviteUser;slot:help
summary: Invite a new person to enroll through Authentik by email.
content: |
Enter the person's email, an optional full name and a reason. You need permission to manage users, writes must be enabled, and your host must configure an enrollment flow. The email must not already belong to an existing user.
Review the invitation before continuing. Authentik creates a single-use invitation that expires after seven days and sends the email. You can inspect it in **{{label:authentik_invitation}}** or withdraw it with **{{label:authentikCancelInvitation}}**.
- key: process:authentikCancelInvitation;slot:help
summary: Withdraw an invitation so it can no longer be used to enroll.
content: |
Select the invitation and give a reason. Review your selection before continuing. You need permission to manage users and writes must be enabled.
This action deletes the invitation in Authentik. An invitation that is already absent needs no change. Cancelling an invitation does not delete an account that has already enrolled.
- key: process:authentikDeleteUser;slot:help
summary: Permanently delete an eligible account from Authentik.
content: |
Use **{{label:authentikDeactivateUser}}** if you want to keep the account and its history. Deletion is permanent and requires the host to enable deletion as well as user management and writes.
Give a reason and type the username to confirm, then check **Review** before continuing. You cannot delete protected accounts, your own account or the last identity administrator. You need permission to manage users.
- key: process:authentikDeleteGroup;slot:help
summary: Permanently delete an empty group and the access it grants.
content: |
Remove the group's members and child groups first. Deletion removes the group with its permissions, application access and policy. You need permission to manage groups, and your host must enable deletion, group management and writes.
Give a reason and type the group name to confirm. Check **Review** before continuing. Superuser groups and changes that would remove the last identity administrator are refused.
- key: possibleValueSource:authentik_user;slot:help
summary: Choose an Authentik user from the accounts visible to you.
- key: possibleValueSource:authentik_group;slot:help
summary: Choose an Authentik group for the membership action.
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.EnumSet;
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Coverage of the shipped help with all supported optional metadata present.
*******************************************************************************/
class AuthentikHelpBundleTest
{
/*******************************************************************************
** New objects and non-trivial fields must ship with valid help.
*******************************************************************************/
@Test
void bundleCoversOwnedMetadata() throws Exception
{
var support = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS,
AuthentikFeature.POLICIES, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.DELETE,
AuthentikFeature.MEMBERSHIP, AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS));
HelpBundleAssertions.forInstance(support.qInstance)
.bundles("help/qbit-authentik.yaml")
.owning(target -> target.name().startsWith("authentik"))
.assertComplete();
var bundle = HelpBundles.of(support.qInstance).getBundles().get(0);
assertThat(bundle.getVersion()).isEqualTo(AuthentikQBitProducer.getVersion());
var keys = bundle.getEntries().stream().map(entry -> entry.getKey()).toList();
assertThat(keys).doesNotHaveDuplicates();
// Core's gate currently requires apps, tables, actions and table fields.
// Also pin coverage for this qbit's widgets and possible-value sources.
support.qInstance.getWidgets().keySet().stream().filter(name -> name.startsWith("authentik"))
.forEach(name -> assertThat(keys).contains("widget:" + name + ";slot:label"));
support.qInstance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("authentik"))
.forEach(name -> assertThat(keys).contains("possibleValueSource:" + name + ";slot:help"));
}
/*******************************************************************************
** Metadata-only registration is safe without help storage and follows prefixes.
*******************************************************************************/
@Test
void registersWithoutHelpStorage() throws Exception
{
var instance = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig().withTableNamePrefix("idp"));
assertThat(instance.getTable("helpContent")).isNull();
HelpBundles.syncOnStartup(instance);
var bundles = HelpBundles.of(instance).getBundles();
assertThat(bundles).hasSize(1);
assertThat(bundles.get(0).getEntries()).anySatisfy(entry ->
{
assertThat(entry.getKey()).isEqualTo("table:idp_user;slot:help");
assertThat(entry.getContent()).contains("{{label:idp_user}}").doesNotContain("{{label:authentik");
});
}
}
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more