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