Compare commits

...
Author SHA1 Message Date
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.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.maes 38d9776f25 Merge remote-tracking branch 'origin/develop' into feature/arch-rc5
# Conflicts:
#	CHANGELOG.md
2026-10-13 20:18:53 -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
56 changed files with 3726 additions and 138 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
+2 -2
View File
@@ -45,7 +45,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
## 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[]=`.
@@ -61,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
+18 -1
View File
@@ -4,14 +4,31 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- `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 (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.
@@ -62,7 +79,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel
### 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
+2 -2
View File
@@ -45,7 +45,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
## 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[]=`.
@@ -61,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
+16 -2
View File
@@ -68,7 +68,7 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
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 features and labels through `PermissionsHelper.getAllAvailablePermissions`; they appear when the host's core version exposes them. This qbit does not compile against the unreleased metadata hook. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
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
@@ -83,7 +83,7 @@ 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 is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
| QQQ version | QQQ 4.1 (see `pom.xml` for the exact RC). Java 21. |
| QQQ version | QQQ 4.1.0-RC.6 (released BOM, no core SNAPSHOT). 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)`. |
@@ -110,6 +110,20 @@ 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 temporary session-permission intersection is marked `STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7)`; a reflection bridge also attaches `QCredentialScope` when available. 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`).
+22
View File
@@ -1,5 +1,27 @@
# Upgrading qbit-authentik
## 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
+13 -7
View File
@@ -1,10 +1,10 @@
# 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).
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**.
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;
@@ -36,7 +36,7 @@ curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData
## Manage your tokens
From **Preferences → Access tokens**:
From **Preferences → Access tokens**:
| Action | What happens |
|---|---|
@@ -44,14 +44,20 @@ From **Preferences → Access tokens**:
| **Extend** | Moves the expiry. The limit counts from today. |
| **Revoke** | Deletes the token in Authentik. Anything using it stops working at once. |
You get a reminder in the notification bell, and by email, 7 days and 1 day before a token expires.
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 **all** tokens in the Identity app (**Tokens**). From there they can:
- filter by person, intent and expiry, and sort by age and last use;
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.
- 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.
+28 -14
View File
@@ -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.
@@ -74,6 +74,12 @@ Seven design rules shape everything below:
| 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.
@@ -668,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.
@@ -955,7 +961,7 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh
| 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 |
@@ -1033,7 +1039,7 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a
| 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 |
@@ -1079,16 +1085,18 @@ A **policy** is a JSON document on an Authentik group that grants QQQ permission
}
```
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` = 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); a policy cannot grant them, because a resource cannot contain a dot.
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. `"*"` never includes features, so existing policies do not acquire new grants when a feature is registered.
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_]+`
- 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.
@@ -1132,12 +1140,12 @@ The feature `POLICIES` turns on the editor, the `authentik_policy` table and the
*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 it two things.
**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`; no dependency on the unreleased `TableFeaturePermissionsMetaData` hook is required. The five standard suffixes are excluded.
- 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.
@@ -1147,8 +1155,12 @@ The catalog also follows these rules:
- 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*.
@@ -1161,7 +1173,7 @@ The screen works like this:
- 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
- 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.
@@ -1171,11 +1183,13 @@ Refusals name the reason.
- writes the `authentik_admin_action` row and the Authentik event
- clears the caches
Saving an empty matrix removes the attribute. The canonical form is the reference generator's:
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 |
@@ -1289,7 +1303,7 @@ A QQQ access token is an Authentik **app password** (`intent: app_password`) own
- `password={token}`
- `scope=openid profile email`
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.
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).
@@ -1362,7 +1376,7 @@ Without a broker, the self-service actions are hidden, and Review says why.
| (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. | 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. |
| (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
@@ -1408,7 +1422,7 @@ STAND-IN(core=QRun-IO/qqq#1153, removeBy=the RC that ships #1153, RC.7 at the ea
**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 run finishes asynchronously, the key is lost by design, and the result says "Rotate the token to get a new key".
- 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.
+49 -7
View File
@@ -32,11 +32,11 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is
| `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 | Each one a QQQ permission base name matching `[A-Za-z0-9_]+`: a table, process, app, report or widget name (or its custom `permissionBaseName`) |
| `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. `*` stands for the five standard actions only; it never includes table features. The result is the union over all statements, and over all of the user's groups.
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 |
|---|---|---|
@@ -52,11 +52,51 @@ Examples:
- `{"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`. Every feature grant is explicit, so registering a new feature never broadens an existing `"*"` policy. This matching rule also exposes the available `write` permission of `READ_WRITE` tables as an explicit named action; it is not part of `*`.
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`, wildcard or prefix resources, conditions, and field-level rules. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots. They stay in the group attribute `qqq.io/permissions`.
**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
@@ -68,13 +108,15 @@ A document grants **nothing**, and the parser logs a warning, when any of these
- 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_]+`
- 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
The qbit always writes the 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 `*`
@@ -105,7 +147,7 @@ Authentik puts the merged document into the token through a scope mapping. Steps
- `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 continue to accept only standard actions. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the existing available-permission API without compiling against the unreleased `TableFeaturePermissionsMetaData` hook; features become available when the host's core supplies those entries.
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.
+5 -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>
@@ -237,7 +239,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.5 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 +247,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -61,6 +61,8 @@ public class AuthentikQBitConfig implements QBitConfig
private List<String> tokenProtectedGroups = new ArrayList<>(List.of("authentik Admins", "identity-admins"));
private Boolean tokenTwoPersonApproval = false;
private Boolean tokenScopesEnabled = false;
private String tokenScopeClaim = "qqq_token";
private String tokenReminderSchedulerName;
private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}";
private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}";
private String tokenEndpointUrl = null;
@@ -112,6 +114,7 @@ public class AuthentikQBitConfig implements QBitConfig
private java.util.Map<String, List<String>> staticGroupPermissions = new java.util.LinkedHashMap<>();
@JsonIgnore
private com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource hostPermissionSource;
private java.util.function.Function<QInstance, java.util.Collection<String>> fullAccessHostPermissions;
@@ -130,6 +133,10 @@ public class AuthentikQBitConfig implements QBitConfig
{
errors.add("tokenMaxDaysServiceAccount must be at least 1");
}
if(tokenScopeClaim == null || tokenScopeClaim.isBlank())
{
errors.add("tokenScopeClaim must not be blank");
}
if(tokenUnusedDays == null || tokenUnusedDays < 1)
{
errors.add("tokenUnusedDays must be at least 1");
@@ -446,6 +453,26 @@ public class AuthentikQBitConfig implements QBitConfig
/*******************************************************************************
** The features that are actually on: the configured set, plus TOKENS when it
** is on by default (writes enabled, not opted out), minus TOKENS when it is
** listed but off (read only or opted out). This is what hosts see in logs.
*******************************************************************************/
public java.util.Set<AuthentikFeature> getEffectiveFeatures()
{
java.util.EnumSet<AuthentikFeature> effective = java.util.EnumSet.noneOf(AuthentikFeature.class);
for(AuthentikFeature feature : AuthentikFeature.values())
{
if(hasFeature(feature))
{
effective.add(feature);
}
}
return (effective);
}
/*******************************************************************************
** Whether a feature is enabled.
*******************************************************************************/
@@ -1405,6 +1432,30 @@ public class AuthentikQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for fullAccessHostPermissions: the host's own permission names
** (custom checkers, named permissions such as "cdn.purge") that QQQ
** metadata cannot list. Full access expands to these as well, computed at
** use time from the current instance. Null means none.
*******************************************************************************/
public java.util.function.Function<QInstance, java.util.Collection<String>> getFullAccessHostPermissions()
{
return (this.fullAccessHostPermissions);
}
/*******************************************************************************
** Fluent setter for fullAccessHostPermissions.
*******************************************************************************/
public AuthentikQBitConfig withFullAccessHostPermissions(java.util.function.Function<QInstance, java.util.Collection<String>> fullAccessHostPermissions)
{
this.fullAccessHostPermissions = fullAccessHostPermissions;
return (this);
}
/*******************************************************************************
** Getter for tokensEnabled.
@@ -1871,4 +1922,54 @@ public class AuthentikQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** Signed JWT claim identifying the Authentik token.
*******************************************************************************/
public String getTokenScopeClaim()
{
return (tokenScopeClaim);
}
/*******************************************************************************
** Set the host's signed token claim name.
*******************************************************************************/
public void setTokenScopeClaim(String value)
{
tokenScopeClaim = value;
}
/*******************************************************************************
** Fluent signed token claim name.
*******************************************************************************/
public AuthentikQBitConfig withTokenScopeClaim(String value)
{
tokenScopeClaim = value;
return (this);
}
/*******************************************************************************
** Optional scheduler; otherwise use the instance's first scheduler.
*******************************************************************************/
public String getTokenReminderSchedulerName()
{
return (tokenReminderSchedulerName);
}
/*******************************************************************************
** Select a scheduler when the host defines several.
*******************************************************************************/
public void setTokenReminderSchedulerName(String value)
{
tokenReminderSchedulerName = value;
}
/*******************************************************************************
** Fluent scheduler selection.
*******************************************************************************/
public AuthentikQBitConfig withTokenReminderSchedulerName(String value)
{
tokenReminderSchedulerName = value;
return (this);
}
}
@@ -192,7 +192,7 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
}
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());
}
@@ -46,10 +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<>();
@@ -79,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.
*******************************************************************************/
@@ -130,6 +143,8 @@ public class AuthentikRuntime
public void invalidateCache()
{
responseCache.clear();
tokenScopeCache.clear();
tokenActivityCache.clear();
groupGraphCache.clear();
}
@@ -147,10 +162,11 @@ 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();
@@ -159,7 +175,7 @@ public class AuthentikRuntime
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(getPolicyCatalog().permissionNames());
catalog.addAll(currentPolicyCatalog.permissionNames());
permissionCatalog = catalog;
}
return (permissionCatalog);
@@ -168,19 +184,20 @@ public class AuthentikRuntime
/*******************************************************************************
** What a policy can grant in the current instance (built once, lazily: the
** What a policy can grant in the current instance (built lazily per instance: the
** instance is complete by the first request).
*******************************************************************************/
public PolicyCatalog getPolicyCatalog()
{
if(policyCatalog == null)
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(policyCatalog == null || policyCatalog.getQInstance() != qInstance)
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(qInstance == null)
{
return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance()));
}
policyCatalog = PolicyCatalog.of(qInstance);
permissionCatalog = null;
}
return (policyCatalog);
}
@@ -280,4 +297,37 @@ public class AuthentikRuntime
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);
}
}
@@ -43,6 +43,7 @@ import com.kingsrook.qbits.authentik.policy.PolicyFormat;
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.qbits.authentik.policy.PolicyStore;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
@@ -328,7 +329,8 @@ public class AuthentikAdmin
/*******************************************************************************
** The operator, resolved once per instance from live Authentik data. When
** the operator is not an Authentik user (static or development sessions),
** the session's own permissions stand in. bootstrapOperatorSubjects additionally
** the session's own permissions stand in. Maintenance sessions are local job
** identities and never resolved as Authentik users. bootstrapOperatorSubjects additionally
** hold every authentik.* permission.
*******************************************************************************/
public Operator operator() throws QException
@@ -339,12 +341,13 @@ public class AuthentikAdmin
}
String email = guard.operatorEmail();
boolean bootstrap = email != null && com.kingsrook.qbits.authentik.security.BootstrapOperators.matchesUniqueUser(config.getResolvedBootstrapSubjects(), util, email);
boolean maintenance = AuthentikMaintenance.isMaintenanceSession(QContext.getQSession());
boolean bootstrap = !maintenance && email != null && com.kingsrook.qbits.authentik.security.BootstrapOperators.matchesUniqueUser(config.getResolvedBootstrapSubjects(), util, email);
Integer pk = null;
Set<String> permissions = new TreeSet<>();
Set<String> appAccess = new TreeSet<>();
JSONObject user = email == null ? null : findUserByEmail(email);
JSONObject user = maintenance || email == null ? null : findUserByEmail(email);
if(user != null)
{
pk = user.optInt("pk");
@@ -1307,12 +1310,21 @@ public class AuthentikAdmin
return (plan.refuse("These are not permissions of this app: " + String.join(", ", unknownNew) + ". Pick them in the editor."));
}
if(added.isEmpty() && removed.isEmpty() && storedProblem == null && extrasNew.isEmpty())
boolean fullAccess = newPermissions.contains(PolicyPermissions.FULL_ACCESS);
boolean noChange = added.isEmpty() && removed.isEmpty() && storedProblem == null && extrasNew.isEmpty();
if(noChange && !fullAccess)
{
return (plan.noChange("Nothing to change."));
}
Set<String> needed = new TreeSet<>(added);
if(fullAccess)
{
needed = resolver().expand(PolicyPermissions.FULL_ACCESS);
}
else if(oldPermissions.contains(PolicyPermissions.FULL_ACCESS))
{
needed.removeAll(resolver().expand(PolicyPermissions.FULL_ACCESS));
}
for(String extra : extrasNew)
{
needed.addAll(resolver().expand(extra));
@@ -1323,6 +1335,24 @@ public class AuthentikAdmin
return (plan.refuse("You cannot grant permissions you do not hold yourself: " + String.join(", ", missing)));
}
if(noChange)
{
return (plan.noChange("Nothing to change."));
}
if(removed.contains(PolicyPermissions.FULL_ACCESS))
{
GroupGraph.Node current = membersGraph().byPk(request.targetKey());
GroupGraph simulated = AuthentikSafety.withNodes(membersGraph(), new GroupGraph.Node(current.pk(), current.name(),
current.ownSuperuser(), current.parentPks(), current.permissions(), current.appAccess(), current.memberPks(),
current.malformed(), List.copyOf(newPermissions)));
if(AuthentikSafety.removesLastAdmin(adminsNow(), AuthentikSafety.qualifyingAdmins(simulated,
new EffectivePermissionResolver(simulated, runtime.getPermissionCatalog()), userFacts())))
{
return (plan.refuse("Removing this policy would leave nobody able to manage memberships and permissions."));
}
}
plan.withChange(policySummary(oldPermissions), policySummary(newPermissions));
if(request.bool("generated"))
{
@@ -1338,11 +1368,11 @@ public class AuthentikAdmin
}
if(!added.isEmpty())
{
plan.getNotes().add("Adds: " + abbreviate(added));
plan.getNotes().add("Adds: " + (added.contains(PolicyPermissions.FULL_ACCESS) ? PolicyPermissions.FULL_ACCESS_LABEL : abbreviate(added)));
}
if(!removed.isEmpty())
{
plan.getNotes().add("Removes: " + abbreviate(removed));
plan.getNotes().add("Removes: " + (removed.contains(PolicyPermissions.FULL_ACCESS) ? PolicyPermissions.FULL_ACCESS_LABEL : abbreviate(removed)));
}
if(storedProblem != null)
{
@@ -1355,7 +1385,8 @@ public class AuthentikAdmin
}
JSONArray users = group.optJSONArray("users");
plan.getNotes().add((users == null ? 0 : users.length()) + " member(s), and the members of groups below it, get the change at their next sign-in or token refresh.");
plan.getNotes().add(canonical.getJSONArray("Statement").length() + " statement(s), " + newPermissions.size() + " permission(s); the document round-trips.");
int permissionCount = fullAccess ? resolver().expand(PolicyPermissions.FULL_ACCESS).size() : newPermissions.size();
plan.getNotes().add(canonical.getJSONArray("Statement").length() + " statement(s), " + permissionCount + " current permission(s); the document round-trips.");
if(Boolean.TRUE.equals(config.getPolicyTwoPersonApproval()) && !request.bool("approved"))
{
@@ -1655,6 +1686,10 @@ public class AuthentikAdmin
*******************************************************************************/
private static String policySummary(Set<String> permissions)
{
if(permissions.contains(PolicyPermissions.FULL_ACCESS))
{
return (PolicyPermissions.FULL_ACCESS_LABEL);
}
return (permissions.isEmpty() ? "no policy" : permissions.size() + " permission(s): " + abbreviate(permissions));
}
@@ -2221,7 +2256,8 @@ public class AuthentikAdmin
try
{
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissions.size(), guard.operatorEmail(),
int permissionCount = permissions.contains(PolicyPermissions.FULL_ACCESS) ? resolver().expand(PolicyPermissions.FULL_ACCESS).size() : permissions.size();
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissionCount, guard.operatorEmail(),
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format, runtime.getPolicyCatalog()));
}
catch(Exception e)
@@ -293,10 +293,16 @@ public final class AuthentikLocalTables
{
table.withField(ro(name, QFieldType.TEXT));
}
for(String name : List.of("createdAt", "rotatedAt", "lastReminder7d", "lastReminder1d"))
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,161 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 java.util.TreeSet;
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.qbits.authentik.security.IdentityPermission;
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.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
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.
**
** The operator must start with maintenance: or run-once: and have a nonblank
** suffix. Its name and the required reason are recorded verbatim. A dedicated
** non-system session holds policies.manage and the document's current grants.
** 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
{
MaintenanceSession session = new MaintenanceSession();
session.setUser(new QUser().withIdReference(operatorName).withFullName("Maintenance (" + operatorName + ")"));
session.setPermissions(new TreeSet<>(Set.of(IdentityPermission.POLICIES_MANAGE.getSessionPermission())));
QContext.init(caller.qInstance(), session);
AuthentikAdmin admin = AuthentikAdmin.forBackend(apiBackendName);
AuthentikRuntime runtime = admin.getUtil().getRuntime();
runtime.getGuard().authorize(AdminAction.SAVE_POLICY);
Set<String> grants = StringUtils.hasContent(policyJson)
? PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog()).permissions() : Set.of();
session.getPermissions().addAll(grants.contains(PolicyPermissions.FULL_ACCESS)
? PolicyPermissions.allPermissions(caller.qInstance(), runtime.getPermissionCatalog()) : grants);
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);
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), 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);
}
}
/*******************************************************************************
** 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));
}
}
/*******************************************************************************
** Recognize only sessions created here, never an ordinary user's name prefix.
*******************************************************************************/
static boolean isMaintenanceSession(QSession session)
{
return (session instanceof MaintenanceSession);
}
/*******************************************************************************
** A local job identity, never an Authentik user or a QSystemUserSession.
*******************************************************************************/
private static final class MaintenanceSession extends QSession
{
}
}
@@ -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);
}
}
}
@@ -167,6 +167,13 @@ public class AuthentikTokens
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())
@@ -412,7 +419,7 @@ public class AuthentikTokens
}
JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey());
JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before));
checkProtected(owner);
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);
@@ -433,6 +440,7 @@ public class AuthentikTokens
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)
@@ -462,13 +470,15 @@ public class AuthentikTokens
/*******************************************************************************
** Guard owner and intent after every PATCH or set_key, with a safe warning.
*******************************************************************************/
private static void raceGuard(JSONObject before, JSONObject after) throws QUserFacingException
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)));
}
@@ -487,6 +497,7 @@ public class AuthentikTokens
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));
@@ -569,8 +580,9 @@ public class AuthentikTokens
util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk));
}
JSONObject owner = owner(pk);
checkProtected(owner);
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"),
@@ -656,4 +668,36 @@ public class AuthentikTokens
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.");
}
}
}
@@ -65,7 +65,7 @@ public class TokenScopeStore
/*******************************************************************************
** Fetch the unique scope row.
** Fetch the unique scope row as a trusted SYSTEM read (QueryInput default).
*******************************************************************************/
public QRecord find(String identifier) throws QException
{
@@ -139,4 +139,54 @@ public class TokenScopeStore
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,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,7 @@ 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;
@@ -803,6 +805,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)
@@ -925,6 +931,51 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** 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.
*******************************************************************************/
@@ -70,11 +70,11 @@ public final class AuthentikApiSurface
///////////////////////////////////////////////////////////
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);
}
@@ -419,6 +419,10 @@ public final class AuthentikTables
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);
}
@@ -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);
}
}
}
@@ -74,7 +74,7 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
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(AuthentikDriftReportWidgetProducer.NAME),
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)))
{
if(produced.get(QWidgetMetaData.class, widgetName) != null)
@@ -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,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,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()));
}
}
@@ -152,7 +152,19 @@ public final class AccessComparison
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid)
{
Set<String> rs = new TreeSet<>(resolver.resolveGroups(groupPks).sessionPermissions());
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)))
@@ -121,7 +121,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
AuthentikRuntime runtime = runtime();
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format,
runtime == null ? (qInstance == null ? null : PolicyCatalog.of(qInstance)) : runtime.getPolicyCatalog()));
qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance)));
if(runtime != null)
{
@@ -170,6 +170,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims);
}
@@ -326,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)
{
@@ -25,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
@@ -47,6 +51,7 @@ public class EffectivePermissionResolver
{
private final GroupGraph graph;
private final Set<String> catalog;
private final QInstance qInstance;
@@ -68,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)
{
@@ -86,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<>();
@@ -103,7 +130,10 @@ public class EffectivePermissionResolver
}
appAccess.addAll(node.appAccess());
List<String> raws = new ArrayList<>(node.permissions());
raws.addAll(node.policyPermissions());
if(!node.pk().equals(excludePolicyOfGroupPk))
{
raws.addAll(node.policyPermissions());
}
for(String raw : raws)
{
Set<String> expanded = expand(raw);
@@ -146,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))
{
@@ -92,6 +92,9 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
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;
}
@@ -101,6 +104,9 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
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)));
}
@@ -113,14 +113,16 @@ public final class PolicyCatalog
private final List<Group> groups;
private final Map<String, Resource> byName;
private final QInstance qInstance;
/*******************************************************************************
** Constructor.
*******************************************************************************/
private PolicyCatalog(List<Group> groups)
private PolicyCatalog(List<Group> groups, QInstance qInstance)
{
this.qInstance = qInstance;
this.groups = groups;
this.byName = new LinkedHashMap<>();
for(Group group : groups)
@@ -139,17 +141,7 @@ public final class PolicyCatalog
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance)
{
return (of(qInstance, PermissionsHelper.getAllAvailablePermissions(qInstance)));
}
/*******************************************************************************
** Build using an explicit permission source, including features registered
** by core. This needs no dependency on the feature metadata hook itself.
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance, Collection<AvailablePermission> availablePermissions)
{
Collection<AvailablePermission> availablePermissions = PermissionsHelper.getAllAvailablePermissions(qInstance);
Map<String, String> appOfObject = appsOfObjects(qInstance);
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
@@ -266,7 +258,17 @@ public final class PolicyCatalog
}
groups.add(new Group(entry.getKey(), groupLabel, resources));
}
return (new PolicyCatalog(groups));
return (new PolicyCatalog(groups, qInstance));
}
/*******************************************************************************
** The source instance, used for live full-access expansion, not a snapshot.
*******************************************************************************/
public QInstance getQInstance()
{
return (qInstance);
}
@@ -474,7 +476,7 @@ public final class PolicyCatalog
Set<String> rs = new TreeSet<>();
for(String permission : permissions)
{
if(!known.contains(permission))
if(!known.contains(permission) && !PolicyPermissions.FULL_ACCESS.equals(permission))
{
rs.add(permission);
}
@@ -17,15 +17,26 @@
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;
@@ -33,6 +44,7 @@ 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;
@@ -41,8 +53,8 @@ import org.json.JSONObject;
** 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, and the group's current document read live from
** Authentik.
** 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
{
@@ -58,6 +70,8 @@ public final class PolicyEditorWidget
/** Widget parameter (process value) with the group's pk. */
public static final String GROUP_PK_PARAM = "groupPk";
private static final int MAX_MEMBERS = 200;
/*******************************************************************************
@@ -74,12 +88,17 @@ public final class PolicyEditorWidget
*******************************************************************************/
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))
@@ -107,6 +126,7 @@ public final class PolicyEditorWidget
// 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)
{
@@ -119,11 +139,61 @@ public final class PolicyEditorWidget
{
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.
@@ -158,6 +228,31 @@ public final class PolicyEditorWidget
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);
}
@@ -337,5 +432,68 @@ public final class PolicyEditorWidget
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);
}
}
}
@@ -32,7 +32,7 @@ import org.json.JSONObject;
** (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;
** no "*". Every generated document is checked to round-trip through
** 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.
*******************************************************************************/
@@ -68,6 +68,17 @@ public final class PolicyGenerator
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<>();
@@ -53,7 +53,7 @@ public final class PolicyParser
/*******************************************************************************
** The result of a successful parse.
**
** @param permissions QQQ permission names ({resource}.{action}), sorted
** @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)
@@ -120,7 +120,7 @@ public final class PolicyParser
/*******************************************************************************
** Parse standard actions and features registered for every listed resource.
** Without a catalog only the five standard actions are accepted.
** Without a catalog only standard actions and full access are accepted.
*******************************************************************************/
public static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
{
@@ -163,8 +163,13 @@ public final class PolicyParser
}
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 : strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action"))
for(String name : statementActions)
{
if(PolicyAction.WILDCARD.equals(name))
{
@@ -186,6 +191,12 @@ public final class PolicyParser
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_]+"));
@@ -197,7 +208,7 @@ public final class PolicyParser
}
}
}
return (new Parsed(permissions, resources));
return (new Parsed(permissions.contains(PolicyPermissions.FULL_ACCESS) ? Set.of(PolicyPermissions.FULL_ACCESS) : permissions, resources));
}
@@ -17,9 +17,15 @@
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;
@@ -29,6 +35,12 @@ import org.json.JSONObject;
*******************************************************************************/
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";
@@ -67,7 +79,61 @@ public final class PolicyPermissions
{
return (Collections.emptySet());
}
return (PolicyParser.parse(claims.opt(f.claim()), f, catalog));
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);
}
@@ -147,8 +147,8 @@ public final class AuthentikProcesses
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
createTokenFields.add(new QFieldMetaData("readOnly", QFieldType.BOOLEAN));
createTokenFields.add(new QFieldMetaData("apps", QFieldType.TEXT));
createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT));
createTokenFields.add(new QFieldMetaData("apps", QFieldType.TEXT).withLabel("Apps (names, one per line)"));
createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT).withLabel("Tables (names, one per line)"));
}
createTokenFields.add(reason);
defs.add(new Def("createToken", "Create Token", AdminAction.CREATE_TOKEN, Kind.CREATE, AuthentikTables.TOKEN, "key", false,
@@ -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);
}
}
}
}
}
@@ -36,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;
@@ -0,0 +1,475 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
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.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);
}
}
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7): intersect, never grant.
*******************************************************************************/
public static void narrow(QSession session, TokenScope scope)
{
narrow(QContext.getQInstance(), session, scope);
}
/*******************************************************************************
** Keep only permitted bases and read operations, then attach core's scope
** when available. Management hasAccess permissions are not read operations.
*******************************************************************************/
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."));
}
}
}
/*******************************************************************************
** Optional core bridge. An incompatible installed contract fails closed;
** an absent class uses the permission intersection above.
*******************************************************************************/
/*******************************************************************************
** 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);
}
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1168, removeBy=4.1.0-RC.7): until the qbit's QQQ
** version has QCredentialScope (qqq#1153), narrow() above enforces the scope
** by intersecting the session's permissions; this attaches the core scope by
** reflection when it exists. Replace both with a direct withCredentialScope.
*******************************************************************************/
private static void attachCoreScope(QSession session, TokenScope scope)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.session.QCredentialScope");
Object value = type.getConstructor().newInstance();
///////////////////////////////////////////////////////////////////////
// core treats a null set as unrestricted and an empty set as //
// "nothing": an unlisted dimension must therefore be passed as null //
///////////////////////////////////////////////////////////////////////
Map<String, Object> fields = new java.util.LinkedHashMap<>();
fields.put("CredentialId", scope.identifier());
fields.put("ReadOnly", scope.readOnly());
fields.put("Apps", nullIfEmpty(scope.apps()));
fields.put("Tables", nullIfEmpty(scope.tables()));
fields.put("Processes", nullIfEmpty(scope.processes()));
fields.put("Reports", nullIfEmpty(scope.reports()));
fields.put("Widgets", nullIfEmpty(scope.widgets()));
fields.put("OperationIds", nullIfEmpty(scope.operationIds()));
for(var field : fields.entrySet())
{
Method setter = Arrays.stream(type.getMethods()).filter(m -> m.getName().equals("with" + field.getKey()) && m.getParameterCount() == 1)
.findFirst().orElseThrow(NoSuchMethodException::new);
Object arg = field.getValue();
if(arg instanceof Set<?> set && List.class.isAssignableFrom(setter.getParameterTypes()[0]))
{
arg = List.copyOf(set);
}
setter.invoke(value, arg);
}
session.getClass().getMethod("withCredentialScope", type).invoke(session, value);
}
catch(ClassNotFoundException ignored)
{
// RC.2 has no credential scope; the stand-in above is active.
}
catch(ReflectiveOperationException | IllegalArgumentException e)
{
session.setPermissions(new TreeSet<>());
LOG.warn("The installed credential-scope contract is incompatible; denying credential permissions.");
}
}
}
@@ -236,4 +236,17 @@ class AuthentikQBitConfigTest
assertThat(config.getTokenBrokerClientSecret()).isNull();
}
/*******************************************************************************
** The startup log lists TOKENS when it is on by default (writes enabled).
*******************************************************************************/
@org.junit.jupiter.api.Test
void effectiveFeaturesIncludeDefaultTokens()
{
AuthentikQBitConfig writes = new AuthentikQBitConfig().withFeatures(java.util.EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS)).withTokensEnabled(true);
org.assertj.core.api.Assertions.assertThat(writes.getEffectiveFeatures()).contains(AuthentikFeature.TOKENS, AuthentikFeature.READ, AuthentikFeature.USERS);
AuthentikQBitConfig readOnly = new AuthentikQBitConfig().withFeatures(java.util.EnumSet.of(AuthentikFeature.READ));
org.assertj.core.api.Assertions.assertThat(readOnly.getEffectiveFeatures()).containsExactly(AuthentikFeature.READ);
}
}
@@ -174,4 +174,18 @@ public class SchemaDriftTest
return (null);
}
/*******************************************************************************
** Login aggregation uses only parameters documented by the pinned schema.
*******************************************************************************/
@Test
void tokenActivitySurfacePinsLoginOrderingWithoutUnsupportedDateFilter()
{
assertThat(AuthentikApiSurface.operations()).anySatisfy(operation ->
{
assertThat(operation.path()).isEqualTo("/events/events/");
assertThat(operation.method()).isEqualTo("get");
assertThat(operation.params()).contains("action", "ordering", "page", "page_size").doesNotContain("created__gt");
});
}
}
@@ -0,0 +1,336 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.EnumSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
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.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Host maintenance uses the audited Save Policy path with a local job identity.
*******************************************************************************/
class AuthentikMaintenanceTest
{
private static final String GROUP = "kof-website-analysts";
private static final String OPERATOR = "run-once:KOF22_RUN_ONCE";
private static final String REASON = " owner request 2026-10-14: full-access policy for kof22-admins ";
private AdminTestSupport t;
private String fullAccess;
/*******************************************************************************
** A real qbit instance backed by the in-memory Authentik API and audit store.
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.GROUPS, AuthentikFeature.POLICIES));
fullAccess = AuthentikMaintenance.fullAccessDocument(t.config);
}
/*******************************************************************************
** A system job gets a dedicated, fully expanded session. The audit precedes
** PATCH, the event identifies the job, and an equivalent document is a no-op.
*******************************************************************************/
@Test
void appliesFullAccessAuditedAndIdempotently() throws Exception
{
QContext.setQSession(new QSystemUserSession());
QContext.getActionStack().push(new QueryInput());
QContext.enterAuthorizedActionScope();
CapturedContext caller = QContext.capture();
Set<String> all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog());
t.authentik.user(10, "collision", OPERATOR, "internal", true);
t.config.withBootstrapOperatorSubjects("10");
FixtureActionUtil.handler = request ->
{
QSession session = QContext.getQSession();
assertThat(session).isNotInstanceOf(QSystemUserSession.class);
assertThat(session.getUser().getIdReference()).isEqualTo(OPERATOR);
assertThat(session.getUser().getFullName()).isEqualTo("Maintenance (" + OPERATOR + ")");
assertThat(session.getPermissions()).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
assertThat(QContext.getActionStack()).isNotSameAs(caller.actionStack());
assertThat(QContext.getAuthorizedActionScopeDepth()).isZero();
if(request.getMethod().equals("PATCH"))
{
try
{
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
assertThat(row.getValueString("status")).isEqualTo("RUNNING"));
}
catch(QException e)
{
throw (new AssertionError(e));
}
}
return (t.authentik.handle(request));
};
AuthentikAdmin.AdminResult result = apply(GROUP, fullAccess);
assertThat(result.ok()).as(result.message()).isTrue();
assertThat(result.noChange()).isFalse();
assertThat(result.pending()).isFalse();
assertThat(QContext.capture()).isEqualTo(caller);
assertThat(t.authentik.groupByName(GROUP).getJSONObject("attributes").getString(t.config.getPolicyAttribute())).isEqualTo(fullAccess);
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
{
assertThat(row.getValueString("operator")).isEqualTo(OPERATOR);
assertThat(row.getValueString("reason")).isEqualTo(REASON);
assertThat(row.getValueString("status")).isEqualTo("SUCCEEDED");
});
assertThat(t.authentik.events).singleElement().satisfies(event ->
{
assertThat(event.getString("action")).isEqualTo("custom_qqq_admin_action");
assertThat(event.getJSONObject("context").getString("operator")).isEqualTo(OPERATOR);
assertThat(event.getJSONObject("context").getString("reason")).isEqualTo(REASON);
});
assertThat(result.eventPk()).isNotBlank();
int writes = t.writes().size();
assertThat(apply(GROUP, new JSONObject(fullAccess).toString(2)).noChange()).isTrue();
assertThat(t.writes()).hasSize(writes);
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).hasSize(1);
assertThat(QContext.capture()).isEqualTo(caller);
assertThat(FixtureActionUtil.REQUESTS).noneMatch(request -> request.getURI().getPath().contains("/core/users/"));
}
/*******************************************************************************
** Ordinary policies need only their grants and policies.manage, and honor
** the configured spec/version. The maintenance: prefix is accepted too.
*******************************************************************************/
@Test
void appliesNamedPolicyWithLimitedSession() throws Exception
{
t.config.withPolicySpec("host-iam");
String document = AuthentikMaintenance.fullAccessDocument(t.config).replace("\"Action\":[\"*\"]", "\"Action\":[\"read\"]")
.replace("\"Resource\":[\"*\"]", "\"Resource\":[\"cms_page\"]");
FixtureActionUtil.handler = request ->
{
assertThat(QContext.getQSession().getPermissions()).containsExactlyInAnyOrder("cms_page.read", IdentityPermission.POLICIES_MANAGE.getSessionPermission());
return (t.authentik.handle(request));
};
AuthentikAdmin.AdminResult result = AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), GROUP, document, "maintenance:policy", REASON);
assertThat(result.ok()).as(result.message()).isTrue();
assertThat(t.authentik.groupByName(GROUP).getJSONObject("attributes").getString(t.config.getPolicyAttribute())).contains("host-iam", "cms_page");
}
/*******************************************************************************
** Superuser ancestry remains protected, including when the policy matches.
*******************************************************************************/
@Test
void refusesSuperuserDescendant() throws Exception
{
t.authentik.group("protected-child", false, List.of(), List.of(), "authentik Admins");
t.authentik.groupByName("protected-child").getJSONObject("attributes").put(t.config.getPolicyAttribute(), fullAccess);
CapturedContext caller = QContext.capture();
AuthentikAdmin.AdminResult result = apply("protected-child", fullAccess);
assertThat(result.ok()).isFalse();
assertThat(result.message()).contains("Superuser groups");
assertThat(t.writes()).isEmpty();
assertThat(QContext.capture()).isEqualTo(caller);
}
/*******************************************************************************
** Exact names must resolve uniquely; neither missing nor ambiguous is safe.
*******************************************************************************/
@Test
void refusesMissingAndAmbiguousGroups() throws Exception
{
CapturedContext caller = QContext.capture();
assertThatThrownBy(() -> apply("kof-website", fullAccess)).hasMessageContaining("exactly one");
t.authentik.group("duplicate", false, List.of(), List.of());
t.authentik.groupByName("duplicate").put("name", GROUP);
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("exactly one");
assertThat(t.writes()).isEmpty();
assertThat(QContext.capture()).isEqualTo(caller);
}
/*******************************************************************************
** Invalid inputs fail without contacting Authentik or altering the caller.
*******************************************************************************/
@Test
void validatesInputs() throws Exception
{
CapturedContext caller = QContext.capture();
for(String name : new String[] { null, "", "operator@example.test", "run-once:", "maintenance: " })
{
assertThatThrownBy(() -> AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), GROUP, fullAccess, name, REASON))
.hasMessageContaining("operator name");
}
assertThatThrownBy(() -> AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), GROUP, fullAccess, OPERATOR, " "))
.hasMessageContaining("reason");
assertThatThrownBy(() -> apply(" ", fullAccess)).hasMessageContaining("group name");
assertThatThrownBy(() -> apply(GROUP, "{bad json")).hasMessageContaining("policy is not valid");
assertThat(t.writes()).isEmpty();
assertThat(QContext.capture()).isEqualTo(caller);
}
/*******************************************************************************
** The kill switch and feature gate are applied even to maintenance jobs.
*******************************************************************************/
@Test
void refusesReadOnlyAndDisabledPolicies() throws Exception
{
CapturedContext caller = QContext.capture();
t.config.withReadOnly(true);
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("read-only");
t.config.withReadOnly(false).withFeatures(EnumSet.of(AuthentikFeature.READ));
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("POLICIES");
assertThat(t.writes()).isEmpty();
assertThat(QContext.capture()).isEqualTo(caller);
}
/*******************************************************************************
** Approval remains a second person's decision; maintenance only queues it.
*******************************************************************************/
@Test
void queuesTwoPersonApproval() throws Exception
{
t.config.withPolicyTwoPersonApproval(true);
CapturedContext caller = QContext.capture();
AuthentikAdmin.AdminResult result = apply(GROUP, fullAccess);
assertThat(result.ok()).isTrue();
assertThat(result.pending()).isTrue();
assertThat(t.writes()).isEmpty();
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
assertThat(row.getValueString("status")).isEqualTo("PENDING_APPROVAL"));
assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST)).singleElement().satisfies(row ->
{
assertThat(row.getValueString("requester")).isEqualTo(OPERATOR);
assertThat(row.getValueString("reason")).isEqualTo(REASON);
});
assertThat(QContext.capture()).isEqualTo(caller);
}
/*******************************************************************************
** A change after the name lookup is caught when execute plans against live data.
*******************************************************************************/
@Test
void refusesStaleGroup() throws Exception
{
FixtureActionUtil.handler = request ->
{
if(request.getURI().getPath().equals("/api/v3/core/groups/"))
{
var response = t.authentik.handle(request);
t.authentik.groupByName(GROUP).getJSONObject("attributes").put("qqq.io/changed", true);
return (response);
}
return (t.authentik.handle(request));
};
AuthentikAdmin.AdminResult result = apply(GROUP, fullAccess);
assertThat(result.ok()).isFalse();
assertThat(result.message()).contains("was changed");
assertThat(t.writes()).isEmpty();
}
/*******************************************************************************
** Failed audit persistence prevents the write and still restores the caller.
*******************************************************************************/
@Test
void auditFailurePreventsWrite() throws Exception
{
CapturedContext caller = QContext.capture();
t.qInstance.getTables().remove(t.config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
assertThatThrownBy(() -> apply(GROUP, fullAccess)).isInstanceOf(QException.class);
assertThat(t.writes()).isEmpty();
assertThat(QContext.capture()).isEqualTo(caller);
}
/*******************************************************************************
** Direct system writes are still refused, even with a previously minted permit.
*******************************************************************************/
@Test
void systemSessionStillCannotWrite() throws Exception
{
var util = t.admin().getUtil();
var authorization = util.getRuntime().getGuard().authorize(AdminAction.SAVE_POLICY);
QSystemUserSession system = new QSystemUserSession();
system.setUser(QContext.getQSession().getUser());
QContext.setQSession(system);
assertThatThrownBy(() -> util.getRuntime().getGuard().authorize(AdminAction.SAVE_POLICY)).hasMessageContaining("System sessions");
assertThatThrownBy(() -> util.getRuntime().getGuard().checkWrite(AuthentikTables.get(AuthentikTables.GROUP), "update")).hasMessageContaining("System sessions");
assertThatThrownBy(() -> util.write(authorization, "PATCH", "/core/groups/" + t.authentik.pk(GROUP) + "/", new JSONObject()))
.hasMessageContaining("System sessions");
assertThat(t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(GROUP), REASON)
.with(AuthentikAdmin.POLICY_DOCUMENT, fullAccess)).ok()).isFalse();
assertThat(t.writes()).isEmpty();
}
/*******************************************************************************
** Apply using the production public API.
*******************************************************************************/
private AuthentikAdmin.AdminResult apply(String group, String document) throws QException
{
return (AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), group, document, OPERATOR, REASON));
}
/*******************************************************************************
** Inspect the real local audit and approval tables.
*******************************************************************************/
private List<QRecord> rows(String table) throws QException
{
QueryInput input = new QueryInput();
input.setTableName(t.config.applyTablePrefix(table));
return (new QueryAction().execute(input).getRecords());
}
}
@@ -460,8 +460,38 @@ public class FakeAuthentik
{
continue;
}
if(query.containsKey("is_active") && user.optBoolean("is_active") != Boolean.parseBoolean(first(query, "is_active")))
{
continue;
}
if(query.containsKey("path_startswith") && !user.optString("path").startsWith(first(query, "path_startswith")))
{
continue;
}
List<String> names = groupNamesOf(user.getInt("pk"));
if(query.containsKey("groups_by_name") && names.stream().noneMatch(query.get("groups_by_name")::contains))
{
continue;
}
if(query.containsKey("groups_by_pk") && names.stream().map(this::pk).noneMatch(query.get("groups_by_pk")::contains))
{
continue;
}
rs.add(rendered);
}
if("username".equals(first(query, "ordering")))
{
rs.sort(java.util.Comparator.comparing(user -> user.getString("username")));
}
if(query.containsKey("page_size"))
{
int size = Integer.parseInt(first(query, "page_size"));
int number = Integer.parseInt(first(query, "page"));
int from = Math.min(rs.size(), (number - 1) * size);
int to = Math.min(rs.size(), from + size);
return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", rs.size()).put("current", number)
.put("total_pages", Math.max(1, (rs.size() + size - 1) / size))).put("results", new JSONArray(rs.subList(from, to)))));
}
return (page(rs));
}
if(path.matches("/core/users/\\d+/"))
@@ -532,7 +562,22 @@ public class FakeAuthentik
}
if(path.equals("/events/events/"))
{
return (page(loginEvents));
List<JSONObject> selected = new ArrayList<>(loginEvents);
String action = first(query, "action");
if(action != null)
{
selected.removeIf(event -> !action.equals(event.optString("action")));
}
if("-created".equals(first(query, "ordering")))
{
selected.sort(java.util.Comparator.comparing((JSONObject event) -> java.time.Instant.parse(event.getString("created"))).reversed());
}
int size = query.containsKey("page_size") ? Integer.parseInt(first(query, "page_size")) : 100;
int number = query.containsKey("page") ? Integer.parseInt(first(query, "page")) : 1;
int from = Math.min(selected.size(), (number - 1) * size);
int to = Math.min(selected.size(), from + size);
return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", selected.size()).put("current", number)
.put("total_pages", Math.max(1, (selected.size() + size - 1) / size))).put("results", new JSONArray(selected.subList(from, to)))));
}
if(path.equals("/managed/blueprints/") || path.equals("/core/applications/"))
{
@@ -22,12 +22,17 @@ import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
@@ -64,12 +69,97 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*******************************************************************************/
class PolicyEditorTest
{
private static final String FULL_ACCESS = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"*\"],\"Resource\":[\"*\"]}]}";
private static final String PAGE_READ_EDIT = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"cms_page\"]}]}";
private AdminTestSupport t;
/*******************************************************************************
** Full access requires every current grant, even when some were already in
** the target policy. An operator holding all grants may save it explicitly.
*******************************************************************************/
@Test
void fullAccessSubsetRuleAndEditorFlag() throws Exception
{
String group = "kof-website-analysts";
assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getFullAccess()).isFalse();
assertThat(save(group, FULL_ACCESS).message()).contains("do not hold yourself", "cms_page.delete");
assertThat(t.changes()).isEmpty();
Set<String> all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog());
Set<String> almostAll = new TreeSet<>(all);
almostAll.remove("cms_page.delete");
t.config.withHostPermissionSource((email, groups) -> almostAll);
assertThat(save(group, FULL_ACCESS).message()).contains("do not hold yourself", "cms_page.delete");
t.config.withHostPermissionSource((email, groups) -> all);
AdminPlan plan = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(group), "because")
.with(AuthentikAdmin.POLICY_DOCUMENT, FULL_ACCESS));
assertThat(plan.isRunnable()).as(plan.getMessage()).isTrue();
assertThat(plan.getAfter()).isEqualTo(PolicyPermissions.FULL_ACCESS_LABEL);
assertThat(save(group, FULL_ACCESS).ok()).isTrue();
assertThat(t.authentik.groupByName(group).getJSONObject("attributes").getString("qqq.io/policy")).isEqualTo(FULL_ACCESS);
PolicyEditorWidget.Data widget = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
assertThat(widget.getFullAccess()).isTrue();
assertThat(new JSONObject(widget).getBoolean("fullAccess")).isTrue();
assertThat(policyRows().get(0).getValueInteger("permissionCount")).isEqualTo(all.size());
assertThat(widget.getDocumentProblem()).isNull();
assertThat(widget.getDocument()).doesNotContain(PolicyPermissions.FULL_ACCESS);
t.config.withHostPermissionSource((email, groups) -> Set.of());
assertThat(save(group, FULL_ACCESS).message()).contains("do not hold yourself");
assertThat(save(group, PAGE_READ_EDIT).ok()).isTrue();
assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getFullAccess()).isFalse();
}
/*******************************************************************************
** A full-access operator can grant full access. Both mapper paths, comparison
** and preview bases expand it, including grants outside the editor matrix.
*******************************************************************************/
@Test
void fullAccessGraphClaimsComparisonAndMemberPreview() throws Exception
{
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/policy", FULL_ACCESS);
assertThat(save("kof-website-analysts", FULL_ACCESS).ok()).isTrue();
t.authentik.user(10, "full-member", "full@example.test", "internal", true, "kof-website-analysts", "kof-website-publishers");
Set<String> all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog());
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper();
assertThat(mapper.resolve(t.qInstance, List.of("kof-website-analysts"), "10", "full@example.test", true))
.containsAll(all).containsAll(PermissionsHelper.getAllAvailablePermissionNames(t.qInstance)).doesNotContain(PolicyPermissions.FULL_ACCESS);
t.config.withMapperEnabled(true).withMapperOverride(true);
QSession session = new QSession();
JSONObject claims = new JSONObject().put("groups", List.of()).put("sub", "10").put("qqq_policy", new JSONObject(FULL_ACCESS));
mapper.customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
assertThat(session.getPermissions()).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
assertThat(AccessComparison.run(t.config, t.admin().getUtil(), t.admin().getUtil().getRuntime().getPermissionCatalog()))
.anyMatch(row -> row.username().equals("full-member") && row.after().containsAll(all));
Map<String, Object> member = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-publishers")).getMembers().stream()
.filter(row -> row.get("username").equals("full-member")).findFirst().orElseThrow();
assertThat((List<String>) member.get("basePermissions")).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
Map<String, Object> excluded = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-analysts")).getMembers().stream()
.filter(row -> row.get("username").equals("full-member")).findFirst().orElseThrow();
assertThat((List<String>) excluded.get("basePermissions")).doesNotContain("cms_page.delete");
}
/*******************************************************************************
** Removing a full-access policy must not remove the last qualifying admin.
*******************************************************************************/
@Test
void fullAccessPolicyProtectsLastAdmin() throws Exception
{
JSONObject attributes = t.authentik.groupByName("identity-admins").getJSONObject("attributes");
attributes.put("qqq.io/permissions", List.of()).remove("qqq.io/policy");
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", FULL_ACCESS);
t.authentik.user(10, "last-admin", "last@example.test", "internal", true, "kof-website-analysts");
assertThat(save("kof-website-analysts", "").message()).contains("leave nobody");
assertThat(t.changes()).isEmpty();
}
/*******************************************************************************
** The operator's own group carries a policy, so the subset rule lets them
** grant cms_page read/edit and content access (but not delete).
@@ -350,6 +440,7 @@ class PolicyEditorTest
PolicyEditorWidget.Data broken = PolicyEditorWidget.load(t.config, groupPk);
assertThat(broken.getDocumentProblem()).contains("not a JSON object");
assertThat(broken.getDocument()).isNull();
assertThat(broken.getFullAccess()).isFalse();
assertThat(PolicyEditorWidget.load(t.config, null).getGroupLabel()).isNull();
assertThatThrownBy(() -> PolicyEditorWidget.load(t.config, "../x")).hasMessageContaining("Not a group id");
@@ -360,4 +451,154 @@ class PolicyEditorTest
RenderWidgetOutput output = new RenderWidgetAction().execute(input);
assertThat(output.getWidgetData().getType()).isEqualTo("AUTHENTIK_POLICY_EDITOR");
}
/*******************************************************************************
** Direct and descendant members get all other grants, including inherited
** policies, wildcard attributes and static permissions of direct groups.
*******************************************************************************/
@Test
void memberPreviewPreservesOtherGrants() throws Exception
{
String group = "kof-website-analysts";
t.authentik.groupByName(group).getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT);
t.authentik.group("child", false, List.of(), List.of(), group)
.group("grandchild", false, List.of(), List.of(), "child")
.group("other-parent", false, List.of(), List.of())
.group("other", false, List.of("cms_page.*"), List.of(), "other-parent")
.user(10, "zoe", "zoe@example.test", "internal", true, group, "child", "other")
.user(11, "amy", "amy@example.test", "external", true, "child", "other-parent")
.user(12, "ben", "ben@example.test", "internal", true, "grandchild")
.user(13, "inactive", "inactive@example.test", "internal", false, group);
t.authentik.groupByName("other-parent").getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT.replace("\"read\",\"edit\"", "\"insert\""));
t.config.withStaticGroupPermissions(Map.of("child", List.of("static.floor"), group, List.of("direct.floor")));
PolicyEditorWidget.Data data = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
assertThat(data.getMembers()).extracting(m -> m.get("username")).containsExactly("amy", "ben", "zoe");
assertThat(data.getMembersTruncated()).isFalse();
assertThat(data.getMembers().get(0)).containsExactlyInAnyOrderEntriesOf(Map.of(
"username", "amy", "label", "amy Name (amy)", "viaGroups", List.of("child"),
"basePermissions", List.of("cms_page.insert", "kof22.analytics.hasAccess", "static.floor")));
assertThat(data.getMembers().get(1)).containsEntry("viaGroups", List.of("grandchild"))
.containsEntry("basePermissions", List.of("kof22.analytics.hasAccess"));
assertThat(data.getMembers().get(2)).containsEntry("viaGroups", List.of("child", group))
.containsEntry("basePermissions", List.of("cms_page.delete", "cms_page.edit", "cms_page.insert", "cms_page.read", "direct.floor", "kof22.analytics.hasAccess", "static.floor"));
}
/*******************************************************************************
** The cap is applied after eligibility and username ordering, and duplicate
** memberships do not consume multiple slots. Exactly 200 is not truncated.
*******************************************************************************/
@Test
void memberPreviewCapsEligiblePeople() throws Exception
{
String group = "kof-website-analysts";
t.authentik.group("child", false, List.of(), List.of(), group);
for(int i = 199; i >= 0; i--)
{
String username = "person%03d".formatted(i);
t.authentik.user(100 + i, username, username + "@example.test", "internal", true, group, "child");
}
t.authentik.user(400, "aaa-inactive", "inactive@example.test", "internal", false, group);
PolicyEditorWidget.Data exact = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
assertThat(exact.getMembers()).hasSize(200);
assertThat(exact.getMembersTruncated()).isFalse();
t.authentik.user(401, "aaa-active", "active@example.test", "internal", true, "child");
t.admin().getUtil().getRuntime().invalidateCache();
FixtureActionUtil.REQUESTS.clear();
PolicyEditorWidget.Data capped = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
assertThat(capped.getMembers()).hasSize(200);
assertThat(capped.getMembersTruncated()).isTrue();
assertThat(capped.getMembers()).extracting(m -> (String) m.get("username")).isSorted().doesNotContain("person199", "aaa-inactive");
assertThat(capped.getMembers().get(0)).containsEntry("username", "aaa-active");
List<String> queries = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().equals("/api/v3/core/users/"))
.map(r -> r.getURI().getQuery()).toList();
assertThat(queries).hasSize(3).allSatisfy(query -> assertThat(query)
.contains("ordering=username", "is_active=true", "groups_by_pk=" + t.authentik.pk(group), "groups_by_pk=" + t.authentik.pk("child")));
}
/*******************************************************************************
** Scope restrictions apply even to inherited members; no extra user-read
** permission is needed beyond the editor permission.
*******************************************************************************/
@Test
void memberPreviewRespectsScopeAndEditorPermission() throws Exception
{
String group = "kof-website-analysts";
t.authentik.group("child", false, List.of(), List.of(), group)
.group("scope", false, List.of(), List.of())
.user(10, "allowed", "allowed@example.test", "internal", true, "child", "scope")
.user(11, "wrong-path", "path@example.test", "internal", true, group, "scope")
.user(12, "wrong-group", "group@example.test", "internal", true, group);
t.authentik.users.get(11).put("path", "outside");
t.config.withUserScopePathStartsWith("users").withUserScopeGroupNames(List.of("scope"));
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.POLICIES_MANAGE);
assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getMembers())
.extracting(m -> m.get("username")).containsExactly("allowed");
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ);
FixtureActionUtil.REQUESTS.clear();
assertThatThrownBy(() -> PolicyEditorWidget.load(t.config, t.authentik.pk(group))).hasMessageContaining("authentik.policies.manage");
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
assertThatThrownBy(() -> t.admin().getUtil().policyPreviewUsers(List.of(t.authentik.pk(group)), 201)).hasMessageContaining("authentik.policies.manage");
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
}
/*******************************************************************************
** Bootstrap subjects retain identity grants by immutable pk or uuid; a
** superuser flag alone never grants QQQ permissions or leaks private fields.
*******************************************************************************/
@Test
void memberPreviewIncludesBootstrapWithoutSuperuserEscalation() throws Exception
{
String group = "kof-website-analysts";
t.authentik.user(10, "bootstrap-pk", "pk@example.test", "internal", true, group)
.user(11, "bootstrap-uuid", "uuid@example.test", "internal", true, group)
.user(12, "superuser", "superuser@example.test", "internal", true, group, "authentik Admins");
t.authentik.users.get(11).put("uuid", "immutable-subject");
t.config.withBootstrapOperatorSubjects("10,immutable-subject");
List<Map<String, Object>> members = PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getMembers();
for(Map<String, Object> member : members.subList(0, 2))
{
assertThat((List<?>) member.get("basePermissions")).hasSize(IdentityPermission.values().length + 1);
for(IdentityPermission permission : IdentityPermission.values())
{
assertThat(((List<?>) member.get("basePermissions")).contains(permission.getSessionPermission())).isTrue();
}
}
assertThat(members.get(2)).containsEntry("basePermissions", List.of("kof22.analytics.hasAccess"));
assertThat(members.get(2)).containsOnlyKeys("username", "label", "viaGroups", "basePermissions");
}
/*******************************************************************************
** The added fields serialize like the existing widget fields, including an
** empty member list when no group is selected and the configured mapper flag.
*******************************************************************************/
@Test
void memberPreviewPayloadAndMapperFlag() throws Exception
{
t.config.withMapperEnabled(false);
FixtureActionUtil.REQUESTS.clear();
PolicyEditorWidget.Data empty = PolicyEditorWidget.load(t.config, null);
assertThat(empty.getMembers()).isEmpty();
assertThat(empty.getMembersTruncated()).isFalse();
assertThat(empty.getMapperEnabled()).isFalse();
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
t.config.withMapperEnabled(true);
PolicyEditorWidget.Data data = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-publishers"));
JSONObject json = new JSONObject(data);
assertThat(json.getBoolean("mapperEnabled")).isTrue();
assertThat(json.getBoolean("membersTruncated")).isFalse();
assertThat(json.getJSONArray("members").getJSONObject(0).keySet()).containsExactlyInAnyOrder("username", "label", "viaGroups", "basePermissions");
}
}
@@ -135,6 +135,7 @@ class TokenManagementTest
void createOwnAndRotateScopeThenRevoke() throws QException
{
t.config.withTokenScopesEnabled(true);
QContext.getQSession().withPermission("cms.publish.hasAccess");
AuthentikAdmin.AdminResult result = t.admin().execute(create().with("readOnly", true).with("tables", "post"));
assertThat(result.ok()).as(result.message()).isTrue();
OneTimeKey key = result.takeSecret();
@@ -0,0 +1,300 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.metadata.AuthentikTokenReminderProcessProducer;
import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
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.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Event aggregation, scheduling, delivery dedupe and standard admin widgets.
*******************************************************************************/
class TokenOperationsTest
{
private AdminTestSupport t;
private AuthentikActionUtil util;
private final List<AuthentikTokenAlerts.Notice> notices = new ArrayList<>();
private final Instant now = Instant.now();
/*******************************************************************************
** Capture delivery without a dependency on Alerts or a real mail service.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
t = AdminTestSupport.create();
util = AuthentikActionUtil.forBackend(t.config.getApiBackendName());
util.getRuntime().withTokenAlerts(notice -> { notices.add(notice); return (true); });
}
/*******************************************************************************
** Seed an expiring token for the chosen owner.
*******************************************************************************/
private JSONObject token(String id, int owner, int days)
{
return (t.authentik.seedToken(id, owner, "app_password", now.plus(days, ChronoUnit.DAYS).toString()));
}
/*******************************************************************************
** A login event using the pinned context.method_args.identifier contract.
*******************************************************************************/
private JSONObject event(String id, Instant at)
{
return (new JSONObject().put("action", "login").put("created", at.toString())
.put("context", new JSONObject().put("method", "token").put("method_args", new JSONObject().put("identifier", id))));
}
/*******************************************************************************
** Create non-secret bookkeeping with a known age for widget assertions.
*******************************************************************************/
private void created(String id, int days) throws QException
{
TokenScopeStore store = new TokenScopeStore(t.config);
store.create(id, t.authentik.users.get(3), AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test"), "sam@example.test");
QRecord row = store.find(id).withValue("createdAt", now.minus(days, ChronoUnit.DAYS));
new UpdateAction().execute(new UpdateInput().withTableName("authentik_token_scope").withRecords(List.of(row)));
}
/*******************************************************************************
** Page, stop at 90 days, exclude non-token events, and reuse the 5-minute map.
*******************************************************************************/
@Test
void eventPagingWindowAndCache() throws QException
{
for(int i = 0; i < 101; i++)
{
t.authentik.loginEvents.add(event("token-" + i, now.minus(i, ChronoUnit.HOURS)));
}
t.authentik.loginEvents.add(event("token-0", now.minus(20, ChronoUnit.DAYS)));
t.authentik.loginEvents.add(event("password", now).put("context", new JSONObject().put("method", "password")));
t.authentik.loginEvents.add(event("wrong-action", now).put("action", "logout"));
t.authentik.loginEvents.add(event("old", now.minus(91, ChronoUnit.DAYS)));
Map<String, Instant> used = AuthentikTokenActivity.lastUsed(util);
assertThat(used).hasSize(101).containsEntry("token-0", now).doesNotContainKeys("password", "wrong-action", "old");
long requests = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count();
assertThat(requests).isEqualTo(2);
t.authentik.loginEvents.add(event("later", now));
assertThat(AuthentikTokenActivity.lastUsed(util)).isEqualTo(used);
assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count()).isEqualTo(requests);
assertThat(util.getRuntime().getTokenActivityCache().getTimeout()).isEqualTo(java.time.Duration.ofMinutes(5));
}
/*******************************************************************************
** API activity is explicitly not tracked, and local creation/scope is shown.
*******************************************************************************/
@Test
void computedFields() throws QException
{
token("personal", 3, 5);
created("personal", 31);
t.authentik.loginEvents.add(event("personal", now));
QRecord record = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), util.getApiJson("/core/tokens/personal/"), true);
assertThat(record.getValueInstant("lastUsed")).isEqualTo(now);
assertThat(record.getValueInstant("createdAt")).isEqualTo(now.minus(31, ChronoUnit.DAYS));
assertThat(record.getValueString("scopeSummary")).isEqualTo("Owner permissions");
JSONObject api = token("api", 3, 5).put("intent", "api");
QRecord untracked = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), api, true);
assertThat(untracked.getValue("lastUsed")).isNull();
assertThat(untracked.getValueString("lastUsedNote")).isEqualTo("not tracked");
}
/*******************************************************************************
** Each window sends once to the owner's email; a minimal external row does
** not invent restrictions or a creation date. Extending resets the windows.
*******************************************************************************/
@Test
void remindersDedupeAcrossBothWindows() throws QException
{
token("external", 3, 5);
token("tomorrow", 3, 1);
token("api", 3, 1).put("intent", "api");
token("expired", 3, -1);
token("later", 3, 20);
token("no-email", 6, 2);
AuthentikTokenReminders.run(util, now);
AuthentikTokenReminders.run(util, now);
assertThat(notices).hasSize(2).allMatch(n -> "sam@example.test".equals(n.recipient()));
assertThat(notices.getFirst().message()).contains("Your access token Test access (external)", "Preferences > Access tokens.");
TokenScopeStore store = new TokenScopeStore(t.config);
QRecord row = store.find("external");
assertThat(row.getValue("createdAt")).isNull();
assertThat(row.getValue("readOnly")).isNull();
assertThat(row.getValue("lastReminder7d")).isNotNull();
assertThat(row.getValue("lastReminder1d")).isNull();
AuthentikTokenReminders.run(util, now.plus(4, ChronoUnit.DAYS));
assertThat(notices).hasSize(3);
assertThat(store.find("external").getValue("lastReminder1d")).isNotNull();
store.resetReminders("external");
assertThat(store.find("external").getValue("lastReminder7d")).isNull();
AuthentikTokenScopes.apply(QContext.getQSession(), new JSONObject().put("qqq_token", "external"));
assertThat(QContext.getQSession().getPermissions()).isNotEmpty();
}
/*******************************************************************************
** Delivery absence leaves dedupe unset so installing Alerts permits retry.
*******************************************************************************/
@Test
void unavailableDeliveryAndUnauthorizedRun() throws QException
{
token("external", 3, 5);
util.getRuntime().withTokenAlerts(new AuthentikTokenAlerts()::send);
AuthentikTokenReminders.run(util, now);
//////////////////////////////////////////////////////////////////////
// RC.6 core has the notification SPI: the reminder goes out through //
// QAlerts.notify and is recorded for dedupe //
//////////////////////////////////////////////////////////////////////
assertThat(new TokenScopeStore(t.config).find("external")).isNotNull();
//////////////////////////////////////////////////////////////////////
// core QAlerts exists from RC.5: guard incidents always have a path //
//////////////////////////////////////////////////////////////////////
assertThat(new AuthentikTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null, "safe"))).isTrue();
t.loginAs("sam@example.test");
assertThatThrownBy(() -> AuthentikTokenReminders.run(util, now)).hasMessageContaining("tokens.manage");
assertThatThrownBy(() -> AuthentikTokenWidget.data(util, now)).hasMessageContaining("tokens.manage");
}
/*******************************************************************************
** The hidden daily process runs with core's system scheduler session.
*******************************************************************************/
@Test
void scheduledProcessAndBulkRevokeMetadata() throws QException
{
t.qInstance.addScheduler(new SimpleSchedulerMetaData().withName("test"));
AuthentikTokenReminderProcessProducer producer = new AuthentikTokenReminderProcessProducer();
producer.setQBitConfig(t.config);
var process = producer.produce(t.qInstance);
assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(86400);
assertThat(process.getSchedule().getSchedulerName()).isEqualTo("test");
assertThat(process.getIsHidden()).isTrue();
token("external", 3, 5);
QContext.init(t.qInstance, new QSystemUserSession());
new RunProcessAction().execute(new RunProcessInput().withProcessName("authentikTokenReminders"));
assertThat(notices).hasSize(1);
var revoke = t.qInstance.getProcess("authentikRevokeToken");
assertThat(revoke.getTableName()).isEqualTo("authentik_token");
assertThat(revoke.getMinInputRecords()).isEqualTo(1);
assertThat(revoke.getMaxInputRecords()).isEqualTo(AuthentikAdmin.MAX_BULK);
assertThat(Boolean.TRUE.equals(revoke.getIsHidden())).isFalse();
}
/*******************************************************************************
** Widget counts, known/unknown ages, links, deactivated owners and inactivity.
*******************************************************************************/
@Test
void adminWidgetUsesStandardTableAndSafeFilters() throws QException
{
token("new", 3, 5); created("new", 10);
token("middle", 3, 5); created("middle", 45);
token("unused", 3, 5); created("unused", 100);
token("ancient", 3, 5); created("ancient", 400);
token("unknown", 7, 5);
token("api", 3, 5).put("intent", "api"); created("api", 100);
t.authentik.loginEvents.add(event("middle", now));
TableData data = AuthentikTokenWidget.data(util, now);
assertThat(data.getRows()).contains(Map.of("category", "Age: unknown", "count", 1, "tokens", data.getRows().stream()
.filter(row -> "Age: unknown".equals(row.get("category"))).findFirst().orElseThrow().get("tokens")));
assertThat(data.getRows()).filteredOn(row -> "Unused for 60 days".equals(row.get("category"))).hasSize(2);
assertThat(data.getRows()).filteredOn(row -> "Owner deactivated".equals(row.get("category"))).hasSize(1);
assertThat(data.getRows().toString()).contains("?filter=", "identifier", "username").doesNotContain("lastUsed%22");
QWidgetMetaData widget = (QWidgetMetaData) t.qInstance.getWidget("authentikAccessTokens");
assertThat(widget.getType()).isEqualTo(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType());
assertThat(new AuthentikTokenWidget().render(new RenderWidgetInput().withWidgetMetaData(widget))).isNotNull();
t.config.withTokenUnusedDays(120);
assertThat(AuthentikTokenWidget.data(util, now).getRows()).noneMatch(row -> "Unused for 120 days".equals(row.get("category")));
}
/*******************************************************************************
** A post-write owner move is a high-severity incident without a token key.
*******************************************************************************/
@Test
void protectedOwnerRaceRaisesAlert() throws QException
{
t.authentik.users.get(6).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365");
token("service", 6, 5);
t.authentik.moveTokenAfterUpdate = true;
var result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "test").with("expiresInDays", 20));
assertThat(result.ok()).isFalse();
assertThat(notices).hasSize(1);
assertThat(notices.getFirst().key()).isEqualTo("authentik_token_guard_bypass");
assertThat(notices.getFirst().severity()).isEqualTo("high");
assertThat(notices.toString()).doesNotContain(t.authentik.tokenKeys.get("service"));
}
/*******************************************************************************
** External extension opens a new reminder window without rewriting scope.
*******************************************************************************/
@Test
void externalExtensionStartsNewReminderWindow() throws QException
{
JSONObject token = token("external", 3, 5);
AuthentikTokenReminders.run(util, now);
token.put("expires", now.plus(20, ChronoUnit.DAYS).toString());
util.getRuntime().invalidateCache();
AuthentikTokenReminders.run(util, now.plus(15, ChronoUnit.DAYS));
assertThat(notices).hasSize(2);
}
}
@@ -0,0 +1,275 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
import com.kingsrook.qbits.authentik.policy.AuthentikPolicyClaimMapper;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.Mode;
import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
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.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Scope enforcement intersects existing permissions without Docker or OAuth.
*******************************************************************************/
class TokenScopesTest
{
private AdminTestSupport t;
/*******************************************************************************
** Instance includes a table whose permission base differs from its name.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
t = AdminTestSupport.create();
t.config.withTokenScopesEnabled(true).withTokenBrokerClientId("broker").withTokenBrokerClientSecret("secret")
.withSubjectTokenSource(session -> "subject:3");
TokenKeyDelivery.set((output, key) -> {});
}
/*******************************************************************************
** Reset the response-only test adapter.
*******************************************************************************/
@AfterEach
void tearDown()
{
TokenKeyDelivery.set(null);
}
/*******************************************************************************
** Seed a credential and optional local metadata.
*******************************************************************************/
private void seed(String id, AdminRequest scope) throws QException
{
t.authentik.seedToken(id, 3, "app_password", Instant.now().plusSeconds(86400).toString());
if(scope != null)
{
new TokenScopeStore(t.config).create(id, t.authentik.users.get(3), scope, "sam@example.test");
}
}
/*******************************************************************************
** No claim means no scope lookup and no changes to the permission set.
*******************************************************************************/
@Test
void absentAndExternalClaims() throws QException
{
QSession session = QContext.getQSession().withPermissions("post.read", "post.edit");
Set<String> before = Set.copyOf(session.getPermissions());
AuthentikTokenScopes.apply(session, new JSONObject());
assertThat(session.getPermissions()).isEqualTo(before);
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, null)).isEmpty();
seed("external", null);
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, new JSONObject().put("qqq_token", "external")).orElseThrow().mode()).isEqualTo(Mode.UNSCOPED);
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "external"));
assertThat(session.getPermissions()).isEqualTo(before);
assertThat(session.getValue("qqq.credentialScope")).isEqualTo("external");
}
/*******************************************************************************
** Read-only strips writes and management hasAccess permissions.
*******************************************************************************/
@Test
void readOnlyAndDeletedToken() throws QException
{
seed("limited", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("readOnly", true));
QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "post.read", "post.edit", "authentik.tokens.manage.hasAccess");
JSONObject claims = new JSONObject().put("qqq_token", "limited");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactlyInAnyOrder("cms.publish.hasAccess", "post.read");
t.authentik.tokens.get("sam").clear();
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, claims).orElseThrow().mode()).isEqualTo(Mode.DENY_ALL);
assertThat(new TokenScopeStore(t.config).find("limited")).isNotNull();
}
/*******************************************************************************
** A scope cannot grant missing read permissions, even with a valid table.
*******************************************************************************/
@Test
void tableNamesResolvePermissionAliasesAndNeverWiden() throws QException
{
seed("table", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("tables", "post"));
QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "kof22.analytics.hasAccess");
JSONObject claims = new JSONObject().put("qqq_token", "table");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactly("cms.publish.hasAccess");
session.setPermissions(Set.of("kof22.analytics.hasAccess"));
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** An app includes its table actions, reports and widgets, not other apps.
*******************************************************************************/
@Test
void appChildrenAndExplicitTableUnion() throws QException
{
var rules = new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION);
QProcessMetaData process = new QProcessMetaData().withName("publish").withTableName("post").withPermissionRules(rules);
QReportMetaData report = new QReportMetaData().withName("report").withPermissionRules(rules);
QWidgetMetaData widget = new QWidgetMetaData().withName("widget").withType("TABLE").withPermissionRules(rules);
t.qInstance.addProcess(process);
t.qInstance.addReport(report);
t.qInstance.addWidget(widget);
t.qInstance.addApp(new QAppMetaData().withName("content").withPermissionRules(rules).withChild(t.qInstance.getTable("post"))
.withChild(report).withWidgets(List.of("widget")));
seed("app", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("apps", "content").with("tables", "analytics"));
QSession session = t.loginAs("sam@example.test").withPermissions("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess",
"widget.hasAccess", "kof22.analytics.hasAccess", "unrelated.read");
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "app"));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess",
"widget.hasAccess", "kof22.analytics.hasAccess");
}
/*******************************************************************************
** The configured claim is enforced even if offering new scopes is disabled.
*******************************************************************************/
@Test
void configuredClaimCacheAndMalformedClaim() throws QException
{
t.config.withTokenScopesEnabled(false).withTokenScopeClaim("credential");
seed("external", null);
QSession session = t.loginAs("sam@example.test").withPermissions("post.read");
JSONObject claims = new JSONObject().put("credential", "external");
AuthentikTokenScopes.apply(session, claims);
t.authentik.tokens.get("sam").clear();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactly("post.read");
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
session.withPermission("post.read");
AuthentikTokenScopes.apply(session, new JSONObject().put("credential", new JSONObject()));
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** Both claims customizers narrow after computing their permission unions.
*******************************************************************************/
@Test
void mappersCannotAddPermissionsAfterDenyAll()
{
JSONObject claims = new JSONObject().put("qqq_token", "deleted");
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
assertThat(session.getPermissions()).isEmpty();
session.withPermission("post.read");
new AuthentikGroupPermissionMapper().withGroupPermissions("test", List.of("post.edit"))
.customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims.put("groups", List.of("test"))));
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** Review validates current access and displays the scope; execution repeats.
*******************************************************************************/
@Test
void reviewRejectsUnknownOrUnheldAndExecutionRechecks() throws QException
{
t.loginAs("sam@example.test");
AdminRequest request = AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("description", "CI").with("expiresInDays", 30).with("tables", "missing");
assertThat(t.admin().plan(request).getMessage()).contains("Unknown");
request.with("tables", "post");
assertThat(t.admin().plan(request).getMessage()).contains("read access");
QContext.getQSession().withPermission("cms.publish.hasAccess");
assertThat(t.admin().plan(request).describe()).contains("tables: post");
QContext.getQSession().setPermissions(Set.of());
assertThat(t.admin().execute(request).ok()).isFalse();
assertThat(t.changes()).isEmpty();
}
/*******************************************************************************
** A long general response cache cannot extend the scope revocation window.
** Lookup outages deny, rather than falling back to unrestricted access.
*******************************************************************************/
@Test
void verificationCacheIsIndependentAndApiFailureDenies() throws QException
{
t.config.withListCacheTtlSeconds(3600);
seed("cached", null);
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
JSONObject claims = new JSONObject().put("qqq_token", "cached");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactly("post.read");
t.authentik.tokens.get("sam").clear();
AuthentikRuntime.forBackend(t.config.getApiBackendName()).getTokenScopeCache().clear();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
session.withPermission("post.read");
com.kingsrook.qbits.authentik.client.FixtureActionUtil.handler = request ->
com.kingsrook.qbits.authentik.client.FixtureActionUtil.response(403, "{}");
AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "unavailable"));
assertThat(session.getPermissions()).isEmpty();
}
/*******************************************************************************
** The ID token may supply policy context while the access token binds scope.
*******************************************************************************/
@Test
void idTokenContextCannotHideAccessTokenScope()
{
QSession session = t.loginAs("sam@example.test").withPermission("post.read");
new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of(
"idToken", new JSONObject().put("email", "sam@example.test"),
"jwtPayloadJsonObject", new JSONObject().put("qqq_token", "deleted")));
assertThat(session.getPermissions()).isEmpty();
}
}
@@ -61,7 +61,7 @@ class AuthentikAppTest
assertThat(app.getChildren()).extracting(c -> c.getName())
.containsExactly("authentik_user", "authentik_invitation", "authentik_group", "authentik_application", "authentik_approval_request", "authentik_admin_action");
assertThat(app.getSections()).extracting(s -> s.getLabel()).containsExactly("People", "Access", "Activity");
assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikDriftReport");
assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikAccessTokens", "authentikDriftReport");
for(String subTable : List.of("authentik_group_member", "authentik_session", "authentik_token", "authentik_event", "authentik_policy_binding", "authentik_role", "authentik_blueprint"))
{
@@ -334,6 +334,79 @@ class TokenContractIT
/*******************************************************************************
** Owner decision A (2026-10-14): the role holds add_token because 2026.8.3
** needs it for service_account and for set_key (POST object permission).
** Pin both: 403 without add_token, 200/204 with it (restored in finally).
*******************************************************************************/
@Test
void serviceAccountActionsNeedAddToken() throws Exception
{
JSONObject seed = expect(admin.post("/core/users/service_account/", new JSONObject().put("name", unique("needs-add"))
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
users.add(seed.getInt("user_pk"));
String seedToken = "service-account-" + seed.getString("username") + "-password";
tokens.add(seedToken);
String role = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
JSONObject permissions = new JSONObject().put("permissions", new org.json.JSONArray().put("authentik_core.add_token"));
String name = unique("no-add-token");
assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", permissions).status()).isEqualTo(204);
try
{
expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name)
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 403);
assertThat(admin.first("/core/users/?username=" + name)).isNull();
expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 403);
}
finally
{
assertThat(admin.post("/rbac/permissions/assigned_by_roles/" + role + "/assign/", permissions).status()).isEqualTo(200);
}
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name)
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
users.add(created.getInt("user_pk"));
tokens.add("service-account-" + name + "-password");
expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 204);
}
/*******************************************************************************
** The alarm also notifies identity-admins whenever the qbit's account
** creates a service account (owner decision 2026-10-14).
*******************************************************************************/
@Test
void detectiveRuleNotifiesOnServiceAccountCreation() throws Exception
{
int member = user("alarm-sa");
expect(admin.post("/core/groups/" + admin.groupPk("identity-admins") + "/add_user/", new JSONObject().put("pk", member)), 204);
JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", unique("alarmed"))
.put("create_group", false).put("expiring", true).put("expires", expires(30))), 200);
int servicePk = created.getInt("user_pk");
users.add(servicePk);
tokens.add("service-account-" + created.getString("username") + "-password");
String[] eventPk = { null };
AuthentikTestStack.waitFor(Duration.ofSeconds(30), "service-account model_created event", () ->
{
JSONArray events = admin.getOk("/events/events/?action=model_created&ordering=-created&page_size=50&username="
+ TestApi.encode(AuthentikTestStack.SERVICE_ACCOUNT)).getJSONArray("results");
for(int i = 0; i < events.length(); i++)
{
JSONObject model = events.getJSONObject(i).getJSONObject("context").optJSONObject("model");
if(model != null && "user".equals(model.optString("model_name")) && String.valueOf(servicePk).equals(model.optString("pk")))
{
eventPk[0] = events.getJSONObject(i).getString("pk");
return (true);
}
}
return (false);
});
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "service-account creation notification", () ->
notifications(member, eventPk[0]).length() > 0);
}
/*******************************************************************************
** Real qbit calls, local audit/scope persistence and the response-only SPI.
*******************************************************************************/
@@ -542,8 +615,13 @@ class TokenContractIT
*******************************************************************************/
private TestApi.Response credentials(String username, String key, String scope) throws Exception
{
//////////////////////////////////////////////////////////////////////
// no client_secret: exactly what a PAT holder sends (the generated //
// usage text). 2026.8.3 authenticates client_credentials by the //
// username and app password even for a confidential client //
//////////////////////////////////////////////////////////////////////
return (form(Map.of("grant_type", "client_credentials", "client_id", AuthentikTestStack.APP_SLUG,
"client_secret", "contract-test-oauth2-client-secret", "username", username, "password", key, "scope", scope)));
"username", username, "password", key, "scope", scope)));
}
@@ -173,4 +173,27 @@ class EffectivePermissionResolverTest
assertThat(resolver.expand(null)).isEmpty();
}
/*******************************************************************************
** Exclusion applies through ancestors and duplicate paths without removing
** overlapping named grants or policies from other groups, or mutating the graph.
*******************************************************************************/
@Test
void excludesOnlyTheSelectedPolicy()
{
GroupGraph graph = new GroupGraph()
.add(new GroupGraph.Node("parent", "Parent", false, List.of(), List.of("cms.publish"), List.of("app"), List.of(), List.of(), List.of("cms.*", "missing")))
.add(new GroupGraph.Node("child", "Child", false, List.of("parent"), List.of(), List.of(), List.of(), List.of(), List.of("cms.edit")));
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, CATALOG);
EffectivePermissionResolver.Resolution base = resolver.resolveGroups(List.of("child", "parent"), "parent");
assertThat(base.sessionPermissions()).containsExactly("cms.edit", "cms.publish");
assertThat(base.grantedBy().get("cms.edit")).containsExactly("Child");
assertThat(base.grantedBy().get("cms.publish")).containsExactly("Parent");
assertThat(base.appAccess()).containsExactly("app");
assertThat(base.unknown()).isEmpty();
assertThat(resolver.resolveGroups(List.of("child")).sessionPermissions()).contains("cms.delete");
assertThat(resolver.resolveGroups(List.of("child"), "absent")).isEqualTo(resolver.resolveGroups(List.of("child")));
}
}
@@ -26,26 +26,25 @@ 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.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
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.permissions.TableFeaturePermission;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
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.session.QSession;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Table features use generic permission suffixes from core's existing catalog
** API. No test or production code depends on the unreleased metadata hook.
** Table features are discovered through core's registered metadata and catalog.
*******************************************************************************/
class PolicyFeaturePermissionsTest
{
@@ -61,31 +60,27 @@ class PolicyFeaturePermissionsTest
QInstance instance = PolicyCatalogTest.instance();
instance.addTable(new QTableMetaData().withName("person_records").withLabel("People").withBackendName("memory")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withPermissionBaseName("person")));
instance.addTable(new QTableMetaData().withName("person_extra").withLabel("Extra People").withBackendName("memory")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.ofOrWithNew(instance);
features.withPermission(feature("comment", "Comment", "person_records", "legacy"))
.withPermission(feature("analyze", "Old label", "person_records"))
.withPermission(feature("analyze", "Analyze", "person_records", "gate", "open_table"))
.withPermission(feature("tag", "Tag", "shared_one", "shared_two"))
.withPermission(feature("share", "Share", "person_extra"))
.withPermission(feature("", "Empty", "person_records"));
return (instance);
}
/*******************************************************************************
** The shape returned by PermissionsHelper when core registers a feature.
** Register a feature only for the named tables; core supplies permission names.
*******************************************************************************/
private static AvailablePermission feature(String name, String label)
private static TableFeaturePermission feature(String suffix, String label, String... tables)
{
return (new AvailablePermission().withName(name).withObjectName("People").withObjectType("Table").withPermissionType(label));
}
/*******************************************************************************
** Deliberately unsorted; duplicates and standard suffixes are not features.
*******************************************************************************/
private static List<AvailablePermission> available()
{
return (List.of(feature("person.comment", "Comment"), feature("person.analyze", "Analyze"), feature("person.analyze", "Analyze"),
feature("person.read", "Read"), feature("person.insert", "Insert"), feature("person.edit", "Edit"), feature("person.delete", "Delete"),
feature("person.hasAccess", "Access"), feature("person_extra.share", "Share"), feature("person.", "Empty"),
feature("gate.analyze", "Analyze"), feature("legacy.comment", "Comment"), feature("legacy.write", "Write"),
feature("shared_base.tag", "Tag"), feature("open_table.analyze", "Analyze")));
return (new TableFeaturePermission().withSuffix(suffix).withLabel(label)
.withAppliesTo((instance, table) -> List.of(tables).contains(table.getName())));
}
@@ -106,11 +101,13 @@ class PolicyFeaturePermissionsTest
@Test
void catalogListsFeaturesSeparately()
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
QInstance instance = instance();
assertThat(PermissionsHelper.getAllAvailablePermissions(instance)).filteredOn(permission -> permission.getName().equals("shared_base.tag")).hasSize(2);
PolicyCatalog catalog = PolicyCatalog.of(instance);
assertThat(catalog.get("person").actions()).containsExactly(PolicyAction.READ, PolicyAction.INSERT, PolicyAction.EDIT, PolicyAction.DELETE);
assertThat(catalog.get("person").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"), new PolicyCatalog.Feature("comment", "Comment"));
assertThat(catalog.get("gate").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"));
assertThat(catalog.get("legacy").features()).containsExactly(new PolicyCatalog.Feature("comment", "Comment"), new PolicyCatalog.Feature("write", "Write"));
assertThat(catalog.get("legacy").features()).containsExactly(new PolicyCatalog.Feature("comment", "Comment"), new PolicyCatalog.Feature("write", "WRITE"));
assertThat(catalog.get("shared_base").features()).containsExactly(new PolicyCatalog.Feature("tag", "Tag"));
assertThat(catalog.get("open_table")).isNull();
assertThat(catalog.permissionNames()).contains("person.analyze", "person.comment", "gate.analyze", "legacy.comment", "legacy.write", "shared_base.tag")
@@ -136,7 +133,7 @@ class PolicyFeaturePermissionsTest
{
QInstance instance = instance();
instance.getTable("person_records").withoutCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
PolicyCatalog.Resource resource = PolicyCatalog.of(instance, available()).get("person");
PolicyCatalog.Resource resource = PolicyCatalog.of(instance).get("person");
assertThat(resource.actions()).isEmpty();
assertThat(resource.features()).hasSize(2);
}
@@ -149,7 +146,7 @@ class PolicyFeaturePermissionsTest
@Test
void parserValidatesFeaturesPerResource() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
assertThat(PolicyParser.parseStrict(document(List.of("read", "analyze"), "person"), FORMAT, catalog).permissions())
.containsExactly("person.analyze", "person.read");
assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "gate"), FORMAT, catalog)).containsExactly("gate.analyze", "person.analyze");
@@ -171,7 +168,7 @@ class PolicyFeaturePermissionsTest
@Test
void wildcardDoesNotGrantFeatures()
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
assertThat(PolicyParser.parse(document(List.of("*"), "person"), FORMAT, catalog))
.containsExactlyInAnyOrder("person.hasAccess", "person.read", "person.insert", "person.edit", "person.delete");
assertThat(PolicyParser.parse(document(List.of("*", "analyze"), "person"), FORMAT, catalog)).hasSize(6).contains("person.analyze").doesNotContain("person.comment");
@@ -185,7 +182,7 @@ class PolicyFeaturePermissionsTest
@Test
void generatorRoundTripsFeatures() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
List<String> permissions = List.of("person.comment", "person.read", "person.analyze", "gate.analyze");
JSONObject generated = PolicyGenerator.generate(permissions, FORMAT, catalog);
String compact = PolicyGenerator.toCompact(generated);
@@ -206,22 +203,18 @@ class PolicyFeaturePermissionsTest
/*******************************************************************************
** The real mapper discovers simulated core entries and adds their exact names.
** The real mapper discovers registered core features and adds their exact names.
*******************************************************************************/
@Test
void claimMapperUsesCoreCatalog()
{
QInstance instance = instance();
try(MockedStatic<PermissionsHelper> helper = Mockito.mockStatic(PermissionsHelper.class, Mockito.CALLS_REAL_METHODS))
{
helper.when(() -> PermissionsHelper.getAllAvailablePermissions(instance)).thenReturn(available());
QSession session = new QSession();
session.setPermissions(Set.of("existing.read"));
JSONObject claims = new JSONObject().put(FORMAT.claim(), document(List.of("analyze"), "person"));
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.read", "person.analyze");
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance))).containsExactly("person.analyze");
}
QSession session = new QSession();
session.setPermissions(Set.of("existing.read"));
JSONObject claims = new JSONObject().put(FORMAT.claim(), document(List.of("analyze"), "person"));
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.read", "person.analyze");
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance))).containsExactly("person.analyze");
}
@@ -232,7 +225,7 @@ class PolicyFeaturePermissionsTest
@Test
void comparisonIncludesInheritedFeatures() throws Exception
{
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
PolicyCatalog catalog = PolicyCatalog.of(instance());
JSONObject parent = new JSONObject().put("pk", "parent").put("name", "Analysts")
.put("attributes", new JSONObject().put(FORMAT.attribute(), document(List.of("analyze"), "person")));
JSONObject child = new JSONObject().put("pk", "child").put("name", "Staff").put("parents", new JSONArray(List.of("parent")))
@@ -0,0 +1,213 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
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.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 com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
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.permissions.TableFeaturePermission;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Full access stays symbolic in documents and expands against live metadata.
*******************************************************************************/
class PolicyFullAccessTest
{
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
/*******************************************************************************
** The exact full-access statement in a valid document envelope.
*******************************************************************************/
private static JSONObject document()
{
return (PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("*"))));
}
/*******************************************************************************
** Mixed documents still validate every statement before dropping redundancy.
*******************************************************************************/
@Test
void parserAcceptsOnlyExplicitFullAccess() throws Exception
{
JSONObject document = document();
assertThat(PolicyParser.parseStrict(document, FORMAT).permissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
document.getJSONArray("Statement").put(PolicyParserTest.statement("Allow", List.of("read"), List.of("cms_page")));
assertThat(PolicyParser.parseStrict(document, FORMAT).permissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
document.getJSONArray("Statement").put(PolicyParserTest.statement("Deny", List.of("read"), List.of("cms_page")));
assertThat(PolicyParser.parse(document, FORMAT)).isEmpty();
for(List<String> actions : List.of(List.of("read"), List.of("*", "read"), List.<String>of()))
{
JSONObject invalid = PolicyParserTest.document(PolicyParserTest.statement("Allow", actions, List.of("*")));
assertThatThrownBy(() -> PolicyParser.parseStrict(invalid, FORMAT)).hasMessageContaining("requires Action");
assertThat(PolicyParser.parse(invalid, FORMAT)).isEmpty();
}
JSONObject badResource = PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("*", "bad.resource")));
assertThat(PolicyParser.parse(badResource, FORMAT)).isEmpty();
JSONObject mixedResources = PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("*", "cms_page")));
assertThat(PolicyParser.parse(mixedResources, FORMAT)).containsExactly(PolicyPermissions.FULL_ACCESS);
}
/*******************************************************************************
** The marker alone creates the wildcard; enumerated grants never do.
*******************************************************************************/
@Test
void canonicalFormAndRoundTrip() throws Exception
{
JSONObject mixed = document();
mixed.getJSONArray("Statement").put(PolicyParserTest.statement("Allow", List.of("*"), List.of("cms_page")));
String expected = PolicyGenerator.toCompact(document());
assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(mixed, FORMAT))).isEqualTo(expected);
JSONObject generated = PolicyGenerator.generate(Set.of(PolicyPermissions.FULL_ACCESS, "cms_page.read"), FORMAT);
assertThat(PolicyGenerator.toCompact(generated)).isEqualTo(expected);
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(generated), FORMAT).permissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
Set<String> fiveActions = PolicyParser.parse(PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("cms_page"))), FORMAT);
assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(fiveActions, FORMAT))).doesNotContain("[\"*\"]");
assertThat(PolicyCatalog.of(PolicyCatalogTest.instance()).unknownPermissions(Set.of(PolicyPermissions.FULL_ACCESS))).isEmpty();
}
/*******************************************************************************
** An already parsed graph and catalog cover later features and processes.
** The member's grants must be a superset of the actual core API's output.
*******************************************************************************/
@Test
void resolverAndClaimsExpandCurrentAndFutureInstancePermissions()
{
QInstance instance = PolicyCatalogTest.instance();
PolicyCatalog catalog = PolicyCatalog.of(instance);
JSONObject parent = new JSONObject().put("pk", "parent").put("name", "Owners")
.put("attributes", new JSONObject().put(FORMAT.attribute(), document()));
JSONObject child = new JSONObject().put("pk", "child").put("name", "Staff").put("parents", List.of("parent")).put("users", List.of(7));
GroupGraph graph = GroupGraph.fromGroups(List.of(parent, child), FORMAT, catalog);
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, catalog.permissionNames(), instance);
assertThat(resolver.resolveUser(7).sessionPermissions()).doesNotContain("cms_page.analyze", "futureProcess.hasAccess");
TableFeaturePermissionsMetaData.ofOrWithNew(instance).withPermission(new TableFeaturePermission().withSuffix("analyze").withLabel("Analyze")
.withAppliesTo((qi, table) -> table.getName().equals("cms_page")));
instance.addProcess(new QProcessMetaData().withName("futureProcess")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
Set<String> permissions = resolver.resolveUser(7).sessionPermissions();
assertThat(permissions).containsAll(PermissionsHelper.getAllAvailablePermissionNames(instance))
.contains("cms_page.analyze", "futureProcess.hasAccess", "legacy.write", "cms.publish.hasAccess", "publishPage.hasAccess",
"content.hasAccess", "pageReport.hasAccess", "pageStats.hasAccess")
.doesNotContain(PolicyPermissions.FULL_ACCESS);
for(IdentityPermission permission : IdentityPermission.values())
{
assertThat(permissions).contains(permission.getSessionPermission());
}
assertThat(resolver.resolveUser(7).grantedBy().get("futureProcess.hasAccess")).containsExactly("Owners");
assertThat(resolver.resolveGroups(List.of("child"), "parent").sessionPermissions()).isEmpty();
assertThat(graph.byPk("parent").policyPermissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
JSONObject claims = new JSONObject().put(FORMAT.claim(), document());
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, catalog)).isEqualTo(permissions);
QSession session = new QSession();
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
assertThat(session.getPermissions()).isEqualTo(permissions);
}
/*******************************************************************************
** QContext-based consumers see a replacement instance without a new graph.
*******************************************************************************/
@Test
void contextReloadAndCatalogOnlyResolution()
{
GroupGraph graph = GroupGraph.fromGroups(List.of(new JSONObject().put("pk", "owners").put("name", "Owners")
.put("attributes", new JSONObject().put(FORMAT.attribute(), document()))), FORMAT);
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, Set.of("host.extra"));
QInstance original = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
QInstance sample = PolicyCatalogTest.instance();
original.addBackend(sample.getBackend("memory"));
original.addTable(sample.getTable("cms_page"));
QContext.init(original, new QSession());
try
{
AuthentikRuntime runtime = new AuthentikRuntime(new AuthentikQBitConfig());
assertThat(runtime.getPermissionCatalog()).contains("cms_page.read");
assertThat(resolver.resolveGroups(List.of("owners")).sessionPermissions()).contains("cms_page.read", "host.extra");
QInstance replacement = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
replacement.addBackend(sample.getBackend("memory"));
replacement.addTable(sample.getTable("orphan"));
QContext.init(replacement, new QSession());
assertThat(runtime.getPermissionCatalog()).contains("orphan.read").doesNotContain("cms_page.read");
assertThat(resolver.resolveGroups(List.of("owners")).sessionPermissions()).contains("host.extra").doesNotContain("cms_page.read");
assertThat(PolicyPermissions.fromClaims(new JSONObject().put(FORMAT.claim(), document()), FORMAT))
.contains(IdentityPermission.USERS_READ.getSessionPermission()).doesNotContain(PolicyPermissions.FULL_ACCESS);
}
finally
{
QContext.clear();
}
assertThat(PolicyPermissions.allPermissions(null, null)).hasSize(IdentityPermission.values().length);
}
/*******************************************************************************
** Host-declared names (custom checkers QQQ metadata cannot list) join the
** expansion for an instance holding that runtime's backend, are computed at
** use time, and never leak into another instance.
*******************************************************************************/
@Test
void hostDeclaredPermissionsJoinFullAccessForTheirInstanceOnly()
{
AuthentikQBitConfig config = new AuthentikQBitConfig().withTableNamePrefix("hostdecl");
List<String> names = new java.util.ArrayList<>(List.of("host.purge"));
config.withFullAccessHostPermissions(qInstance -> names);
AuthentikRuntime.register(new AuthentikRuntime(config));
QInstance mine = new QInstance();
mine.addBackend(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData().withName(config.getApiBackendName()));
assertThat(PolicyPermissions.allPermissions(mine, Set.of())).contains("host.purge");
names.add("host.later");
assertThat(PolicyPermissions.allPermissions(mine, Set.of())).contains("host.purge", "host.later");
assertThat(PolicyPermissions.allPermissions(new QInstance(), Set.of())).doesNotContain("host.purge", "host.later");
config.withFullAccessHostPermissions(qInstance -> null);
assertThat(PolicyPermissions.allPermissions(mine, Set.of())).doesNotContain("host.purge");
}
}
@@ -131,7 +131,14 @@ class AuthentikProcessesTest
for(QProcessMetaData process : t.qInstance.getProcesses().values())
{
assertThat(process.getTableName()).as(process.getName()).isNotBlank();
if(!process.getName().equals("authentikTokenReminders"))
{
assertThat(process.getTableName()).as(process.getName()).isNotBlank();
}
else
{
assertThat(process.getIsHidden()).isTrue();
}
assertThat(t.qInstance.getApp("authentikAdmin").getChildren()).extracting(c -> c.getName()).doesNotContain(process.getName());
}
assertThat(t.qInstance.getProcess("authentikCreateUser").getMaxInputRecords()).isEqualTo(0);
@@ -104,6 +104,9 @@ entries:
# superuser, a member of authentik Admins or identity-admins, or
# qqq.io/protected). If it ever happens, guard (a) was bypassed:
# identity-admins get an Authentik notification and an email.
# It also notifies whenever that account creates a service account (each
# comes with an app password; owner decision 2026-10-14), so in-app
# service-account creation is always visible to identity-admins.
- model: authentik_policies_expression.expressionpolicy
id: qqq-token-alarm-policy
state: present
@@ -121,6 +124,11 @@ entries:
if (event.user or {}).get("username") != "qqq-kof22-admin-idadmin":
return False
model = event.context.get("model") or event.context.get("secret") or {}
# Also notify (owner decision 2026-10-14) whenever the qbit's account
# creates a service account: each one comes with an app password.
if event.action == "model_created" and model.get("app") == "authentik_core" and model.get("model_name") == "user":
created = User.objects.filter(pk=model.get("pk")).first()
return created is not None and created.type == "service_account"
# Two shapes: the usual model reference (app, model_name, pk), and set_key's
# model_to_dict(token) (token_uuid, identifier, intent, user; key masked).
is_reference = model.get("app") == "authentik_core" and model.get("model_name") == "token"