Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 639dc38ca1 feat(policy): phase 5b policy editor and save to Authentik
- Edit Policy record action on groups and policies (feature POLICIES,
  permission authentik.policies.manage): streamed-ETL with the
  AUTHENTIK_POLICY_EDITOR widget on its step; Review parses fail-closed,
  regenerates canonically with the round-trip check, refuses new resources the
  app does not define, applies the subset rule to added permissions and lists
  the changes; Result merge-patches qqq.io/policy into the group's current
  attributes (removed when empty). Optional policyTwoPersonApproval.
- PolicyCatalog: apps, tables, processes (hidden record actions included),
  reports and protected widgets with the actions QQQ checks, grouped by app;
  joins the permission catalog.
- authentik_policy local table (one row per group, field-level audit as the
  change history), nav and child list on the group; DDL in docs/schema.sql.
- Widget authentikPolicyEditor: catalog plus the live document, flagged when
  changed in Authentik since the last QQQ save.
- Tests: catalog, admin rules, process, widget, gating, approval, and a
  savePolicy contract test on Authentik 2026.8.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:47:23 -05:00
21 changed files with 2005 additions and 12 deletions

No files matched your search

+6
View File
@@ -4,6 +4,12 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
### Added (policy editor, phase 5b: DESIGN.md section 12.4)
- **Edit Policy** record action on groups and on policies (feature `POLICIES`, permission `authentik.policies.manage`): a standard streamed-ETL action whose step shows the `AUTHENTIK_POLICY_EDITOR` widget (Next UI). Review parses the submitted document (fail closed), regenerates it in canonical form with the round-trip check, refuses new resources this app does not define, applies the subset rule to the permissions it adds, and lists what is added and removed. Result merge-patches `qqq.io/policy` into the group's current attributes (removed when empty). Optional second-person approval (`policyTwoPersonApproval`).
- **`PolicyCatalog`**: every app, table, process (hidden record actions included), report and protected widget a policy can grant, with the actions QQQ checks, grouped by app. Its permission names join the permission catalog.
- **`authentik_policy`** local table (one row per group; field-level audit is the change history; DDL in `docs/schema.sql`), in the nav under Access and as a child list on the group.
- Widget `authentikPolicyEditor` (type `AUTHENTIK_POLICY_EDITOR`): the catalog and the group's live document, with a flag when it was changed in Authentik since the last save in QQQ.
### Added (policies, phase 5a: DESIGN.md section 12, docs/POLICY-FORMAT.md)
- **Policy documents** (IAM-style statements) on Authentik groups: `PolicyParser` (fails closed: unknown spec or version, non-Allow effect, unknown action, bad resource or malformed JSON grant nothing), `PolicyGenerator` (canonical form with a round-trip check), `PolicyFormat` and the config `policySpec`, `policyVersion`, `policyAttribute`, `policyClaim`, `policyTwoPersonApproval`.
- **Runtime**: `AuthentikPolicyClaimMapper` (reads the `qqq_policy` claim from the ID token or access token; `applySecurityKeys` hook for record locks) and `PolicyPermissions.fromClaims` for a host's own customizer. `AuthentikGroupPermissionMapper` also adds the claim, and the group graph and `EffectivePermissionResolver` read each group's `qqq.io/policy`, so effective permissions, the subset rule and the last-admin check see policy grants. An invalid policy on a group is listed as malformed.
+19
View File
@@ -49,3 +49,22 @@ CREATE TABLE authentik_approval_request
);
CREATE INDEX idx_authentik_approval_request_status ON authentik_approval_request (status);
-- authentik_policy (DESIGN.md section 12): the QQQ record of each group's last policy save.
-- Authentik holds the live document (group attribute qqq.io/policy); field-level QQQ audit on
-- this table is the policy's change history.
CREATE TABLE authentik_policy
(
id SERIAL PRIMARY KEY,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
group_pk VARCHAR(64) NOT NULL,
group_name VARCHAR(255),
policy_json TEXT,
statement_count INTEGER,
permission_count INTEGER,
saved_by VARCHAR(255),
saved_date TIMESTAMP WITH TIME ZONE,
authentik_fingerprint VARCHAR(64),
CONSTRAINT uk_authentik_policy_group_pk UNIQUE (group_pk)
);
@@ -32,6 +32,12 @@ public enum AuthentikFeature
/** Create and edit groups, permissions and app-access attributes (phase 2/3). */
GROUPS,
/** Edit group policies (IAM-style statements) with the policy editor (DESIGN.md section 12). */
POLICIES,
/** Delete users and groups as an explicit action (DESIGN.md section 12.5). */
DELETE,
/** Add and remove members (phase 2). */
MEMBERSHIP,
@@ -46,11 +46,11 @@ public class AuthentikQBitConfig implements QBitConfig
{
/** Features implemented in this release. Others are refused by validate(). */
public static final Set<AuthentikFeature> AVAILABLE_FEATURES = EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS);
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES);
/** Features that change Authentik data; they need a local backend for the audit trail. */
public static final Set<AuthentikFeature> WRITE_FEATURES = EnumSet.of(AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS);
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES);
private static final Pattern APP_KEY_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,48}[a-z0-9]");
private static final Pattern PREFIX_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9]{0,30}");
@@ -179,6 +179,11 @@ public class AuthentikQBitConfig implements QBitConfig
errors.add("features USERS and CREDENTIALS need recoveryEmailStage");
}
if(requested.contains(AuthentikFeature.POLICIES) && !requested.contains(AuthentikFeature.GROUPS))
{
errors.add("feature POLICIES needs GROUPS");
}
if(requested.contains(AuthentikFeature.WEBHOOK) && !StringUtils.hasContent(getResolvedWebhookSecret()))
{
errors.add("feature WEBHOOK needs webhookSecret");
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.policy.PolicyCatalog;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
@@ -45,6 +46,7 @@ public class AuthentikRuntime
private final Memoization<String, String> responseCache;
private final Memoization<String, GroupGraph> groupGraphCache;
private volatile java.util.Set<String> permissionCatalog;
private volatile PolicyCatalog policyCatalog;
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
@@ -149,14 +151,39 @@ public class AuthentikRuntime
if(permissionCatalog == null)
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
permissionCatalog = qInstance == null ? java.util.Set.of()
: new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
if(qInstance == null)
{
return (java.util.Set.of());
}
java.util.Set<String> catalog = new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
catalog.addAll(getPolicyCatalog().permissionNames());
permissionCatalog = catalog;
}
return (permissionCatalog);
}
/*******************************************************************************
** What a policy can grant in the current instance (built once, lazily: the
** instance is complete by the first request).
*******************************************************************************/
public PolicyCatalog getPolicyCatalog()
{
if(policyCatalog == null)
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(qInstance == null)
{
return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance()));
}
policyCatalog = PolicyCatalog.of(qInstance);
}
return (policyCatalog);
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
@@ -38,6 +38,7 @@ public enum AdminAction
CREATE_GROUP("Create group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_GROUP("Edit group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
EDIT_PERMISSIONS("Edit permissions and app access", "group", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
SAVE_POLICY("Save policy", "group", IdentityPermission.POLICIES_MANAGE, AuthentikFeature.POLICIES, Confirmation.CONFIRM),
SEND_RECOVERY_EMAIL("Send password reset email", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.CONFIRM),
RESET_MFA("Reset MFA", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
REVOKE_APP_PASSWORDS("Revoke app passwords", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
@@ -39,6 +39,11 @@ import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.policy.PolicyStore;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qbits.authentik.security.WriteAuthorization;
@@ -85,6 +90,9 @@ public class AuthentikAdmin
public static final int TYPED_ABOVE = 25;
public static final int APPROVAL_HOURS = 24;
/** Process value / request parameter that carries a submitted policy document. */
public static final String POLICY_DOCUMENT = "policyDocument";
private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
private static final Pattern USERNAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._@+-]{0,149}");
private static final Pattern EMAIL = Pattern.compile("[^@\\s]+@[^@\\s]+\\.[^@\\s]+");
@@ -252,6 +260,7 @@ public class AuthentikAdmin
case CREATE_GROUP -> planCreateGroup(plan, request);
case EDIT_GROUP -> planEditGroup(plan, request);
case EDIT_PERMISSIONS -> planEditPermissions(plan, request);
case SAVE_POLICY -> planSavePolicy(plan, request);
case SEND_RECOVERY_EMAIL -> planRecovery(plan, request);
case RESET_MFA -> planResetMfa(plan, request);
case REVOKE_APP_PASSWORDS -> planAppPasswords(plan, request);
@@ -1002,6 +1011,133 @@ public class AuthentikAdmin
/*******************************************************************************
** Save a group's policy document (DESIGN.md section 12.4): parse it (fail
** closed), regenerate it in canonical form (round-trip checked), refuse new
** resources this app does not know, and apply the subset rule to the
** permissions it adds.
*******************************************************************************/
private AdminPlan planSavePolicy(AdminPlan plan, AdminRequest request) throws QException
{
JSONObject group = targetGroup(plan, request.targetKey());
if(group == null)
{
return (plan);
}
PolicyFormat format = config.getPolicyFormat();
Set<String> newPermissions;
JSONObject canonical;
try
{
String submitted = request.str(POLICY_DOCUMENT);
newPermissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(newPermissions, format);
}
catch(PolicyParseException e)
{
return (plan.refuse("The policy is not valid: " + e.getMessage()));
}
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
Set<String> oldPermissions = new TreeSet<>();
String storedProblem = null;
if(stored != null && !JSONObject.NULL.equals(stored) && !"".equals(stored))
{
try
{
oldPermissions.addAll(PolicyParser.parseStrict(stored, format).permissions());
}
catch(PolicyParseException e)
{
storedProblem = e.getMessage();
}
}
String fingerprint = request.str("expectedFingerprint");
if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.groupFingerprint(group)))
{
return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again."));
}
Set<String> added = new TreeSet<>(newPermissions);
added.removeAll(oldPermissions);
Set<String> removed = new TreeSet<>(oldPermissions);
removed.removeAll(newPermissions);
Set<String> unknownNew = runtime.getPolicyCatalog().unknownPermissions(added);
if(!unknownNew.isEmpty())
{
return (plan.refuse("These are not permissions of this app: " + String.join(", ", unknownNew) + ". Pick them in the editor."));
}
if(added.isEmpty() && removed.isEmpty() && storedProblem == null)
{
return (plan.noChange("Nothing to change."));
}
Set<String> missing = AuthentikSafety.missing(added, operator().permissions());
if(!missing.isEmpty())
{
return (plan.refuse("You cannot grant permissions you do not hold yourself: " + String.join(", ", missing)));
}
plan.withChange(policySummary(oldPermissions), policySummary(newPermissions));
if(!added.isEmpty())
{
plan.getNotes().add("Adds: " + abbreviate(added));
}
if(!removed.isEmpty())
{
plan.getNotes().add("Removes: " + abbreviate(removed));
}
if(storedProblem != null)
{
plan.getNotes().add("The policy stored in Authentik was not valid (" + storedProblem + "); saving replaces it.");
}
Set<String> keptUnknown = runtime.getPolicyCatalog().unknownPermissions(newPermissions);
if(!keptUnknown.isEmpty())
{
plan.getNotes().add("Kept although this app does not define them: " + abbreviate(keptUnknown));
}
JSONArray users = group.optJSONArray("users");
plan.getNotes().add((users == null ? 0 : users.length()) + " member(s), and the members of groups below it, get the change at their next sign-in or token refresh.");
plan.getNotes().add(canonical.getJSONArray("Statement").length() + " statement(s), " + newPermissions.size() + " permission(s); the document round-trips.");
if(Boolean.TRUE.equals(config.getPolicyTwoPersonApproval()) && !request.bool("approved"))
{
plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("policy changes need a second person");
}
return (plan);
}
/*******************************************************************************
** "n permission(s): a, b, ..." for the audit before/after.
*******************************************************************************/
private static String policySummary(Set<String> permissions)
{
return (permissions.isEmpty() ? "no policy" : permissions.size() + " permission(s): " + abbreviate(permissions));
}
/*******************************************************************************
** The first 12 names, then "and n more".
*******************************************************************************/
private static String abbreviate(Set<String> names)
{
List<String> list = new ArrayList<>(names);
if(list.size() <= 12)
{
return (String.join(", ", list));
}
return (String.join(", ", list.subList(0, 12)) + " and " + (list.size() - 12) + " more");
}
/*******************************************************************************
** Send the password-reset email (Authentik's own email; no link is shown).
*******************************************************************************/
@@ -1440,6 +1576,8 @@ public class AuthentikAdmin
util.write(auth, "PATCH", "/core/groups/" + safeKey(request.targetKey()) + "/", new JSONObject().put("attributes", attributes));
return ("Permissions and app access updated.");
}
case SAVE_POLICY:
return (performSavePolicy(plan, auth));
case SEND_RECOVERY_EMAIL:
sendRecovery(auth, request.targetKey());
return ("Password reset email sent by Authentik.");
@@ -1469,6 +1607,62 @@ public class AuthentikAdmin
/*******************************************************************************
** Write the canonical policy into the group's CURRENT attributes (re-read
** right before, so other attributes and UI edits survive), then record the
** save in authentik_policy. Authentik is the source of truth: if only the
** QQQ record fails, the result says so but the save stands.
*******************************************************************************/
private String performSavePolicy(AdminPlan plan, WriteAuthorization auth) throws QException
{
AdminRequest request = plan.getRequest();
PolicyFormat format = config.getPolicyFormat();
Set<String> permissions;
JSONObject canonical;
try
{
String submitted = request.str(POLICY_DOCUMENT);
permissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format).permissions() : new TreeSet<>();
canonical = PolicyGenerator.generate(permissions, format);
}
catch(PolicyParseException e)
{
throw (new QUserFacingException("The policy is not valid: " + e.getMessage()));
}
runtime.invalidateCache();
JSONObject fresh = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false");
if(fresh == null)
{
throw (new QUserFacingException("That group no longer exists."));
}
JSONObject attributes = fresh.optJSONObject("attributes") == null ? new JSONObject() : new JSONObject(fresh.getJSONObject("attributes").toString());
String compact = PolicyGenerator.toCompact(canonical);
if(permissions.isEmpty())
{
attributes.remove(format.attribute());
}
else
{
attributes.put(format.attribute(), compact);
}
util.write(auth, "PATCH", "/core/groups/" + safeKey(request.targetKey()) + "/", new JSONObject().put("attributes", attributes));
try
{
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissions.size(), guard.operatorEmail(),
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format));
}
catch(Exception e)
{
LOG.warn("The policy was saved to Authentik but its QQQ record could not be written", logPair("group", request.targetKey()), logPair("error", e.getMessage()));
return ("Policy saved to Authentik, but its QQQ record could not be written: " + e.getMessage());
}
return (permissions.isEmpty() ? "Policy removed from the group in Authentik." : "Policy saved to Authentik.");
}
/*******************************************************************************
** Create the user (no password), add them to their groups, send the
** password email. A failure after the user exists says so.
@@ -1834,7 +2028,7 @@ public class AuthentikAdmin
/*******************************************************************************
** A path segment that is a plain key (digits, letters, dashes, dots, colons).
*******************************************************************************/
static String safeKey(String key) throws QUserFacingException
public static String safeKey(String key) throws QUserFacingException
{
if(key == null || !key.matches("[A-Za-z0-9._-]{1,150}"))
{
@@ -20,6 +20,8 @@ package com.kingsrook.qbits.authentik.admin;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
@@ -27,6 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValu
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
@@ -41,6 +44,7 @@ public final class AuthentikLocalTables
{
public static final String ADMIN_ACTION = "admin_action";
public static final String APPROVAL_REQUEST = "approval_request";
public static final String POLICY = "policy";
public static final String PVS_ACTION_STATUS = "authentik_action_status";
public static final String PVS_APPROVAL_STATUS = "authentik_approval_status";
@@ -212,6 +216,30 @@ public final class AuthentikLocalTables
/*******************************************************************************
** authentik_policy: one row per group with a policy (DESIGN.md section 12).
** Authentik holds the live document; this row is the QQQ record of the last
** save, and field-level audit is its change history. Written only by the
** Edit Policy action.
*******************************************************************************/
public static QTableMetaData policy(AuthentikQBitConfig config)
{
return (base(config, POLICY, "Policies", "policy", IdentityPermission.USERS_READ)
.withRecordLabelFormatAndFields("%s", "groupName")
.withUniqueKey(new UniqueKey("groupPk"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withField(ro("groupPk", QFieldType.STRING).withLabel("Group Id").withMaxLength(64))
.withField(ro("groupName", QFieldType.STRING).withLabel("Group").withMaxLength(255))
.withField(ro("policyJson", QFieldType.TEXT).withLabel("Policy Document"))
.withField(ro("statementCount", QFieldType.INTEGER).withLabel("Statements"))
.withField(ro("permissionCount", QFieldType.INTEGER).withLabel("Permissions"))
.withField(ro("savedBy", QFieldType.STRING).withLabel("Saved By").withMaxLength(255))
.withField(ro("savedDate", QFieldType.DATE_TIME).withLabel("Saved"))
.withField(ro("authentikFingerprint", QFieldType.STRING).withLabel("Authentik Fingerprint").withMaxLength(64)));
}
/*******************************************************************************
** Common shape: id, create and modify dates, no generic writes.
*******************************************************************************/
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.authentik.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -85,6 +86,7 @@ public final class AuthentikRelations
groupSections.add(new QFieldSection("permissions", "Permissions and app access", new QIcon("admin_panel_settings"), Tier.T2,
List.of("permissions", "effectivePermissions", "appAccess")));
relate(qInstance, groupSections, "members", "Members", "group", group, membership, "pk", "groupPk");
relate(qInstance, groupSections, "policy", "Policy", "policy", group, config.applyTablePrefix(AuthentikLocalTables.POLICY), "pk", "groupPk");
groupSections.add(new QFieldSection("attributes", "Attributes", new QIcon("data_object"), Tier.T3, List.of("attributes")));
applySections(qInstance, group, groupSections);
@@ -70,7 +70,7 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
// records as child lists //
///////////////////////////////////////////////////////////////////////////
addSection(app, produced, config, "people", "People", "person", AuthentikTables.USER, AuthentikTables.INVITATION);
addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikTables.APPLICATION);
addSection(app, produced, config, "access", "Access", "admin_panel_settings", AuthentikTables.GROUP, AuthentikLocalTables.POLICY, AuthentikTables.APPLICATION);
addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION);
List<String> widgets = new ArrayList<>();
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The policy editor widget (DESIGN.md section 12.4), shown on the Edit Policy
** step. Gated by authentik.policies.manage.
*******************************************************************************/
public class AuthentikPolicyEditorWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Only with the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(PolicyEditorWidget.NAME))
.withLabel("Policy")
.withType(PolicyEditorWidget.TYPE)
.withGridColumns(12)
.withIsCard(false)
.withCodeReference(new QCodeReference(PolicyEditorWidget.Renderer.class))
.withPermissionRules(IdentityPermission.POLICIES_MANAGE.rules())
.withDefaultValue(PolicyEditorWidget.BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (460);
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** authentik_policy: the QQQ record (with change history) of each group policy.
*******************************************************************************/
public class AuthentikPolicyTableProducer extends QBitComponentMetaDataProducer<QTableMetaData, AuthentikQBitConfig>
{
/*******************************************************************************
** Needs the local backend and the POLICIES feature.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null && getQBitConfig().getLocalBackendName() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QTableMetaData produce(QInstance qInstance) throws QException
{
return (AuthentikLocalTables.policy(getQBitConfig()));
}
/*******************************************************************************
** After the backend.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (300);
}
}
@@ -0,0 +1,456 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** What a policy can grant in a QInstance (DESIGN.md section 12.4): every app,
** table, process, report and protected widget, with its permission base name
** (the policy resource) and the actions QQQ checks for it, grouped by app.
**
** Unlike PermissionsHelper.getAllAvailablePermissions, hidden tables and
** processes are included: record actions are hidden from the nav but still
** permission-checked. Objects with a custom permission checker or that are
** NOT_PROTECTED are left out (a policy cannot grant them).
*******************************************************************************/
public final class PolicyCatalog
{
/** Group label of objects that are in no app. */
public static final String OTHER = "Other";
/** Kinds of resources. */
public enum Kind
{
APP, TABLE, PROCESS, REPORT, WIDGET
}
/*******************************************************************************
** One grantable resource.
**
** @param name the policy resource (permission base name)
** @param label what operators see
** @param kind app, table, process, report or widget
** @param actions the actions QQQ checks, in canonical order
** @param note a plain note (e.g. why an action is missing), or null
*******************************************************************************/
public record Resource(String name, String label, Kind kind, List<PolicyAction> actions, String note)
{
}
/*******************************************************************************
** The resources of one app (or of no app: OTHER).
*******************************************************************************/
public record Group(String app, String label, List<Resource> resources)
{
}
private final List<Group> groups;
private final Map<String, Resource> byName;
/*******************************************************************************
** Constructor.
*******************************************************************************/
private PolicyCatalog(List<Group> groups)
{
this.groups = groups;
this.byName = new LinkedHashMap<>();
for(Group group : groups)
{
for(Resource resource : group.resources())
{
byName.putIfAbsent(resource.name(), resource);
}
}
}
/*******************************************************************************
** Build the catalog of an instance.
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance)
{
Map<String, String> appOfObject = appsOfObjects(qInstance);
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
List<QAppMetaData> apps = new ArrayList<>(qInstance.getApps().values());
apps.sort(Comparator.comparing((QAppMetaData a) -> a.getSortOrder() == null ? 500 : a.getSortOrder()).thenComparing(a -> label(a.getLabel(), a.getName())));
for(QAppMetaData app : apps)
{
byApp.put(app.getName(), new LinkedHashMap<>());
}
byApp.put(null, new LinkedHashMap<>());
for(QAppMetaData app : apps)
{
add(byApp, app.getName(), qInstance, app, Kind.APP, label(app.getLabel(), app.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QTableMetaData> tables = new ArrayList<>(qInstance.getTables().values());
tables.sort(Comparator.comparing(t -> label(t.getLabel(), t.getName())));
for(QTableMetaData table : tables)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(table, qInstance);
Set<PolicyAction> actions = EnumSet.noneOf(PolicyAction.class);
String note = null;
if(rules.getLevel() == PermissionLevel.HAS_ACCESS_PERMISSION)
{
actions.add(PolicyAction.HAS_ACCESS);
}
else
{
QBackendMetaData backend = qInstance.getBackend(table.getBackendName());
if(table.isCapabilityEnabled(backend, Capability.TABLE_QUERY) || table.isCapabilityEnabled(backend, Capability.TABLE_GET))
{
actions.add(PolicyAction.READ);
}
if(rules.getLevel() == PermissionLevel.READ_WRITE_PERMISSIONS)
{
note = "This table uses one write permission, which policies cannot grant yet; only read is offered.";
}
else
{
if(table.isCapabilityEnabled(backend, Capability.TABLE_INSERT))
{
actions.add(PolicyAction.INSERT);
}
if(table.isCapabilityEnabled(backend, Capability.TABLE_UPDATE))
{
actions.add(PolicyAction.EDIT);
}
if(table.isCapabilityEnabled(backend, Capability.TABLE_DELETE))
{
actions.add(PolicyAction.DELETE);
}
}
}
add(byApp, appOfObject.get("table:" + table.getName()), qInstance, table, Kind.TABLE, label(table.getLabel(), table.getName()), actions, note);
}
List<QProcessMetaData> processes = new ArrayList<>(qInstance.getProcesses().values());
processes.sort(Comparator.comparing(p -> label(p.getLabel(), p.getName())));
for(QProcessMetaData process : processes)
{
String app = appOfObject.get("process:" + process.getName());
if(app == null && process.getTableName() != null)
{
app = appOfObject.get("table:" + process.getTableName());
}
String label = label(process.getLabel(), process.getName());
if(process.getTableName() != null && qInstance.getTable(process.getTableName()) != null)
{
label = label + " (" + label(qInstance.getTable(process.getTableName()).getLabel(), process.getTableName()) + " action)";
}
add(byApp, app, qInstance, process, Kind.PROCESS, label, EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QReportMetaData> reports = new ArrayList<>(qInstance.getReports().values());
reports.sort(Comparator.comparing(r -> label(r.getLabel(), r.getName())));
for(QReportMetaData report : reports)
{
add(byApp, appOfObject.get("report:" + report.getName()), qInstance, report, Kind.REPORT, label(report.getLabel(), report.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<QWidgetMetaDataInterface> widgets = new ArrayList<>(qInstance.getWidgets().values());
widgets.sort(Comparator.comparing(w -> label(w.getLabel(), w.getName())));
for(QWidgetMetaDataInterface widget : widgets)
{
add(byApp, appOfObject.get("widget:" + widget.getName()), qInstance, widget, Kind.WIDGET, label(widget.getLabel(), widget.getName()), EnumSet.of(PolicyAction.HAS_ACCESS), null);
}
List<Group> groups = new ArrayList<>();
for(Map.Entry<String, Map<String, ResourceDraft>> entry : byApp.entrySet())
{
if(entry.getValue().isEmpty())
{
continue;
}
String groupLabel = entry.getKey() == null ? OTHER : label(qInstance.getApp(entry.getKey()).getLabel(), entry.getKey());
List<Resource> resources = new ArrayList<>();
for(ResourceDraft draft : entry.getValue().values())
{
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), draft.note));
}
groups.add(new Group(entry.getKey(), groupLabel, resources));
}
return (new PolicyCatalog(groups));
}
/*******************************************************************************
** Accumulates one resource while building (several objects can share a
** permission base name).
*******************************************************************************/
private static class ResourceDraft
{
private final String name;
private final Kind kind;
private final Set<String> labels = new LinkedHashSet<>();
private final Set<PolicyAction> actions = new TreeSet<>();
private String note;
/*******************************************************************************
** Constructor.
*******************************************************************************/
ResourceDraft(String name, Kind kind)
{
this.name = name;
this.kind = kind;
}
}
/*******************************************************************************
** Add an object under its app, unless a policy cannot grant it.
*******************************************************************************/
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
Set<PolicyAction> actions, String note)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(object, qInstance);
if(rules == null || rules.getLevel() == null || rules.getLevel() == PermissionLevel.NOT_PROTECTED || rules.getCustomPermissionChecker() != null || actions.isEmpty())
{
return;
}
String name = StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : nameOf(object);
if(!PolicyParser.RESOURCE_PATTERN.matcher(name).matches())
{
return;
}
////////////////////////////////////////////////////////////////////
// a base name shared with an object already listed (in any app) //
// joins that entry, so a resource appears once //
////////////////////////////////////////////////////////////////////
for(Map<String, ResourceDraft> resources : byApp.values())
{
ResourceDraft existing = resources.get(name);
if(existing != null)
{
existing.labels.add(label);
existing.actions.addAll(actions);
return;
}
}
ResourceDraft draft = new ResourceDraft(name, kind);
draft.labels.add(label);
draft.actions.addAll(actions);
draft.note = note;
byApp.computeIfAbsent(byApp.containsKey(app) ? app : null, k -> new LinkedHashMap<>()).put(name, draft);
}
/*******************************************************************************
** Which app directly lists each object ("table:x", "process:y", ...): app
** children (sections included) and app widgets. The first app wins.
*******************************************************************************/
private static Map<String, String> appsOfObjects(QInstance qInstance)
{
Map<String, String> rs = new LinkedHashMap<>();
for(QAppMetaData app : qInstance.getApps().values())
{
if(app.getChildren() != null)
{
for(QAppChildMetaData child : app.getChildren())
{
String type = child instanceof QTableMetaData ? "table" : child instanceof QProcessMetaData ? "process" : child instanceof QReportMetaData ? "report" : null;
if(type != null)
{
rs.putIfAbsent(type + ":" + child.getName(), app.getName());
}
}
}
if(app.getWidgets() != null)
{
for(String widget : app.getWidgets())
{
rs.putIfAbsent("widget:" + widget, app.getName());
}
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String nameOf(MetaDataWithPermissionRules object)
{
if(object instanceof QTableMetaData t)
{
return (t.getName());
}
if(object instanceof QProcessMetaData p)
{
return (p.getName());
}
if(object instanceof QAppMetaData a)
{
return (a.getName());
}
if(object instanceof QReportMetaData r)
{
return (r.getName());
}
if(object instanceof QWidgetMetaDataInterface w)
{
return (w.getName());
}
return ("");
}
/*******************************************************************************
**
*******************************************************************************/
private static String label(String label, String name)
{
return (StringUtils.hasContent(label) ? label : name);
}
/*******************************************************************************
** The groups, in app order, "Other" last.
*******************************************************************************/
public List<Group> getGroups()
{
return (groups);
}
/*******************************************************************************
** A resource by policy name, or null.
*******************************************************************************/
public Resource get(String name)
{
return (byName.get(name));
}
/*******************************************************************************
** Every QQQ permission a policy can grant here ({resource}.{action}).
*******************************************************************************/
public Set<String> permissionNames()
{
Set<String> rs = new TreeSet<>();
for(Resource resource : byName.values())
{
for(PolicyAction action : resource.actions())
{
rs.add(action.permission(resource.name()));
}
}
return (rs);
}
/*******************************************************************************
** The resources of a permission set this catalog does not know, or that
** grant an action the resource does not have (sorted, as permission names).
*******************************************************************************/
public Set<String> unknownPermissions(Set<String> permissions)
{
Set<String> known = permissionNames();
Set<String> rs = new TreeSet<>();
for(String permission : permissions)
{
if(!known.contains(permission))
{
rs.add(permission);
}
}
return (rs);
}
/*******************************************************************************
** As JSON for the editor widget.
*******************************************************************************/
public JSONArray toJson()
{
JSONArray rs = new JSONArray();
for(Group group : groups)
{
JSONArray resources = new JSONArray();
for(Resource resource : group.resources())
{
JSONArray actions = new JSONArray();
resource.actions().forEach(a -> actions.put(a.getJsonName()));
JSONObject json = new JSONObject()
.put("name", resource.name())
.put("label", resource.label())
.put("kind", resource.kind().name())
.put("actions", actions);
if(resource.note() != null)
{
json.put("note", resource.note());
}
resources.put(json);
}
rs.put(new JSONObject().put("app", group.app() == null ? JSONObject.NULL : group.app()).put("label", group.label()).put("resources", resources));
}
return (rs);
}
}
@@ -0,0 +1,341 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.List;
import java.util.Map;
import java.util.Objects;
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.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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;
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.JSONObject;
/*******************************************************************************
** The policy editor widget (type AUTHENTIK_POLICY_EDITOR, DESIGN.md section
** 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.
*******************************************************************************/
public final class PolicyEditorWidget
{
/** The widget type the Next UI renders. */
public static final String TYPE = "AUTHENTIK_POLICY_EDITOR";
/** Short widget name (prefixed with the qbit namespace). */
public static final String NAME = "policyEditor";
/** Widget default value naming the qbit's API backend. */
public static final String BACKEND_NAME_KEY = "backendName";
/** Widget parameter (process value) with the group's pk. */
public static final String GROUP_PK_PARAM = "groupPk";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyEditorWidget()
{
}
/*******************************************************************************
** The data for a group (or for none: an empty document).
*******************************************************************************/
public static Data load(AuthentikQBitConfig config, String groupPk) throws QException
{
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())
.withOutputFieldName(AuthentikAdmin.POLICY_DOCUMENT);
if(!StringUtils.hasContent(groupPk))
{
return (data);
}
if(!groupPk.matches("[A-Za-z0-9-]+"))
{
throw (new QUserFacingException("Not a group id: " + groupPk));
}
JSONObject group = AuthentikActionUtil.forBackend(config.getApiBackendName()).getApiJsonOrNull("/core/groups/" + groupPk + "/?include_users=false&include_parents=false&include_children=false");
if(group == null)
{
throw (new QUserFacingException("That group no longer exists."));
}
data.withGroupLabel(group.optString("name"));
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
if(stored != null && !JSONObject.NULL.equals(stored) && !"".equals(stored))
{
try
{
/////////////////////////////////////////////////////////////////
// canonical form of what is stored (unknown resources survive) //
/////////////////////////////////////////////////////////////////
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format)));
}
catch(PolicyParseException e)
{
data.withDocumentProblem(e.getMessage());
}
}
QRecord row = PolicyStore.find(config, groupPk);
if(row != null)
{
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format)));
}
return (data);
}
/*******************************************************************************
** Renderer. QQQ checks the widget's permission rules
** (authentik.policies.manage) before calling it.
*******************************************************************************/
public static class Renderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY));
AuthentikQBitConfig config = AuthentikRuntime.forBackend(backendName).getConfig();
Map<String, String> params = input.getQueryParams();
return (new RenderWidgetOutput(load(config, params == null ? null : params.get(GROUP_PK_PARAM))));
}
}
/*******************************************************************************
** The widget's data (see the Next UI's PolicyEditorPayload).
*******************************************************************************/
public static class Data extends QWidgetData
{
private String spec;
private String version;
private String groupLabel;
private List<Object> catalog;
private String document;
private String documentProblem;
private Boolean changedInAuthentik = false;
private String outputFieldName;
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getType()
{
return (TYPE);
}
/*******************************************************************************
** Getter for spec
*******************************************************************************/
public String getSpec()
{
return (spec);
}
/*******************************************************************************
** Fluent setter for spec
*******************************************************************************/
public Data withSpec(String spec)
{
this.spec = spec;
return (this);
}
/*******************************************************************************
** Getter for version
*******************************************************************************/
public String getVersion()
{
return (version);
}
/*******************************************************************************
** Fluent setter for version
*******************************************************************************/
public Data withVersion(String version)
{
this.version = version;
return (this);
}
/*******************************************************************************
** Getter for groupLabel
*******************************************************************************/
public String getGroupLabel()
{
return (groupLabel);
}
/*******************************************************************************
** Fluent setter for groupLabel
*******************************************************************************/
public Data withGroupLabel(String groupLabel)
{
this.groupLabel = groupLabel;
return (this);
}
/*******************************************************************************
** Getter for catalog (groups of resources, as plain JSON values)
*******************************************************************************/
public List<Object> getCatalog()
{
return (catalog);
}
/*******************************************************************************
** Fluent setter for catalog
*******************************************************************************/
public Data withCatalog(List<Object> catalog)
{
this.catalog = catalog;
return (this);
}
/*******************************************************************************
** Getter for document (the group's current document, pretty; null for none)
*******************************************************************************/
public String getDocument()
{
return (document);
}
/*******************************************************************************
** Fluent setter for document
*******************************************************************************/
public Data withDocument(String document)
{
this.document = document;
return (this);
}
/*******************************************************************************
** Getter for documentProblem (why the stored document is invalid)
*******************************************************************************/
public String getDocumentProblem()
{
return (documentProblem);
}
/*******************************************************************************
** Fluent setter for documentProblem
*******************************************************************************/
public Data withDocumentProblem(String documentProblem)
{
this.documentProblem = documentProblem;
return (this);
}
/*******************************************************************************
** Getter for changedInAuthentik (differs from the last save in QQQ)
*******************************************************************************/
public Boolean getChangedInAuthentik()
{
return (changedInAuthentik);
}
/*******************************************************************************
** Fluent setter for changedInAuthentik
*******************************************************************************/
public Data withChangedInAuthentik(Boolean changedInAuthentik)
{
this.changedInAuthentik = changedInAuthentik;
return (this);
}
/*******************************************************************************
** Getter for outputFieldName (the process value the editor writes)
*******************************************************************************/
public String getOutputFieldName()
{
return (outputFieldName);
}
/*******************************************************************************
** Fluent setter for outputFieldName
*******************************************************************************/
public Data withOutputFieldName(String outputFieldName)
{
this.outputFieldName = outputFieldName;
return (this);
}
}
}
@@ -0,0 +1,160 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** The authentik_policy rows (DESIGN.md section 12.2): the QQQ record of each
** group's last policy save, plus the fingerprint used to tell when the policy
** was changed in Authentik since.
*******************************************************************************/
public final class PolicyStore
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PolicyStore()
{
}
/*******************************************************************************
** A fingerprint of a group's policy attribute value: of its canonical form
** when it parses (so formatting differences do not count), else of the raw
** text. Null for no policy.
*******************************************************************************/
public static String fingerprint(Object attributeValue, PolicyFormat format)
{
if(attributeValue == null || JSONObject.NULL.equals(attributeValue) || "".equals(attributeValue))
{
return (null);
}
String text;
try
{
text = PolicyGenerator.toCompact(PolicyGenerator.canonical(attributeValue, format));
}
catch(PolicyParseException e)
{
text = String.valueOf(attributeValue);
}
try
{
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))));
}
catch(NoSuchAlgorithmException e)
{
throw (new IllegalStateException(e));
}
}
/*******************************************************************************
** The row of a group, or null (also null when the table is not set up).
*******************************************************************************/
public static QRecord find(AuthentikQBitConfig config, String groupPk) throws QException
{
String table = config.applyTablePrefix(AuthentikLocalTables.POLICY);
if(QContext.getQInstance() == null || QContext.getQInstance().getTable(table) == null)
{
return (null);
}
QueryInput queryInput = new QueryInput();
queryInput.setTableName(table);
queryInput.setFilter(new QQueryFilter(new QFilterCriteria("groupPk", QCriteriaOperator.EQUALS, groupPk)));
QueryOutput output = new QueryAction().execute(queryInput);
return (output.getRecords().isEmpty() ? null : output.getRecords().get(0));
}
/*******************************************************************************
** Insert or update the row of a group after a save to Authentik.
**
** @param document the canonical document that was saved (empty statements
** when the policy was removed)
*******************************************************************************/
public static void save(AuthentikQBitConfig config, String groupPk, String groupName, JSONObject document, int permissionCount, String savedBy, String fingerprint) throws QException
{
String table = config.applyTablePrefix(AuthentikLocalTables.POLICY);
QRecord existing = find(config, groupPk);
QRecord record = new QRecord()
.withValue("groupPk", groupPk)
.withValue("groupName", groupName)
.withValue("policyJson", PolicyGenerator.toPretty(document))
.withValue("statementCount", document.optJSONArray("Statement") == null ? 0 : document.getJSONArray("Statement").length())
.withValue("permissionCount", permissionCount)
.withValue("savedBy", savedBy)
.withValue("savedDate", Instant.now())
.withValue("authentikFingerprint", fingerprint)
.withValue("modifyDate", Instant.now());
if(existing == null)
{
record.setValue("createDate", Instant.now());
InsertInput insertInput = new InsertInput();
insertInput.setTableName(table);
insertInput.setRecords(List.of(record));
InsertOutput output = new InsertAction().execute(insertInput);
if(output.getRecords().isEmpty() || !output.getRecords().get(0).getErrors().isEmpty())
{
throw (new QException("The policy record could not be written: " + (output.getRecords().isEmpty() ? "no record" : output.getRecords().get(0).getErrors())));
}
}
else
{
record.setValue("id", existing.getValue("id"));
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(table);
updateInput.setRecords(List.of(record));
UpdateOutput output = new UpdateAction().execute(updateInput);
if(output.getRecords().isEmpty() || !output.getRecords().get(0).getErrors().isEmpty())
{
throw (new QException("The policy record could not be written: " + (output.getRecords().isEmpty() ? "no record" : output.getRecords().get(0).getErrors())));
}
}
}
}
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.admin.AuthentikSafety;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -88,7 +89,9 @@ public final class AuthentikProcesses
/** Selected invitations. */
INVITATIONS,
/** Selected approval requests. */
APPROVALS
APPROVALS,
/** Selected policy records (authentik_policy); the target is the record's group. */
POLICIES
}
/*******************************************************************************
@@ -184,6 +187,11 @@ public final class AuthentikProcesses
new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"),
hidden("expectedFingerprint"), reason),
"You can only add permissions you hold yourself. Adding authentik.* needs a second person to approve."));
String policyHelp = "Tick what members of this group, and of the groups below it, may do. The document is what is saved to Authentik. "
+ "You can only grant permissions you hold yourself.";
List<QFieldMetaData> policyFields = List.of(hidden("groupPk"), hidden("expectedFingerprint"), reason);
defs.add(new Def("editPolicy", "Edit Policy", AdminAction.SAVE_POLICY, Kind.GROUPS, groups, "policy", true, policyFields, policyHelp));
defs.add(new Def("editPolicyRecord", "Edit Policy", AdminAction.SAVE_POLICY, Kind.POLICIES, AuthentikLocalTables.POLICY, "policy", true, policyFields, policyHelp));
defs.add(new Def("addMembers", "Add Members", AdminAction.ADD_MEMBER, Kind.GROUP_USERNAMES, groups, "person_add", false, List.of(
new QFieldMetaData("usernames", QFieldType.TEXT).withLabel("Usernames (one per line)").withIsRequired(true),
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason), null));
@@ -272,11 +280,11 @@ public final class AuthentikProcesses
switch(def.kind())
{
case CREATE -> process.setMaxInputRecords(0);
case USERS, GROUP_USERNAMES, GROUPS, INVITATIONS, APPROVALS ->
case USERS, GROUP_USERNAMES, GROUPS, INVITATIONS, APPROVALS, POLICIES ->
{
process.setMinInputRecords(1);
process.setMaxInputRecords(def.action() == AdminAction.EDIT_USER || def.action() == AdminAction.EDIT_GROUP || def.action() == AdminAction.EDIT_PERMISSIONS
? 1 : AuthentikAdmin.MAX_BULK);
|| def.action() == AdminAction.SAVE_POLICY ? 1 : AuthentikAdmin.MAX_BULK);
}
default ->
{
@@ -290,6 +298,10 @@ public final class AuthentikProcesses
{
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.HELP_TEXT).withValue("text", def.help()));
}
if(def.action() == AdminAction.SAVE_POLICY)
{
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", config.applyPrefix(PolicyEditorWidget.NAME)));
}
details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
process.withStep(0, details);
if(def.prefill())
@@ -340,6 +352,7 @@ public final class AuthentikProcesses
case CREATE_GROUP -> w("group will be created", "groups will be created", "group was created", "groups were created");
case EDIT_GROUP -> w("group will be updated", "groups will be updated", "group was updated", "groups were updated");
case EDIT_PERMISSIONS -> w("group's permissions will be updated", "groups' permissions will be updated", "group's permissions were updated", "groups' permissions were updated");
case SAVE_POLICY -> w("group's policy will be saved to Authentik", "groups' policies will be saved to Authentik", "group's policy was saved to Authentik", "groups' policies were saved to Authentik");
case SEND_RECOVERY_EMAIL -> w("password reset email will be sent", "password reset emails will be sent", "password reset email was sent", "password reset emails were sent");
case RESET_MFA -> w("user's MFA will be reset", "users' MFA will be reset", "user's MFA was reset", "users' MFA were reset");
case REVOKE_APP_PASSWORDS -> w("user's app passwords will be revoked", "users' app passwords will be revoked", "user's app passwords were revoked", "users' app passwords were revoked");
@@ -409,6 +422,7 @@ public final class AuthentikProcesses
}
}
case GROUPS, INVITATIONS -> rs.add(withParams(AdminRequest.of(def.action(), String.valueOf(record.getValue("pk") != null ? record.getValue("pk") : record.getValue("uuid")), reason), def, input));
case POLICIES -> rs.add(withParams(AdminRequest.of(def.action(), record.getValueString("groupPk"), reason), def, input));
default ->
{
}
@@ -423,6 +437,14 @@ public final class AuthentikProcesses
*******************************************************************************/
private static AdminRequest withParams(AdminRequest request, Def def, RunBackendStepInput input)
{
//////////////////////////////////////////////////////////////////////
// the policy editor widget writes the document as a process value //
// (not a form field), as does an API client //
//////////////////////////////////////////////////////////////////////
if(def.action() == AdminAction.SAVE_POLICY && input.getValue(AuthentikAdmin.POLICY_DOCUMENT) != null)
{
request.with(AuthentikAdmin.POLICY_DOCUMENT, input.getValueString(AuthentikAdmin.POLICY_DOCUMENT));
}
for(QFieldMetaData field : def.fields())
{
if(field.getName().equals("reason"))
@@ -490,9 +512,18 @@ public final class AuthentikProcesses
}
QRecord record = input.getRecords().get(0);
AuthentikAdmin admin = admin(input);
String pk = String.valueOf(record.getValue("pk"));
String pk = def.kind() == Kind.POLICIES ? record.getValueString("groupPk") : String.valueOf(record.getValue("pk"));
switch(def.action())
{
case SAVE_POLICY ->
{
JSONObject group = admin.getUtil().getApiJsonOrNull("/core/groups/" + AuthentikAdmin.safeKey(pk) + "/?include_users=true&include_parents=true&include_children=true");
output.addValue(PolicyEditorWidget.GROUP_PK_PARAM, pk);
if(group != null)
{
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
}
}
case EDIT_USER ->
{
JSONObject user = admin.getUtil().getObject(AuthentikTables.get(AuthentikTables.USER), pk);
@@ -38,6 +38,7 @@ public enum IdentityPermission
GROUPS_MANAGE("authentik.groups.manage"),
MEMBERSHIPS_MANAGE("authentik.memberships.manage"),
PERMISSIONS_MANAGE("authentik.permissions.manage"),
POLICIES_MANAGE("authentik.policies.manage"),
ROLES_MANAGE("authentik.roles.manage"),
CREDENTIALS_RESET("authentik.credentials.reset"),
SESSIONS_REVOKE("authentik.sessions.revoke"),
@@ -28,16 +28,23 @@ import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
@@ -125,6 +132,22 @@ public final class AdminTestSupport
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(table[1])));
}
if(features.contains(AuthentikFeature.POLICIES))
{
//////////////////////////////////////////////////////////////////
// things a policy can grant: an app with a table (insert, edit, //
// delete, read) and a hidden record action on it //
//////////////////////////////////////////////////////////////////
QTableMetaData page = new QTableMetaData().withName("cms_page").withLabel("Pages").withBackendName(LOCAL).withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
instance.addTable(page);
instance.addProcess(new QProcessMetaData().withName("publishPage").withLabel("Publish Page").withTableName("cms_page").withIsHidden(true)
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(NoopStep.class)))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addApp(new QAppMetaData().withName("content").withLabel("Content").withChild(page)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
}
new AuthentikQBitProducer().withQBitConfig(support.config).produce(instance);
AuthentikTestSupport.useActionUtil(instance, support.config, FixtureActionUtil.class);
support.qInstance = instance;
@@ -182,4 +205,22 @@ public final class AdminTestSupport
return (authentik.writes.stream().filter(w -> !w.contains("/events/events/")).toList());
}
/*******************************************************************************
** A backend step that does nothing (for fixture processes).
*******************************************************************************/
public static class NoopStep implements BackendStep
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output)
{
///////////////////////////
// nothing; fixture only //
///////////////////////////
}
}
}
@@ -0,0 +1,317 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.admin;
import java.io.Serializable;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The policy editor (DESIGN.md section 12.4): saving a policy to Authentik,
** the guardrails, the authentik_policy history row, the process and widget.
*******************************************************************************/
class PolicyEditorTest
{
private static final String PAGE_READ_EDIT = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"cms_page\"]}]}";
private AdminTestSupport t;
/*******************************************************************************
** The operator's own group carries a policy, so the subset rule lets them
** grant cms_page read/edit and content access (but not delete).
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, AuthentikFeature.POLICIES));
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/policy",
PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("cms_page.read", "cms_page.edit", "content.hasAccess", "publishPage.hasAccess"), PolicyFormat.DEFAULT)));
}
/*******************************************************************************
**
*******************************************************************************/
private AuthentikAdmin.AdminResult save(String group, String document) throws QException
{
return (t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(group), "because").with(AuthentikAdmin.POLICY_DOCUMENT, document)));
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> policyRows() throws QException
{
QueryInput input = new QueryInput();
input.setTableName(t.config.applyTablePrefix(AuthentikLocalTables.POLICY));
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
** Save writes the canonical compact string into the CURRENT attributes, and
** an authentik_policy row with the pretty document.
*******************************************************************************/
@Test
void saveWritesCanonicalDocumentAndHistoryRow() throws Exception
{
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("note", "set in the Authentik UI");
AuthentikAdmin.AdminResult result = save("kof-website-analysts", PAGE_READ_EDIT);
assertThat(result.ok()).as(result.message()).isTrue();
assertThat(result.message()).isEqualTo("Policy saved to Authentik.");
JSONObject attributes = t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes");
assertThat(attributes.getString("note")).isEqualTo("set in the Authentik UI");
assertThat(attributes.getString("qqq.io/policy")).isEqualTo(PolicyGenerator.toCompact(PolicyGenerator.canonical(PAGE_READ_EDIT, PolicyFormat.DEFAULT)));
assertThat(PolicyParser.parse(attributes.get("qqq.io/policy"), PolicyFormat.DEFAULT)).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
List<QRecord> rows = policyRows();
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getValueString("groupName")).isEqualTo("kof-website-analysts");
assertThat(rows.get(0).getValueInteger("permissionCount")).isEqualTo(2);
assertThat(rows.get(0).getValueString("policyJson")).contains("\"Action\": [\"read\"]");
assertThat(rows.get(0).getValueString("savedBy")).isEqualTo(com.kingsrook.qbits.authentik.AuthentikTestSupport.OPERATOR);
//////////////////////////////////////////////////////////////
// the effective permissions of the group now include them //
//////////////////////////////////////////////////////////////
t.admin().getUtil().getRuntime().invalidateCache();
assertThat(t.admin().getUtil().groupGraph().byName("kof-website-analysts").policyPermissions()).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
///////////////////////////////////////////////////////////////////
// saving it again is no change; removing it drops the attribute //
///////////////////////////////////////////////////////////////////
assertThat(save("kof-website-analysts", PAGE_READ_EDIT).noChange()).isTrue();
AuthentikAdmin.AdminResult removed = save("kof-website-analysts", "");
assertThat(removed.message()).contains("removed");
assertThat(t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").has("qqq.io/policy")).isFalse();
assertThat(policyRows()).hasSize(1);
assertThat(policyRows().get(0).getValueInteger("permissionCount")).isEqualTo(0);
}
/*******************************************************************************
** The guardrails: invalid documents, unknown resources, the subset rule,
** superuser groups and a stale fingerprint are refused, and nothing is sent.
*******************************************************************************/
@Test
void refusals() throws Exception
{
assertThat(save("kof-website-analysts", "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}]}").message())
.contains("not valid").contains("Deny");
assertThat(save("kof-website-analysts", "{\"PolicySpec\":\"other\",\"PolicyVersion\":\"1.0.0\"}").message()).contains("PolicySpec");
assertThat(save("kof-website-analysts", PAGE_READ_EDIT.replace("cms_page", "no_such_table")).message()).contains("not permissions of this app").contains("no_such_table.read");
assertThat(save("kof-website-analysts", PAGE_READ_EDIT.replace("\"edit\"", "\"delete\"")).message()).contains("do not hold yourself").contains("cms_page.delete");
assertThat(save("authentik Admins", PAGE_READ_EDIT).message()).contains("Superuser groups");
AuthentikAdmin.AdminResult stale = t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because")
.with(AuthentikAdmin.POLICY_DOCUMENT, PAGE_READ_EDIT).with("expectedFingerprint", "not-the-fingerprint"));
assertThat(stale.message()).contains("changed");
assertThat(t.changes()).isEmpty();
assertThat(policyRows()).isEmpty();
}
/*******************************************************************************
** Unknown resources already in the stored policy are kept (with a note); a
** stored document that is invalid is replaced.
*******************************************************************************/
@Test
void keptUnknownsAndInvalidStoredDocuments() throws Exception
{
String withGone = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\"]}]}";
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", withGone);
AdminPlan plan = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because")
.with(AuthentikAdmin.POLICY_DOCUMENT, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\",\"cms_page\"]}]}"));
assertThat(plan.isRunnable()).as(plan.getMessage()).isTrue();
assertThat(plan.getNotes()).anyMatch(n -> n.startsWith("Adds: cms_page.read")).anyMatch(n -> n.contains("Kept although") && n.contains("gone_table.read"));
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", "garbage");
AdminPlan replace = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because").with(AuthentikAdmin.POLICY_DOCUMENT, ""));
assertThat(replace.isRunnable()).isTrue();
assertThat(replace.getNotes()).anyMatch(n -> n.contains("was not valid"));
}
/*******************************************************************************
** Permission gating: without authentik.policies.manage, or without the
** POLICIES feature, there is no save (and no metadata).
*******************************************************************************/
@Test
void gating() throws Exception
{
t.loginAs(com.kingsrook.qbits.authentik.AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ, IdentityPermission.GROUPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
assertThat(save("kof-website-analysts", PAGE_READ_EDIT).ok()).isFalse();
assertThat(t.changes()).isEmpty();
AdminTestSupport off = AdminTestSupport.create();
assertThat(off.qInstance.getProcess("authentikEditPolicy")).isNull();
assertThat(off.qInstance.getWidget("authentikPolicyEditor")).isNull();
assertThat(off.qInstance.getTable("authentik_policy")).isNull();
AuthentikAdmin.AdminResult refused = off.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, off.authentik.pk("kof-website-analysts"), "because").with(AuthentikAdmin.POLICY_DOCUMENT, PAGE_READ_EDIT));
assertThat(refused.ok()).isFalse();
}
/*******************************************************************************
** A second person approves policy saves when policyTwoPersonApproval is on.
*******************************************************************************/
@Test
void twoPersonApproval() throws Exception
{
t.config.withPolicyTwoPersonApproval(true);
AuthentikAdmin.AdminResult result = save("kof-website-analysts", PAGE_READ_EDIT);
assertThat(result.pending()).isTrue();
assertThat(t.changes()).isEmpty();
}
/*******************************************************************************
** The record action: prepare fills the group and fingerprint, the step shows
** the editor widget, Review is a dry run, Result saves.
*******************************************************************************/
@Test
void editPolicyProcess() throws Exception
{
String groupPk = t.authentik.pk("kof-website-analysts");
QFrontendStepMetaData details = (QFrontendStepMetaData) t.qInstance.getProcess("authentikEditPolicy").getStep("details");
assertThat(details.getComponents()).anyMatch(c -> c.getType() == QComponentType.WIDGET && "authentikPolicyEditor".equals(c.getValues().get("widgetName")));
assertThat(t.qInstance.getProcess("authentikEditPolicy").getTableName()).isEqualTo(t.config.applyTablePrefix(AuthentikTables.GROUP));
assertThat(t.qInstance.getProcess("authentikEditPolicyRecord").getTableName()).isEqualTo("authentik_policy");
RunProcessInput input = new RunProcessInput();
input.setProcessName("authentikEditPolicy");
input.addValue("recordIds", groupPk);
RunProcessOutput output = new RunProcessAction().execute(input);
assertThat(output.getValues().get("groupPk")).isEqualTo(groupPk);
assertThat(output.getValues().get("expectedFingerprint")).isNotNull();
input.setProcessUUID(output.getProcessUUID());
Map<String, Serializable> form = Map.of("reason", "editors read pages", AuthentikAdmin.POLICY_DOCUMENT, PAGE_READ_EDIT);
form.forEach(input::addValue);
input.setStartAfterStep("details");
output = new RunProcessAction().execute(input);
assertThat(output.getException()).isEmpty();
@SuppressWarnings("unchecked")
List<ProcessSummaryLine> review = (List<ProcessSummaryLine>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
assertThat(review).anyMatch(l -> l.getStatus() == Status.OK && l.getMessage().contains("policy will be saved"));
assertThat(review).anyMatch(l -> l.getMessage().contains("Adds: cms_page.edit, cms_page.read"));
assertThat(t.changes()).as("review is a dry run").isEmpty();
input.setStartAfterStep("review");
output = new RunProcessAction().execute(input);
assertThat(output.getException()).isEmpty();
assertThat(PolicyParser.parse(t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").get("qqq.io/policy"), PolicyFormat.DEFAULT))
.containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
////////////////////////////////////////////////////////
// the same action from the policy record (its group) //
////////////////////////////////////////////////////////
RunProcessInput again = new RunProcessInput();
again.setProcessName("authentikEditPolicyRecord");
again.addValue("recordIds", String.valueOf(policyRows().get(0).getValue("id")));
RunProcessOutput prepared = new RunProcessAction().execute(again);
assertThat(prepared.getValues().get("groupPk")).isEqualTo(groupPk);
}
/*******************************************************************************
** The widget: catalog plus the group's live document; a change made in
** Authentik after the last QQQ save is flagged; invalid stored documents are
** reported.
*******************************************************************************/
@Test
void widgetData() throws Exception
{
String groupPk = t.authentik.pk("kof-website-analysts");
PolicyEditorWidget.Data empty = PolicyEditorWidget.load(t.config, groupPk);
assertThat(empty.getType()).isEqualTo("AUTHENTIK_POLICY_EDITOR");
assertThat(empty.getSpec()).isEqualTo("qqq-iam");
assertThat(empty.getDocument()).isNull();
assertThat(empty.getGroupLabel()).isEqualTo("kof-website-analysts");
assertThat(empty.getOutputFieldName()).isEqualTo("policyDocument");
assertThat(new JSONObject(Map.of("c", empty.getCatalog())).toString()).contains("\"cms_page\"").contains("\"publishPage\"");
save("kof-website-analysts", PAGE_READ_EDIT);
PolicyEditorWidget.Data saved = PolicyEditorWidget.load(t.config, groupPk);
assertThat(saved.getDocument()).contains("\"cms_page\"");
assertThat(saved.getChangedInAuthentik()).isFalse();
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", PAGE_READ_EDIT.replace("\"edit\"", "\"insert\""));
t.admin().getUtil().getRuntime().invalidateCache();
assertThat(PolicyEditorWidget.load(t.config, groupPk).getChangedInAuthentik()).isTrue();
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", "garbage");
t.admin().getUtil().getRuntime().invalidateCache();
PolicyEditorWidget.Data broken = PolicyEditorWidget.load(t.config, groupPk);
assertThat(broken.getDocumentProblem()).contains("not a JSON object");
assertThat(broken.getDocument()).isNull();
assertThat(PolicyEditorWidget.load(t.config, null).getGroupLabel()).isNull();
assertThatThrownBy(() -> PolicyEditorWidget.load(t.config, "../x")).hasMessageContaining("Not a group id");
RenderWidgetInput input = new RenderWidgetInput();
input.setWidgetMetaData(t.qInstance.getWidget("authentikPolicyEditor"));
input.setQueryParams(new java.util.HashMap<>(Map.of("groupPk", groupPk)));
RenderWidgetOutput output = new RenderWidgetAction().execute(input);
assertThat(output.getWidgetData().getType()).isEqualTo("AUTHENTIK_POLICY_EDITOR");
}
}
@@ -73,6 +73,9 @@ class QbitWriteContractIT
{
private static final String OPERATOR_EMAIL = "wr.operator@example.test";
/** The operator's own policy: they may grant read and edit on wr_page. */
private static final String WR_POLICY = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"wr_page\"]}]}";
/** The phase 2/3 permissions of DESIGN.md section 5.1 (plus view_flow to resolve the invitation flow by slug). */
private static final List<String> ROLE_PERMISSIONS = List.of(
"authentik_core.add_user", "authentik_core.change_user", "authentik_core.reset_user_password",
@@ -110,7 +113,8 @@ class QbitWriteContractIT
admin.post("/rbac/permissions/assigned_by_roles/" + roleUuid + "/assign/", new JSONObject().put("permissions", new JSONArray(ROLE_PERMISSIONS)));
admin.post("/core/groups/", new JSONObject().put("name", "wr-admins").put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.*"))).put("qqq.io/app-access", new JSONArray(List.of(AuthentikTestStack.APP_SLUG)))));
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.*"))).put("qqq.io/app-access", new JSONArray(List.of(AuthentikTestStack.APP_SLUG)))
.put("qqq.io/policy", WR_POLICY)));
admin.post("/core/groups/", new JSONObject().put("name", "wr-readers").put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray(List.of("authentik.users.read")))));
admin.post("/core/users/", new JSONObject().put("username", "wr.operator").put("name", "WR Operator").put("email", OPERATOR_EMAIL).put("type", "internal").put("path", "users"));
@@ -162,11 +166,15 @@ class QbitWriteContractIT
.withRecoveryEmailStage("kof22-recovery-email")
.withInvitationFlow("wr-enrollment")
.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS));
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES));
qInstance = new QInstance();
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
qInstance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
qInstance.addTable(new com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData().withName("wr_page").withBackendName("memory").withPrimaryKeyField("id")
.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("id", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER))
.withPermissionRules(new com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules()
.withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)));
new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
Set<String> permissions = new HashSet<>();
@@ -305,6 +313,38 @@ class QbitWriteContractIT
/*******************************************************************************
** Save a policy on a real Authentik: the canonical compact string lands in
** the group's attributes next to the others, the qbit reads it back, and a
** grant the operator does not hold is refused.
*******************************************************************************/
@Test
void savePolicy() throws Exception
{
admin.post("/core/groups/", new JSONObject().put("name", "wr-policy").put("attributes", new JSONObject().put("owner-note", "set in the Authentik UI")));
String pk = admin.groupPk("wr-policy");
String document = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"delete\"],\"Resource\":[\"wr_page\"]}]}";
AuthentikAdmin.AdminResult escalation = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, document));
assertThat(escalation.ok()).isFalse();
assertThat(escalation.message()).contains("wr_page.delete");
String allowed = document.replace(",\"delete\"", "");
AuthentikAdmin.AdminResult saved = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, allowed));
assertThat(saved.ok()).as(saved.message()).isTrue();
JSONObject attributes = admin.get("/core/groups/" + pk + "/").json().getJSONObject("attributes");
assertThat(attributes.getString("owner-note")).isEqualTo("set in the Authentik UI");
assertThat(attributes.getString("qqq.io/policy")).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"wr_page\"]}]}");
assertThat(AuthentikActionUtil.forBackend(config.getApiBackendName()).groupGraph().byPk(pk).policyPermissions()).containsExactly("wr_page.read");
AuthentikAdmin.AdminResult cleared = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, ""));
assertThat(cleared.ok()).as(cleared.message()).isTrue();
assertThat(admin.get("/core/groups/" + pk + "/").json().getJSONObject("attributes").has("qqq.io/policy")).isFalse();
}
/*******************************************************************************
** Group create and edit (parents), and the superuser parent refused by the
** guard.
@@ -0,0 +1,175 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.policy;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Unit tests for PolicyCatalog: the editor's enumeration of what a policy can
** grant in a QInstance, grouped by app.
*******************************************************************************/
class PolicyCatalogTest
{
/*******************************************************************************
**
*******************************************************************************/
private static QPermissionRules rules(PermissionLevel level)
{
return (new QPermissionRules().withLevel(level));
}
/*******************************************************************************
**
*******************************************************************************/
private static QTableMetaData table(String name, String label, PermissionLevel level)
{
return (new QTableMetaData().withName(name).withLabel(label).withBackendName("memory").withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withPermissionRules(rules(level)));
}
/*******************************************************************************
** An instance with every kind of object and permission shape.
*******************************************************************************/
static QInstance instance()
{
QInstance instance = new QInstance();
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
QTableMetaData page = table("cms_page", "Pages", PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS);
QTableMetaData audit = table("cms_audit", "Audit", PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
QTableMetaData legacy = table("legacy", "Legacy", PermissionLevel.READ_WRITE_PERMISSIONS);
QTableMetaData gate = table("gate", "Gate", PermissionLevel.HAS_ACCESS_PERMISSION);
QTableMetaData shared1 = table("shared_one", "Shared One", PermissionLevel.HAS_ACCESS_PERMISSION);
shared1.getPermissionRules().setPermissionBaseName("shared_base");
QTableMetaData shared2 = table("shared_two", "Shared Two", PermissionLevel.HAS_ACCESS_PERMISSION);
shared2.getPermissionRules().setPermissionBaseName("shared_base");
QTableMetaData open = table("open_table", "Open", PermissionLevel.NOT_PROTECTED);
QTableMetaData dotted = table("dotted", "Dotted", PermissionLevel.HAS_ACCESS_PERMISSION);
dotted.getPermissionRules().setPermissionBaseName("cms.publish");
QTableMetaData orphan = table("orphan", "Orphan", PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS);
for(QTableMetaData t : List.of(page, audit, legacy, gate, shared1, shared2, open, dotted, orphan))
{
instance.addTable(t);
}
instance.addProcess(new QProcessMetaData().withName("publishPage").withLabel("Publish").withTableName("cms_page").withIsHidden(true)
.withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addProcess(new QProcessMetaData().withName("loose").withLabel("Loose").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addReport(new QReportMetaData().withName("pageReport").withLabel("Page Report").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addWidget(new QWidgetMetaData().withName("pageStats").withLabel("Page Stats").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addWidget(new QWidgetMetaData().withName("openWidget").withLabel("Open Widget").withPermissionRules(rules(PermissionLevel.NOT_PROTECTED)));
instance.addApp(new QAppMetaData().withName("content").withLabel("Content").withSortOrder(1)
.withChild(page).withChild(audit).withChild(legacy).withChild(gate).withChild(shared1).withChild(shared2).withChild(open).withChild(dotted)
.withChild(instance.getReport("pageReport")).withWidgets(List.of("pageStats"))
.withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
instance.addApp(new QAppMetaData().withName("emptyApp").withLabel("Empty").withSortOrder(2).withPermissionRules(rules(PermissionLevel.NOT_PROTECTED)));
return (instance);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void groupsByAppWithTheActionsQqqChecks()
{
PolicyCatalog catalog = PolicyCatalog.of(instance());
assertThat(catalog.getGroups()).extracting(PolicyCatalog.Group::label).containsExactly("Content", "Other");
PolicyCatalog.Group content = catalog.getGroups().get(0);
assertThat(content.resources()).extracting(PolicyCatalog.Resource::name)
.containsExactly("content", "cms_audit", "gate", "legacy", "cms_page", "shared_base", "publishPage", "pageReport", "pageStats");
assertThat(catalog.get("content").actions()).containsExactly(PolicyAction.HAS_ACCESS);
assertThat(catalog.get("cms_page").actions()).containsExactly(PolicyAction.READ, PolicyAction.INSERT, PolicyAction.EDIT, PolicyAction.DELETE);
assertThat(catalog.get("cms_audit").actions()).as("capabilities limit the actions").containsExactly(PolicyAction.READ);
assertThat(catalog.get("legacy").actions()).containsExactly(PolicyAction.READ);
assertThat(catalog.get("legacy").note()).contains("write permission");
assertThat(catalog.get("gate").actions()).containsExactly(PolicyAction.HAS_ACCESS);
assertThat(catalog.get("publishPage").kind()).isEqualTo(PolicyCatalog.Kind.PROCESS);
assertThat(catalog.get("publishPage").label()).as("a hidden record action, with its table").isEqualTo("Publish (Pages action)");
assertThat(catalog.get("shared_base").label()).as("one entry per base name").isEqualTo("Shared One / Shared Two");
assertThat(catalog.getGroups().get(1).resources()).extracting(PolicyCatalog.Resource::name).containsExactly("orphan", "loose");
assertThat(catalog.get("open_table")).as("not protected: nothing to grant").isNull();
assertThat(catalog.get("openWidget")).isNull();
assertThat(catalog.get("emptyApp")).isNull();
assertThat(catalog.get("cms.publish")).as("a dotted base name cannot be a resource").isNull();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void permissionNamesAndUnknowns()
{
PolicyCatalog catalog = PolicyCatalog.of(instance());
assertThat(catalog.permissionNames()).contains("cms_page.read", "cms_page.delete", "content.hasAccess", "publishPage.hasAccess", "pageReport.hasAccess")
.doesNotContain("cms_page.hasAccess", "cms_audit.edit", "open_table.read");
assertThat(catalog.unknownPermissions(Set.of("cms_page.read", "cms_page.hasAccess", "gone.read"))).containsExactly("cms_page.hasAccess", "gone.read");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void json()
{
JSONArray json = PolicyCatalog.of(instance()).toJson();
JSONObject content = json.getJSONObject(0);
assertThat(content.getString("app")).isEqualTo("content");
assertThat(content.getString("label")).isEqualTo("Content");
JSONObject legacy = content.getJSONArray("resources").getJSONObject(3);
assertThat(legacy.getString("name")).isEqualTo("legacy");
assertThat(legacy.getString("kind")).isEqualTo("TABLE");
assertThat(legacy.getJSONArray("actions").toList()).containsExactly("read");
assertThat(legacy.has("note")).isTrue();
assertThat(json.getJSONObject(1).isNull("app")).isTrue();
assertThat(PolicyCatalog.of(new QInstance()).getGroups()).isEmpty();
}
}