feat(policy): member preview payload for the policy editor #29
No files matched your search
@@ -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
@@ -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)))
|
||||
|
||||
+15
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
+23
@@ -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")));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user