From 612307bde879c2b0e7153f7d08d7e724721fa7c9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 12:13:13 -0500 Subject: [PATCH] feat(policy): member preview payload for the policy editor The editor widget now lists the group's members (direct and through descendant groups, active and in scope, capped at 200) with each person's sign-in permissions WITHOUT this group's policy (basePermissions), plus mapperEnabled. The Next widget computes the live effective permissions for a chosen member and what the edit adds or removes. EffectivePermissionResolver gains an exclude-one-policy resolution; loading requires authentik.policies.manage. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/DESIGN.md | 4 +- .../authentik/client/AuthentikActionUtil.java | 47 ++++++ .../authentik/client/AuthentikApiSurface.java | 2 +- .../permissions/AccessComparison.java | 14 +- .../EffectivePermissionResolver.java | 16 +- .../authentik/policy/PolicyEditorWidget.java | 139 +++++++++++++++- .../qbits/authentik/admin/FakeAuthentik.java | 30 ++++ .../authentik/admin/PolicyEditorTest.java | 152 ++++++++++++++++++ .../EffectivePermissionResolverTest.java | 23 +++ 10 files changed, 422 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3624b50..002e38e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +- Edit Policy supplies per-member preview data for up to 200 active, in-scope direct and descendant members, with relevant direct group names, base permissions excluding only the edited policy, a truncation flag, and the configured mapper-enabled flag. The Next UI can compute effective permissions and additions/removals locally while preserving inherited, named, static and bootstrap grants. - Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group. - `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 75847b9..bb5f481 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1138,7 +1138,7 @@ The feature `POLICIES` turns on the editor, the `authentik_policy` table and the *Edit Policy* is a record action on `authentik_group` and on `authentik_policy`, never in the nav. It is a standard streamed-ETL process with three steps. -**1. Edit** (frontend step). The step shows the widget `authentikPolicyEditor`, type `AUTHENTIK_POLICY_EDITOR`, a lazy-loaded Next UI widget. The server sends it two things. +**1. Edit** (frontend step). The step shows the widget `authentikPolicyEditor`, type `AUTHENTIK_POLICY_EDITOR`, a lazy-loaded Next UI widget. The server sends its catalog, current document and member preview data. The **catalog** lists every app, table, process, report and protected widget in the QInstance. Each entry has its permission base name, label, kind, app, and the actions QQQ actually checks for it: - `hasAccess` for apps, processes, reports and widgets @@ -1155,6 +1155,8 @@ The catalog also follows these rules: The **current document** is read live from the group in Authentik, the source of truth, together with the group's fingerprint for the stale check. If it differs from the last QQQ save, a notice says it was changed in Authentik. +**Preview for a member.** On editor load only, under `authentik.policies.manage`, the payload adds `members` (up to 200 active, in-scope members of this group or its descendants, sorted by username), `membersTruncated` (true when more eligible people exist), and `mapperEnabled` from config so the UI can say "not enforced at sign-in". Each member has `username`, `label` (`Full Name (username)`), `viaGroups` (sorted direct group names that are this group or its descendants), and `basePermissions` (sorted session permissions with only this group's policy document excluded). Named `qqq.io/permissions`, other groups' and ancestors' policies, wildcard expansion, static direct-group fallbacks and bootstrap-subject grants still count. The Next UI combines that base with the current and edited documents to preview effective permissions and additions/removals locally. + The screen works like this: - Resources are grouped by app, with a search box. - Each row has a checkbox per action, plus *all* for the row. Each action column and each app has *select all*. diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java index 020cf6c..263e2de 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java @@ -22,6 +22,7 @@ import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; +import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Optional; @@ -30,6 +31,7 @@ import com.kingsrook.qbits.authentik.AuthentikRuntime; import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver; import com.kingsrook.qbits.authentik.permissions.GroupGraph; import com.kingsrook.qbits.authentik.security.AuthentikGuard; +import com.kingsrook.qbits.authentik.security.IdentityPermission; import com.kingsrook.qbits.authentik.security.WriteAuthorization; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; @@ -929,6 +931,51 @@ public class AuthentikActionUtil extends BaseAPIActionUtil + /******************************************************************************* + ** A bounded, username-ordered policy-preview lookup. Membership, active + ** status and user scope are evaluated by Authentik before the limit. + ** This read needs only the editor permission, not the users table permission. + *******************************************************************************/ + public List policyPreviewUsers(Collection groupPks, int maxObjects) throws QException + { + if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.POLICIES_MANAGE)) + { + throw (new QPermissionDeniedException("You do not have permission to preview policies (authentik.policies.manage).")); + } + if(groupPks.isEmpty()) + { + return (List.of()); + } + try + { + URIBuilder builder = new URIBuilder("/core/users/"); + builder.addParameter("include_groups", "true"); + builder.addParameter("include_roles", "false"); + builder.addParameter("is_active", "true"); + builder.addParameter("ordering", "username"); + for(String pk : groupPks) + { + builder.addParameter("groups_by_pk", pk); + } + addUserScope(builder, List.of()); + List users = new ArrayList<>(); + for(JSONObject user : getAllPages(builder.build().toString(), maxObjects)) + { + if(runtime().getGuard().isUserInScope(user) && user.optBoolean("is_active", false)) + { + users.add(user); + } + } + return (users); + } + catch(java.net.URISyntaxException e) + { + throw (new QException("Bad Authentik URL", e)); + } + } + + + /******************************************************************************* ** A resolver over the current group graph and the instance's permissions. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java index 759b6fb..073c794 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java @@ -70,7 +70,7 @@ public final class AuthentikApiSurface /////////////////////////////////////////////////////////// rs.add(new Operation("get", "/core/groups/", Set.of("include_users", "include_parents", "include_children", "name", "ordering", "page", "page_size"))); rs.add(new Operation("get", "/core/groups/{key}/", Set.of("include_users"))); - rs.add(new Operation("get", "/core/users/", Set.of("username", "include_groups", "attributes", "type", "path_startswith", "groups_by_name", "page", "page_size"))); + rs.add(new Operation("get", "/core/users/", Set.of("username", "include_groups", "include_roles", "attributes", "type", "path_startswith", "groups_by_name", "groups_by_pk", "is_active", "ordering", "page", "page_size"))); rs.add(new Operation("get", "/core/users/{key}/", Set.of())); rs.add(new Operation("get", "/managed/blueprints/", Set.of("page", "page_size"))); rs.add(new Operation("get", "/core/tokens/", Set.of("user__username", "intent", "page", "page_size"))); diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java index 16581f3..4282166 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java @@ -152,7 +152,19 @@ public final class AccessComparison public static Set mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection groupPks, Collection directGroupNames, String userPk, String userUuid) { - Set rs = new TreeSet<>(resolver.resolveGroups(groupPks).sessionPermissions()); + return (mapperPermissions(config, resolver, groupPks, directGroupNames, userPk, userUuid, null)); + } + + + + /******************************************************************************* + ** Preview the mapper with one group's policy removed, preserving the + ** static floor and immutable bootstrap-subject grants. + *******************************************************************************/ + public static Set mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection groupPks, Collection directGroupNames, + String userPk, String userUuid, String excludePolicyOfGroupPk) + { + Set rs = new TreeSet<>(resolver.resolveGroups(groupPks, excludePolicyOfGroupPk).sessionPermissions()); rs.addAll(staticPermissions(config, directGroupNames)); Set subjects = config.getResolvedBootstrapSubjects(); if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid))) diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java index a60bd10..ef8d3a7 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java @@ -86,6 +86,17 @@ public class EffectivePermissionResolver ** Effective permissions of a set of groups (e.g. a user's direct groups). *******************************************************************************/ public Resolution resolveGroups(Collection groupPks) + { + return (resolveGroups(groupPks, null)); + } + + + + /******************************************************************************* + ** Resolve normally, except for one group's policy document. Its named + ** permissions and every other group's policy still contribute. + *******************************************************************************/ + public Resolution resolveGroups(Collection groupPks, String excludePolicyOfGroupPk) { Set permissions = new TreeSet<>(); Map> grantedBy = new LinkedHashMap<>(); @@ -103,7 +114,10 @@ public class EffectivePermissionResolver } appAccess.addAll(node.appAccess()); List raws = new ArrayList<>(node.permissions()); - raws.addAll(node.policyPermissions()); + if(!node.pk().equals(excludePolicyOfGroupPk)) + { + raws.addAll(node.policyPermissions()); + } for(String raw : raws) { Set expanded = expand(raw); diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java index 99e2c8d..cdbee01 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java @@ -17,15 +17,26 @@ package com.kingsrook.qbits.authentik.policy; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; import com.kingsrook.qbits.authentik.AuthentikQBitConfig; import com.kingsrook.qbits.authentik.AuthentikRuntime; import com.kingsrook.qbits.authentik.admin.AuthentikAdmin; import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.permissions.AccessComparison; +import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver; +import com.kingsrook.qbits.authentik.permissions.GroupGraph; +import com.kingsrook.qbits.authentik.security.AuthentikGuard; +import com.kingsrook.qbits.authentik.security.IdentityPermission; import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; @@ -33,6 +44,7 @@ import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import org.json.JSONArray; import org.json.JSONObject; @@ -41,8 +53,8 @@ import org.json.JSONObject; ** 12.4). It is shown on the Edit Policy step; the Next UI widget of the same ** type draws the matrix and hands the generated document back to the step as ** the process value policyDocument. The data here: the catalog of what a - ** policy can grant, and the group's current document read live from - ** Authentik. + ** policy can grant, the group's current document read live from Authentik, + ** and the base permissions of members who inherit it. *******************************************************************************/ public final class PolicyEditorWidget { @@ -58,6 +70,8 @@ public final class PolicyEditorWidget /** Widget parameter (process value) with the group's pk. */ public static final String GROUP_PK_PARAM = "groupPk"; + private static final int MAX_MEMBERS = 200; + /******************************************************************************* @@ -74,12 +88,17 @@ public final class PolicyEditorWidget *******************************************************************************/ public static Data load(AuthentikQBitConfig config, String groupPk) throws QException { + if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.POLICIES_MANAGE)) + { + throw (new QPermissionDeniedException("You do not have permission to edit policies (authentik.policies.manage).")); + } AuthentikRuntime runtime = AuthentikRuntime.forBackend(config.getApiBackendName()); PolicyFormat format = config.getPolicyFormat(); Data data = new Data() .withSpec(format.spec()) .withVersion(format.version()) .withCatalog(runtime.getPolicyCatalog().toJson().toList()) + .withMapperEnabled(config.getMapperEnabled()) .withOutputFieldName(AuthentikAdmin.POLICY_DOCUMENT); if(!StringUtils.hasContent(groupPk)) @@ -119,11 +138,61 @@ public final class PolicyEditorWidget { data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format, runtime.getPolicyCatalog()))); } + loadMembers(config, groupPk, data); return (data); } + /******************************************************************************* + ** Members who inherit this policy, with only the edited document removed + ** from their sign-in grants. Fetch one extra eligible member to detect truncation. + *******************************************************************************/ + private static void loadMembers(AuthentikQBitConfig config, String groupPk, Data data) throws QException + { + AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName()); + GroupGraph graph = util.groupGraph(); + EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, util.getRuntime().getPermissionCatalog()); + Set affectedGroups = graph.nodes().stream() + .filter(node -> node.pk().equals(groupPk) || graph.ancestorPks(node.pk()).contains(groupPk)) + .map(GroupGraph.Node::pk).collect(Collectors.toSet()); + List users = new ArrayList<>(util.policyPreviewUsers(affectedGroups, MAX_MEMBERS + 1)); + users.sort(Comparator.comparing(user -> user.optString("username"))); + List> members = new ArrayList<>(); + for(JSONObject user : users) + { + List groupPks = new ArrayList<>(); + JSONArray groups = user.optJSONArray("groups_obj"); + for(int i = 0; groups != null && i < groups.length(); i++) + { + String pk = groups.getJSONObject(i).getString("pk"); + if(graph.byPk(pk) != null) + { + groupPks.add(pk); + } + } + List viaGroups = groupPks.stream().filter(affectedGroups::contains).map(pk -> graph.byPk(pk).name()).sorted().toList(); + if(viaGroups.isEmpty()) + { + continue; + } + if(members.size() == MAX_MEMBERS) + { + data.withMembersTruncated(true); + break; + } + List names = groupPks.stream().map(pk -> graph.byPk(pk).name()).toList(); + Set basePermissions = AccessComparison.mapperPermissions(config, resolver, groupPks, names, + String.valueOf(user.getInt("pk")), user.optString("uuid"), groupPk); + String username = user.optString("username"); + members.add(Map.of("username", username, "label", user.optString("name") + " (" + username + ")", + "viaGroups", viaGroups, "basePermissions", basePermissions.stream().sorted().toList())); + } + data.withMembers(members); + } + + + /******************************************************************************* ** Renderer. QQQ checks the widget's permission rules ** (authentik.policies.manage) before calling it. @@ -158,6 +227,9 @@ public final class PolicyEditorWidget private String documentProblem; private Boolean changedInAuthentik = false; private String outputFieldName; + private List> members = List.of(); + private Boolean membersTruncated = false; + private Boolean mapperEnabled; @@ -337,5 +409,68 @@ public final class PolicyEditorWidget this.outputFieldName = outputFieldName; return (this); } + + + + /******************************************************************************* + ** Getter for members. + *******************************************************************************/ + public List> getMembers() + { + return (members); + } + + + + /******************************************************************************* + ** Fluent setter for members. + *******************************************************************************/ + public Data withMembers(List> members) + { + this.members = members; + return (this); + } + + + + /******************************************************************************* + ** Getter for membersTruncated. + *******************************************************************************/ + public Boolean getMembersTruncated() + { + return (membersTruncated); + } + + + + /******************************************************************************* + ** Fluent setter for membersTruncated. + *******************************************************************************/ + public Data withMembersTruncated(Boolean membersTruncated) + { + this.membersTruncated = membersTruncated; + return (this); + } + + + + /******************************************************************************* + ** Getter for mapperEnabled. + *******************************************************************************/ + public Boolean getMapperEnabled() + { + return (mapperEnabled); + } + + + + /******************************************************************************* + ** Fluent setter for mapperEnabled. + *******************************************************************************/ + public Data withMapperEnabled(Boolean mapperEnabled) + { + this.mapperEnabled = mapperEnabled; + return (this); + } } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java b/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java index 1e3d5d1..f7a67d2 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/FakeAuthentik.java @@ -460,8 +460,38 @@ public class FakeAuthentik { continue; } + if(query.containsKey("is_active") && user.optBoolean("is_active") != Boolean.parseBoolean(first(query, "is_active"))) + { + continue; + } + if(query.containsKey("path_startswith") && !user.optString("path").startsWith(first(query, "path_startswith"))) + { + continue; + } + List names = groupNamesOf(user.getInt("pk")); + if(query.containsKey("groups_by_name") && names.stream().noneMatch(query.get("groups_by_name")::contains)) + { + continue; + } + if(query.containsKey("groups_by_pk") && names.stream().map(this::pk).noneMatch(query.get("groups_by_pk")::contains)) + { + continue; + } rs.add(rendered); } + if("username".equals(first(query, "ordering"))) + { + rs.sort(java.util.Comparator.comparing(user -> user.getString("username"))); + } + if(query.containsKey("page_size")) + { + int size = Integer.parseInt(first(query, "page_size")); + int number = Integer.parseInt(first(query, "page")); + int from = Math.min(rs.size(), (number - 1) * size); + int to = Math.min(rs.size(), from + size); + return (ok(200, new JSONObject().put("pagination", new JSONObject().put("count", rs.size()).put("current", number) + .put("total_pages", Math.max(1, (rs.size() + size - 1) / size))).put("results", new JSONArray(rs.subList(from, to))))); + } return (page(rs)); } if(path.matches("/core/users/\\d+/")) diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java index 860e2b0..4d81332 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java @@ -23,6 +23,8 @@ import java.util.EnumSet; import java.util.List; import java.util.Map; import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikTestSupport; +import com.kingsrook.qbits.authentik.client.FixtureActionUtil; import com.kingsrook.qbits.authentik.client.AuthentikTables; import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget; import com.kingsrook.qbits.authentik.policy.PolicyFormat; @@ -360,4 +362,154 @@ class PolicyEditorTest RenderWidgetOutput output = new RenderWidgetAction().execute(input); assertThat(output.getWidgetData().getType()).isEqualTo("AUTHENTIK_POLICY_EDITOR"); } + + + + /******************************************************************************* + ** Direct and descendant members get all other grants, including inherited + ** policies, wildcard attributes and static permissions of direct groups. + *******************************************************************************/ + @Test + void memberPreviewPreservesOtherGrants() throws Exception + { + String group = "kof-website-analysts"; + t.authentik.groupByName(group).getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT); + t.authentik.group("child", false, List.of(), List.of(), group) + .group("grandchild", false, List.of(), List.of(), "child") + .group("other-parent", false, List.of(), List.of()) + .group("other", false, List.of("cms_page.*"), List.of(), "other-parent") + .user(10, "zoe", "zoe@example.test", "internal", true, group, "child", "other") + .user(11, "amy", "amy@example.test", "external", true, "child", "other-parent") + .user(12, "ben", "ben@example.test", "internal", true, "grandchild") + .user(13, "inactive", "inactive@example.test", "internal", false, group); + t.authentik.groupByName("other-parent").getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT.replace("\"read\",\"edit\"", "\"insert\"")); + t.config.withStaticGroupPermissions(Map.of("child", List.of("static.floor"), group, List.of("direct.floor"))); + + PolicyEditorWidget.Data data = PolicyEditorWidget.load(t.config, t.authentik.pk(group)); + assertThat(data.getMembers()).extracting(m -> m.get("username")).containsExactly("amy", "ben", "zoe"); + assertThat(data.getMembersTruncated()).isFalse(); + assertThat(data.getMembers().get(0)).containsExactlyInAnyOrderEntriesOf(Map.of( + "username", "amy", "label", "amy Name (amy)", "viaGroups", List.of("child"), + "basePermissions", List.of("cms_page.insert", "kof22.analytics.hasAccess", "static.floor"))); + assertThat(data.getMembers().get(1)).containsEntry("viaGroups", List.of("grandchild")) + .containsEntry("basePermissions", List.of("kof22.analytics.hasAccess")); + assertThat(data.getMembers().get(2)).containsEntry("viaGroups", List.of("child", group)) + .containsEntry("basePermissions", List.of("cms_page.delete", "cms_page.edit", "cms_page.insert", "cms_page.read", "direct.floor", "kof22.analytics.hasAccess", "static.floor")); + } + + + + /******************************************************************************* + ** The cap is applied after eligibility and username ordering, and duplicate + ** memberships do not consume multiple slots. Exactly 200 is not truncated. + *******************************************************************************/ + @Test + void memberPreviewCapsEligiblePeople() throws Exception + { + String group = "kof-website-analysts"; + t.authentik.group("child", false, List.of(), List.of(), group); + for(int i = 199; i >= 0; i--) + { + String username = "person%03d".formatted(i); + t.authentik.user(100 + i, username, username + "@example.test", "internal", true, group, "child"); + } + t.authentik.user(400, "aaa-inactive", "inactive@example.test", "internal", false, group); + PolicyEditorWidget.Data exact = PolicyEditorWidget.load(t.config, t.authentik.pk(group)); + assertThat(exact.getMembers()).hasSize(200); + assertThat(exact.getMembersTruncated()).isFalse(); + + t.authentik.user(401, "aaa-active", "active@example.test", "internal", true, "child"); + t.admin().getUtil().getRuntime().invalidateCache(); + FixtureActionUtil.REQUESTS.clear(); + PolicyEditorWidget.Data capped = PolicyEditorWidget.load(t.config, t.authentik.pk(group)); + assertThat(capped.getMembers()).hasSize(200); + assertThat(capped.getMembersTruncated()).isTrue(); + assertThat(capped.getMembers()).extracting(m -> (String) m.get("username")).isSorted().doesNotContain("person199", "aaa-inactive"); + assertThat(capped.getMembers().get(0)).containsEntry("username", "aaa-active"); + List queries = FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().equals("/api/v3/core/users/")) + .map(r -> r.getURI().getQuery()).toList(); + assertThat(queries).hasSize(3).allSatisfy(query -> assertThat(query) + .contains("ordering=username", "is_active=true", "groups_by_pk=" + t.authentik.pk(group), "groups_by_pk=" + t.authentik.pk("child"))); + } + + + + /******************************************************************************* + ** Scope restrictions apply even to inherited members; no extra user-read + ** permission is needed beyond the editor permission. + *******************************************************************************/ + @Test + void memberPreviewRespectsScopeAndEditorPermission() throws Exception + { + String group = "kof-website-analysts"; + t.authentik.group("child", false, List.of(), List.of(), group) + .group("scope", false, List.of(), List.of()) + .user(10, "allowed", "allowed@example.test", "internal", true, "child", "scope") + .user(11, "wrong-path", "path@example.test", "internal", true, group, "scope") + .user(12, "wrong-group", "group@example.test", "internal", true, group); + t.authentik.users.get(11).put("path", "outside"); + t.config.withUserScopePathStartsWith("users").withUserScopeGroupNames(List.of("scope")); + t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.POLICIES_MANAGE); + assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getMembers()) + .extracting(m -> m.get("username")).containsExactly("allowed"); + + t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ); + FixtureActionUtil.REQUESTS.clear(); + assertThatThrownBy(() -> PolicyEditorWidget.load(t.config, t.authentik.pk(group))).hasMessageContaining("authentik.policies.manage"); + assertThat(FixtureActionUtil.REQUESTS).isEmpty(); + assertThatThrownBy(() -> t.admin().getUtil().policyPreviewUsers(List.of(t.authentik.pk(group)), 201)).hasMessageContaining("authentik.policies.manage"); + assertThat(FixtureActionUtil.REQUESTS).isEmpty(); + } + + + + /******************************************************************************* + ** Bootstrap subjects retain identity grants by immutable pk or uuid; a + ** superuser flag alone never grants QQQ permissions or leaks private fields. + *******************************************************************************/ + @Test + void memberPreviewIncludesBootstrapWithoutSuperuserEscalation() throws Exception + { + String group = "kof-website-analysts"; + t.authentik.user(10, "bootstrap-pk", "pk@example.test", "internal", true, group) + .user(11, "bootstrap-uuid", "uuid@example.test", "internal", true, group) + .user(12, "superuser", "superuser@example.test", "internal", true, group, "authentik Admins"); + t.authentik.users.get(11).put("uuid", "immutable-subject"); + t.config.withBootstrapOperatorSubjects("10,immutable-subject"); + List> members = PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getMembers(); + for(Map member : members.subList(0, 2)) + { + assertThat((List) member.get("basePermissions")).hasSize(IdentityPermission.values().length + 1); + for(IdentityPermission permission : IdentityPermission.values()) + { + assertThat(((List) member.get("basePermissions")).contains(permission.getSessionPermission())).isTrue(); + } + } + assertThat(members.get(2)).containsEntry("basePermissions", List.of("kof22.analytics.hasAccess")); + assertThat(members.get(2)).containsOnlyKeys("username", "label", "viaGroups", "basePermissions"); + } + + + + /******************************************************************************* + ** The added fields serialize like the existing widget fields, including an + ** empty member list when no group is selected and the configured mapper flag. + *******************************************************************************/ + @Test + void memberPreviewPayloadAndMapperFlag() throws Exception + { + t.config.withMapperEnabled(false); + FixtureActionUtil.REQUESTS.clear(); + PolicyEditorWidget.Data empty = PolicyEditorWidget.load(t.config, null); + assertThat(empty.getMembers()).isEmpty(); + assertThat(empty.getMembersTruncated()).isFalse(); + assertThat(empty.getMapperEnabled()).isFalse(); + assertThat(FixtureActionUtil.REQUESTS).isEmpty(); + t.config.withMapperEnabled(true); + PolicyEditorWidget.Data data = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-publishers")); + JSONObject json = new JSONObject(data); + assertThat(json.getBoolean("mapperEnabled")).isTrue(); + assertThat(json.getBoolean("membersTruncated")).isFalse(); + assertThat(json.getJSONArray("members").getJSONObject(0).keySet()).containsExactlyInAnyOrder("username", "label", "viaGroups", "basePermissions"); + } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java index 3d2ca2d..9560002 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java @@ -173,4 +173,27 @@ class EffectivePermissionResolverTest assertThat(resolver.expand(null)).isEmpty(); } + + + /******************************************************************************* + ** Exclusion applies through ancestors and duplicate paths without removing + ** overlapping named grants or policies from other groups, or mutating the graph. + *******************************************************************************/ + @Test + void excludesOnlyTheSelectedPolicy() + { + GroupGraph graph = new GroupGraph() + .add(new GroupGraph.Node("parent", "Parent", false, List.of(), List.of("cms.publish"), List.of("app"), List.of(), List.of(), List.of("cms.*", "missing"))) + .add(new GroupGraph.Node("child", "Child", false, List.of("parent"), List.of(), List.of(), List.of(), List.of(), List.of("cms.edit"))); + EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, CATALOG); + EffectivePermissionResolver.Resolution base = resolver.resolveGroups(List.of("child", "parent"), "parent"); + assertThat(base.sessionPermissions()).containsExactly("cms.edit", "cms.publish"); + assertThat(base.grantedBy().get("cms.edit")).containsExactly("Child"); + assertThat(base.grantedBy().get("cms.publish")).containsExactly("Parent"); + assertThat(base.appAccess()).containsExactly("app"); + assertThat(base.unknown()).isEmpty(); + assertThat(resolver.resolveGroups(List.of("child")).sessionPermissions()).contains("cms.delete"); + assertThat(resolver.resolveGroups(List.of("child"), "absent")).isEqualTo(resolver.resolveGroups(List.of("child"))); + } + } -- 2.54.0