feat(tokens): scopes, last used, reminders and admin views (phase 6c) #26
No files matched your search
+7
-1
@@ -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.
|
||||
@@ -67,7 +73,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
|
||||
|
||||
@@ -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`).
|
||||
+13
-7
@@ -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.
|
||||
|
||||
|
||||
+9
-3
@@ -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-<appKey>-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.
|
||||
@@ -1289,7 +1295,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).
|
||||
@@ -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
|
||||
@@ -1408,7 +1414,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.
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
private List<String> 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -46,10 +46,13 @@ public class AuthentikRuntime
|
||||
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
|
||||
private final Memoization<String, String> responseCache;
|
||||
private final Memoization<String, GroupGraph> groupGraphCache;
|
||||
private final Memoization<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> tokenScopeCache = new Memoization<>(Duration.ofSeconds(60));
|
||||
private final Memoization<String, Map<String, java.time.Instant>> tokenActivityCache = new Memoization<>(Duration.ofMinutes(5));
|
||||
private volatile java.util.Set<String> 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<String, AuthentikTableSpec> 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<String, com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.TokenScope> getTokenScopeCache()
|
||||
{
|
||||
return (tokenScopeCache);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Login-event aggregation is shared by every token record in this runtime.
|
||||
*******************************************************************************/
|
||||
public Memoization<String, Map<String, java.time.Instant>> 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
{
|
||||
if("high".equals(notice.severity()))
|
||||
{
|
||||
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: 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(RuntimeException e)
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// never fail the caller; never log the notice payload //
|
||||
///////////////////////////////////////////////////////////
|
||||
LOG.info("Access-token notification delivery failed; notification skipped.");
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String> 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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<JSONObject> tokens = util.getAllPages("/core/tokens/", Integer.MAX_VALUE);
|
||||
Map<String, Instant> activity = AuthentikTokenActivity.lastUsed(util);
|
||||
Map<String, List<String>> owners = new LinkedHashMap<>();
|
||||
Map<String, List<String>> ages = new LinkedHashMap<>();
|
||||
for(String bucket : List.of("<30d", "30-90d", "90-365d", ">365d", "unknown"))
|
||||
{
|
||||
ages.put(bucket, new ArrayList<>());
|
||||
}
|
||||
Map<Integer, JSONObject> users = new LinkedHashMap<>();
|
||||
List<Map<String, Object>> 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<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(row("Total", tokens.size(), link(list(config, null, List.of()), "All access tokens")));
|
||||
owners.entrySet().stream().sorted(Comparator.<Map.Entry<String, List<String>>>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<String, Object> 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<String> 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 ("<a href=\"" + escape(url) + "\">" + escape(text) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal HTML escaping for a standard table link cell.
|
||||
*******************************************************************************/
|
||||
private static String escape(String value)
|
||||
{
|
||||
return (value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, Instant> lastUsed(AuthentikActionUtil util) throws QException
|
||||
{
|
||||
return (util.getRuntime().getTokenActivityCache().getResultThrowing("login", key ->
|
||||
{
|
||||
Map<String, Instant> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -74,7 +74,7 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
|
||||
addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION);
|
||||
|
||||
List<String> 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)
|
||||
|
||||
+72
@@ -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<QProcessMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
}
|
||||
}
|
||||
+70
@@ -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<QWidgetMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
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(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()));
|
||||
}
|
||||
}
|
||||
+1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
/*
|
||||
* 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<String> apps, Set<String> tables,
|
||||
Set<String> processes, Set<String> reports, Set<String> widgets, Set<String> 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<String> 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<TokenScope> 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 -> lookupAsSystem(instance, () ->
|
||||
{
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
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.", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String> 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<String> 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<String> 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<String> nullIfEmpty(Set<String> 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<String, Object> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -532,7 +532,22 @@ public class FakeAuthentik
|
||||
}
|
||||
if(path.equals("/events/events/"))
|
||||
{
|
||||
return (page(loginEvents));
|
||||
List<JSONObject> 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/"))
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* 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<AuthentikTokenAlerts.Notice> 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<String, Instant> 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);
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// 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 //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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(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")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String> 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"))
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
@@ -542,8 +615,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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user