Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dfef79929
|
||
|
|
eb5d019fe7 | ||
|
|
38d9776f25
|
||
|
|
d138c5c911
|
No files matched your search
@@ -45,7 +45,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
|
||||
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
|
||||
- Never filter client-side; reject what Authentik cannot evaluate.
|
||||
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
|
||||
@@ -61,7 +61,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Dependencies
|
||||
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
|
||||
## Release
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
- 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.
|
||||
- Token-manager read access, expanded token metadata, secret redaction and in-memory process/persistence tests.
|
||||
|
||||
### Changed (core baseline, #20 / F19)
|
||||
- Build against the released QQQ BOM `4.1.0-RC.5` through the existing `qqq.snapshot.version` property; no core SNAPSHOT is used. No Java API compatibility fixes were needed.
|
||||
- Core actions now enforce permissions for non-HTTP callers. Authentik-specific guards remain for direct util calls and domain rules. See [UPGRADE.md](UPGRADE.md) for host impact and the remaining test stand-in.
|
||||
|
||||
### Added (automated sessions)
|
||||
- `AuthentikGroupPermissionMapper` resolves tokenless automated sessions by a unique active Authentik email match, applying direct and inherited group permissions, static fallbacks and the sign-in cut-over guard while preserving existing session permissions.
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
- Every `AuthentikActionUtil` entry point calls the guard first (in addition to core action permission enforcement, retain Authentik-specific rules and protection for direct util calls).
|
||||
- Never log query strings (search terms are PII), the token, successful GET bodies, or bodies of sensitive endpoints; run anything stored through `AuthentikRedactor`.
|
||||
- Never filter client-side; reject what Authentik cannot evaluate.
|
||||
- Multi-value params are repeated plain names (`type=a&type=b`). Authentik silently ignores `type[]=`.
|
||||
@@ -61,7 +61,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
|
||||
## Dependencies
|
||||
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
Java 21. Parent `qbit-build-parent:2.0.0`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` (ADR-0007, as in qbit-sql-workbench). The project uses `qqq-backend-core` and `qqq-backend-module-api`. Test dependencies: JUnit 5, AssertJ, Mockito, Testcontainers 1.21.4, and Jackson YAML (from qqq) for the schema tests.
|
||||
|
||||
## Release
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
|
||||
|
||||
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
|
||||
|
||||
The editor discovers features and labels through `PermissionsHelper.getAllAvailablePermissions`; they appear when the host's core version exposes them. This qbit does not compile against the unreleased metadata hook. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
|
||||
The editor discovers features and labels through `PermissionsHelper.getAllAvailablePermissions`; they appear when the host's core version exposes them. The pinned RC.5 release does not include `TableFeaturePermissionsMetaData`; its later core implementation is a follow-up for the next baseline upgrade. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
|
||||
|
||||
### Authentik side
|
||||
|
||||
@@ -83,7 +83,7 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
|
||||
| Item | What to do |
|
||||
|---|---|
|
||||
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
|
||||
| QQQ version | QQQ 4.1 (see `pom.xml` for the exact RC). Java 21. |
|
||||
| QQQ version | QQQ 4.1.0-RC.5 (released BOM, no core SNAPSHOT). Java 21. |
|
||||
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. |
|
||||
| Tables | Create `authentik_admin_action`, `authentik_approval_request` and `authentik_token_scope` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
|
||||
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. |
|
||||
|
||||
+27
@@ -1,5 +1,32 @@
|
||||
# Upgrading qbit-authentik
|
||||
|
||||
## QQQ 4.1.0-RC.5 baseline (#20 / F19)
|
||||
|
||||
Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.5`
|
||||
and use Java 21. The historical `qqq-snapshot` profile and `qqq.snapshot.version`
|
||||
property names remain, but the imported core version is a release, not a SNAPSHOT.
|
||||
Core actions now enforce permissions and field security locks for non-HTTP callers
|
||||
(QRun-IO/qqq#1061, #1062); callers must supply an appropriately authorized session.
|
||||
The qbit keeps its guards, scope checks and sensitive-field stripping because they
|
||||
also protect direct backend calls and implement Authentik-specific rules.
|
||||
No qbit configuration, stored policy format or schema changes are introduced.
|
||||
|
||||
Stand-in audit against core tag `v4.1.0-RC.5`:
|
||||
|
||||
- `PolicyFeaturePermissionsTest` supplies a synthetic available-permission catalog:
|
||||
`STAND-IN(core=QRun-IO/qqq#1136, removeBy=4.1.0-RC.6)`. RC.6 is the removal target,
|
||||
subject to verifying that release contains the metadata hook. Retain malformed
|
||||
and duplicate catalog test cases when adding real metadata registration coverage.
|
||||
- The requested stand-in/RC grep found only the old baseline references; the broader
|
||||
audit found the test catalog above and stale core-enforcement explanations.
|
||||
No production bridge can safely be removed by this baseline bump.
|
||||
- `TableFeaturePermissionsMetaData` (core commit `9a302136d`, #1136) and client
|
||||
channels (core commit `741bb4974`, PR #1141) exist after RC.5, but neither commit
|
||||
is an ancestor of the RC.5 tag and neither API is present in that release.
|
||||
Follow up on a released baseline containing them: verify the policy editor's
|
||||
catalog with real feature metadata and adopt client-channel attribution and
|
||||
identity/credential channel restrictions. These features are not added here.
|
||||
|
||||
## Permissions renamed `identity.*` → `authentik.*` (unreleased → first release)
|
||||
|
||||
The qbit's permissions follow the same owner naming rule (2026-10-12) and now
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@ Seven design rules shape everything below:
|
||||
- A group's QQQ permissions live in the group attribute `qqq.io/permissions`.
|
||||
- The reusable `AuthentikGroupPermissionMapper` turns a user's groups, including inherited parents, into QQQ session permissions.
|
||||
- Who can operate the qbit is decided by those same permissions (`authentik.*`). No operator group is hard-coded.
|
||||
4. **The qbit checks permissions itself.** QQQ core actions do not enforce permissions (QRun-IO/qqq#1061), and `FieldSecurityLock` is not enforced (#1062). An `AuthentikGuard` in the backend action layer checks every read and write.
|
||||
4. **The qbit checks permissions itself.** The original core gaps (QRun-IO/qqq#1061, #1062) are fixed in the RC.5 baseline. Direct backend calls and Authentik-specific rules still require local checks. An `AuthentikGuard` in the backend action layer checks every read and write.
|
||||
5. **No escalation.**
|
||||
- Operators can only grant permissions they hold themselves, whether through memberships or permission attributes.
|
||||
- Operators cannot edit themselves.
|
||||
@@ -668,7 +668,7 @@ This is weaker than revision 1's per-group object permissions. That is the cost
|
||||
- `AuthentikActionUtil.doQuery/doGet/doInsert/doUpdate/doDelete` call `AuthentikGuard.check(table, op, session)` first. The guard checks the QQQ permission, the feature flag, `readOnly` and scope.
|
||||
- A session with no user is refused, except `MirrorSync`'s internal read-only session.
|
||||
- Every qbit process's first backend step re-checks `checkProcessPermissionThrowing` and the per-record rules (§5.4). It does not trust that the middleware ran.
|
||||
- Field-level protection does not use `FieldSecurityLock` (#1062):
|
||||
- Field-level protection retains explicit stripping for direct backend calls; core also enforces `FieldSecurityLock` in the RC.5 baseline (#1062):
|
||||
- Sensitive columns are removed in `jsonObjectToRecord` without `authentik.audit.read`. These are `attributes` (except the `qqq.io/*` keys, shown under `users.read`), `last_ip`, `user_agent` and `geo_ip`.
|
||||
- Filters on those fields are rejected for that session.
|
||||
- Tables also declare `QPermissionRules`, so the UI hides what the user can't use.
|
||||
@@ -955,7 +955,7 @@ Authenticated API calls are not throttled by Authentik, and kof22's Authentik sh
|
||||
| A blueprint re-apply reverts QQQ edits | No `present` identity entries, no member lists (CI lint); bootstrap as `created` (verified, §4.2); reversion detector |
|
||||
| Lost update between QQQ and the Authentik UI | Stale check at execute; attribute merge-patch after a fresh read |
|
||||
| Account takeover through a recovery link | Links are never shown; email only |
|
||||
| A non-HTTP caller bypasses permissions (#1061) | Guard in the backend util, plus ArchUnit |
|
||||
| A direct backend caller bypasses core actions | Guard in the backend util, plus ArchUnit |
|
||||
| A field leaks because `FieldSecurityLock` isn't enforced (#1062) | Sensitive fields stripped in the util |
|
||||
| A forged webhook | Shared secret, constant-time compare; it only triggers re-reads |
|
||||
| Stale session permissions after a demotion | Re-evaluation on token refresh plus webhook-triggered marking (§5.6); live groups used for guard checks |
|
||||
@@ -1033,7 +1033,7 @@ The estimates assume one senior engineer with AI assistance, plus about 20% of a
|
||||
| The service account can lock administrators out (remove members from `authentik Admins`, deactivate break-glass, strip `qqq.io/protected`, §0.2 ii-b). Re-parenting under a superuser group **is** refused by Authentik (§0.2 ii) | Guard refusal of every write that targets a protected account or a superuser group; drift report; recovery through cluster access |
|
||||
| Load on the shared Postgres | Throttle, caps, mirror |
|
||||
| Webhook loss | Inbox, 15-minute reconcile, nightly sweep |
|
||||
| QQQ core gaps (#1061, #1062) | Guard in the backend util, ArchUnit, field stripping |
|
||||
| Direct backend calls outside core enforcement | Guard in the backend util, ArchUnit, field stripping |
|
||||
| Token leakage | Redaction tests; no body logging on sensitive paths |
|
||||
| An Authentik upgrade changes fields | Pinned schema, fixtures per version, version check |
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ Authentik puts the merged document into the token through a scope mapping. Steps
|
||||
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
|
||||
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
|
||||
|
||||
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog continue to accept only standard actions. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the existing available-permission API without compiling against the unreleased `TableFeaturePermissionsMetaData` hook; features become available when the host's core supplies those entries.
|
||||
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog continue to accept only standard actions. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the existing available-permission API without compiling against the `TableFeaturePermissionsMetaData` hook (absent from the pinned RC.5 release); features become available when the host's core supplies those entries.
|
||||
|
||||
The mapping merges the statements of every group in `request.user.all_groups()` (direct groups and their ancestors) whose document has the right spec and version. It always emits a document, with an empty `Statement` array when the user has no policies.
|
||||
|
||||
|
||||
@@ -12,17 +12,6 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated
|
||||
| 3. Full user lifecycle | done, except the optional snapshot export |
|
||||
| 4. RBAC role management | post-v1, not started |
|
||||
| 5. Policies and full CRUD (DESIGN.md section 12) | 5a policy model (#14), 5b policy editor (#15), 5c user and group CRUD additions; the Next UI widget is a separate qqq-frontend-next PR |
|
||||
| 6d. Personal access token Preferences | implemented on `feature/token-preferences`; draft until a QQQ RC contains QRun-IO/qqq#1162 |
|
||||
|
||||
### Phase 6d: Access tokens Preferences
|
||||
|
||||
The `TOKENS` feature registers the `access-tokens` section and user-menu shortcut without an app or permission gate. The provider resolves the current Authentik operator and reads only their app passwords, with read-only metadata and optional scope inputs. Create, rotate, extend and revoke use `AuthentikAdmin`, including live protection/ownership checks, broker exchange and audit. Preferences uses core's one-time secret response independently of process delivery; keys never enter section values or local storage.
|
||||
|
||||
This branch builds against the locally installed `4.1.0-PREFS-SNAPSHOT`. Keep any PR draft until the RC shipping #1162 is available, then replace the marked POM stand-in. Last-used tracking is not implemented in this branch, so the section omits it. Next UI renders the section and user-menu entry through the core Preferences contract.
|
||||
|
||||
`AccessTokenPreferencesTest` exercises the core dispatcher with FakeAuthentik and an in-memory QInstance, including lifecycle, scoped rotation, ownership, protected-user/broker refusals, read-only validation, and searches of local tables, audit, reloaded section values, events and captured logs for leaked keys.
|
||||
|
||||
Validation (2026-10-13): `./mvnw.cmd -q -o verify -DskipITs` passed on Windows: 253 tests, no failures/errors/skips; JaCoCo 91.48% instruction and 100% class coverage. Contract ITs were intentionally excluded.
|
||||
|
||||
### Built in this line (0.1)
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
<profiles>
|
||||
<!-- Needs qqq 4.1 (QBit producer API). Until qbit-build-parent imports the -->
|
||||
<!-- released 4.1 BOM, this profile is active by default and imports -->
|
||||
<!-- qqq-bom-pom 4.1.0-RC.2 ahead of the parent 4.0.0 BOM, as in -->
|
||||
<!-- qqq-bom-pom 4.1.0-RC.5 ahead of the parent 4.0.0 BOM, as in -->
|
||||
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
@@ -245,8 +245,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<!-- STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7): local build of feature/user-preferences; switch to the RC that ships #1162 -->
|
||||
<qqq.snapshot.version>4.1.0-PREFS-SNAPSHOT</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -25,7 +25,6 @@ import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qbits.authentik.app.AuthentikRelations;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.authentik.preferences.AccessTokenPreferences;
|
||||
import com.kingsrook.qbits.authentik.processes.AuthentikProcesses;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -186,10 +185,6 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
|
||||
output.addSelfToInstance(qInstance);
|
||||
|
||||
addPossibleValueSources(qInstance);
|
||||
if(qBitConfig.hasFeature(AuthentikFeature.TOKENS))
|
||||
{
|
||||
AccessTokenPreferences.register(qInstance, qBitConfig);
|
||||
}
|
||||
AuthentikRelations.apply(qInstance, qBitConfig);
|
||||
for(QProcessMetaData process : AuthentikProcesses.processes(qBitConfig))
|
||||
{
|
||||
|
||||
@@ -130,10 +130,6 @@ public class AuthentikTokens
|
||||
boolean own = Objects.equals(operatorPk, ownerPk);
|
||||
boolean service = "service_account".equals(owner.optString("type"));
|
||||
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
|
||||
if(isPreferences(request) && (!own || service || token != null && !"app_password".equals(token.optString("intent"))))
|
||||
{
|
||||
return (plan.refuse("Preferences only manages your own personal app-password tokens."));
|
||||
}
|
||||
if(service && action == AdminAction.CREATE_TOKEN)
|
||||
{
|
||||
return (plan.refuse("Service accounts have one app password. Use Rotate Token or Create Service Account."));
|
||||
@@ -173,7 +169,7 @@ public class AuthentikTokens
|
||||
}
|
||||
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
|
||||
{
|
||||
if(!isPreferences(request) && !TokenKeyDelivery.get().available())
|
||||
if(!TokenKeyDelivery.get().available())
|
||||
{
|
||||
return (plan.refuse(DELIVERY_UNAVAILABLE));
|
||||
}
|
||||
@@ -406,7 +402,7 @@ public class AuthentikTokens
|
||||
{
|
||||
AdminRequest request = plan.getRequest();
|
||||
if((request.action() == AdminAction.CREATE_TOKEN || request.action() == AdminAction.ROTATE_TOKEN || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
|
||||
&& (!isPreferences(request) || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT) && !TokenKeyDelivery.get().available())
|
||||
&& !TokenKeyDelivery.get().available())
|
||||
{
|
||||
throw (new QUserFacingException(DELIVERY_UNAVAILABLE));
|
||||
}
|
||||
@@ -420,10 +416,6 @@ public class AuthentikTokens
|
||||
boolean service = "service_account".equals(owner.optString("type"));
|
||||
boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk"));
|
||||
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
|
||||
if(isPreferences(request) && (!own || service || before != null && !"app_password".equals(before.optString("intent"))))
|
||||
{
|
||||
throw (new QUserFacingException("Preferences only manages your own personal app-password tokens."));
|
||||
}
|
||||
if((!own || service) && !manager || !own && !service && request.action() != AdminAction.REVOKE_TOKEN)
|
||||
{
|
||||
throw (new QUserFacingException("Token ownership changed; review again."));
|
||||
@@ -614,7 +606,7 @@ public class AuthentikTokens
|
||||
/*******************************************************************************
|
||||
** Scope summary contains metadata only.
|
||||
*******************************************************************************/
|
||||
public String scopeSummary(String identifier) throws QException
|
||||
private String scopeSummary(String identifier) throws QException
|
||||
{
|
||||
QRecord row = scopes.find(identifier);
|
||||
List<String> parts = new ArrayList<>();
|
||||
@@ -630,16 +622,6 @@ public class AuthentikTokens
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preferences has core's response-only secret channel, independent of processes.
|
||||
*******************************************************************************/
|
||||
private static boolean isPreferences(AdminRequest request)
|
||||
{
|
||||
return ("preferences".equals(request.str("deliveryChannel")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the isolated one-time object, including its response-only usage text.
|
||||
*******************************************************************************/
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the drift report as a TableData. Re-checks authentik.admin itself
|
||||
** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
** for direct renderer calls as well as calls through core RenderWidgetAction.
|
||||
*******************************************************************************/
|
||||
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.preferences;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceListMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Self-service token metadata for core's generic Preferences clients.
|
||||
*******************************************************************************/
|
||||
public final class AccessTokenPreferences
|
||||
{
|
||||
public static final String SECTION = "access-tokens";
|
||||
public static final String LIST = "tokens";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register without an app or permission gate: every signed-in user may enter.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance, AuthentikQBitConfig config)
|
||||
{
|
||||
UserPreferenceListMetaData tokens = new UserPreferenceListMetaData().withName(LIST).withLabel("Tokens").withRowLabel("Token")
|
||||
.withField(readOnly("description", "Description", QFieldType.STRING))
|
||||
.withField(readOnly("identifier", "Identifier", QFieldType.STRING))
|
||||
.withField(readOnly("intent", "Intent", QFieldType.STRING))
|
||||
.withField(readOnly("expires", "Expires", QFieldType.DATE_TIME))
|
||||
.withField(readOnly("scope", "Scope", QFieldType.STRING));
|
||||
UserPreferenceActionMetaData create = new UserPreferenceActionMetaData().withName("create").withLabel("Create token")
|
||||
.withScope(UserPreferenceActionScope.SECTION).withOrder(10)
|
||||
.withInputField(new QFieldMetaData("description", QFieldType.STRING).withLabel("Description").withIsRequired(true))
|
||||
.withInputField(expiry().withDefaultValue(30));
|
||||
if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
|
||||
{
|
||||
create.withInputField(new QFieldMetaData("readOnly", QFieldType.BOOLEAN).withLabel("Read only"))
|
||||
.withInputField(new QFieldMetaData("apps", QFieldType.TEXT).withLabel("Apps"))
|
||||
.withInputField(new QFieldMetaData("tables", QFieldType.TEXT).withLabel("Tables"));
|
||||
}
|
||||
UserPreferencesMetaData.ofOrWithNew(instance).withSection(new UserPreferenceSectionMetaData()
|
||||
.withName(SECTION).withLabel("Access tokens").withIcon(new QIcon("key"))
|
||||
.withDescription("Personal access tokens for scripts and integrations. Keys are shown once.")
|
||||
.withOrder(30).withShowInUserMenu(true).withProvider(new QCodeReference(AccessTokenPreferencesProvider.class))
|
||||
.withList(tokens).withAction(create)
|
||||
.withAction(rowAction("rotate", "Rotate token", 20).withIsDestructive(true)
|
||||
.withConfirmationText("Rotate this token? The old key stops working now."))
|
||||
.withAction(rowAction("extend", "Extend expiry", 30).withInputField(expiry()))
|
||||
.withAction(rowAction("revoke", "Revoke token", 40).withIsDestructive(true)
|
||||
.withConfirmationText("Revoke this token? Anything using it stops working now.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Token attributes can only be changed by the audited actions.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData readOnly(String name, String label, QFieldType type)
|
||||
{
|
||||
return (new QFieldMetaData(name, type).withLabel(label).withIsEditable(false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both creation and extension require an explicit bounded lifetime.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData expiry()
|
||||
{
|
||||
return (new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withLabel("Expires in days").withIsRequired(true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All row actions refer to the provider's own-token list.
|
||||
*******************************************************************************/
|
||||
private static UserPreferenceActionMetaData rowAction(String name, String label, int order)
|
||||
{
|
||||
return (new UserPreferenceActionMetaData().withName(name).withLabel(label).withOrder(order)
|
||||
.withScope(UserPreferenceActionScope.ROW).withListName(LIST));
|
||||
}
|
||||
}
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.preferences;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminAction;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminRequest;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikTokens;
|
||||
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionRequest;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionResult;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
|
||||
import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Live personal app-password metadata and audited actions. Keys exist only in
|
||||
** the immediate action response, never in stored or reloaded preference values.
|
||||
*******************************************************************************/
|
||||
public class AccessTokenPreferencesProvider implements UserPreferenceProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Resolve the configured qbit in this instance, including custom backend names.
|
||||
** Refuse ambiguity rather than silently selecting another identity directory.
|
||||
*******************************************************************************/
|
||||
private AuthentikAdmin admin(UserPreferenceRequestContext context) throws QException
|
||||
{
|
||||
if(QContext.getQSession() != context.getSession())
|
||||
{
|
||||
throw (new QUserFacingException("The preference session changed; sign in again."));
|
||||
}
|
||||
List<AuthentikQBitConfig> configs = QContext.getQInstance().getQBits().values().stream()
|
||||
.map(qbit -> qbit.getConfig()).filter(AuthentikQBitConfig.class::isInstance).map(AuthentikQBitConfig.class::cast)
|
||||
.filter(config -> config.hasFeature(AuthentikFeature.TOKENS)).toList();
|
||||
if(configs.size() != 1)
|
||||
{
|
||||
throw (new QUserFacingException("Access token preferences need one configured Authentik directory."));
|
||||
}
|
||||
AuthentikAdmin admin = AuthentikAdmin.forBackend(configs.get(0).getApiBackendName());
|
||||
admin.getUtil().getRuntime().getGuard().checkTokenPreferencesRead();
|
||||
return (admin);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read all pages with server-side owner and intent constraints. A mismatched
|
||||
** response fails closed; metadata is explicitly allowlisted, never copied whole.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
|
||||
{
|
||||
AuthentikAdmin admin = admin(context);
|
||||
AuthentikActionUtil util = admin.getUtil();
|
||||
util.getRuntime().invalidateCache();
|
||||
Integer pk = admin.operator().pk();
|
||||
if(pk == null)
|
||||
{
|
||||
throw (new QUserFacingException(OAuth2SubjectTokenSource.UNAVAILABLE));
|
||||
}
|
||||
JSONObject owner = util.getApiJson("/core/users/" + pk + "/");
|
||||
String username = owner.getString("username");
|
||||
List<UserPreferenceRow> rows = new ArrayList<>();
|
||||
AuthentikTokens tokens = new AuthentikTokens(admin);
|
||||
for(JSONObject token : util.getAllPages("/core/tokens/?user__username=" + URLEncoder.encode(username, StandardCharsets.UTF_8)
|
||||
+ "&intent=app_password", Integer.MAX_VALUE))
|
||||
{
|
||||
Object user = token.opt("user");
|
||||
int ownerPk = user instanceof JSONObject object ? object.optInt("pk", -1) : token.optInt("user", -1);
|
||||
if(ownerPk != pk || !"app_password".equals(token.optString("intent")))
|
||||
{
|
||||
throw (new QUserFacingException("Authentik returned tokens outside your personal token list."));
|
||||
}
|
||||
String identifier = token.getString("identifier");
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
values.put("description", token.optString("description", ""));
|
||||
values.put("identifier", identifier);
|
||||
values.put("intent", "app_password");
|
||||
values.put("expires", token.isNull("expires") ? null : Instant.parse(token.getString("expires")));
|
||||
values.put("scope", tokens.scopeSummary(identifier));
|
||||
rows.add(new UserPreferenceRow().withKey(identifier).withLabel(token.optString("description", identifier)).withValues(values));
|
||||
}
|
||||
return (new UserPreferenceValues().withLists(Map.of(AccessTokenPreferences.LIST, rows)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scalar and row edits are unavailable; lifecycle actions own every mutation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
|
||||
{
|
||||
throw (new QBadRequestException("Use the access token actions to make changes."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the same live guards, broker and audit as record actions. The delivery
|
||||
** channel is server-selected and never accepted as a client input field.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UserPreferenceActionResult runAction(UserPreferenceRequestContext context, UserPreferenceActionRequest request) throws QException
|
||||
{
|
||||
AdminAction action = switch(request.getActionName())
|
||||
{
|
||||
case "create" -> AdminAction.CREATE_TOKEN;
|
||||
case "rotate" -> AdminAction.ROTATE_TOKEN;
|
||||
case "extend" -> AdminAction.EXTEND_TOKEN;
|
||||
case "revoke" -> AdminAction.REVOKE_TOKEN;
|
||||
default -> throw new QBadRequestException("This action isn't available.");
|
||||
};
|
||||
AdminRequest change = AdminRequest.of(action, request.getRowKey() == null ? "" : request.getRowKey(), "Preferences: " + request.getActionName());
|
||||
for(String field : List.of("description", "expiresInDays", "readOnly", "apps", "tables"))
|
||||
{
|
||||
if(request.getValues().containsKey(field))
|
||||
{
|
||||
change.with(field, request.getValues().get(field));
|
||||
}
|
||||
}
|
||||
change.with("deliveryChannel", "preferences");
|
||||
AuthentikAdmin.AdminResult result = admin(context).execute(change);
|
||||
if(!result.ok())
|
||||
{
|
||||
throw (new QUserFacingException(result.message()));
|
||||
}
|
||||
UserPreferenceActionResult response = new UserPreferenceActionResult().withMessage(result.message());
|
||||
OneTimeKey key = result.takeSecret();
|
||||
if(key != null)
|
||||
{
|
||||
response.withFocusRowKey(key.label()).withSecrets(List.of(new OneTimeSecret().withLabel("Access token")
|
||||
.withValue(key.value()).withMasked(key.masked()).withUsageText(key.usageText())));
|
||||
}
|
||||
result.delivered();
|
||||
return (response);
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,9 @@ import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's own enforcement point (DESIGN.md section 5.3). QQQ core actions
|
||||
** do not enforce permissions for non-HTTP callers (QRun-IO/qqq#1061), so every
|
||||
** call into AuthentikActionUtil is checked here first:
|
||||
** The qbit's own enforcement point (DESIGN.md section 5.3). Core actions now
|
||||
** enforce permissions (QRun-IO/qqq#1061); direct AuthentikActionUtil calls and
|
||||
** Authentik-specific rules still need this guard:
|
||||
** - reads need the table's authentik.* permission (system sessions may read,
|
||||
** for the mirror sync);
|
||||
** - sensitive fields need authentik.audit.read;
|
||||
@@ -155,22 +155,6 @@ public class AuthentikGuard
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Self-service metadata reads need a signed-in person, not table permissions.
|
||||
** The Preferences provider must additionally constrain every read to that owner.
|
||||
*******************************************************************************/
|
||||
public void checkTokenPreferencesRead() throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
requireUser(session);
|
||||
if(session instanceof QSystemUserSession || !config.hasFeature(AuthentikFeature.READ) || !config.hasFeature(AuthentikFeature.TOKENS))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Access token preferences are not available."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the current session may see and filter sensitive fields.
|
||||
*******************************************************************************/
|
||||
|
||||
+3
-1
@@ -45,7 +45,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Table features use generic permission suffixes from core's existing catalog
|
||||
** API. No test or production code depends on the unreleased metadata hook.
|
||||
** API. The metadata hook is absent from the pinned core RC.5 release.
|
||||
** STAND-IN(core=QRun-IO/qqq#1136, removeBy=4.1.0-RC.6)
|
||||
** Replace the synthetic feature catalog with metadata registration when upgrading.
|
||||
*******************************************************************************/
|
||||
class PolicyFeaturePermissionsTest
|
||||
{
|
||||
|
||||
-436
@@ -1,436 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.preferences;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminAction;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminPlan;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminRequest;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
|
||||
import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesKeyValueStoreMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionResult;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionResult;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.core.LogEvent;
|
||||
import org.apache.logging.log4j.core.Logger;
|
||||
import org.apache.logging.log4j.core.appender.AbstractAppender;
|
||||
import org.apache.logging.log4j.core.config.Property;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the real Preferences dispatcher against FakeAuthentik and local tables.
|
||||
*******************************************************************************/
|
||||
class AccessTokenPreferencesTest
|
||||
{
|
||||
private AdminTestSupport t;
|
||||
private final UserPreferencesAction actions = new UserPreferencesAction();
|
||||
private final List<String> logs = new ArrayList<>();
|
||||
private AbstractAppender appender;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No table permissions or process delivery adapter: core owns the response.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
t = AdminTestSupport.create();
|
||||
t.config.withTokenBrokerClientId("test-broker").withTokenBrokerClientSecret("test-broker-secret")
|
||||
.withSubjectTokenSource(session -> "subject:3").withAppClientId("test-app")
|
||||
.withAppPublicUrl("https://app.example.test").withAuthentikPublicUrl("https://auth.example.test");
|
||||
t.authentik.users.get(3).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=90");
|
||||
t.loginAs("sam@example.test");
|
||||
new UserPreferencesKeyValueStoreMetaDataProvider().defineAll(t.qInstance, AdminTestSupport.LOCAL, null);
|
||||
TokenKeyDelivery.set(null);
|
||||
appender = new AbstractAppender("preferences-token-capture", null, null, false, Property.EMPTY_ARRAY)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Retain messages and throwable text for the secret-leak assertion.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void append(LogEvent event)
|
||||
{
|
||||
logs.add(event.getMessage().getFormattedMessage());
|
||||
if(event.getThrownProxy() != null)
|
||||
{
|
||||
logs.add(event.getThrownProxy().getExtendedStackTraceAsString());
|
||||
}
|
||||
}
|
||||
};
|
||||
appender.start();
|
||||
((Logger) LogManager.getRootLogger()).addAppender(appender);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reset global fixture state after every test.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
((Logger) LogManager.getRootLogger()).removeAppender(appender);
|
||||
appender.stop();
|
||||
TokenKeyDelivery.set(null);
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registration is independent of Identity app access; all cells are read-only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void metadataAndFeatureGate() throws QException
|
||||
{
|
||||
UserPreferenceSectionMetaData section = actions.getSection("access-tokens").getSection();
|
||||
assertThat(section.getLabel()).isEqualTo("Access tokens");
|
||||
assertThat(section.getIcon().getName()).isEqualTo("key");
|
||||
assertThat(section.getDescription()).isEqualTo("Personal access tokens for scripts and integrations. Keys are shown once.");
|
||||
assertThat(section.getOrder()).isEqualTo(30);
|
||||
assertThat(section.getShowInUserMenu()).isTrue();
|
||||
assertThat(section.getAppName()).isNull();
|
||||
assertThat(section.getPermissionName()).isNull();
|
||||
assertThat(section.getFields()).isEmpty();
|
||||
assertThat(section.getLists()).hasSize(1);
|
||||
assertThat(section.getLists().get(0).getRowLabel()).isEqualTo("Token");
|
||||
assertThat(section.getLists().get(0).getFields()).extracting(QFieldMetaData::getName)
|
||||
.containsExactly("description", "identifier", "intent", "expires", "scope");
|
||||
assertThat(section.getLists().get(0).getFields()).allMatch(field -> !field.getIsEditable());
|
||||
assertThat(section.getLists().get(0).getFields().get(3).getType()).isEqualTo(QFieldType.DATE_TIME);
|
||||
UserPreferenceActionMetaData create = section.getActions().get(0);
|
||||
assertThat(create.getScope()).isEqualTo(UserPreferenceActionScope.SECTION);
|
||||
assertThat(create.getListName()).isNull();
|
||||
assertThat(create.getInputFields()).extracting(QFieldMetaData::getName).containsExactly("description", "expiresInDays");
|
||||
assertThat(create.getInputFields()).allMatch(QFieldMetaData::getIsRequired);
|
||||
assertThat(create.getInputFields().get(1).getDefaultValue()).isEqualTo(30);
|
||||
assertThat(section.getActions().get(1).getConfirmationText()).isEqualTo("Rotate this token? The old key stops working now.");
|
||||
assertThat(section.getActions().get(1).getIsDestructive()).isTrue();
|
||||
assertThat(section.getActions().get(3).getConfirmationText()).isEqualTo("Revoke this token? Anything using it stops working now.");
|
||||
assertThat(section.getActions().get(3).getIsDestructive()).isTrue();
|
||||
assertThat(actions.listSections()).hasSize(1);
|
||||
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ));
|
||||
assertThat(actions.listSections()).isEmpty();
|
||||
assertThatThrownBy(() -> actions.getSection("access-tokens")).isInstanceOf(QNotFoundException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The service-account list request is owner/intent filtered even for managers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void loadOnlyOwnAppPasswordsAndRejectForeignRows() throws QException
|
||||
{
|
||||
seed("mine", 3, "app_password");
|
||||
seed("my-api", 3, "api");
|
||||
seed("someone-else", 7, "app_password");
|
||||
for(IdentityPermission[] permissions : List.of(new IdentityPermission[0], new IdentityPermission[] { IdentityPermission.TOKENS_MANAGE }))
|
||||
{
|
||||
t.loginAs("sam@example.test", permissions);
|
||||
assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly("mine");
|
||||
assertThatThrownBy(() -> rowAction("revoke", "someone-else", Map.of())).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> rowAction("rotate", "my-api", Map.of())).isInstanceOf(QBadRequestException.class);
|
||||
}
|
||||
assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/core/tokens/")))
|
||||
.allSatisfy(r ->
|
||||
{
|
||||
assertThat(r.getURI().getQuery()).contains("user__username=sam", "intent=app_password");
|
||||
assertThat(r.getFirstHeader("Authorization").getValue()).isEqualTo("Bearer " + t.config.getResolvedApiToken());
|
||||
});
|
||||
assertThat(t.changes()).isEmpty();
|
||||
t.loginAs("dormant@example.test");
|
||||
assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly("someone-else");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create/rotate deliver one key each; extend/revoke never do. Reloads are safe.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void lifecycleAndNoPersistedSecrets() throws QException
|
||||
{
|
||||
assertThat(TokenKeyDelivery.get().available()).isFalse();
|
||||
UserPreferenceActionResult created = create(Map.of("description", "Build script", "expiresInDays", 30));
|
||||
assertThat(created.getSecrets()).hasSize(1);
|
||||
OneTimeSecret first = created.getSecrets().get(0);
|
||||
String oldId = created.getFocusRowKey();
|
||||
assertThat(first.getLabel()).isEqualTo("Access token");
|
||||
assertThat(first.getMasked()).endsWith(first.getValue().substring(first.getValue().length() - 4));
|
||||
assertThat(first.getUsageText()).contains("https://auth.example.test/application/o/token/", "username=sam", "test-app",
|
||||
"https://app.example.test/qqq/v1/metaData", "Expires:");
|
||||
assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly(oldId);
|
||||
assertThat(rows().get(0).getValues()).containsEntry("description", "Build script").containsEntry("scope", "Owner permissions");
|
||||
assertNoKey(first, created);
|
||||
|
||||
UserPreferenceActionResult rotated = rowAction("rotate", oldId, Map.of());
|
||||
assertThat(rotated.getSecrets()).hasSize(1);
|
||||
OneTimeSecret next = rotated.getSecrets().get(0);
|
||||
String newId = rotated.getFocusRowKey();
|
||||
assertThat(newId).isNotEqualTo(oldId);
|
||||
assertThat(next.getValue()).isNotEqualTo(first.getValue());
|
||||
assertThat(t.authentik.tokenKeys).doesNotContainKey(oldId).containsEntry(newId, next.getValue());
|
||||
assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly(newId);
|
||||
assertThat(rows().get(0).getValues().get("description")).isEqualTo("Build script");
|
||||
assertNoKey(next, rotated);
|
||||
|
||||
UserPreferenceActionResult extended = rowAction("extend", newId, Map.of("expiresInDays", 60));
|
||||
assertThat(extended.getSecrets()).isEmpty();
|
||||
assertThat((Instant) rows().get(0).getValues().get("expires")).isAfter(Instant.now().plusSeconds(59 * 86400));
|
||||
assertThat(t.authentik.tokens.get("sam").get(0).getInt("user")).isEqualTo(3);
|
||||
assertThat(t.authentik.tokens.get("sam").get(0).getString("intent")).isEqualTo("app_password");
|
||||
assertNoKey(next, extended);
|
||||
|
||||
UserPreferenceActionResult revoked = rowAction("revoke", newId, Map.of());
|
||||
assertThat(revoked.getSecrets()).isEmpty();
|
||||
assertThat(rows()).isEmpty();
|
||||
assertThat(t.authentik.tokenKeys).doesNotContainKey(newId);
|
||||
assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().contains("/core/tokens/")
|
||||
&& (!"GET".equals(r.getMethod()) || r.getURI().getPath().endsWith("/view_key/"))))
|
||||
.allSatisfy(r -> assertThat(r.getFirstHeader("Authorization").getValue()).isEqualTo("Bearer user-bearer:3"));
|
||||
assertNoKey(first, revoked);
|
||||
assertNoKey(next, revoked);
|
||||
assertThat(new QueryAction().execute(new QueryInput().withTableName("user_preference")).getRecords()).isEmpty();
|
||||
List<QRecord> audits = new QueryAction().execute(new QueryInput().withTableName("authentik_admin_action")).getRecords();
|
||||
assertThat(audits).extracting(r -> r.getValueString("reason"))
|
||||
.containsExactly("Preferences: create", "Preferences: rotate", "Preferences: extend", "Preferences: revoke");
|
||||
assertThat(audits).allSatisfy(r ->
|
||||
{
|
||||
assertThat(r.getValueString("operator")).isEqualTo("sam@example.test");
|
||||
assertThat(r.getValueString("status")).isEqualTo("SUCCEEDED");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scope inputs are conditional and their metadata survives rotation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scopesAreOptIn() throws QException
|
||||
{
|
||||
assertThatThrownBy(() -> create(Map.of("description", "Scoped", "expiresInDays", 30, "readOnly", true)))
|
||||
.isInstanceOf(QBadRequestException.class);
|
||||
t.config.withTokenScopesEnabled(true);
|
||||
UserPreferencesMetaData.of(t.qInstance).getSections().clear();
|
||||
AccessTokenPreferences.register(t.qInstance, t.config);
|
||||
assertThat(actions.getSection("access-tokens").getSection().getActions().get(0).getInputFields()).extracting(QFieldMetaData::getName)
|
||||
.containsExactly("description", "expiresInDays", "readOnly", "apps", "tables");
|
||||
UserPreferenceActionResult created = create(Map.of("description", "Scoped", "expiresInDays", 30, "readOnly", true,
|
||||
"apps", "content", "tables", "post"));
|
||||
assertThat(rows().get(0).getValues().get("scope")).isEqualTo("readOnly: true; apps: content; tables: post");
|
||||
UserPreferenceActionResult rotated = rowAction("rotate", created.getFocusRowKey(), Map.of());
|
||||
assertThat(new TokenScopeStore(t.config).find(rotated.getFocusRowKey()).getValueBoolean("readOnly")).isTrue();
|
||||
assertThat(new TokenScopeStore(t.config).find(created.getFocusRowKey())).isNull();
|
||||
assertNoKey(created.getSecrets().get(0), created);
|
||||
assertNoKey(rotated.getSecrets().get(0), rotated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Domain refusals remain audited, before any token mutation or broker exchange.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void protectedUserAndMissingBrokerRefused() throws QException
|
||||
{
|
||||
t.authentik.groupByName("identity-admins").getJSONArray("users").put(3);
|
||||
assertThatThrownBy(() -> create(Map.of("description", "Refused", "expiresInDays", 30)))
|
||||
.isInstanceOf(QUserFacingException.class).hasMessageContaining("Protected");
|
||||
t.authentik.groupByName("identity-admins").getJSONArray("users").remove(2);
|
||||
t.config.withTokenBrokerClientId("").withTokenBrokerClientSecret("");
|
||||
assertThatThrownBy(() -> create(Map.of("description", "Refused", "expiresInDays", 30)))
|
||||
.isInstanceOf(QUserFacingException.class).hasMessageContaining("token broker");
|
||||
assertThat(t.changes()).isEmpty();
|
||||
assertThat(t.authentik.tokenKeys).isEmpty();
|
||||
assertThat(FixtureActionUtil.REQUESTS).noneMatch(r -> r.getURI().getPath().endsWith("/application/o/token/"));
|
||||
assertThat(new QueryAction().execute(new QueryInput().withTableName("authentik_admin_action")).getRecords())
|
||||
.hasSize(2).allMatch(r -> "REFUSED".equals(r.getValueString("status")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core rejects arbitrary values and row edits; omitted required inputs reach
|
||||
** the existing domain validation rather than silently acquiring defaults.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validationAndReadOnlyRows() throws QException
|
||||
{
|
||||
seed("mine", 3, "app_password");
|
||||
assertThatThrownBy(() -> create(Map.of("description", "Missing expiry"))).hasMessageContaining("Expiry is required");
|
||||
assertThatThrownBy(() -> create(Map.of("expiresInDays", 30))).hasMessageContaining("description is required");
|
||||
assertThatThrownBy(() -> rowAction("extend", "mine", Map.of())).hasMessageContaining("Expiry is required");
|
||||
assertThatThrownBy(() -> create(Map.of("description", "Too long", "expiresInDays", 91))).hasMessageContaining("between 1 and 90");
|
||||
assertThatThrownBy(() -> create(Map.of("description", "Injected", "expiresInDays", 30, "deliveryChannel", "preferences")))
|
||||
.isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> actions.patchSection("access-tokens", Map.of("lists", Map.of("tokens",
|
||||
List.of(Map.of("key", "mine", "values", Map.of("description", "Changed")))))))
|
||||
.isInstanceOf(QBadRequestException.class).hasMessageContaining("can't be changed");
|
||||
assertThatThrownBy(() -> actions.patchSection("access-tokens", Map.of()))
|
||||
.isInstanceOf(QBadRequestException.class).hasMessageContaining("Use the access token actions");
|
||||
assertThat(t.changes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Even a manager cannot use Preferences after the target moves to a person
|
||||
** or service account, or changes intent, after the core row check.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void managerCannotCrossOwnershipOrIntentBoundary() throws QException
|
||||
{
|
||||
t.loginAs("sam@example.test", IdentityPermission.TOKENS_MANAGE);
|
||||
JSONObject token = seed("mine", 3, "app_password");
|
||||
for(int owner : List.of(7, 6))
|
||||
{
|
||||
token.put("user", 3);
|
||||
AdminRequest request = AdminRequest.of(AdminAction.REVOKE_TOKEN, "mine", "Preferences: revoke").with("deliveryChannel", "preferences");
|
||||
AdminPlan plan = t.admin().plan(request);
|
||||
assertThat(plan.isRunnable()).isTrue();
|
||||
token.put("user", owner);
|
||||
assertThat(t.admin().execute(plan, null).ok()).isFalse();
|
||||
}
|
||||
token.put("user", 3).put("intent", "api");
|
||||
assertThat(t.admin().execute(AdminRequest.of(AdminAction.REVOKE_TOKEN, "mine", "Preferences: revoke")
|
||||
.with("deliveryChannel", "preferences")).ok()).isFalse();
|
||||
assertThat(t.changes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A faulty upstream owner filter must never disclose another user's metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesUnexpectedOwnerAndUnresolvedIdentity() throws QException
|
||||
{
|
||||
JSONObject foreign = seed("foreign", 7, "app_password");
|
||||
var original = FixtureActionUtil.handler;
|
||||
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/core/tokens/")
|
||||
? FixtureActionUtil.response(200, new JSONObject().put("pagination", new JSONObject().put("total_pages", 1))
|
||||
.put("results", new JSONArray().put(foreign)).toString()) : original.apply(request);
|
||||
assertThatThrownBy(this::rows).isInstanceOf(QUserFacingException.class).hasMessageContaining("outside your personal token list");
|
||||
t.loginAs("unknown@example.test");
|
||||
assertThatThrownBy(this::rows).isInstanceOf(QUserFacingException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drive a section-level action exactly as the UI API does.
|
||||
*******************************************************************************/
|
||||
private UserPreferenceActionResult create(Map<String, ?> values) throws QException
|
||||
{
|
||||
return (actions.runAction("access-tokens", "create", Map.of("values", values)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drive a row action through core's loaded-row ownership check.
|
||||
*******************************************************************************/
|
||||
private UserPreferenceActionResult rowAction(String action, String key, Map<String, ?> values) throws QException
|
||||
{
|
||||
return (actions.runAction("access-tokens", action, Map.of("rowKey", key, "values", values)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reload instead of reusing a previous action's response.
|
||||
*******************************************************************************/
|
||||
private List<UserPreferenceRow> rows() throws QException
|
||||
{
|
||||
return (actions.getSection("access-tokens").getValues().getLists().get("tokens"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed the upstream fake, never the local preference or token-scope store.
|
||||
*******************************************************************************/
|
||||
private JSONObject seed(String id, int owner, String intent)
|
||||
{
|
||||
return (t.authentik.seedToken(id, owner, intent, Instant.now().plusSeconds(86400).toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect every local table (including audit and KV preferences), reloaded
|
||||
** values, response section, events and captured messages/throwables.
|
||||
*******************************************************************************/
|
||||
private void assertNoKey(OneTimeSecret secret, UserPreferenceActionResult result) throws QException
|
||||
{
|
||||
String key = secret.getValue();
|
||||
assertThat(secret.toString()).doesNotContain(key);
|
||||
assertThat(result.getMessage()).doesNotContain(key);
|
||||
assertThat(JsonUtils.toJson(result.getSection())).doesNotContain(key);
|
||||
UserPreferenceSectionResult reload = actions.getSection("access-tokens");
|
||||
assertThat(JsonUtils.toJson(reload)).doesNotContain(key);
|
||||
assertThat(reload.getValues().getValues()).isEmpty();
|
||||
assertThat(String.join("\n", logs)).doesNotContain(key);
|
||||
assertThat(t.authentik.events.toString()).doesNotContain(key);
|
||||
for(QTableMetaData table : t.qInstance.getTables().values())
|
||||
{
|
||||
if(AdminTestSupport.LOCAL.equals(table.getBackendName()))
|
||||
{
|
||||
assertThat(JsonUtils.toJson(new QueryAction().execute(new QueryInput().withTableName(table.getName())).getRecords()))
|
||||
.as(table.getName()).doesNotContain(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user