Compare commits

...
Author SHA1 Message Date
james.maes 38d9776f25 Merge remote-tracking branch 'origin/develop' into feature/arch-rc5
# Conflicts:
#	CHANGELOG.md
2026-10-13 20:18:53 -05:00
james.maes 9eece9cde5 Merge pull request #21 from QRun-IO/feature/token-spec
docs(spec): access tokens as QQQ's personal access tokens (revision 6)
2026-10-13 20:01:45 -05:00
james.maes b1d9df5370 Merge pull request #22 from QRun-IO/feature/mapper-automated-sessions
feat(permissions): Authentik permissions for automated sessions
2026-10-13 19:55:16 -05:00
james.maesandClaude Opus 5.5 d138c5c911 chore(deps): build on QQQ 4.1.0-RC.5
Move the core baseline from 4.1.0-RC.2 to the released 4.1.0-RC.5. No
source changes were needed. Comments now reflect core's opt-in permission
enforcement in 4.1. The synthetic feature-permission catalog in tests is
tagged STAND-IN(core=QRun-IO/qqq#1136, removeBy=4.1.0-RC.6), because
TableFeaturePermissionsMetaData is on develop but not in RC.5.

Refs #20 (architecture review QRun-IO/qqq#1150, finding F19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:51:47 -05:00
james.maesandClaude Opus 5.5 f151a7344a feat(permissions): Authentik permissions for automated sessions
AuthentikGroupPermissionMapper implements customizeAutomatedSessionForUser.
Server-minted sessions (scheduled saved reports, SSH-key users of
qbit-file-gateway) carry no token, so they got no Authentik permissions.
The email idReference must match exactly one active Authentik user; that
user's direct groups and ancestors go through the same resolver, static
fallbacks and cut-over guard as sign-in. Anything else, or an unreadable
API, grants only the static fallbacks. customizeSession is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:33 -05:00
13 changed files with 363 additions and 18 deletions

No files matched your search

+2 -2
View File
@@ -40,7 +40,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[]=`.
@@ -54,7 +54,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
+7
View File
@@ -4,6 +4,13 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
### 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.
### Added (table feature permissions)
- Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData
+2 -2
View File
@@ -43,7 +43,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[]=`.
@@ -59,7 +59,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
+2 -2
View File
@@ -67,7 +67,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
@@ -82,7 +82,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` and `authentik_approval_request` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. |
+27
View File
@@ -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
+8 -4
View File
@@ -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.
@@ -720,6 +720,10 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace
**Fallback.** If Authentik is unreachable at sign-in, the mapper uses the mirror. If the mirror is empty, it grants only what the static config provides. It never grants everything.
#### Automated sessions
Server-created sessions (scheduled reports and SSH-key users of the SFTP gateway) have no token. `customizeAutomatedSessionForUser` treats `userId` as the email idReference used by `user_email` mode and requires exactly one Authentik user with that email (trimmed, case-insensitive), who must be active. It resolves the API user's direct groups and their ancestors through the same permission resolver, static fallbacks and cut-over guard as sign-in, including the host permission source while the guard blocks. Service accounts follow the same rule. Bootstrap grants match only the resolved user's immutable pk or uuid; email verification is never assumed. Unknown, duplicate, inactive, unreadable or truncated lookups grant only empty-group static fallbacks. Existing session permissions are always preserved; an API failure never grants all permissions.
---
## 6. Data model
@@ -951,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 |
@@ -1029,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 |
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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,7 +245,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -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
{
@@ -17,6 +17,9 @@
package com.kingsrook.qbits.authentik.permissions;
import java.io.Serializable;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
@@ -171,6 +174,132 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
/*******************************************************************************
** Automated sessions have no claims. Resolve their email idReference to one
** active API user before trusting any groups or immutable bootstrap id.
*******************************************************************************/
@Override
public void customizeAutomatedSessionForUser(QInstance qInstance, QSession session, Serializable userId)
{
AuthentikRuntime runtime = runtime();
Set<String> permissions = new TreeSet<>();
String email = userId == null ? "" : userId.toString().trim();
try
{
JSONObject found = null;
int matches = 0;
List<JSONObject> users = email.isEmpty() ? List.of() : client(qInstance).getAllPages(
"/core/users/?email=" + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&include_groups=true&include_roles=false", 10);
for(JSONObject user : users)
{
if(email.equalsIgnoreCase(user.optString("email", "").trim()))
{
found = user;
matches++;
}
}
// A bounded, possibly truncated lookup cannot prove uniqueness.
if(users.size() < 10 && matches == 1 && found.optBoolean("is_active", false))
{
List<String> groups = new ArrayList<>();
JSONArray groupObjects = found.optJSONArray("groups_obj");
if(groupObjects != null)
{
for(int i = 0; i < groupObjects.length(); i++)
{
groups.add(groupObjects.getJSONObject(i).getString("name"));
}
}
else if(found.opt("groups") instanceof JSONArray groupPks && !groupPks.isEmpty())
{
GroupGraph graph = client(qInstance).groupGraph();
for(int i = 0; i < groupPks.length(); i++)
{
GroupGraph.Node group = graph.byPk(groupPks.getString(i));
if(group != null)
{
groups.add(group.name());
}
}
}
String pk = found.optString("pk", "");
String sub = runtime != null && runtime.getConfig().getResolvedBootstrapSubjects().contains(pk) ? pk : found.optString("uuid", pk);
String userEmail = found.getString("email").trim();
boolean verified = found.optBoolean("email_verified", false);
permissions.addAll(resolve(qInstance, groups, sub, userEmail, verified));
if(runtime != null)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups));
permissions = automatedSessionPermissions(qInstance, runtime, permissions, groups, sub, userEmail, verified);
}
}
else
{
LOG.info("Automated session has no unique active Authentik user; granting only the static fallbacks");
}
}
catch(Exception e)
{
permissions.clear();
// Exception messages can contain the lookup email; log only the type.
LOG.warn("Authentik could not be read for an automated session; granting only the static fallbacks", logPair("errorType", e.getClass().getSimpleName()));
}
if(runtime != null)
{
permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), List.of()));
}
if(session.getPermissions() != null)
{
permissions.addAll(session.getPermissions());
}
session.setPermissions(permissions);
}
/*******************************************************************************
** Mirror the sign-in cut-over floor without changing the claims path.
*******************************************************************************/
private Set<String> automatedSessionPermissions(QInstance qInstance, AuthentikRuntime runtime, Set<String> permissions,
List<String> groups, String sub, String email, boolean verified)
{
runtime.markMapperInvoked();
AccessComparison.State state;
try
{
state = AccessComparison.state(runtime, client(qInstance));
}
catch(Exception e)
{
state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now());
}
if(state.enforced())
{
return (permissions);
}
Set<String> floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups));
for(String group : groups)
{
floor.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
}
if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified))
{
for(IdentityPermission permission : IdentityPermission.values())
{
floor.add(permission.getSessionPermission());
}
}
AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource();
if(source != null)
{
floor.addAll(source.userPermissions(email, groups));
}
LOG.warn("Policies are not enforced for this automated session; granting today's access", logPair("status", state.status()));
return (floor);
}
/*******************************************************************************
** The permissions for a user's group names and token identity (sub, email,
** email_verified). Bootstrap operators are matched on immutable ids only.
@@ -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;
@@ -17,11 +17,13 @@
package com.kingsrook.qbits.authentik.permissions;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureRouter;
@@ -35,6 +37,9 @@ import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
@@ -130,6 +135,177 @@ class AuthentikGroupPermissionMapperTest
/*******************************************************************************
** Direct API groups grant their own and inherited permissions to both
** people and service accounts, preserving the host's session permissions.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { "internal", "service_account" })
void automatedSessionsResolveGroups(String type)
{
JSONObject user = user(99, " Person@Example.Test ").put("type", type)
.put("groups_obj", new JSONArray().put(new JSONObject().put("name", "child")));
usersWithEmail(user, user(100, "other@example.test"));
FixtureActionUtil.groupGraphBody = new JSONObject()
.put("pagination", new JSONObject().put("count", 2).put("current", 1).put("total_pages", 1))
.put("results", new JSONArray()
.put(new JSONObject().put("pk", "parent-pk").put("name", "parent").put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.users.read"))))
.put(new JSONObject().put("pk", "child-pk").put("name", "child").put("parents", new JSONArray().put("parent-pk")).put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.groups.manage"))))).toString();
config.withStaticGroupPermissions(Map.of("child", List.of("config.fallback")));
QSession session = new QSession();
session.setPermissions(Set.of("already.there"));
new AuthentikGroupPermissionMapper().withGroupPermissions("child", List.of("mapper.fallback"))
.customizeAutomatedSessionForUser(qInstance, session, " person@example.test ");
assertThat(session.getPermissions()).containsExactlyInAnyOrder("already.there", "config.fallback", "mapper.fallback",
IdentityPermission.USERS_READ.getSessionPermission(), IdentityPermission.GROUPS_MANAGE.getSessionPermission());
assertThat(FixtureActionUtil.REQUESTS).anySatisfy(request -> assertThat(request.getURI().getRawQuery())
.contains("email=person%40example.test", "include_groups=true", "include_roles=false"));
}
/*******************************************************************************
** Unknown, blank, ambiguous and inactive identities never acquire groups,
** host permissions or bootstrap permissions from an email alone.
*******************************************************************************/
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { "unknown", "duplicate", "inactive", "missing-active", "bounded" })
void automatedSessionsRejectUnprovenUsers(String scenario)
{
JSONObject candidate = user(42, "owner@example.test").put("groups_obj",
new JSONArray().put(new JSONObject().put("name", "phase1-child-of-identity-admins")));
if("duplicate".equals(scenario))
{
usersWithEmail(candidate, user(99, " OWNER@example.test ").put("is_active", false));
}
else if("inactive".equals(scenario))
{
usersWithEmail(candidate.put("is_active", false));
}
else if("missing-active".equals(scenario))
{
candidate.remove("is_active");
usersWithEmail(candidate);
}
else if("bounded".equals(scenario))
{
JSONObject[] candidates = new JSONObject[10];
java.util.Arrays.fill(candidates, user(99, "other@example.test"));
candidates[0] = candidate;
usersWithEmail(candidates);
}
else
{
usersWithEmail();
}
config.withStaticGroupPermissions(Map.of("phase1-child-of-identity-admins", List.of("config.fallback")))
.withHostPermissionSource((email, groups) -> Set.of("host.permission"));
QSession session = new QSession();
session.setPermissions(Set.of("already.there"));
new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("mapper.fallback"))
.customizeAutomatedSessionForUser(qInstance, session, scenario == null || scenario.isEmpty() ? scenario : "owner@example.test");
assertThat(session.getPermissions()).containsExactly("already.there");
}
/*******************************************************************************
** Failed API lookups and absent runtimes keep only existing permissions.
*******************************************************************************/
@Test
void automatedSessionLookupFailure()
{
FixtureActionUtil.handler = request -> FixtureActionUtil.response(403, "{\"detail\":\"Forbidden\"}");
QSession session = new QSession();
session.setPermissions(Set.of("already.there"));
new AuthentikGroupPermissionMapper().customizeAutomatedSessionForUser(qInstance, session, "owner@example.test");
assertThat(session.getPermissions()).containsExactly("already.there");
AuthentikGroupPermissionMapper noRuntime = new AuthentikGroupPermissionMapper()
{
/*******************************************************************************
** Deliberately missing backend.
*******************************************************************************/
@Override
protected String getApiBackendName()
{
return ("missingAutomatedBackend");
}
};
noRuntime.customizeAutomatedSessionForUser(qInstance, session, "owner@example.test");
assertThat(session.getPermissions()).containsExactly("already.there");
}
/*******************************************************************************
** A readable user's direct group names retain static fallbacks when the
** group graph fails; group UUIDs can also be resolved when objects are absent.
*******************************************************************************/
@Test
void automatedGroupFallbacks()
{
JSONObject child = new JSONObject(FixtureActionUtil.groupGraphBody).getJSONArray("results").toList().stream()
.map(value -> new JSONObject((Map<?, ?>) value)).filter(group -> "phase1-child-of-identity-admins".equals(group.optString("name")))
.findFirst().orElseThrow();
usersWithEmail(user(99, "person@example.test").put("groups", new JSONArray().put(child.getString("pk"))));
QSession session = new QSession();
new AuthentikGroupPermissionMapper().customizeAutomatedSessionForUser(qInstance, session, "person@example.test");
assertThat(session.getPermissions()).contains(IdentityPermission.USERS_READ.getSessionPermission());
usersWithEmail(user(99, "person@example.test").put("groups_obj", new JSONArray().put(child)));
FixtureActionUtil.groupGraphBody = "not json";
config.withStaticGroupPermissions(Map.of(child.getString("name"), List.of("config.fallback")));
session = new QSession();
new AuthentikGroupPermissionMapper().withGroupPermissions(child.getString("name"), List.of("mapper.fallback"))
.customizeAutomatedSessionForUser(qInstance, session, "person@example.test");
assertThat(session.getPermissions()).containsExactlyInAnyOrder("config.fallback", "mapper.fallback");
}
/*******************************************************************************
** The cut-over floor is identical to sign-in and bootstrap recognizes both
** API immutable ids without inventing an email_verified claim.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { "42", "uuid-42", "not-this-user" })
void automatedCutoverFloor(String bootstrapSubject)
{
config.withBootstrapOperatorSubjects(bootstrapSubject)
.withStaticGroupPermissions(Map.of("phase1-child-of-identity-admins", List.of("config.fallback")))
.withHostPermissionSource((email, groups) ->
{
assertThat(email).isEqualTo("owner@example.test");
assertThat(groups).containsExactly("phase1-child-of-identity-admins");
return (Set.of("host.permission"));
});
AuthentikRuntime.forBackend(config.getApiBackendName()).markMapperInvoked();
AuthentikRuntime.forBackend(config.getApiBackendName()).setMapperState(
new AccessComparison.State(AccessComparison.Status.BLOCKED, "Test guard", Instant.now()));
usersWithEmail(user(42, "owner@example.test").put("groups_obj",
new JSONArray().put(new JSONObject().put("name", "phase1-child-of-identity-admins"))));
QSession session = new QSession();
new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("mapper.fallback"))
.customizeAutomatedSessionForUser(qInstance, session, "owner@example.test");
if("not-this-user".equals(bootstrapSubject))
{
assertThat(session.getPermissions()).containsExactlyInAnyOrder("config.fallback", "mapper.fallback", "host.permission");
}
else
{
assertThat(session.getPermissions()).contains("config.fallback", "mapper.fallback", "host.permission");
for(IdentityPermission permission : IdentityPermission.values())
{
assertThat(session.getPermissions()).contains(permission.getSessionPermission());
}
}
}
/*******************************************************************************
** Bootstrap operators are keyed on immutable ids, never on an email: a
** spoofed email (duplicate, or a different user) is denied, an unverified
@@ -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
{