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"))); + } + }