feat(policy): member preview payload for the policy editor #29

Merged
KofTwentyTwo merged 1 commits from feature/policy-editor-preview into develop 2026-10-14 18:01:12 +00:00
10 changed files with 422 additions and 6 deletions

No files matched your search

+1
View File
@@ -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.
+3 -1
View File
@@ -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*.
@@ -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<JSONObject> policyPreviewUsers(Collection<String> 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<JSONObject> 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.
*******************************************************************************/
@@ -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")));
@@ -152,7 +152,19 @@ public final class AccessComparison
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid)
{
Set<String> 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<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid, String excludePolicyOfGroupPk)
{
Set<String> rs = new TreeSet<>(resolver.resolveGroups(groupPks, excludePolicyOfGroupPk).sessionPermissions());
rs.addAll(staticPermissions(config, directGroupNames));
Set<String> subjects = config.getResolvedBootstrapSubjects();
if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid)))
@@ -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<String> 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<String> groupPks, String excludePolicyOfGroupPk)
{
Set<String> permissions = new TreeSet<>();
Map<String, Set<String>> grantedBy = new LinkedHashMap<>();
@@ -103,7 +114,10 @@ public class EffectivePermissionResolver
}
appAccess.addAll(node.appAccess());
List<String> raws = new ArrayList<>(node.permissions());
raws.addAll(node.policyPermissions());
if(!node.pk().equals(excludePolicyOfGroupPk))
{
raws.addAll(node.policyPermissions());
}
for(String raw : raws)
{
Set<String> expanded = expand(raw);
@@ -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<String> affectedGroups = graph.nodes().stream()
.filter(node -> node.pk().equals(groupPk) || graph.ancestorPks(node.pk()).contains(groupPk))
.map(GroupGraph.Node::pk).collect(Collectors.toSet());
List<JSONObject> users = new ArrayList<>(util.policyPreviewUsers(affectedGroups, MAX_MEMBERS + 1));
users.sort(Comparator.comparing(user -> user.optString("username")));
List<Map<String, Object>> members = new ArrayList<>();
for(JSONObject user : users)
{
List<String> 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<String> 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<String> names = groupPks.stream().map(pk -> graph.byPk(pk).name()).toList();
Set<String> 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<Map<String, Object>> 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<Map<String, Object>> getMembers()
{
return (members);
}
/*******************************************************************************
** Fluent setter for members.
*******************************************************************************/
public Data withMembers(List<Map<String, Object>> 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);
}
}
}
@@ -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<String> 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+/"))
@@ -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<String> 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<Map<String, Object>> members = PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getMembers();
for(Map<String, Object> 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");
}
}
@@ -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")));
}
}