Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 d91b81499f feat(tokens): Preferences section "Access tokens" (DESIGN.md section 13, phase 6d)
Self-service through the core Preferences contract (QRun-IO/qqq#1162):
section access-tokens, shown in the user menu, lists the signed-in
user's own app passwords (metadata only) with Create token (section),
Rotate, Extend and Revoke (row) actions. Each action runs through
AuthentikAdmin, so the guardrails, the broker exchange as the user and
the audit apply exactly as for the record actions. The key is returned
once as a core OneTimeSecret with usage text and is never stored.

STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7): builds against
4.1.0-PREFS-SNAPSHOT until an RC ships #1162 (draft until then).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:10:53 -05:00
17 changed files with 788 additions and 54 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 (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
- Never filter client-side; reject what Authentik cannot evaluate.
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
@@ -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.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.
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
## Release
-4
View File
@@ -12,10 +12,6 @@ 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 (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
- Never filter client-side; reject what Authentik cannot evaluate.
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
@@ -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.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.
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
## Release
+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. 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.
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.
### 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.0-RC.5 (released BOM, no core SNAPSHOT). Java 21. |
| QQQ version | QQQ 4.1 (see `pom.xml` for the exact RC). Java 21. |
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. |
| 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,32 +1,5 @@
# 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.** 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.
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.
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 retains explicit stripping for direct backend calls; core also enforces `FieldSecurityLock` in the RC.5 baseline (#1062):
- Field-level protection does not use `FieldSecurityLock` (#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 direct backend caller bypasses core actions | Guard in the backend util, plus ArchUnit |
| A non-HTTP caller bypasses permissions (#1061) | 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 |
| Direct backend calls outside core enforcement | Guard in the backend util, ArchUnit, field stripping |
| QQQ core gaps (#1061, #1062) | 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 `TableFeaturePermissionsMetaData` hook (absent from the pinned RC.5 release); 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 unreleased `TableFeaturePermissionsMetaData` hook; 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,6 +12,17 @@ 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)
+3 -2
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.5 ahead of the parent 4.0.0 BOM, as in -->
<!-- qqq-bom-pom 4.1.0-RC.2 ahead of the parent 4.0.0 BOM, as in -->
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
@@ -245,7 +245,8 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
<!-- 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>
</properties>
<dependencyManagement>
<dependencies>
@@ -25,6 +25,7 @@ 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;
@@ -185,6 +186,10 @@ 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,6 +130,10 @@ 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."));
@@ -169,7 +173,7 @@ public class AuthentikTokens
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
{
if(!TokenKeyDelivery.get().available())
if(!isPreferences(request) && !TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
@@ -402,7 +406,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)
&& !TokenKeyDelivery.get().available())
&& (!isPreferences(request) || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT) && !TokenKeyDelivery.get().available())
{
throw (new QUserFacingException(DELIVERY_UNAVAILABLE));
}
@@ -416,6 +420,10 @@ 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."));
@@ -606,7 +614,7 @@ public class AuthentikTokens
/*******************************************************************************
** Scope summary contains metadata only.
*******************************************************************************/
private String scopeSummary(String identifier) throws QException
public String scopeSummary(String identifier) throws QException
{
QRecord row = scopes.find(identifier);
List<String> parts = new ArrayList<>();
@@ -622,6 +630,16 @@ 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
** for direct renderer calls as well as calls through core RenderWidgetAction.
** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
*******************************************************************************/
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
{
@@ -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.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));
}
}
@@ -0,0 +1,174 @@
/*
* 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). Core actions now
** enforce permissions (QRun-IO/qqq#1061); direct AuthentikActionUtil calls and
** Authentik-specific rules still need this guard:
** The qbit's own enforcement point (DESIGN.md section 5.3). QQQ core actions
** do not enforce permissions for non-HTTP callers (QRun-IO/qqq#1061), so every
** call into AuthentikActionUtil is checked here first:
** - reads need the table's authentik.* permission (system sessions may read,
** for the mirror sync);
** - sensitive fields need authentik.audit.read;
@@ -155,6 +155,22 @@ 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,9 +45,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Table features use generic permission suffixes from core's existing catalog
** 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.
** API. No test or production code depends on the unreleased metadata hook.
*******************************************************************************/
class PolicyFeaturePermissionsTest
{
@@ -0,0 +1,436 @@
/*
* 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);
}
}
}
}