Compare commits

..
Author SHA1 Message Date
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.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
17 changed files with 54 additions and 788 deletions

No files matched your search

+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
+4
View File
@@ -12,6 +12,10 @@ All notable changes to this project are documented here. Format: [Keep a Changel
- 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.5` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. No Java API compatibility fixes were needed.
- 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 the remaining test stand-in.
### 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.
+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
+2 -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 features and labels through `PermissionsHelper.getAllAvailablePermissions`; they appear when the host's core version exposes them. The pinned RC.5 release does not include `TableFeaturePermissionsMetaData`; its later core implementation is a follow-up for the next baseline upgrade. 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.5 (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)`. |
+27
View File
@@ -1,5 +1,32 @@
# Upgrading qbit-authentik
## QQQ 4.1.0-RC.5 baseline (#20 / F19)
Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.5`
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.
Stand-in audit against core tag `v4.1.0-RC.5`:
- `PolicyFeaturePermissionsTest` supplies a synthetic available-permission catalog:
`STAND-IN(core=QRun-IO/qqq#1136, removeBy=4.1.0-RC.6)`. RC.6 is the removal target,
subject to verifying that release contains the metadata hook. Retain malformed
and duplicate catalog test cases when adding real metadata registration coverage.
- The requested stand-in/RC grep found only the old baseline references; the broader
audit found the test catalog above and stale core-enforcement explanations.
No production bridge can safely be removed by this baseline bump.
- `TableFeaturePermissionsMetaData` (core commit `9a302136d`, #1136) and client
channels (core commit `741bb4974`, PR #1141) exist after RC.5, but neither commit
is an ancestor of the RC.5 tag and neither API is present in that release.
Follow up on a released baseline containing them: verify the policy editor's
catalog with real feature metadata and adopt client-channel attribution and
identity/credential channel restrictions. These features are not added here.
## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release)
The qbit's permissions follow the same owner naming rule (2026-10-12) and now
+4 -4
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.
@@ -668,7 +668,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 +955,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 +1033,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 |
+1 -1
View File
@@ -105,7 +105,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 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 `TableFeaturePermissionsMetaData` hook (absent from the pinned RC.5 release); features become available when the host's core supplies those entries.
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.
-11
View File
@@ -12,17 +12,6 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated
| 3. Full user lifecycle | done, except the optional snapshot export |
| 4. RBAC role management | post-v1, not started |
| 5. Policies and full CRUD (DESIGN.md section 12) | 5a policy model (#14), 5b policy editor (#15), 5c user and group CRUD additions; the Next UI widget is a separate qqq-frontend-next PR |
| 6d. Personal access token Preferences | implemented on `feature/token-preferences`; draft until a QQQ RC contains QRun-IO/qqq#1162 |
### Phase 6d: Access tokens Preferences
The `TOKENS` feature registers the `access-tokens` section and user-menu shortcut without an app or permission gate. The provider resolves the current Authentik operator and reads only their app passwords, with read-only metadata and optional scope inputs. Create, rotate, extend and revoke use `AuthentikAdmin`, including live protection/ownership checks, broker exchange and audit. Preferences uses core's one-time secret response independently of process delivery; keys never enter section values or local storage.
This branch builds against the locally installed `4.1.0-PREFS-SNAPSHOT`. Keep any PR draft until the RC shipping #1162 is available, then replace the marked POM stand-in. Last-used tracking is not implemented in this branch, so the section omits it. Next UI renders the section and user-menu entry through the core Preferences contract.
`AccessTokenPreferencesTest` exercises the core dispatcher with FakeAuthentik and an in-memory QInstance, including lifecycle, scoped rotation, ownership, protected-user/broker refusals, read-only validation, and searches of local tables, audit, reloaded section values, events and captured logs for leaked keys.
Validation (2026-10-13): `./mvnw.cmd -q -o verify -DskipITs` passed on Windows: 253 tests, no failures/errors/skips; JaCoCo 91.48% instruction and 100% class coverage. Contract ITs were intentionally excluded.
### Built in this line (0.1)
+2 -3
View File
@@ -237,7 +237,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,8 +245,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<!-- STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7): local build of feature/user-preferences; switch to the RC that ships #1162 -->
<qqq.snapshot.version>4.1.0-PREFS-SNAPSHOT</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -25,7 +25,6 @@ import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.app.AuthentikRelations;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
import com.kingsrook.qbits.authentik.preferences.AccessTokenPreferences;
import com.kingsrook.qbits.authentik.processes.AuthentikProcesses;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -186,10 +185,6 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
output.addSelfToInstance(qInstance);
addPossibleValueSources(qInstance);
if(qBitConfig.hasFeature(AuthentikFeature.TOKENS))
{
AccessTokenPreferences.register(qInstance, qBitConfig);
}
AuthentikRelations.apply(qInstance, qBitConfig);
for(QProcessMetaData process : AuthentikProcesses.processes(qBitConfig))
{
@@ -130,10 +130,6 @@ public class AuthentikTokens
boolean own = Objects.equals(operatorPk, ownerPk);
boolean service = "service_account".equals(owner.optString("type"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if(isPreferences(request) && (!own || service || token != null && !"app_password".equals(token.optString("intent"))))
{
return (plan.refuse("Preferences only manages your own personal app-password tokens."));
}
if(service && action == AdminAction.CREATE_TOKEN)
{
return (plan.refuse("Service accounts have one app password. Use Rotate Token or Create Service Account."));
@@ -173,7 +169,7 @@ public class AuthentikTokens
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
{
if(!isPreferences(request) && !TokenKeyDelivery.get().available())
if(!TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
@@ -406,7 +402,7 @@ public class AuthentikTokens
{
AdminRequest request = plan.getRequest();
if((request.action() == AdminAction.CREATE_TOKEN || request.action() == AdminAction.ROTATE_TOKEN || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
&& (!isPreferences(request) || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT) && !TokenKeyDelivery.get().available())
&& !TokenKeyDelivery.get().available())
{
throw (new QUserFacingException(DELIVERY_UNAVAILABLE));
}
@@ -420,10 +416,6 @@ public class AuthentikTokens
boolean service = "service_account".equals(owner.optString("type"));
boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if(isPreferences(request) && (!own || service || before != null && !"app_password".equals(before.optString("intent"))))
{
throw (new QUserFacingException("Preferences only manages your own personal app-password tokens."));
}
if((!own || service) && !manager || !own && !service && request.action() != AdminAction.REVOKE_TOKEN)
{
throw (new QUserFacingException("Token ownership changed; review again."));
@@ -614,7 +606,7 @@ public class AuthentikTokens
/*******************************************************************************
** Scope summary contains metadata only.
*******************************************************************************/
public String scopeSummary(String identifier) throws QException
private String scopeSummary(String identifier) throws QException
{
QRecord row = scopes.find(identifier);
List<String> parts = new ArrayList<>();
@@ -630,16 +622,6 @@ public class AuthentikTokens
/*******************************************************************************
** Preferences has core's response-only secret channel, independent of processes.
*******************************************************************************/
private static boolean isPreferences(AdminRequest request)
{
return ("preferences".equals(request.str("deliveryChannel")));
}
/*******************************************************************************
** Build the isolated one-time object, including its response-only usage text.
*******************************************************************************/
@@ -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
{
@@ -1,106 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.preferences;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
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.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionScope;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceListMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
/*******************************************************************************
** Self-service token metadata for core's generic Preferences clients.
*******************************************************************************/
public final class AccessTokenPreferences
{
public static final String SECTION = "access-tokens";
public static final String LIST = "tokens";
/*******************************************************************************
** Register without an app or permission gate: every signed-in user may enter.
*******************************************************************************/
public static void register(QInstance instance, AuthentikQBitConfig config)
{
UserPreferenceListMetaData tokens = new UserPreferenceListMetaData().withName(LIST).withLabel("Tokens").withRowLabel("Token")
.withField(readOnly("description", "Description", QFieldType.STRING))
.withField(readOnly("identifier", "Identifier", QFieldType.STRING))
.withField(readOnly("intent", "Intent", QFieldType.STRING))
.withField(readOnly("expires", "Expires", QFieldType.DATE_TIME))
.withField(readOnly("scope", "Scope", QFieldType.STRING));
UserPreferenceActionMetaData create = new UserPreferenceActionMetaData().withName("create").withLabel("Create token")
.withScope(UserPreferenceActionScope.SECTION).withOrder(10)
.withInputField(new QFieldMetaData("description", QFieldType.STRING).withLabel("Description").withIsRequired(true))
.withInputField(expiry().withDefaultValue(30));
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
{
create.withInputField(new QFieldMetaData("readOnly", QFieldType.BOOLEAN).withLabel("Read only"))
.withInputField(new QFieldMetaData("apps", QFieldType.TEXT).withLabel("Apps"))
.withInputField(new QFieldMetaData("tables", QFieldType.TEXT).withLabel("Tables"));
}
UserPreferencesMetaData.ofOrWithNew(instance).withSection(new UserPreferenceSectionMetaData()
.withName(SECTION).withLabel("Access tokens").withIcon(new QIcon("key"))
.withDescription("Personal access tokens for scripts and integrations. Keys are shown once.")
.withOrder(30).withShowInUserMenu(true).withProvider(new QCodeReference(AccessTokenPreferencesProvider.class))
.withList(tokens).withAction(create)
.withAction(rowAction("rotate", "Rotate token", 20).withIsDestructive(true)
.withConfirmationText("Rotate this token? The old key stops working now."))
.withAction(rowAction("extend", "Extend expiry", 30).withInputField(expiry()))
.withAction(rowAction("revoke", "Revoke token", 40).withIsDestructive(true)
.withConfirmationText("Revoke this token? Anything using it stops working now.")));
}
/*******************************************************************************
** Token attributes can only be changed by the audited actions.
*******************************************************************************/
private static QFieldMetaData readOnly(String name, String label, QFieldType type)
{
return (new QFieldMetaData(name, type).withLabel(label).withIsEditable(false));
}
/*******************************************************************************
** Both creation and extension require an explicit bounded lifetime.
*******************************************************************************/
private static QFieldMetaData expiry()
{
return (new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withLabel("Expires in days").withIsRequired(true));
}
/*******************************************************************************
** All row actions refer to the provider's own-token list.
*******************************************************************************/
private static UserPreferenceActionMetaData rowAction(String name, String label, int order)
{
return (new UserPreferenceActionMetaData().withName(name).withLabel(label).withOrder(order)
.withScope(UserPreferenceActionScope.ROW).withListName(LIST));
}
}
@@ -1,174 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.preferences;
import java.io.Serializable;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.admin.AdminRequest;
import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
import com.kingsrook.qbits.authentik.admin.AuthentikTokens;
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionRequest;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionResult;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
import org.json.JSONObject;
/*******************************************************************************
** Live personal app-password metadata and audited actions. Keys exist only in
** the immediate action response, never in stored or reloaded preference values.
*******************************************************************************/
public class AccessTokenPreferencesProvider implements UserPreferenceProviderInterface
{
/*******************************************************************************
** Resolve the configured qbit in this instance, including custom backend names.
** Refuse ambiguity rather than silently selecting another identity directory.
*******************************************************************************/
private AuthentikAdmin admin(UserPreferenceRequestContext context) throws QException
{
if(QContext.getQSession() != context.getSession())
{
throw (new QUserFacingException("The preference session changed; sign in again."));
}
List<AuthentikQBitConfig> configs = QContext.getQInstance().getQBits().values().stream()
.map(qbit -> qbit.getConfig()).filter(AuthentikQBitConfig.class::isInstance).map(AuthentikQBitConfig.class::cast)
.filter(config -> config.hasFeature(AuthentikFeature.TOKENS)).toList();
if(configs.size() != 1)
{
throw (new QUserFacingException("Access token preferences need one configured Authentik directory."));
}
AuthentikAdmin admin = AuthentikAdmin.forBackend(configs.get(0).getApiBackendName());
admin.getUtil().getRuntime().getGuard().checkTokenPreferencesRead();
return (admin);
}
/*******************************************************************************
** Read all pages with server-side owner and intent constraints. A mismatched
** response fails closed; metadata is explicitly allowlisted, never copied whole.
*******************************************************************************/
@Override
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
{
AuthentikAdmin admin = admin(context);
AuthentikActionUtil util = admin.getUtil();
util.getRuntime().invalidateCache();
Integer pk = admin.operator().pk();
if(pk == null)
{
throw (new QUserFacingException(OAuth2SubjectTokenSource.UNAVAILABLE));
}
JSONObject owner = util.getApiJson("/core/users/" + pk + "/");
String username = owner.getString("username");
List<UserPreferenceRow> rows = new ArrayList<>();
AuthentikTokens tokens = new AuthentikTokens(admin);
for(JSONObject token : util.getAllPages("/core/tokens/?user__username=" + URLEncoder.encode(username, StandardCharsets.UTF_8)
+ "&intent=app_password", Integer.MAX_VALUE))
{
Object user = token.opt("user");
int ownerPk = user instanceof JSONObject object ? object.optInt("pk", -1) : token.optInt("user", -1);
if(ownerPk != pk || !"app_password".equals(token.optString("intent")))
{
throw (new QUserFacingException("Authentik returned tokens outside your personal token list."));
}
String identifier = token.getString("identifier");
Map<String, Serializable> values = new LinkedHashMap<>();
values.put("description", token.optString("description", ""));
values.put("identifier", identifier);
values.put("intent", "app_password");
values.put("expires", token.isNull("expires") ? null : Instant.parse(token.getString("expires")));
values.put("scope", tokens.scopeSummary(identifier));
rows.add(new UserPreferenceRow().withKey(identifier).withLabel(token.optString("description", identifier)).withValues(values));
}
return (new UserPreferenceValues().withLists(Map.of(AccessTokenPreferences.LIST, rows)));
}
/*******************************************************************************
** Scalar and row edits are unavailable; lifecycle actions own every mutation.
*******************************************************************************/
@Override
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
{
throw (new QBadRequestException("Use the access token actions to make changes."));
}
/*******************************************************************************
** Use the same live guards, broker and audit as record actions. The delivery
** channel is server-selected and never accepted as a client input field.
*******************************************************************************/
@Override
public UserPreferenceActionResult runAction(UserPreferenceRequestContext context, UserPreferenceActionRequest request) throws QException
{
AdminAction action = switch(request.getActionName())
{
case "create" -> AdminAction.CREATE_TOKEN;
case "rotate" -> AdminAction.ROTATE_TOKEN;
case "extend" -> AdminAction.EXTEND_TOKEN;
case "revoke" -> AdminAction.REVOKE_TOKEN;
default -> throw new QBadRequestException("This action isn't available.");
};
AdminRequest change = AdminRequest.of(action, request.getRowKey() == null ? "" : request.getRowKey(), "Preferences: " + request.getActionName());
for(String field : List.of("description", "expiresInDays", "readOnly", "apps", "tables"))
{
if(request.getValues().containsKey(field))
{
change.with(field, request.getValues().get(field));
}
}
change.with("deliveryChannel", "preferences");
AuthentikAdmin.AdminResult result = admin(context).execute(change);
if(!result.ok())
{
throw (new QUserFacingException(result.message()));
}
UserPreferenceActionResult response = new UserPreferenceActionResult().withMessage(result.message());
OneTimeKey key = result.takeSecret();
if(key != null)
{
response.withFocusRowKey(key.label()).withSecrets(List.of(new OneTimeSecret().withLabel("Access token")
.withValue(key.value()).withMasked(key.masked()).withUsageText(key.usageText())));
}
result.delivered();
return (response);
}
}
@@ -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;
@@ -155,22 +155,6 @@ public class AuthentikGuard
/*******************************************************************************
** Self-service metadata reads need a signed-in person, not table permissions.
** The Preferences provider must additionally constrain every read to that owner.
*******************************************************************************/
public void checkTokenPreferencesRead() throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
requireUser(session);
if(session instanceof QSystemUserSession || !config.hasFeature(AuthentikFeature.READ) || !config.hasFeature(AuthentikFeature.TOKENS))
{
throw (new QPermissionDeniedException("Access token preferences are not available."));
}
}
/*******************************************************************************
** Whether the current session may see and filter sensitive fields.
*******************************************************************************/
@@ -45,7 +45,9 @@ 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.
** API. The metadata hook is absent from the pinned core RC.5 release.
** STAND-IN(core=QRun-IO/qqq#1136, removeBy=4.1.0-RC.6)
** Replace the synthetic feature catalog with metadata registration when upgrading.
*******************************************************************************/
class PolicyFeaturePermissionsTest
{
@@ -1,436 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.preferences;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.admin.AdminPlan;
import com.kingsrook.qbits.authentik.admin.AdminRequest;
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction;
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesKeyValueStoreMetaDataProvider;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionResult;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionScope;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionResult;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.Logger;
import org.apache.logging.log4j.core.appender.AbstractAppender;
import org.apache.logging.log4j.core.config.Property;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Exercise the real Preferences dispatcher against FakeAuthentik and local tables.
*******************************************************************************/
class AccessTokenPreferencesTest
{
private AdminTestSupport t;
private final UserPreferencesAction actions = new UserPreferencesAction();
private final List<String> logs = new ArrayList<>();
private AbstractAppender appender;
/*******************************************************************************
** No table permissions or process delivery adapter: core owns the response.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
t = AdminTestSupport.create();
t.config.withTokenBrokerClientId("test-broker").withTokenBrokerClientSecret("test-broker-secret")
.withSubjectTokenSource(session -> "subject:3").withAppClientId("test-app")
.withAppPublicUrl("https://app.example.test").withAuthentikPublicUrl("https://auth.example.test");
t.authentik.users.get(3).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=90");
t.loginAs("sam@example.test");
new UserPreferencesKeyValueStoreMetaDataProvider().defineAll(t.qInstance, AdminTestSupport.LOCAL, null);
TokenKeyDelivery.set(null);
appender = new AbstractAppender("preferences-token-capture", null, null, false, Property.EMPTY_ARRAY)
{
/*******************************************************************************
** Retain messages and throwable text for the secret-leak assertion.
*******************************************************************************/
@Override
public void append(LogEvent event)
{
logs.add(event.getMessage().getFormattedMessage());
if(event.getThrownProxy() != null)
{
logs.add(event.getThrownProxy().getExtendedStackTraceAsString());
}
}
};
appender.start();
((Logger) LogManager.getRootLogger()).addAppender(appender);
}
/*******************************************************************************
** Reset global fixture state after every test.
*******************************************************************************/
@AfterEach
void tearDown()
{
((Logger) LogManager.getRootLogger()).removeAppender(appender);
appender.stop();
TokenKeyDelivery.set(null);
FixtureActionUtil.reset();
}
/*******************************************************************************
** Registration is independent of Identity app access; all cells are read-only.
*******************************************************************************/
@Test
void metadataAndFeatureGate() throws QException
{
UserPreferenceSectionMetaData section = actions.getSection("access-tokens").getSection();
assertThat(section.getLabel()).isEqualTo("Access tokens");
assertThat(section.getIcon().getName()).isEqualTo("key");
assertThat(section.getDescription()).isEqualTo("Personal access tokens for scripts and integrations. Keys are shown once.");
assertThat(section.getOrder()).isEqualTo(30);
assertThat(section.getShowInUserMenu()).isTrue();
assertThat(section.getAppName()).isNull();
assertThat(section.getPermissionName()).isNull();
assertThat(section.getFields()).isEmpty();
assertThat(section.getLists()).hasSize(1);
assertThat(section.getLists().get(0).getRowLabel()).isEqualTo("Token");
assertThat(section.getLists().get(0).getFields()).extracting(QFieldMetaData::getName)
.containsExactly("description", "identifier", "intent", "expires", "scope");
assertThat(section.getLists().get(0).getFields()).allMatch(field -> !field.getIsEditable());
assertThat(section.getLists().get(0).getFields().get(3).getType()).isEqualTo(QFieldType.DATE_TIME);
UserPreferenceActionMetaData create = section.getActions().get(0);
assertThat(create.getScope()).isEqualTo(UserPreferenceActionScope.SECTION);
assertThat(create.getListName()).isNull();
assertThat(create.getInputFields()).extracting(QFieldMetaData::getName).containsExactly("description", "expiresInDays");
assertThat(create.getInputFields()).allMatch(QFieldMetaData::getIsRequired);
assertThat(create.getInputFields().get(1).getDefaultValue()).isEqualTo(30);
assertThat(section.getActions().get(1).getConfirmationText()).isEqualTo("Rotate this token? The old key stops working now.");
assertThat(section.getActions().get(1).getIsDestructive()).isTrue();
assertThat(section.getActions().get(3).getConfirmationText()).isEqualTo("Revoke this token? Anything using it stops working now.");
assertThat(section.getActions().get(3).getIsDestructive()).isTrue();
assertThat(actions.listSections()).hasSize(1);
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ));
assertThat(actions.listSections()).isEmpty();
assertThatThrownBy(() -> actions.getSection("access-tokens")).isInstanceOf(QNotFoundException.class);
}
/*******************************************************************************
** The service-account list request is owner/intent filtered even for managers.
*******************************************************************************/
@Test
void loadOnlyOwnAppPasswordsAndRejectForeignRows() throws QException
{
seed("mine", 3, "app_password");
seed("my-api", 3, "api");
seed("someone-else", 7, "app_password");
for(IdentityPermission[] permissions : List.of(new IdentityPermission[0], new IdentityPermission[] { IdentityPermission.TOKENS_MANAGE }))
{
t.loginAs("sam@example.test", permissions);
assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly("mine");
assertThatThrownBy(() -> rowAction("revoke", "someone-else", Map.of())).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> rowAction("rotate", "my-api", Map.of())).isInstanceOf(QBadRequestException.class);
}
assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/core/tokens/")))
.allSatisfy(r ->
{
assertThat(r.getURI().getQuery()).contains("user__username=sam", "intent=app_password");
assertThat(r.getFirstHeader("Authorization").getValue()).isEqualTo("Bearer " + t.config.getResolvedApiToken());
});
assertThat(t.changes()).isEmpty();
t.loginAs("dormant@example.test");
assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly("someone-else");
}
/*******************************************************************************
** Create/rotate deliver one key each; extend/revoke never do. Reloads are safe.
*******************************************************************************/
@Test
void lifecycleAndNoPersistedSecrets() throws QException
{
assertThat(TokenKeyDelivery.get().available()).isFalse();
UserPreferenceActionResult created = create(Map.of("description", "Build script", "expiresInDays", 30));
assertThat(created.getSecrets()).hasSize(1);
OneTimeSecret first = created.getSecrets().get(0);
String oldId = created.getFocusRowKey();
assertThat(first.getLabel()).isEqualTo("Access token");
assertThat(first.getMasked()).endsWith(first.getValue().substring(first.getValue().length() - 4));
assertThat(first.getUsageText()).contains("https://auth.example.test/application/o/token/", "username=sam", "test-app",
"https://app.example.test/qqq/v1/metaData", "Expires:");
assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly(oldId);
assertThat(rows().get(0).getValues()).containsEntry("description", "Build script").containsEntry("scope", "Owner permissions");
assertNoKey(first, created);
UserPreferenceActionResult rotated = rowAction("rotate", oldId, Map.of());
assertThat(rotated.getSecrets()).hasSize(1);
OneTimeSecret next = rotated.getSecrets().get(0);
String newId = rotated.getFocusRowKey();
assertThat(newId).isNotEqualTo(oldId);
assertThat(next.getValue()).isNotEqualTo(first.getValue());
assertThat(t.authentik.tokenKeys).doesNotContainKey(oldId).containsEntry(newId, next.getValue());
assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly(newId);
assertThat(rows().get(0).getValues().get("description")).isEqualTo("Build script");
assertNoKey(next, rotated);
UserPreferenceActionResult extended = rowAction("extend", newId, Map.of("expiresInDays", 60));
assertThat(extended.getSecrets()).isEmpty();
assertThat((Instant) rows().get(0).getValues().get("expires")).isAfter(Instant.now().plusSeconds(59 * 86400));
assertThat(t.authentik.tokens.get("sam").get(0).getInt("user")).isEqualTo(3);
assertThat(t.authentik.tokens.get("sam").get(0).getString("intent")).isEqualTo("app_password");
assertNoKey(next, extended);
UserPreferenceActionResult revoked = rowAction("revoke", newId, Map.of());
assertThat(revoked.getSecrets()).isEmpty();
assertThat(rows()).isEmpty();
assertThat(t.authentik.tokenKeys).doesNotContainKey(newId);
assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().contains("/core/tokens/")
&& (!"GET".equals(r.getMethod()) || r.getURI().getPath().endsWith("/view_key/"))))
.allSatisfy(r -> assertThat(r.getFirstHeader("Authorization").getValue()).isEqualTo("Bearer user-bearer:3"));
assertNoKey(first, revoked);
assertNoKey(next, revoked);
assertThat(new QueryAction().execute(new QueryInput().withTableName("user_preference")).getRecords()).isEmpty();
List<QRecord> audits = new QueryAction().execute(new QueryInput().withTableName("authentik_admin_action")).getRecords();
assertThat(audits).extracting(r -> r.getValueString("reason"))
.containsExactly("Preferences: create", "Preferences: rotate", "Preferences: extend", "Preferences: revoke");
assertThat(audits).allSatisfy(r ->
{
assertThat(r.getValueString("operator")).isEqualTo("sam@example.test");
assertThat(r.getValueString("status")).isEqualTo("SUCCEEDED");
});
}
/*******************************************************************************
** Scope inputs are conditional and their metadata survives rotation.
*******************************************************************************/
@Test
void scopesAreOptIn() throws QException
{
assertThatThrownBy(() -> create(Map.of("description", "Scoped", "expiresInDays", 30, "readOnly", true)))
.isInstanceOf(QBadRequestException.class);
t.config.withTokenScopesEnabled(true);
UserPreferencesMetaData.of(t.qInstance).getSections().clear();
AccessTokenPreferences.register(t.qInstance, t.config);
assertThat(actions.getSection("access-tokens").getSection().getActions().get(0).getInputFields()).extracting(QFieldMetaData::getName)
.containsExactly("description", "expiresInDays", "readOnly", "apps", "tables");
UserPreferenceActionResult created = create(Map.of("description", "Scoped", "expiresInDays", 30, "readOnly", true,
"apps", "content", "tables", "post"));
assertThat(rows().get(0).getValues().get("scope")).isEqualTo("readOnly: true; apps: content; tables: post");
UserPreferenceActionResult rotated = rowAction("rotate", created.getFocusRowKey(), Map.of());
assertThat(new TokenScopeStore(t.config).find(rotated.getFocusRowKey()).getValueBoolean("readOnly")).isTrue();
assertThat(new TokenScopeStore(t.config).find(created.getFocusRowKey())).isNull();
assertNoKey(created.getSecrets().get(0), created);
assertNoKey(rotated.getSecrets().get(0), rotated);
}
/*******************************************************************************
** Domain refusals remain audited, before any token mutation or broker exchange.
*******************************************************************************/
@Test
void protectedUserAndMissingBrokerRefused() throws QException
{
t.authentik.groupByName("identity-admins").getJSONArray("users").put(3);
assertThatThrownBy(() -> create(Map.of("description", "Refused", "expiresInDays", 30)))
.isInstanceOf(QUserFacingException.class).hasMessageContaining("Protected");
t.authentik.groupByName("identity-admins").getJSONArray("users").remove(2);
t.config.withTokenBrokerClientId("").withTokenBrokerClientSecret("");
assertThatThrownBy(() -> create(Map.of("description", "Refused", "expiresInDays", 30)))
.isInstanceOf(QUserFacingException.class).hasMessageContaining("token broker");
assertThat(t.changes()).isEmpty();
assertThat(t.authentik.tokenKeys).isEmpty();
assertThat(FixtureActionUtil.REQUESTS).noneMatch(r -> r.getURI().getPath().endsWith("/application/o/token/"));
assertThat(new QueryAction().execute(new QueryInput().withTableName("authentik_admin_action")).getRecords())
.hasSize(2).allMatch(r -> "REFUSED".equals(r.getValueString("status")));
}
/*******************************************************************************
** Core rejects arbitrary values and row edits; omitted required inputs reach
** the existing domain validation rather than silently acquiring defaults.
*******************************************************************************/
@Test
void validationAndReadOnlyRows() throws QException
{
seed("mine", 3, "app_password");
assertThatThrownBy(() -> create(Map.of("description", "Missing expiry"))).hasMessageContaining("Expiry is required");
assertThatThrownBy(() -> create(Map.of("expiresInDays", 30))).hasMessageContaining("description is required");
assertThatThrownBy(() -> rowAction("extend", "mine", Map.of())).hasMessageContaining("Expiry is required");
assertThatThrownBy(() -> create(Map.of("description", "Too long", "expiresInDays", 91))).hasMessageContaining("between 1 and 90");
assertThatThrownBy(() -> create(Map.of("description", "Injected", "expiresInDays", 30, "deliveryChannel", "preferences")))
.isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> actions.patchSection("access-tokens", Map.of("lists", Map.of("tokens",
List.of(Map.of("key", "mine", "values", Map.of("description", "Changed")))))))
.isInstanceOf(QBadRequestException.class).hasMessageContaining("can't be changed");
assertThatThrownBy(() -> actions.patchSection("access-tokens", Map.of()))
.isInstanceOf(QBadRequestException.class).hasMessageContaining("Use the access token actions");
assertThat(t.changes()).isEmpty();
}
/*******************************************************************************
** Even a manager cannot use Preferences after the target moves to a person
** or service account, or changes intent, after the core row check.
*******************************************************************************/
@Test
void managerCannotCrossOwnershipOrIntentBoundary() throws QException
{
t.loginAs("sam@example.test", IdentityPermission.TOKENS_MANAGE);
JSONObject token = seed("mine", 3, "app_password");
for(int owner : List.of(7, 6))
{
token.put("user", 3);
AdminRequest request = AdminRequest.of(AdminAction.REVOKE_TOKEN, "mine", "Preferences: revoke").with("deliveryChannel", "preferences");
AdminPlan plan = t.admin().plan(request);
assertThat(plan.isRunnable()).isTrue();
token.put("user", owner);
assertThat(t.admin().execute(plan, null).ok()).isFalse();
}
token.put("user", 3).put("intent", "api");
assertThat(t.admin().execute(AdminRequest.of(AdminAction.REVOKE_TOKEN, "mine", "Preferences: revoke")
.with("deliveryChannel", "preferences")).ok()).isFalse();
assertThat(t.changes()).isEmpty();
}
/*******************************************************************************
** A faulty upstream owner filter must never disclose another user's metadata.
*******************************************************************************/
@Test
void refusesUnexpectedOwnerAndUnresolvedIdentity() throws QException
{
JSONObject foreign = seed("foreign", 7, "app_password");
var original = FixtureActionUtil.handler;
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/core/tokens/")
? FixtureActionUtil.response(200, new JSONObject().put("pagination", new JSONObject().put("total_pages", 1))
.put("results", new JSONArray().put(foreign)).toString()) : original.apply(request);
assertThatThrownBy(this::rows).isInstanceOf(QUserFacingException.class).hasMessageContaining("outside your personal token list");
t.loginAs("unknown@example.test");
assertThatThrownBy(this::rows).isInstanceOf(QUserFacingException.class);
}
/*******************************************************************************
** Drive a section-level action exactly as the UI API does.
*******************************************************************************/
private UserPreferenceActionResult create(Map<String, ?> values) throws QException
{
return (actions.runAction("access-tokens", "create", Map.of("values", values)));
}
/*******************************************************************************
** Drive a row action through core's loaded-row ownership check.
*******************************************************************************/
private UserPreferenceActionResult rowAction(String action, String key, Map<String, ?> values) throws QException
{
return (actions.runAction("access-tokens", action, Map.of("rowKey", key, "values", values)));
}
/*******************************************************************************
** Reload instead of reusing a previous action's response.
*******************************************************************************/
private List<UserPreferenceRow> rows() throws QException
{
return (actions.getSection("access-tokens").getValues().getLists().get("tokens"));
}
/*******************************************************************************
** Seed the upstream fake, never the local preference or token-scope store.
*******************************************************************************/
private JSONObject seed(String id, int owner, String intent)
{
return (t.authentik.seedToken(id, owner, intent, Instant.now().plusSeconds(86400).toString()));
}
/*******************************************************************************
** Inspect every local table (including audit and KV preferences), reloaded
** values, response section, events and captured messages/throwables.
*******************************************************************************/
private void assertNoKey(OneTimeSecret secret, UserPreferenceActionResult result) throws QException
{
String key = secret.getValue();
assertThat(secret.toString()).doesNotContain(key);
assertThat(result.getMessage()).doesNotContain(key);
assertThat(JsonUtils.toJson(result.getSection())).doesNotContain(key);
UserPreferenceSectionResult reload = actions.getSection("access-tokens");
assertThat(JsonUtils.toJson(reload)).doesNotContain(key);
assertThat(reload.getValues().getValues()).isEmpty();
assertThat(String.join("\n", logs)).doesNotContain(key);
assertThat(t.authentik.events.toString()).doesNotContain(key);
for(QTableMetaData table : t.qInstance.getTables().values())
{
if(AdminTestSupport.LOCAL.equals(table.getBackendName()))
{
assertThat(JsonUtils.toJson(new QueryAction().execute(new QueryInput().withTableName(table.getName())).getRecords()))
.as(table.getName()).doesNotContain(key);
}
}
}
}