From 7f8e742120e57aec93ffda303c67c0dbf3b8cc69 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 22:26:53 -0500 Subject: [PATCH 1/9] feat(tokens): scopes, last used, reminders and admin views (DESIGN.md section 13, phase 6c) - Scopes: Create Token takes read only, apps and tables when tokenScopesEnabled (validated against the instance and the operator's own access). AuthentikTokenScopes reads the signed qqq_token claim in both mappers (and AuthentikTokenScopes.apply for hosts with their own customizer): no row = unscoped, deleted token = no permissions, any verification failure = no permissions. STAND-IN for core QCredentialScope (qqq#1168, RC.7): permissions are intersected; the core scope is attached by reflection when present (empty dimension = null = unrestricted). - Last used from Authentik login events (method token, 90 days, cached 5 minutes); createdAt and scopeSummary from the scope row. - Daily expiry reminders (7 days, 1 day) through the optional Alerts producer, deduplicated on the scope row; guard-bypass alert. - Admin widget on the app home (authentik.tokens.manage): by owner, by age, unused for tokenUnusedDays, deactivated owners; bulk Revoke Token on the list. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +- README.md | 14 + docs/ACCESS-TOKENS.md | 20 +- .../qbits/authentik/AuthentikQBitConfig.java | 56 +++ .../qbits/authentik/AuthentikRuntime.java | 38 ++ .../authentik/admin/AuthentikTokenAlerts.java | 111 +++++ .../authentik/admin/AuthentikTokens.java | 50 ++- .../authentik/admin/TokenScopeStore.java | 52 ++- .../authentik/app/AuthentikTokenWidget.java | 180 ++++++++ .../authentik/client/AuthentikActionUtil.java | 4 + .../authentik/client/AuthentikApiSurface.java | 2 +- .../authentik/client/AuthentikTables.java | 4 + .../client/AuthentikTokenActivity.java | 112 +++++ .../AuthentikAppMetaDataProducer.java | 2 +- ...AuthentikTokenReminderProcessProducer.java | 72 +++ .../AuthentikTokenWidgetProducer.java | 70 +++ .../AuthentikGroupPermissionMapper.java | 1 + .../policy/AuthentikPolicyClaimMapper.java | 6 + .../processes/AuthentikProcesses.java | 4 +- .../processes/AuthentikTokenReminders.java | 105 +++++ .../security/AuthentikTokenScopes.java | 417 ++++++++++++++++++ .../qbits/authentik/SchemaDriftTest.java | 14 + .../qbits/authentik/admin/FakeAuthentik.java | 17 +- .../authentik/admin/TokenManagementTest.java | 1 + .../authentik/admin/TokenOperationsTest.java | 293 ++++++++++++ .../authentik/admin/TokenScopesTest.java | 275 ++++++++++++ .../qbits/authentik/app/AuthentikAppTest.java | 2 +- .../processes/AuthentikProcessesTest.java | 9 +- 28 files changed, 1920 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java create mode 100644 src/main/java/com/kingsrook/qbits/authentik/app/AuthentikTokenWidget.java create mode 100644 src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenActivity.java create mode 100644 src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenReminderProcessProducer.java create mode 100644 src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenWidgetProducer.java create mode 100644 src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikTokenReminders.java create mode 100644 src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java create mode 100644 src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java create mode 100644 src/test/java/com/kingsrook/qbits/authentik/admin/TokenScopesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f797ad..4b62dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to this project are documented here. Format: [Keep a Changel - Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group. - `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events. +### Added (access tokens 6c) +- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153. +- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked. +- Daily expiry reminders with persistent per-window deduplication and optional Alerts delivery; high-severity guard-bypass alerts. +- A standard Access tokens TABLE widget with owner counts, age buckets and cleanup links, plus the existing record/list bulk revoke action. + ### Added (access tokens, DESIGN.md section 13) - Default-on `TOKENS` with personal create/rotate/revoke/extend, service-account creation and rotation, protected-owner guardrails and optional two-person approval. - Action-scoped OAuth2 token exchange, response-only one-time key delivery (fail closed on older QQQ), generated usage instructions and non-secret token scope records. @@ -62,7 +68,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ### Changed -- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md). +- **Permissions renamed `identity.*` → `authentik.*`** (same suffixes; owner naming rule, 2026-10-12; spec #1065 amended). Legacy names are reported as unknown and never granted. The old names are kept as `@Deprecated` `IdentityPermission.LEGACY_PREFIX`/`LEGACY_WILDCARD`/`getLegacyShortName()`. Update the `qqq.io/permissions` group attributes before deploying; see [UPGRADE.md](UPGRADE.md). - **Table names are lowercase snake_case with the `authentik_` prefix** (owner naming rule, 2026-10-12): `authentik_user`, `authentik_group`, `authentik_group_member`, `authentik_session`, `authentik_event` (were `authentikUser`, ...). New `AuthentikQBitConfig.applyTablePrefix` and `toSnakeCase`; apps, widgets, the backend and processes keep camelCase names (`authentikAdmin`, `authentikApi`). The old names are kept as `@Deprecated` `AuthentikTables.LEGACY_*_TABLE_NAME` constants. See [UPGRADE.md](UPGRADE.md). ### Documentation diff --git a/README.md b/README.md index 6f678f6..b85f4f1 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,20 @@ Apache 2.0 Access-token metadata is readable with `authentik.audit.read` or `authentik.tokens.manage`. Personal actions have no additional process permission; ownership is checked live. Managing service-account tokens needs `authentik.tokens.manage`, and creating a service account also needs `authentik.users.manage`. Configure `tokenBrokerClientId`, `tokenBrokerClientSecret` (environment references supported), `appClientId`, `appPublicUrl` and `authentikPublicUrl` for exchange and usage instructions. Scope fields remain unavailable until `tokenScopesEnabled=true`. +| Access-token setting | Default | Purpose | +|---|---|---| +| `tokenScopesEnabled` | `false` | Offer read-only, apps and tables fields after the signed claim mapping is installed. Names must exist and the operator must currently have read/hasAccess permission. | +| `tokenScopeClaim` | `qqq_token` | Signed JWT claim containing the Authentik token identifier. Claims are enforced even when offering new scopes is disabled. | +| `tokenUnusedDays` | `60` | Inactivity threshold for cleanup suggestions. With no login found, a known creation date and a threshold at most 90 days are required. | +| `tokenReminderSchedulerName` | First registered scheduler | Run `authentikTokenReminders` daily. Without a scheduler the hidden process is available for a host to invoke. Register the scheduler before producing the qbit. | + +The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list. + +Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The temporary session-permission intersection is marked `STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7)`; a reflection bridge also attaches `QCredentialScope` when available. It does not add permissions. + +The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`. + + Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added. Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`). diff --git a/docs/ACCESS-TOKENS.md b/docs/ACCESS-TOKENS.md index 14f328f..7cfd1e0 100644 --- a/docs/ACCESS-TOKENS.md +++ b/docs/ACCESS-TOKENS.md @@ -1,10 +1,10 @@ # Using access tokens -An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6). +An access token lets a script, a CLI or another service call a QQQ app as you, without a browser sign-in. In apps that sign in with Authentik, access tokens are Authentik **app passwords**, managed from inside the app by `qbit-authentik`. The design is in [DESIGN.md §13](DESIGN.md#13-access-tokens-qqqs-personal-access-tokens-revision-6). ## Create a token -1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**. +1. Open **Preferences → Access tokens**. It's also in the user menu under **Access tokens**. 2. Choose **Create token** and fill in: - a description, for example "nightly export script"; - an expiry. It's required: at most 90 days for people, and 365 for service accounts; @@ -36,7 +36,7 @@ curl -s -H "Authorization: Bearer $JWT" "https://app.example.com/qqq/v1/metaData ## Manage your tokens -From **Preferences → Access tokens**: +From **Preferences → Access tokens**: | Action | What happens | |---|---| @@ -44,14 +44,20 @@ From **Preferences → Access tokens**: | **Extend** | Moves the expiry. The limit counts from today. | | **Revoke** | Deletes the token in Authentik. Anything using it stops working at once. | -You get a reminder in the notification bell, and by email, 7 days and 1 day before a token expires. +When the host has configured a scheduler and the optional Alerts delivery adapter, the daily job sends owner reminders in the 7-day and 1-day expiry windows. Delivery channels (bell/email) follow the host’s Alerts configuration. Each window sends once; if first seen within one day, only the urgent window sends. Extending a token through the qbit resets its reminder windows. Without delivery support, the job logs and leaves the reminder pending. ## For administrators -People with `authentik.tokens.manage` see **all** tokens in the Identity app (**Tokens**). From there they can: -- filter by person, intent and expiry, and sort by age and last use; +People with `authentik.tokens.manage` see the **Access tokens** TABLE widget on the Identity home page, with a link to **all access tokens**. From there they can: +- filter by person, intent, expiring status or token identifier; view computed creation time, scope and last use (these computed columns cannot be filtered or sorted by the Authentik API); - revoke tokens one at a time or in bulk, for example the tokens the home page lists as unused for 60 days, or as belonging to deactivated people; -- create a **service account** for an integration (**Users → Create Service Account**). The account gets one app password, shown once, and the groups you choose. Rotate and extend it from its token record. +- create a **service account** for an integration (**Users → Create Service Account**). The account gets one app password, shown once, and the groups you choose. Rotate and extend it from its token record. + +The widget shows the top ten owners plus a total, age buckets (`<30d`, `30–90d`, `90–365d`, `>365d`, and unknown), unused tokens and tokens with deactivated owners. Cleanup rows include owner and token links; age buckets link to the explicit token identifiers. Creation dates are known only for tokens created by the qbit. External tokens can receive reminders without acquiring a scope or an invented creation date. + +Last use comes from app-password login events over the last 90 days, cached for five minutes. The pinned Authentik schema has no date-range parameter for this endpoint, so the qbit pages newest-first to the window boundary. API-intent tokens show **not tracked** and never appear as unused. A token with no recorded login is suggested as unused only if its known creation date is older than `tokenUnusedDays` and that threshold is at most 90 days. + +Create Token offers read-only, apps and tables scopes only when `tokenScopesEnabled` is enabled after installing the signed claim mapping. Enter one metadata name per line. Review checks existence and the operator’s current read/hasAccess permission and shows the scope. The claims mappers intersect permissions after resolving them; scopes never grant access the owner lacks. A deleted token receives no permissions even if its local scope row remains, subject to the 60-second verification cache. Administrators can never create, rotate or extend a token for **another person**. Each person manages their own. diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java index c482fa1..33e5382 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java @@ -61,6 +61,8 @@ public class AuthentikQBitConfig implements QBitConfig private List tokenProtectedGroups = new ArrayList<>(List.of("authentik Admins", "identity-admins")); private Boolean tokenTwoPersonApproval = false; private Boolean tokenScopesEnabled = false; + private String tokenScopeClaim = "qqq_token"; + private String tokenReminderSchedulerName; private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}"; private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}"; private String tokenEndpointUrl = null; @@ -130,6 +132,10 @@ public class AuthentikQBitConfig implements QBitConfig { errors.add("tokenMaxDaysServiceAccount must be at least 1"); } + if(tokenScopeClaim == null || tokenScopeClaim.isBlank()) + { + errors.add("tokenScopeClaim must not be blank"); + } if(tokenUnusedDays == null || tokenUnusedDays < 1) { errors.add("tokenUnusedDays must be at least 1"); @@ -1871,4 +1877,54 @@ public class AuthentikQBitConfig implements QBitConfig return (this); } + /******************************************************************************* + ** Signed JWT claim identifying the Authentik token. + *******************************************************************************/ + public String getTokenScopeClaim() + { + return (tokenScopeClaim); + } + + /******************************************************************************* + ** Set the host's signed token claim name. + *******************************************************************************/ + public void setTokenScopeClaim(String value) + { + tokenScopeClaim = value; + } + + /******************************************************************************* + ** Fluent signed token claim name. + *******************************************************************************/ + public AuthentikQBitConfig withTokenScopeClaim(String value) + { + tokenScopeClaim = value; + return (this); + } + + /******************************************************************************* + ** Optional scheduler; otherwise use the instance's first scheduler. + *******************************************************************************/ + public String getTokenReminderSchedulerName() + { + return (tokenReminderSchedulerName); + } + + /******************************************************************************* + ** Select a scheduler when the host defines several. + *******************************************************************************/ + public void setTokenReminderSchedulerName(String value) + { + tokenReminderSchedulerName = value; + } + + /******************************************************************************* + ** Fluent scheduler selection. + *******************************************************************************/ + public AuthentikQBitConfig withTokenReminderSchedulerName(String value) + { + tokenReminderSchedulerName = value; + return (this); + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java index 6ac9413..e2cfad6 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java @@ -46,10 +46,13 @@ public class AuthentikRuntime private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper(); private final Memoization responseCache; private final Memoization groupGraphCache; + private final Memoization tokenScopeCache = new Memoization<>(Duration.ofSeconds(60)); + private final Memoization> tokenActivityCache = new Memoization<>(Duration.ofMinutes(5)); private volatile java.util.Set permissionCatalog; private volatile PolicyCatalog policyCatalog; private volatile AccessComparison.State mapperState; private volatile boolean mapperInvoked; + private com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink tokenAlerts = new com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts()::send; private final Map specsByTableName = new ConcurrentHashMap<>(); @@ -130,6 +133,8 @@ public class AuthentikRuntime public void invalidateCache() { responseCache.clear(); + tokenScopeCache.clear(); + tokenActivityCache.clear(); groupGraphCache.clear(); } @@ -280,4 +285,37 @@ public class AuthentikRuntime mapperState = null; } } + /******************************************************************************* + ** Token claims are cached independently of the general list TTL. + *******************************************************************************/ + public Memoization getTokenScopeCache() + { + return (tokenScopeCache); + } + + /******************************************************************************* + ** Login-event aggregation is shared by every token record in this runtime. + *******************************************************************************/ + public Memoization> getTokenActivityCache() + { + return (tokenActivityCache); + } + + /******************************************************************************* + ** Optional Alerts delivery, replaceable by a host or a capturing test sink. + *******************************************************************************/ + public com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink getTokenAlerts() + { + return (tokenAlerts); + } + + /******************************************************************************* + ** Install a sink for this runtime only. + *******************************************************************************/ + public AuthentikRuntime withTokenAlerts(com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts.Sink sink) + { + tokenAlerts = java.util.Objects.requireNonNull(sink); + return (this); + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java new file mode 100644 index 0000000..5531efb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java @@ -0,0 +1,111 @@ +/* + * 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.admin; + + +import java.lang.reflect.Method; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; + + +/******************************************************************************* + ** Optional Alerts bridge. The qbit compiles on RC.2 without either Alerts API. + ** No credentials enter this interface. Hosts and tests can supply a sink. + *******************************************************************************/ +public class AuthentikTokenAlerts +{ + private static final QLogger LOG = QLogger.getLogger(AuthentikTokenAlerts.class); + + /******************************************************************************* + ** Safe notification envelope. A null recipient denotes the admin audience. + *******************************************************************************/ + public record Notice(String key, String severity, String recipient, String message) + { + } + + + + /******************************************************************************* + ** Return true only when delivery was accepted, so absence never dedupes. + *******************************************************************************/ + @FunctionalInterface + public interface Sink + { + /******************************************************************************* + ** Accept a safe notification, returning false when delivery is unavailable. + *******************************************************************************/ + boolean send(Notice notice); + } + + + + /******************************************************************************* + ** Constructor for the reflection adapter. + *******************************************************************************/ + public AuthentikTokenAlerts() + { + } + + + + /******************************************************************************* + ** RC.5 Alerts raises guard incidents. The installed qbit's notification + ** API supplies owner-directed reminders until core ships notifications. + *******************************************************************************/ + public boolean send(Notice notice) + { + try + { + Class alerts = Class.forName("com.kingsrook.qqq.backend.core.alerts.QAlerts"); + if("high".equals(notice.severity())) + { + Class signalType = Class.forName("com.kingsrook.qqq.backend.core.alerts.AlertSignal"); + Object signal = signalType.getConstructor(String.class).newInstance("authentik.token.guard-bypass"); + signalType.getMethod("withDedupKey", String.class).invoke(signal, notice.key()); + signalType.getMethod("withTitle", String.class).invoke(signal, "Access token guard bypass"); + signalType.getMethod("withSummary", String.class).invoke(signal, notice.message()); + Class severity = Class.forName("com.kingsrook.qqq.backend.core.alerts.AlertSeverity"); + signalType.getMethod("withSeverity", severity).invoke(signal, severity.getField("CRITICAL").get(null)); + alerts.getMethod("raise", signalType).invoke(null, signal); + return (true); + } + Class runtimeType = Class.forName("com.kingsrook.qbits.alerts.AlertsRuntime"); + Object runtime = runtimeType.getMethod("find", QInstance.class).invoke(null, QContext.getQInstance()); + if(runtime != null) + { + Object sink = runtimeType.getMethod("sink").invoke(runtime); + Class signalType = Class.forName("com.kingsrook.qbits.alerts.spi.NotificationSignal"); + Class audienceType = Class.forName("com.kingsrook.qbits.alerts.spi.NotificationAudience"); + Object signal = signalType.getConstructor(String.class).newInstance("Access token expiry reminder"); + signalType.getMethod("withBody", String.class).invoke(signal, notice.message()); + signalType.getMethod("withSourceName", String.class).invoke(signal, "authentik"); + signalType.getMethod("withSourceKey", String.class).invoke(signal, notice.key()); + signalType.getMethod("withAudience", audienceType).invoke(signal, audienceType.getMethod("user", String.class).invoke(null, notice.recipient())); + Method notify = sink.getClass().getMethod("notify", signalType); + notify.invoke(sink, signal); + return (true); + } + } + catch(ReflectiveOperationException | LinkageError e) + { + // Optional dependency absent or incompatible. Never log the payload. + } + LOG.info("Access-token notification delivery is unavailable; notification skipped."); + return (false); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokens.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokens.java index 13d3e26..8c1dc14 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokens.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokens.java @@ -167,6 +167,13 @@ public class AuthentikTokens return (plan.refuse("Scopes are not enabled on this app yet.")); } } + if(action == AdminAction.CREATE_TOKEN && Boolean.TRUE.equals(config.getTokenScopesEnabled())) + { + com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.validate(request); + QRecord scope = new QRecord(); + request.params().forEach(scope::setValue); + plan.withChange(null, TokenScopeStore.summary(scope)); + } if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN) { if(!TokenKeyDelivery.get().available()) @@ -412,7 +419,7 @@ public class AuthentikTokens } JSONObject before = request.action() == AdminAction.CREATE_TOKEN ? null : token(request.targetKey()); JSONObject owner = owner(before == null ? admin.operator().pk() : ownerPk(before)); - checkProtected(owner); + checkProtectedAfterRead(owner); 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); @@ -433,6 +440,7 @@ public class AuthentikTokens util.writeAs(bearer, auth, "PATCH", path(request.targetKey()), new JSONObject().put("intent", before.getString("intent")) .put("user", owner.getInt("pk")).put("expires", expiry(request, service).toString()).put("expiring", true)); raceGuard(before, token(request.targetKey())); + scopes.resetReminders(request.targetKey()); return (success("Token expiry updated.")); case ROTATE_TOKEN: if(!service) @@ -462,13 +470,15 @@ public class AuthentikTokens /******************************************************************************* ** Guard owner and intent after every PATCH or set_key, with a safe warning. *******************************************************************************/ - private static void raceGuard(JSONObject before, JSONObject after) throws QUserFacingException + private void raceGuard(JSONObject before, JSONObject after) throws QException { if(ownerPk(before) != ownerPk(after) || !before.getString("intent").equals(after.getString("intent"))) { + alertGuardBypass(); LOG.warn(TOKEN_MOVED); throw (new QUserFacingException(TOKEN_MOVED)); } + checkProtectedAfterRead(owner(ownerPk(after))); } @@ -487,6 +497,7 @@ public class AuthentikTokens try { JSONObject created = util.getAs(bearer, auth, path(identifier)); + checkProtectedAfterRead(owner(ownerPk(created))); if(ownerPk(created) != owner.getInt("pk") || !"app_password".equals(created.optString("intent"))) { throw (new QUserFacingException(TOKEN_MOVED)); @@ -569,8 +580,9 @@ public class AuthentikTokens util.write(auth, "POST", "/core/groups/" + AuthentikAdmin.safeKey(group.pk()) + "/add_user/", new JSONObject().put("pk", pk)); } JSONObject owner = owner(pk); - checkProtected(owner); + checkProtectedAfterRead(owner); JSONObject after = token(identifier); + checkProtectedAfterRead(owner(ownerPk(after))); scopes.create(identifier, owner, request, admin.operator().email()); return (success("Service account created. " + String.join(" ", plan.getNotes())) .withSecret(oneTime(identifier, created.getString("token"), owner, checkExpiry(after, true), "Owner permissions"), @@ -656,4 +668,36 @@ public class AuthentikTokens String slug = username.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-]+", "-"); return ("qqq-pat-" + slug + "-" + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC).format(Instant.now()) + "-" + suffix); } + /******************************************************************************* + ** A protected owner observed after Review or a write is a guard incident. + *******************************************************************************/ + private void checkProtectedAfterRead(JSONObject owner) throws QException + { + try + { + checkProtected(owner); + } + catch(QUserFacingException e) + { + alertGuardBypass(); + throw (e); + } + } + + /******************************************************************************* + ** Safe incident payload: no response bodies, credentials or owner details. + *******************************************************************************/ + private void alertGuardBypass() + { + try + { + util.getRuntime().getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null, + "An access token changed owner or intent, or a protected owner was observed after Review. Investigate in Authentik.")); + } + catch(RuntimeException e) + { + LOG.warn("Access-token guard alert delivery failed."); + } + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/TokenScopeStore.java b/src/main/java/com/kingsrook/qbits/authentik/admin/TokenScopeStore.java index 6b877d8..60e79c8 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/TokenScopeStore.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/TokenScopeStore.java @@ -65,7 +65,7 @@ public class TokenScopeStore /******************************************************************************* - ** Fetch the unique scope row. + ** Fetch the unique scope row as a trusted SYSTEM read (QueryInput default). *******************************************************************************/ public QRecord find(String identifier) throws QException { @@ -139,4 +139,54 @@ public class TokenScopeStore throw (new QException("Token scope metadata could not be saved.")); } } + /******************************************************************************* + ** Human-readable, non-secret scope for lists and Review. + *******************************************************************************/ + public static String summary(QRecord row) + { + java.util.ArrayList parts = new java.util.ArrayList<>(); + for(String field : SCOPE_FIELDS) + { + if(row != null && row.getValue(field) != null && !row.getValueString(field).isBlank()) + { + parts.add(field + ": " + row.getValueString(field)); + } + } + return (parts.isEmpty() ? "Owner permissions" : String.join("; ", parts)); + } + + /******************************************************************************* + ** Persist dedupe metadata. External tokens get no invented creation date or + ** restrictions; their token row remains unscoped. + *******************************************************************************/ + public void reminded(String identifier, JSONObject owner, String field, Instant at) throws QException + { + QRecord row = find(identifier); + if(row == null) + { + row = new QRecord().withValue("tokenIdentifier", identifier).withValue("ownerPk", owner.getInt("pk")) + .withValue("ownerUsername", owner.getString("username")).withValue(field, at); + check(new InsertAction().execute(new InsertInput().withTableName(table()).withRecords(List.of(row))).getRecords()); + } + else + { + row.setValue(field, at); + check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords()); + } + } + + /******************************************************************************* + ** A new expiry starts a new set of reminder windows. + *******************************************************************************/ + public void resetReminders(String identifier) throws QException + { + QRecord row = find(identifier); + if(row != null) + { + row.setValue("lastReminder7d", null); + row.setValue("lastReminder1d", null); + check(new UpdateAction().execute(new UpdateInput().withTableName(table()).withRecords(List.of(row))).getRecords()); + } + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikTokenWidget.java b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikTokenWidget.java new file mode 100644 index 0000000..90ce714 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikTokenWidget.java @@ -0,0 +1,180 @@ +/* + * 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.app; + + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.admin.TokenScopeStore; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity; +import com.kingsrook.qbits.authentik.security.AuthentikGuard; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** Standard TABLE widget: inventory, age and actionable cleanup suggestions. + ** Computed fields never become API filters; links use owner or token keys. + *******************************************************************************/ +public class AuthentikTokenWidget extends AbstractWidgetRenderer +{ + /******************************************************************************* + ** Check the permission here as well as in metadata (non-HTTP callers). + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + String backend = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get("backendName")); + return (new RenderWidgetOutput(data(AuthentikActionUtil.forBackend(backend), Instant.now()))); + } + + + + /******************************************************************************* + ** Report complete inventory and explicit unknown ages. An API token's + ** activity is untracked and never qualifies as unused. + *******************************************************************************/ + public static TableData data(AuthentikActionUtil util, Instant now) throws QException + { + if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE)) + { + throw (new QPermissionDeniedException("Access token widgets require authentik.tokens.manage.")); + } + AuthentikQBitConfig config = util.getRuntime().getConfig(); + List tokens = util.getAllPages("/core/tokens/", Integer.MAX_VALUE); + Map activity = AuthentikTokenActivity.lastUsed(util); + Map> owners = new LinkedHashMap<>(); + Map> ages = new LinkedHashMap<>(); + for(String bucket : List.of("<30d", "30-90d", "90-365d", ">365d", "unknown")) + { + ages.put(bucket, new ArrayList<>()); + } + Map users = new LinkedHashMap<>(); + List> cleanup = new ArrayList<>(); + TokenScopeStore scopes = new TokenScopeStore(config); + for(JSONObject token : tokens) + { + String id = token.getString("identifier"); + JSONObject owner = token.getJSONObject("user_obj"); + String username = owner.getString("username"); + owners.computeIfAbsent(username, k -> new ArrayList<>()).add(id); + QRecord scope = scopes.find(id); + Instant created = scope == null ? null : scope.getValueInstant("createdAt"); + long days = created == null ? -1 : ChronoUnit.DAYS.between(created, now); + ages.get(days < 0 ? "unknown" : days < 30 ? "<30d" : days < 90 ? "30-90d" : days <= 365 ? "90-365d" : ">365d").add(id); + int pk = owner.getInt("pk"); + if(!users.containsKey(pk)) + { + users.put(pk, util.getApiJsonOrNull("/core/users/" + pk + "/")); + } + JSONObject live = users.get(pk); + String links = link(list(config, "username", List.of(username)), username) + " / " + link(list(config, "identifier", List.of(id)), id); + if(live != null && !live.optBoolean("is_active", true)) + { + cleanup.add(row("Owner deactivated", 1, links)); + } + Instant last = activity.get(id); + Instant cutoff = now.minus(config.getTokenUnusedDays(), ChronoUnit.DAYS); + // Without a creation time or an event we cannot prove age. A 90-day + // event window cannot prove inactivity beyond 90 days either. + boolean unused = last != null ? last.isBefore(cutoff) + : config.getTokenUnusedDays() <= 90 && created != null && created.isBefore(cutoff); + if("app_password".equals(token.optString("intent")) && unused) + { + cleanup.add(row("Unused for " + config.getTokenUnusedDays() + " days", 1, links)); + } + } + List> rows = new ArrayList<>(); + rows.add(row("Total", tokens.size(), link(list(config, null, List.of()), "All access tokens"))); + owners.entrySet().stream().sorted(Comparator.>>comparingInt(e -> e.getValue().size()).reversed().thenComparing(Map.Entry::getKey)) + .limit(10).forEach(e -> rows.add(row("Owner: " + e.getKey(), e.getValue().size(), link(list(config, "username", List.of(e.getKey())), "Tokens")))); + ages.forEach((bucket, ids) -> rows.add(row("Age: " + bucket, ids.size(), ids.isEmpty() ? "" : link(list(config, "identifier", ids), "Tokens")))); + rows.addAll(cleanup); + return (new TableData("Access tokens", List.of( + new TableData.Column("default", "Category", "category", "35%", "left"), + new TableData.Column("default", "Count", "count", "10%", "right"), + new TableData.Column("html", "Access tokens", "tokens", "55%", "left")), rows) + .withRowsPerPage(25).withLinkText("All access tokens / bulk revoke").withLinkURL(list(config, null, List.of()))); + } + + + + /******************************************************************************* + ** A standard table row; only the link cell contains escaped anchor markup. + *******************************************************************************/ + private static Map row(String category, int count, String tokens) + { + return (Map.of("category", category, "count", count, "tokens", tokens)); + } + + + + /******************************************************************************* + ** Native list filter format shared by Material and Next. Primary-key IN is + ** supported by the API adapter as individual GETs; no computed filtering. + *******************************************************************************/ + private static String list(AuthentikQBitConfig config, String field, List values) + { + String url = (config.getLinkPrefix() == null ? "" : config.getLinkPrefix()) + "/" + config.applyTablePrefix("token"); + if(field != null) + { + JSONObject criteria = new JSONObject().put("fieldName", field).put("operator", values.size() == 1 ? "EQUALS" : "IN").put("values", values); + String filter = new JSONObject().put("booleanOperator", "AND").put("criteria", new JSONArray().put(criteria)).toString(); + url += "?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8); + } + return (url); + } + + + + /******************************************************************************* + ** Escape both the link target and text; descriptions are never HTML. + *******************************************************************************/ + private static String link(String url, String text) + { + return ("" + escape(text) + ""); + } + + + + /******************************************************************************* + ** Minimal HTML escaping for a standard table link cell. + *******************************************************************************/ + private static String escape(String value) + { + return (value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java index 7b8d133..020cf6c 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java @@ -803,6 +803,10 @@ public class AuthentikActionUtil extends BaseAPIActionUtil record.setDisplayValue("isSuperuserInherited", UNAVAILABLE); } } + else if(AuthentikTables.TOKEN.equals(spec.getName())) + { + AuthentikTokenActivity.enrich(this, record); + } return (record); } catch(JSONException | IllegalArgumentException e) diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java index 80d5553..759b6fb 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java @@ -74,7 +74,7 @@ public final class AuthentikApiSurface rs.add(new Operation("get", "/core/users/{key}/", Set.of())); rs.add(new Operation("get", "/managed/blueprints/", Set.of("page", "page_size"))); rs.add(new Operation("get", "/core/tokens/", Set.of("user__username", "intent", "page", "page_size"))); - rs.add(new Operation("get", "/events/events/", Set.of("username", "ordering", "page", "page_size"))); + rs.add(new Operation("get", "/events/events/", Set.of("username", "action", "ordering", "page", "page_size"))); return (rs); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java index d6514e6..8312849 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java @@ -419,6 +419,10 @@ public final class AuthentikTables spec.withField(new AuthentikFieldSpec("ownerType", "Owner Type", QFieldType.STRING, at("user_obj", "type"))); spec.withField(new AuthentikFieldSpec("ownerName", "Owner", QFieldType.STRING, at("user_obj", "name"))); spec.withField(new AuthentikFieldSpec("managed", "Managed", QFieldType.STRING, string("managed"))); + spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, o -> null)); + spec.withField(new AuthentikFieldSpec("lastUsedNote", "Last Used Note", QFieldType.STRING, o -> null)); + spec.withField(new AuthentikFieldSpec("createdAt", "Created", QFieldType.DATE_TIME, o -> null)); + spec.withField(new AuthentikFieldSpec("scopeSummary", "Scope", QFieldType.STRING, o -> null)); return (spec); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenActivity.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenActivity.java new file mode 100644 index 0000000..09a5189 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenActivity.java @@ -0,0 +1,112 @@ +/* + * 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.client; + + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.Map; +import com.kingsrook.qbits.authentik.admin.TokenScopeStore; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** Non-secret local metadata and the most recent app-password login per token. + *******************************************************************************/ +public final class AuthentikTokenActivity +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AuthentikTokenActivity() + { + } + + + + /******************************************************************************* + ** The pinned schema has no created__gt. Walk descending pages until the + ** 90-day boundary instead of sending a silently ignored query parameter. + *******************************************************************************/ + public static Map lastUsed(AuthentikActionUtil util) throws QException + { + return (util.getRuntime().getTokenActivityCache().getResultThrowing("login", key -> + { + Map latest = new LinkedHashMap<>(); + Instant cutoff = Instant.now().minus(90, ChronoUnit.DAYS); + for(int page = 1; ; page++) + { + String path = "/events/events/?action=login&ordering=-created&page=" + page + "&page_size=100"; + util.getRuntime().getResponseCache().clearKey(util.getRuntime().getConfig().getApiRoot() + path); + JSONObject response = util.getApiJson(path); + JSONArray events = response.getJSONArray("results"); + for(int i = 0; i < events.length(); i++) + { + JSONObject event = events.getJSONObject(i); + Instant at = Instant.parse(event.getString("created")); + if(at.isBefore(cutoff)) + { + return (Map.copyOf(latest)); + } + JSONObject context = event.optJSONObject("context"); + if("login".equals(event.optString("action")) && context != null && "token".equals(context.optString("method"))) + { + JSONObject args = context.optJSONObject("method_args"); + String identifier = args == null ? "" : args.optString("identifier"); + if(!identifier.isBlank()) + { + latest.merge(identifier, at, (a, b) -> a.isAfter(b) ? a : b); + } + } + } + if(events.isEmpty() || page >= response.getJSONObject("pagination").getInt("total_pages")) + { + return (Map.copyOf(latest)); + } + } + }).orElseThrow()); + } + + + + /******************************************************************************* + ** Enrich only token records, never leaking token keys into the table. + *******************************************************************************/ + public static void enrich(AuthentikActionUtil util, QRecord record) throws QException + { + var config = util.getRuntime().getConfig(); + QRecord scope = QContext.getQInstance().getTable(config.applyTablePrefix("token_scope")) == null ? null + : new TokenScopeStore(config).find(record.getValueString("identifier")); + record.setValue("createdAt", scope == null ? null : scope.getValue("createdAt")); + record.setValue("scopeSummary", TokenScopeStore.summary(scope)); + if("api".equals(record.getValueString("intent"))) + { + record.setValue("lastUsedNote", "not tracked"); + record.setDisplayValue("lastUsed", "not tracked"); + } + else + { + record.setValue("lastUsed", lastUsed(util).get(record.getValueString("identifier"))); + record.setValue("lastUsedNote", record.getValue("lastUsed") == null ? "No login in the last 90 days" : null); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java index e367c09..7b03b70 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java @@ -74,7 +74,7 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer< addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION); List widgets = new ArrayList<>(); - for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME), + for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikTokenWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME), config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.COMPARE))) { if(produced.get(QWidgetMetaData.class, widgetName) != null) diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenReminderProcessProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenReminderProcessProducer.java new file mode 100644 index 0000000..fafa28f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenReminderProcessProducer.java @@ -0,0 +1,72 @@ +/* + * 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.metadata; + + +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +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.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData; + + +/******************************************************************************* + ** Daily background task, hidden from navigation; no scheduler dependency. + *******************************************************************************/ +public class AuthentikTokenReminderProcessProducer extends QBitComponentMetaDataProducer +{ + /******************************************************************************* + ** Reminders require local dedupe storage and the TOKENS feature. + *******************************************************************************/ + @Override + public boolean isEnabled() + { + return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS)); + } + + + + /******************************************************************************* + ** Use the configured scheduler or the instance's first registered scheduler. + *******************************************************************************/ + @Override + public QProcessMetaData produce(QInstance instance) + { + AuthentikQBitConfig config = getQBitConfig(); + QProcessMetaData process = new QProcessMetaData().withName(config.applyPrefix("tokenReminders")) + .withLabel("Access token reminders").withIsHidden(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules()) + .withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(AuthentikTokenReminders.class)) + .withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData() + .withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("backendName", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING) + .withDefaultValue(config.getApiBackendName())))); + String scheduler = config.getTokenReminderSchedulerName(); + if(scheduler == null && !instance.getSchedulers().isEmpty()) + { + scheduler = instance.getSchedulers().keySet().iterator().next(); + } + if(scheduler != null) + { + process.withSchedule(new QScheduleMetaData().withSchedulerName(scheduler).withRepeatSeconds(86400).withInitialDelaySeconds(3600)); + } + return (process); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenWidgetProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenWidgetProducer.java new file mode 100644 index 0000000..9ef0c04 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenWidgetProducer.java @@ -0,0 +1,70 @@ +/* + * 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.metadata; + + +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +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.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer; + + +/******************************************************************************* + ** Access token administration uses a standard TABLE widget (guardrail G5). + *******************************************************************************/ +public class AuthentikTokenWidgetProducer extends QBitComponentMetaDataProducer +{ + public static final String NAME = "accessTokens"; + + /******************************************************************************* + ** Token inventory needs the local metadata table. + *******************************************************************************/ + @Override + public boolean isEnabled() + { + return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.TOKENS)); + } + + + + /******************************************************************************* + ** Register before the app, alongside the existing home widgets. + *******************************************************************************/ + @Override + public int getSortOrder() + { + return (500); + } + + + + /******************************************************************************* + ** Permission is checked again in the renderer. + *******************************************************************************/ + @Override + public QWidgetMetaData produce(QInstance instance) + { + AuthentikQBitConfig config = getQBitConfig(); + return (new QWidgetMetaData().withName(config.applyPrefix(NAME)).withLabel("Access tokens").withType("TABLE") + .withGridColumns(12).withIsCard(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules()) + .withCodeReference(new QCodeReference(AuthentikTokenWidget.class)).withDefaultValue("backendName", config.getApiBackendName())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java index 58a9fd8..a561b9b 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java @@ -170,6 +170,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust permissions.addAll(qSession.getPermissions()); } qSession.setPermissions(permissions); + com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java b/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java index ced2ef9..54ff7bd 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java @@ -92,6 +92,9 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz if(claims == null) { LOG.info("No policy claim in the token; no policy permissions added", logPair("claim", format.claim())); + com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any); + com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, + context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY))); return; } @@ -101,6 +104,9 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz permissions.addAll(qSession.getPermissions()); } qSession.setPermissions(permissions); + com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any); + com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, + context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY))); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java index 24f7073..10935cd 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java +++ b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java @@ -147,8 +147,8 @@ public final class AuthentikProcesses if(Boolean.TRUE.equals(config.getTokenScopesEnabled())) { createTokenFields.add(new QFieldMetaData("readOnly", QFieldType.BOOLEAN)); - createTokenFields.add(new QFieldMetaData("apps", QFieldType.TEXT)); - createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT)); + createTokenFields.add(new QFieldMetaData("apps", QFieldType.TEXT).withLabel("Apps (names, one per line)")); + createTokenFields.add(new QFieldMetaData("tables", QFieldType.TEXT).withLabel("Tables (names, one per line)")); } createTokenFields.add(reason); defs.add(new Def("createToken", "Create Token", AdminAction.CREATE_TOKEN, Kind.CREATE, AuthentikTables.TOKEN, "key", false, diff --git a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikTokenReminders.java b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikTokenReminders.java new file mode 100644 index 0000000..fc040f4 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikTokenReminders.java @@ -0,0 +1,105 @@ +/* + * 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.processes; + + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import com.kingsrook.qbits.authentik.AuthentikRuntime; +import com.kingsrook.qbits.authentik.admin.AuthentikTokenAlerts; +import com.kingsrook.qbits.authentik.admin.TokenScopeStore; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.security.AuthentikGuard; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONObject; + + +/******************************************************************************* + ** Daily app-password expiry reminders, deduplicated in local token metadata. + *******************************************************************************/ +public class AuthentikTokenReminders implements BackendStep +{ + /******************************************************************************* + ** The scheduler invokes the same guarded path as manual process execution. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + run(AuthentikActionUtil.forBackend(input.getValueString("backendName")), Instant.now()); + } + + + + /******************************************************************************* + ** Two non-overlapping windows avoid two reminders when first seen on day 1. + ** Synchronization prevents overlapping local scheduled runs from duplicating. + *******************************************************************************/ + public static void run(AuthentikActionUtil util, Instant now) throws QException + { + if(!(QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession) + && !AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE)) + { + throw (new QPermissionDeniedException("Token reminders require authentik.tokens.manage.")); + } + AuthentikRuntime runtime = util.getRuntime(); + synchronized(runtime) + { + TokenScopeStore store = new TokenScopeStore(runtime.getConfig()); + for(JSONObject token : util.getAllPages("/core/tokens/?intent=app_password", Integer.MAX_VALUE)) + { + if(!"app_password".equals(token.optString("intent")) || !token.optBoolean("expiring") || token.isNull("expires")) + { + continue; + } + Instant expires = Instant.parse(token.getString("expires")); + if(!expires.isAfter(now) || expires.isAfter(now.plus(7, ChronoUnit.DAYS))) + { + continue; + } + String field = expires.isAfter(now.plus(1, ChronoUnit.DAYS)) ? "lastReminder7d" : "lastReminder1d"; + String id = token.getString("identifier"); + QRecord scope = store.find(id); + Instant remindedAt = scope == null ? null : scope.getValueInstant(field); + Instant windowStart = expires.minus(field.equals("lastReminder7d") ? 7 : 1, ChronoUnit.DAYS); + if(remindedAt != null && !remindedAt.isBefore(windowStart)) + { + continue; + } + int pk = token.has("user") ? token.getInt("user") : token.getJSONObject("user_obj").getInt("pk"); + JSONObject owner = util.getApiJsonOrNull("/core/users/" + pk + "/"); + if(owner == null || owner.optString("email").isBlank()) + { + continue; + } + String message = "Your access token " + token.optString("description") + " (" + id + ") expires on " + expires + + ". Extend or rotate it in Preferences > Access tokens."; + if(runtime.getTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_expiry:" + id + ":" + field + ":" + expires, + "info", owner.getString("email"), message))) + { + store.reminded(id, owner, field, now); + } + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java new file mode 100644 index 0000000..e7d59a5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java @@ -0,0 +1,417 @@ +/* + * 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.security; + + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import com.kingsrook.qbits.authentik.AuthentikRuntime; +import com.kingsrook.qbits.authentik.admin.AdminRequest; +import com.kingsrook.qbits.authentik.admin.TokenScopeStore; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.policy.PolicyCatalog; +import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil; +import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData; +import org.json.JSONObject; + + +/******************************************************************************* + ** Signed token claims can only narrow the permissions computed by a host. + *******************************************************************************/ +public final class AuthentikTokenScopes +{ + private static final QLogger LOG = QLogger.getLogger(AuthentikTokenScopes.class); + + /******************************************************************************* + ** Distinguish unrestricted credentials from revoked credentials. + *******************************************************************************/ + public enum Mode + { + SCOPED, + UNSCOPED, + DENY_ALL + } + + + + /******************************************************************************* + ** Immutable, non-secret credential restrictions, including advanced scopes. + *******************************************************************************/ + public record TokenScope(String identifier, Mode mode, boolean readOnly, Set apps, Set tables, + Set processes, Set reports, Set widgets, Set operationIds) + { + /******************************************************************************* + ** A missing row is unrestricted; a missing token is denied. + *******************************************************************************/ + public static TokenScope marker(String identifier, Mode mode) + { + return (new TokenScope(identifier, mode, false, Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), Set.of())); + } + + /******************************************************************************* + ** Decode a local metadata row without retaining the mutable record. + *******************************************************************************/ + public static TokenScope from(String identifier, QRecord row) + { + return (new TokenScope(identifier, Mode.SCOPED, Boolean.TRUE.equals(row.getValueBoolean("readOnly")), + names(row.getValueString("apps")), names(row.getValueString("tables")), names(row.getValueString("processes")), + names(row.getValueString("reports")), names(row.getValueString("widgets")), names(row.getValueString("operationIds")))); + } + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AuthentikTokenScopes() + { + } + + + + /******************************************************************************* + ** One name per line; immutable sets keep cached scopes safe to share. + *******************************************************************************/ + public static Set names(String value) + { + if(value == null || value.isBlank()) + { + return (Set.of()); + } + return (Set.copyOf(Arrays.stream(value.split("\\R")).map(String::trim).filter(s -> !s.isEmpty()).toList())); + } + + + + /******************************************************************************* + ** Resolve only qbit backends present in this instance. Cache by identifier + ** within each runtime, so tenants never share a scope or token existence. + *******************************************************************************/ + public static Optional forClaims(QInstance instance, JSONObject claims) throws QException + { + if(claims == null) + { + return (Optional.empty()); + } + if(instance != null) + { + for(var backend : instance.getBackends().values()) + { + AuthentikRuntime runtime; + try + { + runtime = AuthentikRuntime.forBackend(backend.getName()); + } + catch(IllegalStateException ignored) + { + continue; + } + String claim = runtime.getConfig().getTokenScopeClaim(); + if(!claims.has(claim)) + { + continue; + } + Object value = claims.opt(claim); + if(!(value instanceof String identifier) || !identifier.matches("[A-Za-z0-9._-]+")) + { + return (Optional.of(TokenScope.marker("invalid", Mode.DENY_ALL))); + } + return (runtime.getTokenScopeCache().getResultThrowing(identifier, id -> + { + APIBackendMetaData api = (APIBackendMetaData) backend; + AuthentikActionUtil util = (AuthentikActionUtil) QCodeLoader.getAdHoc(BaseAPIActionUtil.class, api.getActionUtil()); + util.setBackendMetaData(api); + // Check existence even when a local row remains after an external revoke. + // Do not let a longer general response TTL extend the 60-second + // revocation window of this dedicated cache. + runtime.getResponseCache().clearKey(runtime.getConfig().getApiRoot() + "/core/tokens/" + id + "/"); + if(util.getApiJsonOrNull("/core/tokens/" + id + "/") == null) + { + return (TokenScope.marker(id, Mode.DENY_ALL)); + } + if(instance.getTable(runtime.getConfig().applyTablePrefix("token_scope")) == null) + { + throw (new QException("Token scope metadata is unavailable.")); + } + QRecord row = new TokenScopeStore(runtime.getConfig()).find(id); + return (row == null ? TokenScope.marker(id, Mode.UNSCOPED) : TokenScope.from(id, row)); + })); + } + } + // A default token claim must never silently lose enforcement if the qbit is missing. + return (claims.has("qqq_token") ? Optional.of(TokenScope.marker(claims.optString("qqq_token"), Mode.DENY_ALL)) : Optional.empty()); + } + + + + /******************************************************************************* + ** Hosts with their own customizer call this AFTER computing permissions. + *******************************************************************************/ + public static void apply(QSession session, JSONObject claims) + { + apply(QContext.getQInstance(), session, claims); + } + + + + /******************************************************************************* + ** Explicit instance variant for authentication, before QContext is installed. + *******************************************************************************/ + public static void apply(QInstance instance, QSession session, JSONObject claims) + { + try + { + forClaims(instance, claims).ifPresent(scope -> narrow(instance, session, scope)); + } + catch(Exception e) + { + session.setPermissions(new TreeSet<>()); + LOG.warn("Token scope could not be verified; denying credential permissions."); + } + } + + + + /******************************************************************************* + ** STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7): intersect, never grant. + *******************************************************************************/ + public static void narrow(QSession session, TokenScope scope) + { + narrow(QContext.getQInstance(), session, scope); + } + + + + /******************************************************************************* + ** Keep only permitted bases and read operations, then attach core's scope + ** when available. Management hasAccess permissions are not read operations. + *******************************************************************************/ + private static void narrow(QInstance instance, QSession session, TokenScope scope) + { + Set permissions = new TreeSet<>(session.getPermissions() == null ? Set.of() : session.getPermissions()); + if(scope.mode() == Mode.DENY_ALL) + { + permissions.clear(); + } + else if(scope.mode() == Mode.SCOPED) + { + if(scope.readOnly()) + { + permissions.removeIf(p -> p.startsWith("authentik.") || !(p.endsWith(".read") || p.endsWith(".hasAccess"))); + } + if(!scope.apps().isEmpty() || !scope.tables().isEmpty() || !scope.processes().isEmpty() || !scope.reports().isEmpty() || !scope.widgets().isEmpty()) + { + Set bases = new LinkedHashSet<>(); + if(instance != null) + { + for(String name : scope.tables()) + { + if(instance.getTable(name) != null) + { + bases.add(base(instance, instance.getTable(name), name)); + } + } + // Policy-format names exclude dots; host permission aliases need + // direct metadata traversal as well as the policy catalog. + for(String appName : scope.apps()) + { + var app = instance.getApp(appName); + if(app == null) + { + continue; + } + bases.add(base(instance, app, appName)); + Set appTables = new LinkedHashSet<>(); + if(app.getChildren() != null) + { + for(var child : app.getChildren()) + { + if(child instanceof MetaDataWithPermissionRules secured) + { + bases.add(base(instance, secured, child.getName())); + } + if(instance.getTable(child.getName()) != null) + { + appTables.add(child.getName()); + } + } + } + for(var process : instance.getProcesses().values()) + { + if(process.getTableName() != null && appTables.contains(process.getTableName())) + { + bases.add(base(instance, process, process.getName())); + } + } + if(app.getWidgets() != null) + { + for(String name : app.getWidgets()) + { + if(instance.getWidget(name) != null) + { + bases.add(base(instance, instance.getWidget(name), name)); + } + } + } + } + for(PolicyCatalog.Group group : PolicyCatalog.of(instance).getGroups()) + { + for(PolicyCatalog.Resource resource : group.resources()) + { + if((group.app() != null && scope.apps().contains(group.app())) || scope.processes().contains(resource.name()) + || scope.reports().contains(resource.name()) || scope.widgets().contains(resource.name())) + { + bases.add(resource.name()); + } + } + } + } + permissions.removeIf(p -> p.lastIndexOf('.') < 0 || !bases.contains(p.substring(0, p.lastIndexOf('.')))); + } + } + session.setPermissions(permissions); + session.setValue("qqq.credentialScope", scope.identifier()); + attachCoreScope(session, scope); + } + + + + /******************************************************************************* + ** Use the permission base, which may differ from a table's metadata name. + *******************************************************************************/ + private static String base(QInstance instance, MetaDataWithPermissionRules object, String name) + { + String base = PermissionsHelper.getEffectivePermissionRules(object, instance).getPermissionBaseName(); + return (base == null || base.isBlank() ? name : base); + } + + + + /******************************************************************************* + ** Validate against current operator permissions at Review and execution. + *******************************************************************************/ + public static void validate(AdminRequest request) throws QUserFacingException + { + QInstance instance = QContext.getQInstance(); + for(String field : List.of("apps", "tables")) + { + for(String name : names(request.str(field))) + { + MetaDataWithPermissionRules object = field.equals("apps") ? instance.getApp(name) : instance.getTable(name); + if(object == null) + { + throw (new QUserFacingException("Unknown " + field + " scope: " + name)); + } + String base = base(instance, object, name); + QSession session = QContext.getQSession(); + if(!session.hasPermission(base + ".hasAccess") && !session.hasPermission(base + ".read")) + { + throw (new QUserFacingException("You do not currently have read access to scope: " + name)); + } + } + } + for(String advanced : List.of("processes", "reports", "widgets", "operationIds")) + { + if(!request.str(advanced).isBlank()) + { + throw (new QUserFacingException("Advanced token scopes are not offered by Create Token.")); + } + } + } + + + + /******************************************************************************* + ** Optional core bridge. An incompatible installed contract fails closed; + ** an absent class uses the permission intersection above. + *******************************************************************************/ + /******************************************************************************* + ** An empty scope dimension means "not restricted" (null for core). + *******************************************************************************/ + private static Set nullIfEmpty(Set set) + { + return (set == null || set.isEmpty() ? null : set); + } + + + + /******************************************************************************* + ** STAND-IN(core=QRun-IO/qqq#1168, removeBy=4.1.0-RC.7): until the qbit's QQQ + ** version has QCredentialScope (qqq#1153), narrow() above enforces the scope + ** by intersecting the session's permissions; this attaches the core scope by + ** reflection when it exists. Replace both with a direct withCredentialScope. + *******************************************************************************/ + private static void attachCoreScope(QSession session, TokenScope scope) + { + try + { + Class type = Class.forName("com.kingsrook.qqq.backend.core.model.session.QCredentialScope"); + Object value = type.getConstructor().newInstance(); + /////////////////////////////////////////////////////////////////////// + // core treats a null set as unrestricted and an empty set as // + // "nothing": an unlisted dimension must therefore be passed as null // + /////////////////////////////////////////////////////////////////////// + Map fields = new java.util.LinkedHashMap<>(); + fields.put("CredentialId", scope.identifier()); + fields.put("ReadOnly", scope.readOnly()); + fields.put("Apps", nullIfEmpty(scope.apps())); + fields.put("Tables", nullIfEmpty(scope.tables())); + fields.put("Processes", nullIfEmpty(scope.processes())); + fields.put("Reports", nullIfEmpty(scope.reports())); + fields.put("Widgets", nullIfEmpty(scope.widgets())); + fields.put("OperationIds", nullIfEmpty(scope.operationIds())); + for(var field : fields.entrySet()) + { + Method setter = Arrays.stream(type.getMethods()).filter(m -> m.getName().equals("with" + field.getKey()) && m.getParameterCount() == 1) + .findFirst().orElseThrow(NoSuchMethodException::new); + Object arg = field.getValue(); + if(arg instanceof Set set && List.class.isAssignableFrom(setter.getParameterTypes()[0])) + { + arg = List.copyOf(set); + } + setter.invoke(value, arg); + } + session.getClass().getMethod("withCredentialScope", type).invoke(session, value); + } + catch(ClassNotFoundException ignored) + { + // RC.2 has no credential scope; the stand-in above is active. + } + catch(ReflectiveOperationException | IllegalArgumentException e) + { + session.setPermissions(new TreeSet<>()); + LOG.warn("The installed credential-scope contract is incompatible; denying credential permissions."); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/SchemaDriftTest.java b/src/test/java/com/kingsrook/qbits/authentik/SchemaDriftTest.java index 4c52e63..016c3ef 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/SchemaDriftTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/SchemaDriftTest.java @@ -174,4 +174,18 @@ public class SchemaDriftTest return (null); } + /******************************************************************************* + ** Login aggregation uses only parameters documented by the pinned schema. + *******************************************************************************/ + @Test + void tokenActivitySurfacePinsLoginOrderingWithoutUnsupportedDateFilter() + { + assertThat(AuthentikApiSurface.operations()).anySatisfy(operation -> + { + assertThat(operation.path()).isEqualTo("/events/events/"); + assertThat(operation.method()).isEqualTo("get"); + assertThat(operation.params()).contains("action", "ordering", "page", "page_size").doesNotContain("created__gt"); + }); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java b/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java index 44b733e..1e3d5d1 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java @@ -532,7 +532,22 @@ public class FakeAuthentik } if(path.equals("/events/events/")) { - return (page(loginEvents)); + List selected = new ArrayList<>(loginEvents); + String action = first(query, "action"); + if(action != null) + { + selected.removeIf(event -> !action.equals(event.optString("action"))); + } + if("-created".equals(first(query, "ordering"))) + { + selected.sort(java.util.Comparator.comparing((JSONObject event) -> java.time.Instant.parse(event.getString("created"))).reversed()); + } + int size = query.containsKey("page_size") ? Integer.parseInt(first(query, "page_size")) : 100; + int number = query.containsKey("page") ? Integer.parseInt(first(query, "page")) : 1; + int from = Math.min(selected.size(), (number - 1) * size); + int to = Math.min(selected.size(), from + size); + return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", selected.size()).put("current", number) + .put("total_pages", Math.max(1, (selected.size() + size - 1) / size))).put("results", new JSONArray(selected.subList(from, to))))); } if(path.equals("/managed/blueprints/") || path.equals("/core/applications/")) { diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenManagementTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenManagementTest.java index 5d37410..edc4a37 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenManagementTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenManagementTest.java @@ -135,6 +135,7 @@ class TokenManagementTest void createOwnAndRotateScopeThenRevoke() throws QException { t.config.withTokenScopesEnabled(true); + QContext.getQSession().withPermission("cms.publish.hasAccess"); AuthentikAdmin.AdminResult result = t.admin().execute(create().with("readOnly", true).with("tables", "post")); assertThat(result.ok()).as(result.message()).isTrue(); OneTimeKey key = result.takeSecret(); diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java new file mode 100644 index 0000000..04151e4 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java @@ -0,0 +1,293 @@ +/* + * 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.admin; + + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.authentik.AuthentikRuntime; +import com.kingsrook.qbits.authentik.app.AuthentikTokenWidget; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.client.AuthentikTables; +import com.kingsrook.qbits.authentik.client.AuthentikTokenActivity; +import com.kingsrook.qbits.authentik.client.FixtureActionUtil; +import com.kingsrook.qbits.authentik.metadata.AuthentikTokenReminderProcessProducer; +import com.kingsrook.qbits.authentik.processes.AuthentikTokenReminders; +import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import org.json.JSONObject; +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; + + +/******************************************************************************* + ** Event aggregation, scheduling, delivery dedupe and standard admin widgets. + *******************************************************************************/ +class TokenOperationsTest +{ + private AdminTestSupport t; + private AuthentikActionUtil util; + private final List notices = new ArrayList<>(); + private final Instant now = Instant.now(); + + /******************************************************************************* + ** Capture delivery without a dependency on Alerts or a real mail service. + *******************************************************************************/ + @BeforeEach + void setUp() throws QException + { + t = AdminTestSupport.create(); + util = AuthentikActionUtil.forBackend(t.config.getApiBackendName()); + util.getRuntime().withTokenAlerts(notice -> { notices.add(notice); return (true); }); + } + + + + /******************************************************************************* + ** Seed an expiring token for the chosen owner. + *******************************************************************************/ + private JSONObject token(String id, int owner, int days) + { + return (t.authentik.seedToken(id, owner, "app_password", now.plus(days, ChronoUnit.DAYS).toString())); + } + + + + /******************************************************************************* + ** A login event using the pinned context.method_args.identifier contract. + *******************************************************************************/ + private JSONObject event(String id, Instant at) + { + return (new JSONObject().put("action", "login").put("created", at.toString()) + .put("context", new JSONObject().put("method", "token").put("method_args", new JSONObject().put("identifier", id)))); + } + + + + /******************************************************************************* + ** Create non-secret bookkeeping with a known age for widget assertions. + *******************************************************************************/ + private void created(String id, int days) throws QException + { + TokenScopeStore store = new TokenScopeStore(t.config); + store.create(id, t.authentik.users.get(3), AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test"), "sam@example.test"); + QRecord row = store.find(id).withValue("createdAt", now.minus(days, ChronoUnit.DAYS)); + new UpdateAction().execute(new UpdateInput().withTableName("authentik_token_scope").withRecords(List.of(row))); + } + + + + /******************************************************************************* + ** Page, stop at 90 days, exclude non-token events, and reuse the 5-minute map. + *******************************************************************************/ + @Test + void eventPagingWindowAndCache() throws QException + { + for(int i = 0; i < 101; i++) + { + t.authentik.loginEvents.add(event("token-" + i, now.minus(i, ChronoUnit.HOURS))); + } + t.authentik.loginEvents.add(event("token-0", now.minus(20, ChronoUnit.DAYS))); + t.authentik.loginEvents.add(event("password", now).put("context", new JSONObject().put("method", "password"))); + t.authentik.loginEvents.add(event("wrong-action", now).put("action", "logout")); + t.authentik.loginEvents.add(event("old", now.minus(91, ChronoUnit.DAYS))); + Map used = AuthentikTokenActivity.lastUsed(util); + assertThat(used).hasSize(101).containsEntry("token-0", now).doesNotContainKeys("password", "wrong-action", "old"); + long requests = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count(); + assertThat(requests).isEqualTo(2); + t.authentik.loginEvents.add(event("later", now)); + assertThat(AuthentikTokenActivity.lastUsed(util)).isEqualTo(used); + assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/events/events/")).count()).isEqualTo(requests); + assertThat(util.getRuntime().getTokenActivityCache().getTimeout()).isEqualTo(java.time.Duration.ofMinutes(5)); + } + + + + /******************************************************************************* + ** API activity is explicitly not tracked, and local creation/scope is shown. + *******************************************************************************/ + @Test + void computedFields() throws QException + { + token("personal", 3, 5); + created("personal", 31); + t.authentik.loginEvents.add(event("personal", now)); + QRecord record = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), util.getApiJson("/core/tokens/personal/"), true); + assertThat(record.getValueInstant("lastUsed")).isEqualTo(now); + assertThat(record.getValueInstant("createdAt")).isEqualTo(now.minus(31, ChronoUnit.DAYS)); + assertThat(record.getValueString("scopeSummary")).isEqualTo("Owner permissions"); + JSONObject api = token("api", 3, 5).put("intent", "api"); + QRecord untracked = util.toRecord(AuthentikTables.get(AuthentikTables.TOKEN), api, true); + assertThat(untracked.getValue("lastUsed")).isNull(); + assertThat(untracked.getValueString("lastUsedNote")).isEqualTo("not tracked"); + } + + + + /******************************************************************************* + ** Each window sends once to the owner's email; a minimal external row does + ** not invent restrictions or a creation date. Extending resets the windows. + *******************************************************************************/ + @Test + void remindersDedupeAcrossBothWindows() throws QException + { + token("external", 3, 5); + token("tomorrow", 3, 1); + token("api", 3, 1).put("intent", "api"); + token("expired", 3, -1); + token("later", 3, 20); + token("no-email", 6, 2); + AuthentikTokenReminders.run(util, now); + AuthentikTokenReminders.run(util, now); + assertThat(notices).hasSize(2).allMatch(n -> "sam@example.test".equals(n.recipient())); + assertThat(notices.getFirst().message()).contains("Your access token Test access (external)", "Preferences > Access tokens."); + TokenScopeStore store = new TokenScopeStore(t.config); + QRecord row = store.find("external"); + assertThat(row.getValue("createdAt")).isNull(); + assertThat(row.getValue("readOnly")).isNull(); + assertThat(row.getValue("lastReminder7d")).isNotNull(); + assertThat(row.getValue("lastReminder1d")).isNull(); + AuthentikTokenReminders.run(util, now.plus(4, ChronoUnit.DAYS)); + assertThat(notices).hasSize(3); + assertThat(store.find("external").getValue("lastReminder1d")).isNotNull(); + store.resetReminders("external"); + assertThat(store.find("external").getValue("lastReminder7d")).isNull(); + AuthentikTokenScopes.apply(QContext.getQSession(), new JSONObject().put("qqq_token", "external")); + assertThat(QContext.getQSession().getPermissions()).isNotEmpty(); + } + + + + /******************************************************************************* + ** Delivery absence leaves dedupe unset so installing Alerts permits retry. + *******************************************************************************/ + @Test + void unavailableDeliveryAndUnauthorizedRun() throws QException + { + token("external", 3, 5); + util.getRuntime().withTokenAlerts(new AuthentikTokenAlerts()::send); + AuthentikTokenReminders.run(util, now); + assertThat(new TokenScopeStore(t.config).find("external")).isNull(); + assertThat(new AuthentikTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null, "safe"))).isFalse(); + t.loginAs("sam@example.test"); + assertThatThrownBy(() -> AuthentikTokenReminders.run(util, now)).hasMessageContaining("tokens.manage"); + assertThatThrownBy(() -> AuthentikTokenWidget.data(util, now)).hasMessageContaining("tokens.manage"); + } + + + + /******************************************************************************* + ** The hidden daily process runs with core's system scheduler session. + *******************************************************************************/ + @Test + void scheduledProcessAndBulkRevokeMetadata() throws QException + { + t.qInstance.addScheduler(new SimpleSchedulerMetaData().withName("test")); + AuthentikTokenReminderProcessProducer producer = new AuthentikTokenReminderProcessProducer(); + producer.setQBitConfig(t.config); + var process = producer.produce(t.qInstance); + assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(86400); + assertThat(process.getSchedule().getSchedulerName()).isEqualTo("test"); + assertThat(process.getIsHidden()).isTrue(); + token("external", 3, 5); + QContext.init(t.qInstance, new QSystemUserSession()); + new RunProcessAction().execute(new RunProcessInput().withProcessName("authentikTokenReminders")); + assertThat(notices).hasSize(1); + var revoke = t.qInstance.getProcess("authentikRevokeToken"); + assertThat(revoke.getTableName()).isEqualTo("authentik_token"); + assertThat(revoke.getMinInputRecords()).isEqualTo(1); + assertThat(revoke.getMaxInputRecords()).isEqualTo(AuthentikAdmin.MAX_BULK); + assertThat(Boolean.TRUE.equals(revoke.getIsHidden())).isFalse(); + } + + + + /******************************************************************************* + ** Widget counts, known/unknown ages, links, deactivated owners and inactivity. + *******************************************************************************/ + @Test + void adminWidgetUsesStandardTableAndSafeFilters() throws QException + { + token("new", 3, 5); created("new", 10); + token("middle", 3, 5); created("middle", 45); + token("unused", 3, 5); created("unused", 100); + token("ancient", 3, 5); created("ancient", 400); + token("unknown", 7, 5); + token("api", 3, 5).put("intent", "api"); created("api", 100); + t.authentik.loginEvents.add(event("middle", now)); + TableData data = AuthentikTokenWidget.data(util, now); + assertThat(data.getRows()).contains(Map.of("category", "Age: unknown", "count", 1, "tokens", data.getRows().stream() + .filter(row -> "Age: unknown".equals(row.get("category"))).findFirst().orElseThrow().get("tokens"))); + assertThat(data.getRows()).filteredOn(row -> "Unused for 60 days".equals(row.get("category"))).hasSize(2); + assertThat(data.getRows()).filteredOn(row -> "Owner deactivated".equals(row.get("category"))).hasSize(1); + assertThat(data.getRows().toString()).contains("?filter=", "identifier", "username").doesNotContain("lastUsed%22"); + QWidgetMetaData widget = (QWidgetMetaData) t.qInstance.getWidget("authentikAccessTokens"); + assertThat(widget.getType()).isEqualTo("TABLE"); + assertThat(new AuthentikTokenWidget().render(new RenderWidgetInput().withWidgetMetaData(widget))).isNotNull(); + t.config.withTokenUnusedDays(120); + assertThat(AuthentikTokenWidget.data(util, now).getRows()).noneMatch(row -> "Unused for 120 days".equals(row.get("category"))); + } + + + + /******************************************************************************* + ** A post-write owner move is a high-severity incident without a token key. + *******************************************************************************/ + @Test + void protectedOwnerRaceRaisesAlert() throws QException + { + t.authentik.users.get(6).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=365"); + token("service", 6, 5); + t.authentik.moveTokenAfterUpdate = true; + var result = t.admin().execute(AdminRequest.of(AdminAction.EXTEND_TOKEN, "service", "test").with("expiresInDays", 20)); + assertThat(result.ok()).isFalse(); + assertThat(notices).hasSize(1); + assertThat(notices.getFirst().key()).isEqualTo("authentik_token_guard_bypass"); + assertThat(notices.getFirst().severity()).isEqualTo("high"); + assertThat(notices.toString()).doesNotContain(t.authentik.tokenKeys.get("service")); + } + /******************************************************************************* + ** External extension opens a new reminder window without rewriting scope. + *******************************************************************************/ + @Test + void externalExtensionStartsNewReminderWindow() throws QException + { + JSONObject token = token("external", 3, 5); + AuthentikTokenReminders.run(util, now); + token.put("expires", now.plus(20, ChronoUnit.DAYS).toString()); + util.getRuntime().invalidateCache(); + AuthentikTokenReminders.run(util, now.plus(15, ChronoUnit.DAYS)); + assertThat(notices).hasSize(2); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenScopesTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenScopesTest.java new file mode 100644 index 0000000..07a9e82 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenScopesTest.java @@ -0,0 +1,275 @@ +/* + * 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.admin; + + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.authentik.AuthentikRuntime; +import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper; +import com.kingsrook.qbits.authentik.policy.AuthentikPolicyClaimMapper; +import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery; +import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes; +import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.Mode; +import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Scope enforcement intersects existing permissions without Docker or OAuth. + *******************************************************************************/ +class TokenScopesTest +{ + private AdminTestSupport t; + + /******************************************************************************* + ** Instance includes a table whose permission base differs from its name. + *******************************************************************************/ + @BeforeEach + void setUp() throws QException + { + t = AdminTestSupport.create(); + t.config.withTokenScopesEnabled(true).withTokenBrokerClientId("broker").withTokenBrokerClientSecret("secret") + .withSubjectTokenSource(session -> "subject:3"); + TokenKeyDelivery.set((output, key) -> {}); + } + + + + /******************************************************************************* + ** Reset the response-only test adapter. + *******************************************************************************/ + @AfterEach + void tearDown() + { + TokenKeyDelivery.set(null); + } + + + + /******************************************************************************* + ** Seed a credential and optional local metadata. + *******************************************************************************/ + private void seed(String id, AdminRequest scope) throws QException + { + t.authentik.seedToken(id, 3, "app_password", Instant.now().plusSeconds(86400).toString()); + if(scope != null) + { + new TokenScopeStore(t.config).create(id, t.authentik.users.get(3), scope, "sam@example.test"); + } + } + + + + /******************************************************************************* + ** No claim means no scope lookup and no changes to the permission set. + *******************************************************************************/ + @Test + void absentAndExternalClaims() throws QException + { + QSession session = QContext.getQSession().withPermissions("post.read", "post.edit"); + Set before = Set.copyOf(session.getPermissions()); + AuthentikTokenScopes.apply(session, new JSONObject()); + assertThat(session.getPermissions()).isEqualTo(before); + assertThat(AuthentikTokenScopes.forClaims(t.qInstance, null)).isEmpty(); + seed("external", null); + assertThat(AuthentikTokenScopes.forClaims(t.qInstance, new JSONObject().put("qqq_token", "external")).orElseThrow().mode()).isEqualTo(Mode.UNSCOPED); + AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "external")); + assertThat(session.getPermissions()).isEqualTo(before); + assertThat(session.getValue("qqq.credentialScope")).isEqualTo("external"); + } + + + + /******************************************************************************* + ** Read-only strips writes and management hasAccess permissions. + *******************************************************************************/ + @Test + void readOnlyAndDeletedToken() throws QException + { + seed("limited", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("readOnly", true)); + QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "post.read", "post.edit", "authentik.tokens.manage.hasAccess"); + JSONObject claims = new JSONObject().put("qqq_token", "limited"); + AuthentikTokenScopes.apply(session, claims); + assertThat(session.getPermissions()).containsExactlyInAnyOrder("cms.publish.hasAccess", "post.read"); + t.authentik.tokens.get("sam").clear(); + AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache(); + AuthentikTokenScopes.apply(session, claims); + assertThat(session.getPermissions()).isEmpty(); + assertThat(AuthentikTokenScopes.forClaims(t.qInstance, claims).orElseThrow().mode()).isEqualTo(Mode.DENY_ALL); + assertThat(new TokenScopeStore(t.config).find("limited")).isNotNull(); + } + + + + /******************************************************************************* + ** A scope cannot grant missing read permissions, even with a valid table. + *******************************************************************************/ + @Test + void tableNamesResolvePermissionAliasesAndNeverWiden() throws QException + { + seed("table", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("tables", "post")); + QSession session = t.loginAs("sam@example.test").withPermissions("cms.publish.hasAccess", "kof22.analytics.hasAccess"); + JSONObject claims = new JSONObject().put("qqq_token", "table"); + AuthentikTokenScopes.apply(session, claims); + assertThat(session.getPermissions()).containsExactly("cms.publish.hasAccess"); + session.setPermissions(Set.of("kof22.analytics.hasAccess")); + AuthentikTokenScopes.apply(session, claims); + assertThat(session.getPermissions()).isEmpty(); + } + + + + /******************************************************************************* + ** An app includes its table actions, reports and widgets, not other apps. + *******************************************************************************/ + @Test + void appChildrenAndExplicitTableUnion() throws QException + { + var rules = new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION); + QProcessMetaData process = new QProcessMetaData().withName("publish").withTableName("post").withPermissionRules(rules); + QReportMetaData report = new QReportMetaData().withName("report").withPermissionRules(rules); + QWidgetMetaData widget = new QWidgetMetaData().withName("widget").withType("TABLE").withPermissionRules(rules); + t.qInstance.addProcess(process); + t.qInstance.addReport(report); + t.qInstance.addWidget(widget); + t.qInstance.addApp(new QAppMetaData().withName("content").withPermissionRules(rules).withChild(t.qInstance.getTable("post")) + .withChild(report).withWidgets(List.of("widget"))); + seed("app", AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("apps", "content").with("tables", "analytics")); + QSession session = t.loginAs("sam@example.test").withPermissions("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess", + "widget.hasAccess", "kof22.analytics.hasAccess", "unrelated.read"); + AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "app")); + assertThat(session.getPermissions()).containsExactlyInAnyOrder("content.hasAccess", "cms.publish.hasAccess", "publish.hasAccess", "report.hasAccess", + "widget.hasAccess", "kof22.analytics.hasAccess"); + } + + + + /******************************************************************************* + ** The configured claim is enforced even if offering new scopes is disabled. + *******************************************************************************/ + @Test + void configuredClaimCacheAndMalformedClaim() throws QException + { + t.config.withTokenScopesEnabled(false).withTokenScopeClaim("credential"); + seed("external", null); + QSession session = t.loginAs("sam@example.test").withPermissions("post.read"); + JSONObject claims = new JSONObject().put("credential", "external"); + AuthentikTokenScopes.apply(session, claims); + t.authentik.tokens.get("sam").clear(); + AuthentikTokenScopes.apply(session, claims); + assertThat(session.getPermissions()).containsExactly("post.read"); + AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache(); + AuthentikTokenScopes.apply(session, claims); + assertThat(session.getPermissions()).isEmpty(); + session.withPermission("post.read"); + AuthentikTokenScopes.apply(session, new JSONObject().put("credential", new JSONObject())); + assertThat(session.getPermissions()).isEmpty(); + } + + + + /******************************************************************************* + ** Both claims customizers narrow after computing their permission unions. + *******************************************************************************/ + @Test + void mappersCannotAddPermissionsAfterDenyAll() + { + JSONObject claims = new JSONObject().put("qqq_token", "deleted"); + QSession session = t.loginAs("sam@example.test").withPermission("post.read"); + new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims)); + assertThat(session.getPermissions()).isEmpty(); + session.withPermission("post.read"); + new AuthentikGroupPermissionMapper().withGroupPermissions("test", List.of("post.edit")) + .customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims.put("groups", List.of("test")))); + assertThat(session.getPermissions()).isEmpty(); + } + + + + /******************************************************************************* + ** Review validates current access and displays the scope; execution repeats. + *******************************************************************************/ + @Test + void reviewRejectsUnknownOrUnheldAndExecutionRechecks() throws QException + { + t.loginAs("sam@example.test"); + AdminRequest request = AdminRequest.of(AdminAction.CREATE_TOKEN, "", "test").with("description", "CI").with("expiresInDays", 30).with("tables", "missing"); + assertThat(t.admin().plan(request).getMessage()).contains("Unknown"); + request.with("tables", "post"); + assertThat(t.admin().plan(request).getMessage()).contains("read access"); + QContext.getQSession().withPermission("cms.publish.hasAccess"); + assertThat(t.admin().plan(request).describe()).contains("tables: post"); + QContext.getQSession().setPermissions(Set.of()); + assertThat(t.admin().execute(request).ok()).isFalse(); + assertThat(t.changes()).isEmpty(); + } + /******************************************************************************* + ** A long general response cache cannot extend the scope revocation window. + ** Lookup outages deny, rather than falling back to unrestricted access. + *******************************************************************************/ + @Test + void verificationCacheIsIndependentAndApiFailureDenies() throws QException + { + t.config.withListCacheTtlSeconds(3600); + seed("cached", null); + QSession session = t.loginAs("sam@example.test").withPermission("post.read"); + JSONObject claims = new JSONObject().put("qqq_token", "cached"); + AuthentikTokenScopes.apply(session, claims); + assertThat(session.getPermissions()).containsExactly("post.read"); + t.authentik.tokens.get("sam").clear(); + AuthentikRuntime.forBackend(t.config.getApiBackendName()).getTokenScopeCache().clear(); + AuthentikTokenScopes.apply(session, claims); + assertThat(session.getPermissions()).isEmpty(); + session.withPermission("post.read"); + com.kingsrook.qbits.authentik.client.FixtureActionUtil.handler = request -> + com.kingsrook.qbits.authentik.client.FixtureActionUtil.response(403, "{}"); + AuthentikTokenScopes.apply(session, new JSONObject().put("qqq_token", "unavailable")); + assertThat(session.getPermissions()).isEmpty(); + } + + + + /******************************************************************************* + ** The ID token may supply policy context while the access token binds scope. + *******************************************************************************/ + @Test + void idTokenContextCannotHideAccessTokenScope() + { + QSession session = t.loginAs("sam@example.test").withPermission("post.read"); + new AuthentikPolicyClaimMapper().customizeSession(t.qInstance, session, Map.of( + "idToken", new JSONObject().put("email", "sam@example.test"), + "jwtPayloadJsonObject", new JSONObject().put("qqq_token", "deleted"))); + assertThat(session.getPermissions()).isEmpty(); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java b/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java index 441f6f4..8db5888 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java @@ -61,7 +61,7 @@ class AuthentikAppTest assertThat(app.getChildren()).extracting(c -> c.getName()) .containsExactly("authentik_user", "authentik_invitation", "authentik_group", "authentik_application", "authentik_approval_request", "authentik_admin_action"); assertThat(app.getSections()).extracting(s -> s.getLabel()).containsExactly("People", "Access", "Activity"); - assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikDriftReport"); + assertThat(app.getWidgets()).containsExactly("authentikHome", "authentikAccessTokens", "authentikDriftReport"); for(String subTable : List.of("authentik_group_member", "authentik_session", "authentik_token", "authentik_event", "authentik_policy_binding", "authentik_role", "authentik_blueprint")) { diff --git a/src/test/java/com/kingsrook/qbits/authentik/processes/AuthentikProcessesTest.java b/src/test/java/com/kingsrook/qbits/authentik/processes/AuthentikProcessesTest.java index 30a5df7..6ac6436 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/processes/AuthentikProcessesTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/processes/AuthentikProcessesTest.java @@ -131,7 +131,14 @@ class AuthentikProcessesTest for(QProcessMetaData process : t.qInstance.getProcesses().values()) { - assertThat(process.getTableName()).as(process.getName()).isNotBlank(); + if(!process.getName().equals("authentikTokenReminders")) + { + assertThat(process.getTableName()).as(process.getName()).isNotBlank(); + } + else + { + assertThat(process.getIsHidden()).isTrue(); + } assertThat(t.qInstance.getApp("authentikAdmin").getChildren()).extracting(c -> c.getName()).doesNotContain(process.getName()); } assertThat(t.qInstance.getProcess("authentikCreateUser").getMaxInputRecords()).isEqualTo(0); -- 2.54.0 From 2061677e162f3f3b6bafacf9e4b469d0eae9fd33 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 22:54:10 -0500 Subject: [PATCH 2/9] fix(tokens): access-token widget uses the core TABLE widget type ("table") Co-Authored-By: Claude Opus 5.5 --- .../qbits/authentik/metadata/AuthentikTokenWidgetProducer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenWidgetProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenWidgetProducer.java index 9ef0c04..9eef81c 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenWidgetProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikTokenWidgetProducer.java @@ -63,7 +63,7 @@ public class AuthentikTokenWidgetProducer extends QBitComponentMetaDataProducer< public QWidgetMetaData produce(QInstance instance) { AuthentikQBitConfig config = getQBitConfig(); - return (new QWidgetMetaData().withName(config.applyPrefix(NAME)).withLabel("Access tokens").withType("TABLE") + return (new QWidgetMetaData().withName(config.applyPrefix(NAME)).withLabel("Access tokens").withType(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType()) .withGridColumns(12).withIsCard(true).withPermissionRules(IdentityPermission.TOKENS_MANAGE.rules()) .withCodeReference(new QCodeReference(AuthentikTokenWidget.class)).withDefaultValue("backendName", config.getApiBackendName())); } -- 2.54.0 From 32741508e8be4aad73432429ad8095158be2cf4f Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 22:59:28 -0500 Subject: [PATCH 3/9] fix(tokens): explicit last_reminder_7d/_1d column names for authentik_token_scope A host whose RDBMS backend maps camelCase to snake_case got last_reminder7d, which does not match docs/schema.sql (found by the kof22 adoption's changeset test). Co-Authored-By: Claude Opus 5.5 --- .../qbits/authentik/admin/AuthentikLocalTables.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java index 6f010f7..274b38d 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java @@ -293,10 +293,16 @@ public final class AuthentikLocalTables { table.withField(ro(name, QFieldType.TEXT)); } - for(String name : List.of("createdAt", "rotatedAt", "lastReminder7d", "lastReminder1d")) + for(String name : List.of("createdAt", "rotatedAt")) { table.withField(ro(name, QFieldType.DATE_TIME)); } + //////////////////////////////////////////////////////////////////////// + // explicit column names: a camel-to-snake mapping would give // + // last_reminder7d, while docs/schema.sql (and hosts) use _7d / _1d // + //////////////////////////////////////////////////////////////////////// + table.withField(ro("lastReminder7d", QFieldType.DATE_TIME).withBackendName("last_reminder_7d")); + table.withField(ro("lastReminder1d", QFieldType.DATE_TIME).withBackendName("last_reminder_1d")); return (table); } -- 2.54.0 From 8698d627a969d6fb437d22c322ba925d26f5fa12 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:00:33 -0500 Subject: [PATCH 4/9] test(tokens): widget type is the core TABLE type Co-Authored-By: Claude Opus 5.5 --- .../kingsrook/qbits/authentik/admin/TokenOperationsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java index 04151e4..8dae1a3 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java @@ -252,7 +252,7 @@ class TokenOperationsTest assertThat(data.getRows()).filteredOn(row -> "Owner deactivated".equals(row.get("category"))).hasSize(1); assertThat(data.getRows().toString()).contains("?filter=", "identifier", "username").doesNotContain("lastUsed%22"); QWidgetMetaData widget = (QWidgetMetaData) t.qInstance.getWidget("authentikAccessTokens"); - assertThat(widget.getType()).isEqualTo("TABLE"); + assertThat(widget.getType()).isEqualTo(com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType.TABLE.getType()); assertThat(new AuthentikTokenWidget().render(new RenderWidgetInput().withWidgetMetaData(widget))).isNotNull(); t.config.withTokenUnusedDays(120); assertThat(AuthentikTokenWidget.data(util, now).getRows()).noneMatch(row -> "Unused for 120 days".equals(row.get("category"))); -- 2.54.0 From 5a3802193f85bb2e3c275f8f52e4fb03feab8da3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:03:11 -0500 Subject: [PATCH 5/9] fix(tokens): token-scope lookup runs as the internal system session The lookup runs while a bearer session is still being built (no user), so the guard refused the Authentik read and the scope failed closed for every token. Read Authentik and the scope row as QSystemUserSession, then restore the caller's context. Found by the kof22 adoption test. Co-Authored-By: Claude Opus 5.5 --- .../security/AuthentikTokenScopes.java | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java index e7d59a5..ad619b8 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java @@ -147,7 +147,7 @@ public final class AuthentikTokenScopes { return (Optional.of(TokenScope.marker("invalid", Mode.DENY_ALL))); } - return (runtime.getTokenScopeCache().getResultThrowing(identifier, id -> + return (runtime.getTokenScopeCache().getResultThrowing(identifier, id -> lookupAsSystem(instance, () -> { APIBackendMetaData api = (APIBackendMetaData) backend; AuthentikActionUtil util = (AuthentikActionUtil) QCodeLoader.getAdHoc(BaseAPIActionUtil.class, api.getActionUtil()); @@ -166,7 +166,7 @@ public final class AuthentikTokenScopes } QRecord row = new TokenScopeStore(runtime.getConfig()).find(id); return (row == null ? TokenScope.marker(id, Mode.UNSCOPED) : TokenScope.from(id, row)); - })); + }))); } } // A default token claim must never silently lose enforcement if the qbit is missing. @@ -175,6 +175,64 @@ public final class AuthentikTokenScopes + /******************************************************************************* + ** A lookup step that may throw. + *******************************************************************************/ + @FunctionalInterface + private interface Lookup + { + /*************************************************************************** + ** Run the lookup. + ***************************************************************************/ + TokenScope get() throws Exception; + } + + + + /******************************************************************************* + ** The scope lookup runs while a session is still being built (no user yet), + ** so it reads Authentik and the scope row as the internal system session, + ** then restores the caller's context. + *******************************************************************************/ + private static TokenScope lookupAsSystem(QInstance instance, Lookup lookup) throws QException + { + QInstance previousInstance = QContext.getQInstance(); + com.kingsrook.qqq.backend.core.model.session.QSession previousSession = QContext.getQSession(); + try + { + if(previousInstance == null) + { + QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession()); + } + else + { + QContext.setQSession(new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession()); + } + return (lookup.get()); + } + catch(QException e) + { + throw (e); + } + catch(Exception e) + { + throw (new QException("Token scope lookup failed", e)); + } + finally + { + if(previousInstance == null) + { + QContext.clear(); + } + else + { + QContext.setQSession(previousSession); + } + } + } + + + /******************************************************************************* ** Hosts with their own customizer call this AFTER computing permissions. *******************************************************************************/ -- 2.54.0 From 3e18458fb3e8f5eb938c4402e345292b51c60126 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:18:35 -0500 Subject: [PATCH 6/9] test(tokens): PAT exchange without a client secret, as users send it TokenContractIT now exchanges app passwords exactly like the generated usage text (no client_secret); green on Authentik 2026.8.3. Also logs the cause when a token scope cannot be verified. Co-Authored-By: Claude Opus 5.5 --- .../qbits/authentik/security/AuthentikTokenScopes.java | 2 +- .../qbits/authentik/contract/TokenContractIT.java | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java index ad619b8..72ace5d 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikTokenScopes.java @@ -255,7 +255,7 @@ public final class AuthentikTokenScopes catch(Exception e) { session.setPermissions(new TreeSet<>()); - LOG.warn("Token scope could not be verified; denying credential permissions."); + LOG.warn("Token scope could not be verified; denying credential permissions.", e); } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java index 4f11e05..8d74470 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java @@ -542,8 +542,13 @@ class TokenContractIT *******************************************************************************/ private TestApi.Response credentials(String username, String key, String scope) throws Exception { + ////////////////////////////////////////////////////////////////////// + // no client_secret: exactly what a PAT holder sends (the generated // + // usage text). 2026.8.3 authenticates client_credentials by the // + // username and app password even for a confidential client // + ////////////////////////////////////////////////////////////////////// return (form(Map.of("grant_type", "client_credentials", "client_id", AuthentikTestStack.APP_SLUG, - "client_secret", "contract-test-oauth2-client-secret", "username", username, "password", key, "scope", scope))); + "username", username, "password", key, "scope", scope))); } -- 2.54.0 From 6c56a24960b76528d5ee4c796c548b49d0e683e7 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:28:57 -0500 Subject: [PATCH 7/9] docs(spec): no client secret for the PAT exchange; async one-time hand-off Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 625977f..084121d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1289,7 +1289,7 @@ A QQQ access token is an Authentik **app password** (`intent: app_password`) own - `password={token}` - `scope=openid profile email` - Authentik checks that the owner is active, that the token exists with intent `app_password`, and that the app's policies let the owner in. It records a `login` event with `method: token` and the token's identifier. + No client secret: Authentik 2026.8.3 authenticates this grant by the username and app password, even for a confidential client (client authentication is required only for the code, refresh, device and token-exchange grants; pinned by `TokenContractIT`). Authentik checks that the owner is active, that the token exists with intent `app_password`, and that the app's policies let the owner in. It records a `login` event with `method: token` and the token's identifier. 2. **Call the app** (`/qqq/v1/...` and the published APIs) with `Authorization: Bearer {jwt}`. When the JWT expires (the provider's access-token validity), exchange the token again. The qbit generates these instructions from the instance's real URLs (§13.7). @@ -1408,7 +1408,7 @@ STAND-IN(core=QRun-IO/qqq#1153, removeBy=the RC that ships #1153, RC.7 at the ea **Record actions.** These use core's one-time process output values: `RunBackendStepOutput.addOneTimeSecret(...)`, in the QRun-IO/qqq PR `feature/process-one-time-values`. - The value travels only in the step's HTTP response. - `ProcessState`, traces and lifecycle listeners (ESB) see only the masked form: `••••` plus the last 4 characters. -- If the run finishes asynchronously, the key is lost by design, and the result says "Rotate the token to get a new key". +- If the step outlives the middleware's step timeout (3 s) and finishes as a job, core parks the secrets in memory (`OneTimeSecretHandoff`, 5 minutes, never in state) and returns them once, to the first status poll that sees the job complete. A poll that reaches another server instance or comes too late finds nothing: the key is lost by design, and the result says "Rotate the token to get a new key". (Found in the demo: a create through the broker can take longer than 3 s.) **Where the key never goes.** The qbit never passes it to the audit row, the Authentik event context, `AdminResult.message`, notifications, logs or exceptions. `AuthentikRedactor` also masks any `key` or `token` JSON value in error text. -- 2.54.0 From 3722380db02f0362442549e86b1cc5ec7122c717 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 07:57:19 -0500 Subject: [PATCH 8/9] feat(tokens): owner decision A (add_token); alarm on service-account creation; core notify SPI - DESIGN.md decision 31 (owner, 2026-10-14): the qbit role holds add_token (needed by service_account and by set_key); view_token_key stays not granted. - TokenContractIT pins 403 without add_token and 200/204 with it, and that the alarm notifies identity-admins when the qbit's account creates a service account (fixture = cluster-gitops#103 blueprint, verbatim). - Reminders go through core QAlerts.notify (qqq#1125) by reflection, never another qbit's classes (G1); guard alerts through core QAlerts. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 8 +- .../authentik/admin/AuthentikTokenAlerts.java | 39 +++++----- .../authentik/admin/TokenOperationsTest.java | 5 +- .../authentik/contract/TokenContractIT.java | 73 +++++++++++++++++++ .../blueprints/kof22-qqq-tokens.yaml | 8 ++ 5 files changed, 110 insertions(+), 23 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 5e24ae3..75847b9 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -74,6 +74,12 @@ Seven design rules shape everything below: | 29 | **First class:** usage instructions generated from the instance's real URLs; expiry reminders 7 days and 1 day before (bell and email); last used; an unused-token cleanup suggestion; admin views by user and by age with bulk revoke; the doc page [`ACCESS-TOKENS.md`](ACCESS-TOKENS.md). | | 30 | **Owner-approved revision of the mechanism (2026-10-13, coordinator-approved under the owner's direction).** Authentik 2026.8.3 always creates a token for the *caller* unless the caller is a superuser (`TokenViewSet.perform_create`), so `add_token` cannot mint for anyone else (§13.2). The qbit therefore creates a person's token **as that person**, through RFC 8693 token exchange with a 2-minute API token from the `qqq--token-broker` provider (with a deny policy for protected users). Service accounts use `POST /core/users/service_account/` (create), `set_key` (rotate) and `PATCH` (extend). The qbit role gains `change_token`, `set_token_key` and `add_token`, keeps `delete_token` and `add_user`, and **never** holds `view_token_key`. `add_token` was found necessary by the contract tests (`service_account` needs `add_user` + `add_token`; `set_key` needs the POST object permission `add_token` + `set_token_key`) and is safe because a non-superuser's create is always the caller's (§13.9). Limitation: one app password per service account. | +**Owner decision (2026-10-14): `add_token` (option A).** The contract tests showed that Authentik 2026.8.3 requires `add_token` for `POST /core/users/service_account/` (with `add_user`) and for `POST /core/tokens/{id}/set_key/` (the POST object permission, with `set_token_key`). + +| # | Decision | +|---|---| +| 31 | **Grant `add_token` to the qbit role, so service-account creation and rotation stay in the app.** It is safe: for a non-superuser, `POST /core/tokens/` always creates the token for the caller, so it can never mint a token for anyone else. `view_token_key` stays **not** granted. `TokenContractIT` pins 403 without `add_token` and 200/204 with it. The detective alarm (§13.4 e) also notifies `identity-admins` whenever the qbit's account creates a service account. The blueprint explains this next to the permission (cluster-gitops#103). | + ### 0.2 Phase 0 findings (2026-10-11, verified on Authentik 2026.8.3) The four open points of revision 2 were resolved against a disposable Authentik 2026.8.3 (Testcontainers contract tests in `QRun-IO/qbit-authentik`). Six further facts changed the design; each is pinned by a contract test, so an Authentik upgrade that changes it fails CI. @@ -1362,7 +1368,7 @@ Without a broker, the self-service actions are hidden, and Review says why. | (b) | **Self-service is self-only.** A person without `authentik.tokens.manage` may target only themselves. An admin may also target service accounts, but **never another human**; this covers create, rotate and extend. Revoke is the exception: for incident response, an admin may revoke any token whose owner isn't protected. | The qbit guard; Authentik itself for the as-user calls | | (c) | **Expiry is required.** There's no non-expiring option. The maximum is `tokenMaxDaysUser` (default 90) for people and `tokenMaxDaysServiceAccount` (default 365) for service accounts. Extend counts from now. Authentik's group attribute is a second cap. | The qbit (Review refuses); Authentik (`token-maximum-lifetime`) | | (d) | **Two-person approval** for service-account tokens (create service account, rotate): `tokenTwoPersonApproval`, default **off** while the team is one person. When on, the change is queued in `authentik_approval_request`, and the key is shown to the approver who applies it. | The qbit | -| (e) | **Detective control.** An Authentik notification rule and an expression policy fire whenever the qbit's service account creates or updates a token whose owner is protected, reads its key, or sets its key (`model_created`, `model_updated`, `secret_view` or `secret_rotate` on `authentik_core.token`). They notify `identity-admins` through Authentik notifications. The rule should never fire; if it does, guard (a) was bypassed. | The Authentik blueprint (§13.9). The qbit also raises an `authentik_token_guard_bypass` alert through the Alerts producer when its own re-read finds a protected owner. | +| (e) | **Detective control.** An Authentik notification rule and an expression policy fire whenever the qbit's service account creates or updates a token whose owner is protected, reads its key, or sets its key (`model_created`, `model_updated`, `secret_view` or `secret_rotate` on `authentik_core.token`). They notify `identity-admins` through Authentik notifications. The rule should never fire; if it does, guard (a) was bypassed. It also notifies `identity-admins` whenever the qbit's account creates a service account (decision 31). | The Authentik blueprint (§13.9). The qbit also raises an `authentik_token_guard_bypass` alert through the Alerts producer when its own re-read finds a protected owner. | | (f) | **Everything is audited.** Before each call, every action writes an `authentik_admin_action` row with the client channel (`audit.clientChannel`, for example `web`, `api` or `token:{identifier}`), plus an Authentik `custom_qqq_admin_action` event. The assistant can't reach these tables or actions, because the assistant wall denies `authentik_*`. | The qbit; core audit | ### 13.5 Scopes diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java index 5531efb..68890af 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java @@ -17,10 +17,7 @@ package com.kingsrook.qbits.authentik.admin; -import java.lang.reflect.Method; -import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.logging.QLogger; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; /******************************************************************************* @@ -64,8 +61,8 @@ public class AuthentikTokenAlerts /******************************************************************************* - ** RC.5 Alerts raises guard incidents. The installed qbit's notification - ** API supplies owner-directed reminders until core ships notifications. + ** Guard incidents are raised through core QAlerts (RC.5+); owner reminders + ** through core QAlerts.notify (qqq#1125) when the QQQ version has it. *******************************************************************************/ public boolean send(Notice notice) { @@ -84,22 +81,22 @@ public class AuthentikTokenAlerts alerts.getMethod("raise", signalType).invoke(null, signal); return (true); } - Class runtimeType = Class.forName("com.kingsrook.qbits.alerts.AlertsRuntime"); - Object runtime = runtimeType.getMethod("find", QInstance.class).invoke(null, QContext.getQInstance()); - if(runtime != null) - { - Object sink = runtimeType.getMethod("sink").invoke(runtime); - Class signalType = Class.forName("com.kingsrook.qbits.alerts.spi.NotificationSignal"); - Class audienceType = Class.forName("com.kingsrook.qbits.alerts.spi.NotificationAudience"); - Object signal = signalType.getConstructor(String.class).newInstance("Access token expiry reminder"); - signalType.getMethod("withBody", String.class).invoke(signal, notice.message()); - signalType.getMethod("withSourceName", String.class).invoke(signal, "authentik"); - signalType.getMethod("withSourceKey", String.class).invoke(signal, notice.key()); - signalType.getMethod("withAudience", audienceType).invoke(signal, audienceType.getMethod("user", String.class).invoke(null, notice.recipient())); - Method notify = sink.getClass().getMethod("notify", signalType); - notify.invoke(sink, signal); - return (true); - } + ////////////////////////////////////////////////////////////////////// + // owner reminders go through CORE's notification SPI (bell and // + // email via whichever alerts producer is installed), never through // + // another qbit's classes (guardrail G1). // + // STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.7): reflection // + // until the qbit's QQQ version has NotificationSignal. // + ////////////////////////////////////////////////////////////////////// + Class signalType = Class.forName("com.kingsrook.qqq.backend.core.alerts.NotificationSignal"); + Class audienceType = Class.forName("com.kingsrook.qqq.backend.core.alerts.NotificationAudience"); + Object signal = signalType.getConstructor(String.class).newInstance("Access token expiry reminder"); + signalType.getMethod("withBody", String.class).invoke(signal, notice.message()); + signalType.getMethod("withSourceName", String.class).invoke(signal, "authentik"); + signalType.getMethod("withSourceKey", String.class).invoke(signal, notice.key()); + signalType.getMethod("withAudience", audienceType).invoke(signal, audienceType.getMethod("user", String.class).invoke(null, notice.recipient())); + alerts.getMethod("notify", signalType).invoke(null, signal); + return (true); } catch(ReflectiveOperationException | LinkageError e) { diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java index 8dae1a3..d87dfcb 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java @@ -198,7 +198,10 @@ class TokenOperationsTest util.getRuntime().withTokenAlerts(new AuthentikTokenAlerts()::send); AuthentikTokenReminders.run(util, now); assertThat(new TokenScopeStore(t.config).find("external")).isNull(); - assertThat(new AuthentikTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null, "safe"))).isFalse(); + ////////////////////////////////////////////////////////////////////// + // core QAlerts exists from RC.5: guard incidents always have a path // + ////////////////////////////////////////////////////////////////////// + assertThat(new AuthentikTokenAlerts().send(new AuthentikTokenAlerts.Notice("authentik_token_guard_bypass", "high", null, "safe"))).isTrue(); t.loginAs("sam@example.test"); assertThatThrownBy(() -> AuthentikTokenReminders.run(util, now)).hasMessageContaining("tokens.manage"); assertThatThrownBy(() -> AuthentikTokenWidget.data(util, now)).hasMessageContaining("tokens.manage"); diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java index 8d74470..d6e82ed 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java @@ -334,6 +334,79 @@ class TokenContractIT + /******************************************************************************* + ** Owner decision A (2026-10-14): the role holds add_token because 2026.8.3 + ** needs it for service_account and for set_key (POST object permission). + ** Pin both: 403 without add_token, 200/204 with it (restored in finally). + *******************************************************************************/ + @Test + void serviceAccountActionsNeedAddToken() throws Exception + { + JSONObject seed = expect(admin.post("/core/users/service_account/", new JSONObject().put("name", unique("needs-add")) + .put("create_group", false).put("expiring", true).put("expires", expires(30))), 200); + users.add(seed.getInt("user_pk")); + String seedToken = "service-account-" + seed.getString("username") + "-password"; + tokens.add(seedToken); + String role = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk"); + JSONObject permissions = new JSONObject().put("permissions", new org.json.JSONArray().put("authentik_core.add_token")); + String name = unique("no-add-token"); + assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", permissions).status()).isEqualTo(204); + try + { + expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name) + .put("create_group", false).put("expiring", true).put("expires", expires(30))), 403); + assertThat(admin.first("/core/users/?username=" + name)).isNull(); + expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 403); + } + finally + { + assertThat(admin.post("/rbac/permissions/assigned_by_roles/" + role + "/assign/", permissions).status()).isEqualTo(200); + } + JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", name) + .put("create_group", false).put("expiring", true).put("expires", expires(30))), 200); + users.add(created.getInt("user_pk")); + tokens.add("service-account-" + name + "-password"); + expect(stack.serviceAccount().post(tokenPath(seedToken) + "set_key/", new JSONObject().put("key", UUID.randomUUID().toString() + UUID.randomUUID())), 204); + } + + + + /******************************************************************************* + ** The alarm also notifies identity-admins whenever the qbit's account + ** creates a service account (owner decision 2026-10-14). + *******************************************************************************/ + @Test + void detectiveRuleNotifiesOnServiceAccountCreation() throws Exception + { + int member = user("alarm-sa"); + expect(admin.post("/core/groups/" + admin.groupPk("identity-admins") + "/add_user/", new JSONObject().put("pk", member)), 204); + JSONObject created = expect(stack.serviceAccount().post("/core/users/service_account/", new JSONObject().put("name", unique("alarmed")) + .put("create_group", false).put("expiring", true).put("expires", expires(30))), 200); + int servicePk = created.getInt("user_pk"); + users.add(servicePk); + tokens.add("service-account-" + created.getString("username") + "-password"); + String[] eventPk = { null }; + AuthentikTestStack.waitFor(Duration.ofSeconds(30), "service-account model_created event", () -> + { + JSONArray events = admin.getOk("/events/events/?action=model_created&ordering=-created&page_size=50&username=" + + TestApi.encode(AuthentikTestStack.SERVICE_ACCOUNT)).getJSONArray("results"); + for(int i = 0; i < events.length(); i++) + { + JSONObject model = events.getJSONObject(i).getJSONObject("context").optJSONObject("model"); + if(model != null && "user".equals(model.optString("model_name")) && String.valueOf(servicePk).equals(model.optString("pk"))) + { + eventPk[0] = events.getJSONObject(i).getString("pk"); + return (true); + } + } + return (false); + }); + AuthentikTestStack.waitFor(Duration.ofSeconds(60), "service-account creation notification", () -> + notifications(member, eventPk[0]).length() > 0); + } + + + /******************************************************************************* ** Real qbit calls, local audit/scope persistence and the response-only SPI. *******************************************************************************/ diff --git a/src/test/resources/blueprints/kof22-qqq-tokens.yaml b/src/test/resources/blueprints/kof22-qqq-tokens.yaml index eaf3cc5..ad61262 100644 --- a/src/test/resources/blueprints/kof22-qqq-tokens.yaml +++ b/src/test/resources/blueprints/kof22-qqq-tokens.yaml @@ -104,6 +104,9 @@ entries: # superuser, a member of authentik Admins or identity-admins, or # qqq.io/protected). If it ever happens, guard (a) was bypassed: # identity-admins get an Authentik notification and an email. + # It also notifies whenever that account creates a service account (each + # comes with an app password; owner decision 2026-10-14), so in-app + # service-account creation is always visible to identity-admins. - model: authentik_policies_expression.expressionpolicy id: qqq-token-alarm-policy state: present @@ -121,6 +124,11 @@ entries: if (event.user or {}).get("username") != "qqq-kof22-admin-idadmin": return False model = event.context.get("model") or event.context.get("secret") or {} + # Also notify (owner decision 2026-10-14) whenever the qbit's account + # creates a service account: each one comes with an app password. + if event.action == "model_created" and model.get("app") == "authentik_core" and model.get("model_name") == "user": + created = User.objects.filter(pk=model.get("pk")).first() + return created is not None and created.type == "service_account" # Two shapes: the usual model reference (app, model_name, pk), and set_key's # model_to_dict(token) (token_uuid, identifier, intent, user; key masked). is_reference = model.get("app") == "authentik_core" and model.get("model_name") == "token" -- 2.54.0 From 99a302c7ce1abdad8c76d71d2212172ee3036fe4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 07:59:08 -0500 Subject: [PATCH 9/9] refactor(tokens): token notifications call core QAlerts directly (RC.6) QQQ 4.1.0-RC.6 (now the qbit's baseline) ships the notification SPI (qqq#1125), so the reflection stand-in is gone: guard incidents use QAlerts.raise, owner reminders QAlerts.notify. Co-Authored-By: Claude Opus 5.5 --- .../authentik/admin/AuthentikTokenAlerts.java | 58 +++++++++---------- .../authentik/admin/TokenOperationsTest.java | 6 +- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java index 68890af..3239501 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikTokenAlerts.java @@ -17,6 +17,11 @@ package com.kingsrook.qbits.authentik.admin; +import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; import com.kingsrook.qqq.backend.core.logging.QLogger; @@ -61,48 +66,41 @@ public class AuthentikTokenAlerts /******************************************************************************* - ** Guard incidents are raised through core QAlerts (RC.5+); owner reminders - ** through core QAlerts.notify (qqq#1125) when the QQQ version has it. + ** Guard incidents are raised through core QAlerts; owner reminders through + ** core QAlerts.notify (qqq#1125, QQQ 4.1.0-RC.6+). Never another qbit's API. *******************************************************************************/ public boolean send(Notice notice) { try { - Class alerts = Class.forName("com.kingsrook.qqq.backend.core.alerts.QAlerts"); if("high".equals(notice.severity())) { - Class signalType = Class.forName("com.kingsrook.qqq.backend.core.alerts.AlertSignal"); - Object signal = signalType.getConstructor(String.class).newInstance("authentik.token.guard-bypass"); - signalType.getMethod("withDedupKey", String.class).invoke(signal, notice.key()); - signalType.getMethod("withTitle", String.class).invoke(signal, "Access token guard bypass"); - signalType.getMethod("withSummary", String.class).invoke(signal, notice.message()); - Class severity = Class.forName("com.kingsrook.qqq.backend.core.alerts.AlertSeverity"); - signalType.getMethod("withSeverity", severity).invoke(signal, severity.getField("CRITICAL").get(null)); - alerts.getMethod("raise", signalType).invoke(null, signal); + QAlerts.raise(new AlertSignal("authentik.token.guard-bypass") + .withDedupKey(notice.key()) + .withTitle("Access token guard bypass") + .withSummary(notice.message()) + .withSeverity(AlertSeverity.CRITICAL)); return (true); } - ////////////////////////////////////////////////////////////////////// - // owner reminders go through CORE's notification SPI (bell and // - // email via whichever alerts producer is installed), never through // - // another qbit's classes (guardrail G1). // - // STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.7): reflection // - // until the qbit's QQQ version has NotificationSignal. // - ////////////////////////////////////////////////////////////////////// - Class signalType = Class.forName("com.kingsrook.qqq.backend.core.alerts.NotificationSignal"); - Class audienceType = Class.forName("com.kingsrook.qqq.backend.core.alerts.NotificationAudience"); - Object signal = signalType.getConstructor(String.class).newInstance("Access token expiry reminder"); - signalType.getMethod("withBody", String.class).invoke(signal, notice.message()); - signalType.getMethod("withSourceName", String.class).invoke(signal, "authentik"); - signalType.getMethod("withSourceKey", String.class).invoke(signal, notice.key()); - signalType.getMethod("withAudience", audienceType).invoke(signal, audienceType.getMethod("user", String.class).invoke(null, notice.recipient())); - alerts.getMethod("notify", signalType).invoke(null, signal); + + /////////////////////////////////////////////////////////////////// + // owner reminders: core's notification SPI (bell and email via // + // whichever alerts producer the host installed; qqq#1125) // + /////////////////////////////////////////////////////////////////// + QAlerts.notify(new NotificationSignal("Access token expiry reminder") + .withBody(notice.message()) + .withSourceName("authentik") + .withSourceKey(notice.key()) + .withAudience(NotificationAudience.user(notice.recipient()))); return (true); } - catch(ReflectiveOperationException | LinkageError e) + catch(RuntimeException e) { - // Optional dependency absent or incompatible. Never log the payload. + /////////////////////////////////////////////////////////// + // never fail the caller; never log the notice payload // + /////////////////////////////////////////////////////////// + LOG.info("Access-token notification delivery failed; notification skipped."); + return (false); } - LOG.info("Access-token notification delivery is unavailable; notification skipped."); - return (false); } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java index d87dfcb..7283f31 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/TokenOperationsTest.java @@ -197,7 +197,11 @@ class TokenOperationsTest token("external", 3, 5); util.getRuntime().withTokenAlerts(new AuthentikTokenAlerts()::send); AuthentikTokenReminders.run(util, now); - assertThat(new TokenScopeStore(t.config).find("external")).isNull(); + ////////////////////////////////////////////////////////////////////// + // RC.6 core has the notification SPI: the reminder goes out through // + // QAlerts.notify and is recorded for dedupe // + ////////////////////////////////////////////////////////////////////// + assertThat(new TokenScopeStore(t.config).find("external")).isNotNull(); ////////////////////////////////////////////////////////////////////// // core QAlerts exists from RC.5: guard incidents always have a path // ////////////////////////////////////////////////////////////////////// -- 2.54.0