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); + } + } + } +}