diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee5c38..ce358d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/schema.sql b/docs/schema.sql index 1b1a374..cac7979 100644 --- a/docs/schema.sql +++ b/docs/schema.sql @@ -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) +); diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikFeature.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikFeature.java index 02f6c10..c154f24 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikFeature.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikFeature.java @@ -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, diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java index 3a06d1e..5ee7cd3 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java @@ -46,11 +46,11 @@ public class AuthentikQBitConfig implements QBitConfig { /** Features implemented in this release. Others are refused by validate(). */ public static final Set 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 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"); diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java index 036a871..31e57e9 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java @@ -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 responseCache; private final Memoization groupGraphCache; private volatile java.util.Set permissionCatalog; + private volatile PolicyCatalog policyCatalog; private final Map 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 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 *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AdminAction.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AdminAction.java index 23de9b9..7a1b4ab 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AdminAction.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AdminAction.java @@ -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), diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java index 2567a9e..7028dc4 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java @@ -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 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 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 added = new TreeSet<>(newPermissions); + added.removeAll(oldPermissions); + Set removed = new TreeSet<>(oldPermissions); + removed.removeAll(newPermissions); + + Set 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 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 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 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 names) + { + List 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 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}")) { diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java index 916e49a..2fb0b79 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikLocalTables.java @@ -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. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java index 68ac4c5..98721aa 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java +++ b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java @@ -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); diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java index a0c12bb..7e77921 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java @@ -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 widgets = new ArrayList<>(); diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyEditorWidgetProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyEditorWidgetProducer.java new file mode 100644 index 0000000..7b91468 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyEditorWidgetProducer.java @@ -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 +{ + + /******************************************************************************* + ** 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyTableProducer.java new file mode 100644 index 0000000..922c32e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyTableProducer.java @@ -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 +{ + + /******************************************************************************* + ** 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); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java new file mode 100644 index 0000000..007254a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java @@ -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 actions, String note) + { + } + + + + /******************************************************************************* + ** The resources of one app (or of no app: OTHER). + *******************************************************************************/ + public record Group(String app, String label, List resources) + { + } + + + + private final List groups; + private final Map byName; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + private PolicyCatalog(List 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 appOfObject = appsOfObjects(qInstance); + Map> byApp = new LinkedHashMap<>(); + + List 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 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 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 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 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 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 groups = new ArrayList<>(); + for(Map.Entry> entry : byApp.entrySet()) + { + if(entry.getValue().isEmpty()) + { + continue; + } + String groupLabel = entry.getKey() == null ? OTHER : label(qInstance.getApp(entry.getKey()).getLabel(), entry.getKey()); + List 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 labels = new LinkedHashSet<>(); + private final Set 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> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label, + Set 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 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 appsOfObjects(QInstance qInstance) + { + Map 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 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 permissionNames() + { + Set 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 unknownPermissions(Set permissions) + { + Set known = permissionNames(); + Set 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java new file mode 100644 index 0000000..c8e5e1d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java @@ -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 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 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 getCatalog() + { + return (catalog); + } + + + + /******************************************************************************* + ** Fluent setter for catalog + *******************************************************************************/ + public Data withCatalog(List 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); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyStore.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyStore.java new file mode 100644 index 0000000..8bf8c5b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyStore.java @@ -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()))); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java index bd4892f..55267f0 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java +++ b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java @@ -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 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); diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java b/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java index 03321fc..218a00d 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/IdentityPermission.java @@ -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"), diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/AdminTestSupport.java b/src/test/java/com/kingsrook/qbits/authentik/admin/AdminTestSupport.java index 27a2aed..2b835d2 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/AdminTestSupport.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/AdminTestSupport.java @@ -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 // + /////////////////////////// + } + } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java new file mode 100644 index 0000000..58270b3 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java @@ -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 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 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 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 review = (List) 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"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java index 077d79a..fe6cb60 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java @@ -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 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 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. diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyCatalogTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyCatalogTest.java new file mode 100644 index 0000000..08b1d58 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyCatalogTest.java @@ -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(); + } +}