Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38d9776f25
|
||
|
|
9eece9cde5 | ||
|
|
b1d9df5370 | ||
|
|
d138c5c911
|
||
|
|
f151a7344a
|
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+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
|
||||
{
|
||||
|
||||
+129
@@ -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;
|
||||
|
||||
+176
@@ -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
|
||||
|
||||
+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
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user