Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 d0c5c2971e fix(config): startup log lists the effective features (TOKENS on by default)
The "Authentik QBit produced" line logged the configured feature set, so
TOKENS (on by default whenever writes are enabled) never appeared.
getEffectiveFeatures() reports what hasFeature() says is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:42:58 -05:00
13 changed files with 40 additions and 423 deletions

No files matched your search

-1
View File
@@ -4,7 +4,6 @@ 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.
+1 -3
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 its catalog, current document and member preview data.
**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.
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,8 +1155,6 @@ 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*.
@@ -452,6 +452,26 @@ public class AuthentikQBitConfig implements QBitConfig
/*******************************************************************************
** The features that are actually on: the configured set, plus TOKENS when it
** is on by default (writes enabled, not opted out), minus TOKENS when it is
** listed but off (read only or opted out). This is what hosts see in logs.
*******************************************************************************/
public java.util.Set<AuthentikFeature> getEffectiveFeatures()
{
java.util.EnumSet<AuthentikFeature> effective = java.util.EnumSet.noneOf(AuthentikFeature.class);
for(AuthentikFeature feature : AuthentikFeature.values())
{
if(hasFeature(feature))
{
effective.add(feature);
}
}
return (effective);
}
/*******************************************************************************
** Whether a feature is enabled.
*******************************************************************************/
@@ -192,7 +192,7 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
}
LOG.info("Authentik QBit produced", logPair("version", VERSION), logPair("appKey", qBitConfig.getAppKey()),
logPair("features", qBitConfig.getFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
logPair("features", qBitConfig.getEffectiveFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
return (new MetaDataProducerMultiOutput());
}
@@ -22,7 +22,6 @@ 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;
@@ -31,7 +30,6 @@ 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;
@@ -931,51 +929,6 @@ 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", "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/", Set.of("username", "include_groups", "attributes", "type", "path_startswith", "groups_by_name", "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,19 +152,7 @@ public final class AccessComparison
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
String userPk, String userUuid)
{
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());
Set<String> rs = new TreeSet<>(resolver.resolveGroups(groupPks).sessionPermissions());
rs.addAll(staticPermissions(config, directGroupNames));
Set<String> subjects = config.getResolvedBootstrapSubjects();
if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid)))
@@ -86,17 +86,6 @@ 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<>();
@@ -114,10 +103,7 @@ public class EffectivePermissionResolver
}
appAccess.addAll(node.appAccess());
List<String> raws = new ArrayList<>(node.permissions());
if(!node.pk().equals(excludePolicyOfGroupPk))
{
raws.addAll(node.policyPermissions());
}
raws.addAll(node.policyPermissions());
for(String raw : raws)
{
Set<String> expanded = expand(raw);
@@ -17,26 +17,15 @@
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;
@@ -44,7 +33,6 @@ 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;
@@ -53,8 +41,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, the group's current document read live from Authentik,
** and the base permissions of members who inherit it.
** policy can grant, and the group's current document read live from
** Authentik.
*******************************************************************************/
public final class PolicyEditorWidget
{
@@ -70,8 +58,6 @@ 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;
/*******************************************************************************
@@ -88,17 +74,12 @@ 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))
@@ -138,61 +119,11 @@ 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.
@@ -227,9 +158,6 @@ 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;
@@ -409,68 +337,5 @@ 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);
}
}
}
@@ -236,4 +236,17 @@ class AuthentikQBitConfigTest
assertThat(config.getTokenBrokerClientSecret()).isNull();
}
/*******************************************************************************
** The startup log lists TOKENS when it is on by default (writes enabled).
*******************************************************************************/
@org.junit.jupiter.api.Test
void effectiveFeaturesIncludeDefaultTokens()
{
AuthentikQBitConfig writes = new AuthentikQBitConfig().withFeatures(java.util.EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS)).withTokensEnabled(true);
org.assertj.core.api.Assertions.assertThat(writes.getEffectiveFeatures()).contains(AuthentikFeature.TOKENS, AuthentikFeature.READ, AuthentikFeature.USERS);
AuthentikQBitConfig readOnly = new AuthentikQBitConfig().withFeatures(java.util.EnumSet.of(AuthentikFeature.READ));
org.assertj.core.api.Assertions.assertThat(readOnly.getEffectiveFeatures()).containsExactly(AuthentikFeature.READ);
}
}
@@ -460,38 +460,8 @@ 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,8 +23,6 @@ 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;
@@ -362,154 +360,4 @@ 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,27 +173,4 @@ 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")));
}
}