WIP: feat(tokens): Preferences section "Access tokens" (phase 6d, draft until #1162 RC) #25
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -245,7 +245,8 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.2</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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+174
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
+436
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user