From d91b81499f7783a1bf807836ff2c93fb82b68e86 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 22:10:53 -0500 Subject: [PATCH] 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 --- docs/STATUS.md | 11 + pom.xml | 3 +- .../authentik/AuthentikQBitProducer.java | 5 + .../authentik/admin/AuthentikTokens.java | 24 +- .../preferences/AccessTokenPreferences.java | 106 +++++ .../AccessTokenPreferencesProvider.java | 174 +++++++ .../authentik/security/AuthentikGuard.java | 16 + .../AccessTokenPreferencesTest.java | 436 ++++++++++++++++++ 8 files changed, 771 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferences.java create mode 100644 src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesProvider.java create mode 100644 src/test/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesTest.java diff --git a/docs/STATUS.md b/docs/STATUS.md index becda1a..c79bc67 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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) diff --git a/pom.xml b/pom.xml index 3702407..a63a1a1 100644 --- a/pom.xml +++ b/pom.xml @@ -245,7 +245,8 @@ true - 4.1.0-RC.2 + + 4.1.0-PREFS-SNAPSHOT diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java index 841755d..794272b 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java @@ -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 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. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferences.java b/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferences.java new file mode 100644 index 0000000..64a1120 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferences.java @@ -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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesProvider.java b/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesProvider.java new file mode 100644 index 0000000..e9d0269 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesProvider.java @@ -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 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 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 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java index bca5b01..c26b01c 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java @@ -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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesTest.java b/src/test/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesTest.java new file mode 100644 index 0000000..aa63401 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesTest.java @@ -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 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 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 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 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 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); + } + } + } +} -- 2.54.0