diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da888d..cbe3c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +### Added (policy cut-over, DESIGN.md section 12.8) +- `AuthentikHostPermissionSource` SPI (today's access) and config `mapperEnabled`, `cutoverGuardGroups`, `mapperOverride`, `staticGroupPermissions`. +- **Enforcement status**: banner widget `authentikPolicyEnforcement` (first on group and policy records and on the Edit Policy step) and a "Policies at sign-in" row in the home's Connection card: OFF, ACTIVE, OVERRIDDEN or BLOCKED. +- **Compare Access** widget `authentikCompareAccess` on the app home (`authentik.admin`, read only): per active person, today vs the mapper, with added, removed and unchanged, and the guard result. +- **Cut-over guard**: while a member of a guarded group would lose a permission (and no override), the mapper grants today's access plus the static fallback and bootstrap subjects instead of the policies. +- **Generate Policy From Current Access** group action: the expressible part of today's access (joined with the current policy) becomes the policy, named permissions go to `qqq.io/permissions`, the rest is listed; Review shows the document and diff. + ### Added (full user and group CRUD, phase 5c: DESIGN.md section 12.5) - **Edit User** also changes the username (validated, unique) and the other attributes (a JSON object; reserved `qqq.io/` and `goauthentik.io/` keys are kept and cannot be set; blank leaves them as they are; editing attributes needs `authentik.audit.read`, which is also what shows them on the form). **Edit Group** edits other attributes the same way. - **Delete User** and **Delete Group** record actions behind the new `DELETE` feature (off by default; needs Authentik's `delete_user` / `delete_group`). Typed confirmation; never yourself, a protected account, a superuser group or the last identity administrator; a group must have no members and no child groups first; the group's `authentik_policy` row goes with it. Deactivate stays the default for people. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 92fa2fa..b93a167 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1178,6 +1178,54 @@ Deactivate stays the default for people, and *Delete User* says so in its help t The token needs these only when writes are on. A read-only deployment (`readOnly`, or no write features) keeps the phase 1 read set. +### 12.8 Cut-over: making policies take effect safely + +Saved policies do nothing until the host signs people in through the qbit mapper. The qbit makes that state visible, helps seed policies from today's access, and refuses to switch over in a way that would lock administrators out. + +**Host SPI.** `AuthentikHostPermissionSource` returns what a user (or a group's members) gets *today* from the host, typically a hard-coded grant such as "admins get `adminPermissions()`". The host sets it with `withHostPermissionSource(...)`. + +**Config.** + +| Setting | Default | Meaning | +|---|---|---| +| `mapperEnabled` | `false` | The host's own switch (kof22: `AUTHENTIK_QBIT_MAPPER`). Off means "policies are saved but not enforced". A host that calls the mapper without setting it is treated as on. | +| `cutoverGuardGroups` | none | Groups whose members must lose no permission at the cut-over (kof22: `kof22-admins`) | +| `mapperOverride` | `false` | The owner's explicit override of a failing guard | +| `staticGroupPermissions` | none | Permissions always granted to a group's members (the floor), e.g. today's admin grant | + +**Status** (`AccessComparison.state`, cached 60 s): +- `OFF`: the mapper is not in use. +- `ACTIVE`: the guard passed, or nothing is guarded. +- `OVERRIDDEN`: the owner overrode a failing guard. +- `BLOCKED`: the guard failed or could not reach Authentik. + +The status is shown in three places: +- the *Policy enforcement* banner, first on group and policy records and on the Edit Policy step. When off it reads "Policies are saved but NOT enforced at sign-in on this instance (mapper off)". +- the home's Connection card ("Policies at sign-in") +- **Compare Access** + +**Compare Access** is a read-only `TABLE` widget on the app home (`authentik.admin`). For every active person it compares two sets: +- **today**: the host source +- **with the mapper**: the person's groups' policies and `qqq.io/permissions` including ancestors, the static fallback of their direct groups, and all `authentik.*` for a bootstrap subject + +It shows what is added and removed, and the unchanged count. Guarded people are marked, and the footer says whether the guard passes. + +**Guard.** While the status is `BLOCKED`, the mapper does not apply policies. A sign-in gets: +- today's access from the host source +- the static fallbacks +- the bootstrap subjects + +So switching the mapper on can never take access away from a guarded admin until Compare Access shows no removal for them, or the owner sets `mapperOverride`. The static fallback and the bootstrap subject (the owner's immutable Authentik pk) stay in force in every state, so a lockout is impossible. + +**Generate Policy From Current Access** is a record action on groups that saves a policy. It takes what the group's members get today (`groupPermissions`): +- Permissions a policy can express are joined with the group's current policy, so nothing is removed, and become the document. +- Named permissions the app defines but a policy cannot express (dotted names, `write`) are merged into `qqq.io/permissions`. +- Anything else is listed as skipped. + +Review shows the generated document, the diff and the subset rule before anything is saved. For the subset rule, the operator's own access today (host source plus static fallback) counts. + +**Rollback** is one switch: set the host flag off (kof22: `AUTHENTIK_QBIT_MAPPER=false`). Sign-in returns to the host's own grant immediately. Saved policies stay in Authentik and in `authentik_policy`, unused. + ### 12.6 Tests - **Parser:** every fail-closed case (null, blank, not JSON, unknown spec, unsupported version, `Deny`, unknown action, bad resource, non-array fields), `*` expansion, and merging several statements. diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java index 99434c4..cc7baa4 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java @@ -90,6 +90,12 @@ public class AuthentikQBitConfig implements QBitConfig private String policyAttribute = PolicyFormat.DEFAULT_ATTRIBUTE; private String policyClaim = PolicyFormat.DEFAULT_CLAIM; private Boolean policyTwoPersonApproval = false; + private Boolean mapperEnabled = false; + private Boolean mapperOverride = false; + private List cutoverGuardGroups = new ArrayList<>(); + private java.util.Map> staticGroupPermissions = new java.util.LinkedHashMap<>(); + @JsonIgnore + private com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource hostPermissionSource; @@ -1243,4 +1249,111 @@ public class AuthentikQBitConfig implements QBitConfig return (new PolicyFormat(policySpec, policyVersion, policyAttribute, policyClaim)); } + + + /******************************************************************************* + ** Getter for mapperEnabled: whether the host uses the qbit mapper for sign-in permissions (its own flag, e.g. AUTHENTIK_QBIT_MAPPER); false shows "policies are not enforced" everywhere. + *******************************************************************************/ + public Boolean getMapperEnabled() + { + return (this.mapperEnabled); + } + + + + /******************************************************************************* + ** Fluent setter for mapperEnabled + *******************************************************************************/ + public AuthentikQBitConfig withMapperEnabled(Boolean mapperEnabled) + { + this.mapperEnabled = mapperEnabled; + return (this); + } + + + + /******************************************************************************* + ** Getter for mapperOverride: activate the mapper even when the cut-over guard finds removed permissions (explicit owner override). + *******************************************************************************/ + public Boolean getMapperOverride() + { + return (this.mapperOverride); + } + + + + /******************************************************************************* + ** Fluent setter for mapperOverride + *******************************************************************************/ + public AuthentikQBitConfig withMapperOverride(Boolean mapperOverride) + { + this.mapperOverride = mapperOverride; + return (this); + } + + + + /******************************************************************************* + ** Getter for cutoverGuardGroups: groups whose members must lose no permission at the cut-over (DESIGN.md section 12.8). + *******************************************************************************/ + public List getCutoverGuardGroups() + { + return (this.cutoverGuardGroups); + } + + + + /******************************************************************************* + ** Fluent setter for cutoverGuardGroups + *******************************************************************************/ + public AuthentikQBitConfig withCutoverGuardGroups(List cutoverGuardGroups) + { + this.cutoverGuardGroups = cutoverGuardGroups; + return (this); + } + + + + /******************************************************************************* + ** Getter for staticGroupPermissions: permissions always granted to members of a group (the mapper floor, e.g. today's admin grant), so a cut-over cannot lock admins out. + *******************************************************************************/ + public java.util.Map> getStaticGroupPermissions() + { + return (this.staticGroupPermissions); + } + + + + /******************************************************************************* + ** Fluent setter for staticGroupPermissions + *******************************************************************************/ + public AuthentikQBitConfig withStaticGroupPermissions(java.util.Map> staticGroupPermissions) + { + this.staticGroupPermissions = staticGroupPermissions; + return (this); + } + + + + /******************************************************************************* + ** Getter for hostPermissionSource: the host's current (pre-policy) + ** permission source, or null (DESIGN.md section 12.8). + *******************************************************************************/ + @JsonIgnore + public com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource getHostPermissionSource() + { + return (this.hostPermissionSource); + } + + + + /******************************************************************************* + ** Fluent setter for hostPermissionSource + *******************************************************************************/ + public AuthentikQBitConfig withHostPermissionSource(com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource hostPermissionSource) + { + this.hostPermissionSource = hostPermissionSource; + return (this); + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java index 31e57e9..6ac9413 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java @@ -23,6 +23,7 @@ import java.util.concurrent.ConcurrentHashMap; 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.AccessComparison; import com.kingsrook.qbits.authentik.permissions.GroupGraph; import com.kingsrook.qbits.authentik.policy.PolicyCatalog; import com.kingsrook.qbits.authentik.security.AuthentikGuard; @@ -47,6 +48,8 @@ public class AuthentikRuntime private final Memoization groupGraphCache; private volatile java.util.Set permissionCatalog; private volatile PolicyCatalog policyCatalog; + private volatile AccessComparison.State mapperState; + private volatile boolean mapperInvoked; private final Map specsByTableName = new ConcurrentHashMap<>(); @@ -233,4 +236,48 @@ public class AuthentikRuntime return (responseCache); } + + + /******************************************************************************* + ** The cached mapper state (DESIGN.md section 12.8), or null. + *******************************************************************************/ + public AccessComparison.State getMapperState() + { + return (mapperState); + } + + + + /******************************************************************************* + ** Cache a mapper state. + *******************************************************************************/ + public void setMapperState(AccessComparison.State mapperState) + { + this.mapperState = mapperState; + } + + + + /******************************************************************************* + ** Whether the host has run the qbit mapper at sign-in (counts as "enabled" + ** for hosts that call the mapper without setting mapperEnabled). + *******************************************************************************/ + public boolean isMapperInvoked() + { + return (mapperInvoked); + } + + + + /******************************************************************************* + ** Record that the mapper ran. + *******************************************************************************/ + public void markMapperInvoked() + { + if(!mapperInvoked) + { + mapperInvoked = true; + mapperState = null; + } + } } diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java index 37c76cc..e0a09b1 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java @@ -93,6 +93,9 @@ public class AuthentikAdmin /** Process value / request parameter that carries a submitted policy document. */ public static final String POLICY_DOCUMENT = "policyDocument"; + /** Request parameter: named permissions (one per line) to merge into qqq.io/permissions with a policy save. */ + public static final String EXTRA_PERMISSIONS = "extraPermissions"; + /** Request parameter with the non-reserved attributes (JSON object text) of Edit User and Edit Group. */ public static final String OTHER_ATTRIBUTES = "otherAttributes"; @@ -210,6 +213,21 @@ public class AuthentikAdmin EffectivePermissionResolver.Resolution resolution = resolver().resolveGroups(groupPks); permissions.addAll(resolution.sessionPermissions()); appAccess.addAll(resolution.appAccess()); + + ////////////////////////////////////////////////////////////////////// + // what the operator holds today through the host (and the static // + // fallback) counts for the subset rule too (DESIGN.md 12.8) // + ////////////////////////////////////////////////////////////////////// + List groupNames = new ArrayList<>(); + for(int i = 0; groups != null && i < groups.length(); i++) + { + groupNames.add(groups.getJSONObject(i).optString("name")); + } + permissions.addAll(com.kingsrook.qbits.authentik.permissions.AccessComparison.staticPermissions(config, groupNames)); + if(config.getHostPermissionSource() != null) + { + permissions.addAll(config.getHostPermissionSource().userPermissions(email, groupNames)); + } } else if(QContext.getQSession() != null && QContext.getQSession().getPermissions() != null) { @@ -1059,8 +1077,16 @@ public class AuthentikAdmin { return (plan); } + if(StringUtils.hasContent(request.str("generateProblem"))) + { + return (plan.refuse(request.str("generateProblem"))); + } PolicyFormat format = config.getPolicyFormat(); + List oldNamed = nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)); + Set extrasNew = new TreeSet<>(AuthentikSafety.parseList(request.str(EXTRA_PERMISSIONS))); + extrasNew.removeAll(oldNamed); + Set newPermissions; JSONObject canonical; try @@ -1106,18 +1132,35 @@ public class AuthentikAdmin 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) + if(added.isEmpty() && removed.isEmpty() && storedProblem == null && extrasNew.isEmpty()) { return (plan.noChange("Nothing to change.")); } - Set missing = AuthentikSafety.missing(added, operator().permissions()); + Set needed = new TreeSet<>(added); + for(String extra : extrasNew) + { + needed.addAll(resolver().expand(extra)); + } + Set missing = AuthentikSafety.missing(needed, 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(request.bool("generated")) + { + plan.getNotes().add("Generated from current access: " + PolicyGenerator.toCompact(canonical)); + if(StringUtils.hasContent(request.str("skippedPermissions"))) + { + plan.getNotes().add("Skipped (this app does not define them): " + request.str("skippedPermissions").replace("\n", ", ")); + } + } + if(!extrasNew.isEmpty()) + { + plan.getNotes().add("Also added to qqq.io/permissions (not expressible as a policy): " + abbreviate(extrasNew)); + } if(!added.isEmpty()) { plan.getNotes().add("Adds: " + abbreviate(added)); @@ -1148,6 +1191,61 @@ public class AuthentikAdmin + /******************************************************************************* + ** Fill a Save Policy request from what members of the group get TODAY from + ** the host (DESIGN.md section 12.8): the policy-expressible permissions + ** (joined with the group's current policy, so nothing is removed) become + ** the document; named permissions this app defines but a policy cannot + ** express go to qqq.io/permissions; the rest are listed as skipped. + *******************************************************************************/ + public AdminRequest withGeneratedPolicy(AdminRequest request) throws QException + { + request.with("generated", true); + if(config.getHostPermissionSource() == null) + { + return (request.with("generateProblem", "This app has no host permission source, so there is no current access to generate from.")); + } + JSONObject group = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false"); + if(group == null) + { + return (request.with("generateProblem", "That group no longer exists.")); + } + PolicyFormat format = config.getPolicyFormat(); + Set policy = new TreeSet<>(); + Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute()); + policy.addAll(PolicyParser.parse(stored, format)); + + Set expressible = runtime.getPolicyCatalog().permissionNames(); + Set extras = new TreeSet<>(); + Set skipped = new TreeSet<>(); + for(String permission : config.getHostPermissionSource().groupPermissions(group.optString("name"))) + { + if(expressible.contains(permission)) + { + policy.add(permission); + } + else if(!resolver().expand(permission).isEmpty()) + { + extras.add(permission); + } + else + { + skipped.add(permission); + } + } + try + { + request.with(POLICY_DOCUMENT, PolicyGenerator.toCompact(PolicyGenerator.generate(policy, format))); + } + catch(PolicyParseException e) + { + return (request.with("generateProblem", "The current access cannot be written as a policy: " + e.getMessage())); + } + return (request.with(EXTRA_PERMISSIONS, String.join("\n", extras)).with("skippedPermissions", String.join("\n", skipped))); + } + + + /******************************************************************************* ** Delete a person (DESIGN.md section 12.5): explicit, typed, never yourself, ** a protected account or the last identity administrator. @@ -1929,6 +2027,13 @@ public class AuthentikAdmin } JSONObject attributes = fresh.optJSONObject("attributes") == null ? new JSONObject() : new JSONObject(fresh.getJSONObject("attributes").toString()); String compact = PolicyGenerator.toCompact(canonical); + List extras = AuthentikSafety.parseList(request.str(EXTRA_PERMISSIONS)); + if(!extras.isEmpty()) + { + Set named = new java.util.LinkedHashSet<>(nonNull(AuthentikJson.stringList(attributes, AuthentikJson.ATTR_PERMISSIONS))); + named.addAll(extras); + putList(attributes, AuthentikJson.ATTR_PERMISSIONS, new ArrayList<>(named)); + } if(permissions.isEmpty()) { attributes.remove(format.attribute()); diff --git a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java index 0d38e4e..8f3831f 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java +++ b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java @@ -212,6 +212,22 @@ public final class AuthentikHomeView row(html, "Version", data.getVersion() == null ? "Not reachable" : data.getVersion()); row(html, "Service account", data.getConfig().getEffectiveServiceAccountUsername()); row(html, "Changes", Boolean.TRUE.equals(data.getConfig().getReadOnly()) ? "Off (read-only)" : "On, every change is audited here and in Authentik"); + if(data.getConfig().hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.POLICIES)) + { + String state; + try + { + com.kingsrook.qbits.authentik.permissions.AccessComparison.State mapper = com.kingsrook.qbits.authentik.permissions.AccessComparison.state( + com.kingsrook.qbits.authentik.AuthentikRuntime.forBackend(data.getConfig().getApiBackendName()), + com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(data.getConfig().getApiBackendName())); + state = (mapper.enforced() ? "Enforced. " : "Not enforced. ") + mapper.message(); + } + catch(Exception e) + { + state = "Unknown (" + e.getMessage() + ")"; + } + row(html, "Policies at sign-in", state); + } html.append(""); close(html); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java index 98721aa..5283487 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java +++ b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikRelations.java @@ -86,10 +86,22 @@ 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"); + enforcement(qInstance, config, groupSections); 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); + String policyTable = config.applyTablePrefix(AuthentikLocalTables.POLICY); + if(qInstance.getTable(policyTable) != null) + { + List policySections = new ArrayList<>(); + enforcement(qInstance, config, policySections); + policySections.add(new QFieldSection("policy", "Policy", new QIcon("policy"), Tier.T1, + List.of("groupName", "statementCount", "permissionCount", "savedBy", "savedDate", "policyJson"))); + policySections.add(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3, List.of("id", "groupPk", "authentikFingerprint", "createDate", "modifyDate"))); + applySections(qInstance, policyTable, policySections); + } + List appSections = new ArrayList<>(); appSections.add(new QFieldSection("details", "Details", new QIcon("apps"), Tier.T1, List.of("name", "slug", "group", "providerName", "policyEngineMode", "launchUrl", "publisher", "hiddenFromLibrary"))); @@ -99,6 +111,20 @@ public final class AuthentikRelations + /******************************************************************************* + ** The policy enforcement banner as the first section (when produced). + *******************************************************************************/ + private static void enforcement(QInstance qInstance, AuthentikQBitConfig config, List sections) + { + String widget = config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.ENFORCEMENT); + if(qInstance.getWidget(widget) != null) + { + sections.add(0, new QFieldSection("enforcement", new QIcon("verified_user"), Tier.T2).withLabel("Policy enforcement").withWidgetName(widget)); + } + } + + + /******************************************************************************* ** One join plus its child-list widget and the section that shows it. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java index 7e77921..e367c09 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java @@ -74,7 +74,8 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer< addSection(app, produced, config, "activity", "Activity", "history", AuthentikLocalTables.APPROVAL_REQUEST, AuthentikLocalTables.ADMIN_ACTION); List widgets = new ArrayList<>(); - for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME))) + for(String widgetName : List.of(config.applyPrefix(AuthentikHomeWidgetProducer.NAME), config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME), + config.applyPrefix(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.COMPARE))) { if(produced.get(QWidgetMetaData.class, widgetName) != null) { diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyCompareWidgetProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyCompareWidgetProducer.java new file mode 100644 index 0000000..ad1ff64 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyCompareWidgetProducer.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.metadata; + + +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +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; + + +/******************************************************************************* + ** Compare Access (DESIGN.md section 12.8): today's access against what the policies would grant, per active person. Read only. + *******************************************************************************/ +public class AuthentikPolicyCompareWidgetProducer extends QBitComponentMetaDataProducer +{ + + /******************************************************************************* + ** Only with the POLICIES feature. + *******************************************************************************/ + @Override + public boolean isEnabled() + { + return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QWidgetMetaData produce(QInstance qInstance) throws QException + { + AuthentikQBitConfig config = getQBitConfig(); + return (new QWidgetMetaData() + .withName(config.applyPrefix(PolicyCutoverWidgets.COMPARE)) + .withLabel("Compare Access") + .withType(WidgetType.TABLE.getType()) + .withGridColumns(12) + .withIsCard(true) + .withCodeReference(new QCodeReference(PolicyCutoverWidgets.CompareRenderer.class)) + .withPermissionRules(IdentityPermission.ADMIN.rules()) + .withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName())); + } + + + + /******************************************************************************* + ** After the tables, before the app. + *******************************************************************************/ + @Override + public int getSortOrder() + { + return (470); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyEnforcementWidgetProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyEnforcementWidgetProducer.java new file mode 100644 index 0000000..01c6d84 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikPolicyEnforcementWidgetProducer.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.metadata; + + +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +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 enforcement banner (DESIGN.md section 12.8): whether saved policies take effect at sign-in on this instance. + *******************************************************************************/ +public class AuthentikPolicyEnforcementWidgetProducer extends QBitComponentMetaDataProducer +{ + + /******************************************************************************* + ** Only with the POLICIES feature. + *******************************************************************************/ + @Override + public boolean isEnabled() + { + return (getQBitConfig() != null && getQBitConfig().hasFeature(AuthentikFeature.POLICIES)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QWidgetMetaData produce(QInstance qInstance) throws QException + { + AuthentikQBitConfig config = getQBitConfig(); + return (new QWidgetMetaData() + .withName(config.applyPrefix(PolicyCutoverWidgets.ENFORCEMENT)) + .withLabel("Policy Enforcement") + .withType(WidgetType.HTML.getType()) + .withGridColumns(12) + .withIsCard(false) + .withCodeReference(new QCodeReference(PolicyCutoverWidgets.EnforcementRenderer.class)) + .withPermissionRules(IdentityPermission.USERS_READ.rules()) + .withDefaultValue(PolicyCutoverWidgets.BACKEND_NAME_KEY, config.getApiBackendName())); + } + + + + /******************************************************************************* + ** After the tables, before the app. + *******************************************************************************/ + @Override + public int getSortOrder() + { + return (465); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java new file mode 100644 index 0000000..16581f3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java @@ -0,0 +1,254 @@ +/* + * 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.permissions; + + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.AuthentikRuntime; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import org.json.JSONObject; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** The cut-over from a host's current permission source to Authentik group + ** policies (DESIGN.md section 12.8): for every active person, what they get + ** today (the host source) against what the mapper would grant (their groups' + ** policies and permissions, the static fallback and bootstrap subjects), and + ** the guard that keeps the mapper inactive while a member of a guarded group + ** would lose a permission. + *******************************************************************************/ +public final class AccessComparison +{ + private static final QLogger LOG = QLogger.getLogger(AccessComparison.class); + + /** How long a computed mapper state is reused (sign-ins read it). */ + public static final Duration STATE_TTL = Duration.ofSeconds(60); + + /** Banner shown wherever policies are edited while they are not enforced. */ + public static final String NOT_ENFORCED = "Policies are saved but NOT enforced at sign-in on this instance (mapper off)."; + + + + /******************************************************************************* + ** One person. + *******************************************************************************/ + public record Row(int pk, String username, String email, List groups, Set before, Set after, + Set added, Set removed, boolean guarded) + { + } + + + + /******************************************************************************* + ** Whether the mapper is enforcing policies, and why not. + *******************************************************************************/ + public enum Status + { + /** The host does not use the mapper: policies are saved, not enforced. */ + OFF, + /** Enforced; the guard passed (or there is nothing to guard). */ + ACTIVE, + /** Enforced because the owner overrode a failing guard. */ + OVERRIDDEN, + /** Not enforced: the guard failed (or could not run); sign-in uses the host source. */ + BLOCKED + } + + + + /******************************************************************************* + ** A mapper state with a plain message. + *******************************************************************************/ + public record State(Status status, String message, Instant checkedAt) + { + /******************************************************************************* + ** Whether sign-in permissions come from the policies. + *******************************************************************************/ + public boolean enforced() + { + return (status == Status.ACTIVE || status == Status.OVERRIDDEN); + } + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AccessComparison() + { + } + + + + /******************************************************************************* + ** Compare every active person (internal and external users). + *******************************************************************************/ + public static List run(AuthentikQBitConfig config, AuthentikActionUtil util, Collection catalog) throws QException + { + GroupGraph graph = util.groupGraphWithMembers(); + EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, catalog); + AuthentikHostPermissionSource source = config.getHostPermissionSource(); + List rows = new ArrayList<>(); + for(JSONObject user : util.allUsers()) + { + String type = user.optString("type"); + if(!user.optBoolean("is_active", true) || !("internal".equals(type) || "external".equals(type))) + { + continue; + } + int pk = user.optInt("pk"); + List groupPks = graph.groupsOfUser(pk); + List names = new ArrayList<>(); + for(String groupPk : groupPks) + { + names.add(graph.byPk(groupPk).name()); + } + String email = user.optString("email", null); + Set before = new TreeSet<>(source == null ? Set.of() : source.userPermissions(email, names)); + Set after = mapperPermissions(config, resolver, groupPks, names, String.valueOf(pk), user.optString("uuid")); + Set added = new TreeSet<>(after); + added.removeAll(before); + Set removed = new TreeSet<>(before); + removed.removeAll(after); + boolean guarded = names.stream().anyMatch(n -> config.getCutoverGuardGroups() != null && config.getCutoverGuardGroups().contains(n)); + rows.add(new Row(pk, user.optString("username"), email, names, before, after, added, removed, guarded)); + } + return (rows); + } + + + + /******************************************************************************* + ** What the mapper grants for a person's direct groups: the groups' (and + ** ancestors') qqq.io/permissions and policies, the static fallback of the + ** direct groups, and every authentik.* permission for a bootstrap subject. + *******************************************************************************/ + public static Set mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection groupPks, Collection directGroupNames, + String userPk, String userUuid) + { + Set rs = new TreeSet<>(resolver.resolveGroups(groupPks).sessionPermissions()); + rs.addAll(staticPermissions(config, directGroupNames)); + Set subjects = config.getResolvedBootstrapSubjects(); + if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid))) + { + for(IdentityPermission permission : IdentityPermission.values()) + { + rs.add(permission.getSessionPermission()); + } + } + return (rs); + } + + + + /******************************************************************************* + ** The static fallback permissions of some direct groups. + *******************************************************************************/ + public static Set staticPermissions(AuthentikQBitConfig config, Collection directGroupNames) + { + Set rs = new TreeSet<>(); + if(config.getStaticGroupPermissions() != null) + { + for(String name : directGroupNames) + { + rs.addAll(config.getStaticGroupPermissions().getOrDefault(name, List.of())); + } + } + return (rs); + } + + + + /******************************************************************************* + ** The people in guarded groups who would lose a permission (the guard fails + ** when this is not empty). + *******************************************************************************/ + public static List guardFailures(List rows) + { + return (rows.stream().filter(r -> r.guarded() && !r.removed().isEmpty()).toList()); + } + + + + /******************************************************************************* + ** The mapper state of a backend (cached for STATE_TTL; sign-ins read it). + *******************************************************************************/ + public static State state(AuthentikRuntime runtime, AuthentikActionUtil util) + { + State cached = runtime.getMapperState(); + if(cached != null && cached.checkedAt().plus(STATE_TTL).isAfter(Instant.now())) + { + return (cached); + } + State state = compute(runtime, util); + runtime.setMapperState(state); + return (state); + } + + + + /******************************************************************************* + ** Compute the mapper state now. + *******************************************************************************/ + static State compute(AuthentikRuntime runtime, AuthentikActionUtil util) + { + AuthentikQBitConfig config = runtime.getConfig(); + if(!Boolean.TRUE.equals(config.getMapperEnabled()) && !runtime.isMapperInvoked()) + { + return (new State(Status.OFF, NOT_ENFORCED, Instant.now())); + } + if(config.getCutoverGuardGroups() == null || config.getCutoverGuardGroups().isEmpty()) + { + return (new State(Status.ACTIVE, "Policies are enforced at sign-in.", Instant.now())); + } + if(Boolean.TRUE.equals(config.getMapperOverride())) + { + return (new State(Status.OVERRIDDEN, "Policies are enforced at sign-in; the cut-over guard is overridden by the owner.", Instant.now())); + } + try + { + List failures = guardFailures(run(config, util, runtime.getPermissionCatalog())); + if(failures.isEmpty()) + { + return (new State(Status.ACTIVE, "Policies are enforced at sign-in; the cut-over guard passed (no member of " + + String.join(", ", config.getCutoverGuardGroups()) + " loses a permission).", Instant.now())); + } + List names = failures.stream().map(Row::username).limit(5).toList(); + return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard found " + failures.size() + " member(s) of " + + String.join(", ", config.getCutoverGuardGroups()) + " who would lose permissions (" + String.join(", ", names) + + "). Sign-in keeps today's access until the policies cover it, or the owner overrides the guard.", Instant.now())); + } + catch(Exception e) + { + LOG.warn("The cut-over guard could not run; the mapper stays inactive", logPair("error", e.getMessage())); + return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard could not check Authentik (" + + e.getMessage() + "). Sign-in keeps today's access.", Instant.now())); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java index 2f11ec3..2675641 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java @@ -117,6 +117,49 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust AuthentikRuntime runtime = runtime(); PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat(); permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format)); + + if(runtime != null) + { + permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups)); + + //////////////////////////////////////////////////////////////////////// + // the cut-over guard (DESIGN.md section 12.8): while it blocks, the // + // session gets today's access from the host plus the floor (static // + // fallbacks, bootstrap subjects), never the policies alone // + //////////////////////////////////////////////////////////////////////// + runtime.markMapperInvoked(); + AccessComparison.State state; + try + { + state = AccessComparison.state(runtime, client(qInstance)); + } + catch(Exception e) + { + state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now()); + } + if(!state.enforced()) + { + Set floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups)); + for(String group : groups) + { + floor.addAll(staticGroupPermissions.getOrDefault(group, List.of())); + } + if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified)) + { + for(IdentityPermission permission : IdentityPermission.values()) + { + floor.add(permission.getSessionPermission()); + } + } + AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource(); + if(source != null) + { + floor.addAll(source.userPermissions(email, groups)); + } + LOG.warn("Policies are not enforced at this sign-in; granting today's access", logPair("reason", state.message())); + permissions = floor; + } + } if(qSession.getPermissions() != null) { permissions.addAll(qSession.getPermissions()); diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikHostPermissionSource.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikHostPermissionSource.java new file mode 100644 index 0000000..b4ef0ad --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikHostPermissionSource.java @@ -0,0 +1,54 @@ +/* + * 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.permissions; + + +import java.util.Collection; +import java.util.List; +import java.util.Set; + + +/******************************************************************************* + ** SPI: the permissions a host grants TODAY, before Authentik group policies + ** take over (DESIGN.md section 12.8). Typically a hard-coded grant such as + ** "members of the admins group get adminPermissions()". The qbit uses it to + ** - generate a group's policy from current access, + ** - compare current access with what the mapper would grant, and + ** - fall back to it while the cut-over guard keeps the mapper inactive. + *******************************************************************************/ +public interface AuthentikHostPermissionSource +{ + + /******************************************************************************* + ** The QQQ session permissions a user gets today. + ** + ** @param email the user's email (may be null) + ** @param groupNames the user's direct Authentik group names + *******************************************************************************/ + Set userPermissions(String email, Collection groupNames); + + + + /******************************************************************************* + ** The permissions members of a group get today because of that group. The + ** default asks for a user in only that group. + *******************************************************************************/ + default Set groupPermissions(String groupName) + { + return (userPermissions(null, List.of(groupName))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCutoverWidgets.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCutoverWidgets.java new file mode 100644 index 0000000..b617470 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCutoverWidgets.java @@ -0,0 +1,173 @@ +/* + * 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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.authentik.AuthentikRuntime; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.permissions.AccessComparison; +import com.kingsrook.qbits.authentik.security.AuthentikGuard; +import com.kingsrook.qbits.authentik.security.IdentityPermission; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; + + +/******************************************************************************* + ** The cut-over widgets (DESIGN.md section 12.8): the enforcement banner shown + ** on group and policy screens and on Edit Policy, and the Compare Access + ** report (read only). + *******************************************************************************/ +public final class PolicyCutoverWidgets +{ + /** Widget default value naming the qbit's API backend. */ + public static final String BACKEND_NAME_KEY = "backendName"; + + /** Short name of the banner widget. */ + public static final String ENFORCEMENT = "policyEnforcement"; + + /** Short name of the report widget. */ + public static final String COMPARE = "compareAccess"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private PolicyCutoverWidgets() + { + } + + + + /******************************************************************************* + ** The mapper state for a widget's backend. + *******************************************************************************/ + static AccessComparison.State state(RenderWidgetInput input) throws QException + { + String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY)); + return (AccessComparison.state(AuthentikRuntime.forBackend(backendName), AuthentikActionUtil.forBackend(backendName))); + } + + + + /******************************************************************************* + ** The banner as HTML (also used by the home's health card). + *******************************************************************************/ + public static String bannerHtml(AccessComparison.State state) + { + String style = state.enforced() + ? "border:1px solid #16a34a;background:rgba(22,163,74,.10);" + : "border:1px solid #d97706;background:rgba(217,119,6,.12);"; + return ("
" + + (state.enforced() ? "Enforced. " : "Not enforced. ") + "" + escape(state.message()) + "
"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static String escape(String text) + { + return (text == null ? "" : text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)); + } + + + + /******************************************************************************* + ** The enforcement banner (authentik.users.read). + *******************************************************************************/ + public static class EnforcementRenderer extends AbstractWidgetRenderer + { + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + return (new RenderWidgetOutput(new RawHTML(null, bannerHtml(state(input))))); + } + } + + + + /******************************************************************************* + ** Compare Access (authentik.admin): every active person's access today + ** against what the mapper would grant. Read only. + *******************************************************************************/ + public static class CompareRenderer extends AbstractWidgetRenderer + { + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN)) + { + throw (new QPermissionDeniedException("You do not have permission to compare access.")); + } + String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(BACKEND_NAME_KEY)); + AuthentikRuntime runtime = AuthentikRuntime.forBackend(backendName); + List rows = AccessComparison.run(runtime.getConfig(), AuthentikActionUtil.forBackend(backendName), runtime.getPermissionCatalog()); + + List> data = new ArrayList<>(); + int removedPeople = 0; + for(AccessComparison.Row row : rows) + { + Map map = new LinkedHashMap<>(); + map.put("user", escape(row.username()) + (row.guarded() ? " (guarded)" : "")); + map.put("groups", escape(String.join(", ", row.groups()))); + map.put("added", row.added().isEmpty() ? "-" : escape(String.join(", ", row.added()))); + map.put("removed", row.removed().isEmpty() ? "-" : escape(String.join(", ", row.removed()))); + map.put("unchanged", row.before().size() - row.removed().size()); + data.add(map); + if(!row.removed().isEmpty()) + { + removedPeople++; + } + } + List columns = List.of( + new TableData.Column("html", "User", "user", "15%", "left"), + new TableData.Column("html", "Groups", "groups", "20%", "left"), + new TableData.Column("html", "Added", "added", "27%", "left"), + new TableData.Column("html", "Removed", "removed", "28%", "left"), + new TableData.Column("default", "Unchanged", "unchanged", "10%", "right")); + List failures = AccessComparison.guardFailures(rows); + String summary = rows.size() + " active people; " + removedPeople + " would lose permissions; guard " + + (runtime.getConfig().getCutoverGuardGroups().isEmpty() ? "not configured" : failures.isEmpty() ? "passes" : "FAILS for " + failures.size()) + + (runtime.getConfig().getHostPermissionSource() == null ? ". No host permission source is configured, so today's access is shown as empty." : "."); + TableData table = new TableData("Compare Access (today vs policies)", columns, data) + .withRowsPerPage(25) + .withNoRowsFoundHTML("No active people."); + table.setFooterHTML("

" + escape(summary) + "

"); + return (new RenderWidgetOutput(table)); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java index 634fedb..b7a6c0c 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java +++ b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java @@ -201,6 +201,9 @@ public final class AuthentikProcesses + "You can only grant permissions you hold yourself."; List policyFields = List.of(hidden("groupPk"), hidden("expectedFingerprint"), reason); defs.add(new Def("editPolicy", "Edit Policy", AdminAction.SAVE_POLICY, Kind.GROUPS, groups, "policy", true, policyFields, policyHelp)); + defs.add(new Def("generatePolicy", "Generate Policy From Current Access", AdminAction.SAVE_POLICY, Kind.GROUPS, groups, "auto_fix_high", false, List.of(reason), + "Builds this group's policy from what its members get today from the app (joined with its current policy, so nothing is removed). " + + "Review shows the generated document and what changes before anything is saved.")); 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), @@ -311,6 +314,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(com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets.ENFORCEMENT))); + } + if(def.action() == AdminAction.SAVE_POLICY && !def.key().equals("generatePolicy")) { details.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", config.applyPrefix(PolicyEditorWidget.NAME))); } @@ -435,7 +442,11 @@ public final class AuthentikProcesses rs.add(request); } } - 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 GROUPS, INVITATIONS -> + { + AdminRequest request = withParams(AdminRequest.of(def.action(), String.valueOf(record.getValue("pk") != null ? record.getValue("pk") : record.getValue("uuid")), reason), def, input); + rs.add("generatePolicy".equals(def.key()) ? admin.withGeneratedPolicy(request) : request); + } case POLICIES -> rs.add(withParams(AdminRequest.of(def.action(), record.getValueString("groupPk"), reason), def, input)); default -> { @@ -455,7 +466,7 @@ public final class AuthentikProcesses // 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) + if(def.action() == AdminAction.SAVE_POLICY && !"generatePolicy".equals(def.key()) && input.getValue(AuthentikAdmin.POLICY_DOCUMENT) != null) { request.with(AuthentikAdmin.POLICY_DOCUMENT, input.getValueString(AuthentikAdmin.POLICY_DOCUMENT)); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java new file mode 100644 index 0000000..a4b9e74 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java @@ -0,0 +1,248 @@ +/* + * 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.util.Collection; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import com.kingsrook.qbits.authentik.AuthentikFeature; +import com.kingsrook.qbits.authentik.AuthentikRuntime; +import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.permissions.AccessComparison; +import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper; +import com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource; +import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets; +import com.kingsrook.qbits.authentik.policy.PolicyFormat; +import com.kingsrook.qbits.authentik.policy.PolicyParser; +import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +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.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The cut-over to policies (DESIGN.md section 12.8): enforcement status, the + ** compare report, the guard (and its fallback to today's access), and + ** Generate Policy From Current Access. + *******************************************************************************/ +class PolicyCutoverTest +{ + /** Today's grant: publishers read pages and publish; analysts see analytics. */ + static final AuthentikHostPermissionSource SOURCE = new AuthentikHostPermissionSource() + { + @Override + public Set userPermissions(String email, Collection groupNames) + { + Set rs = new TreeSet<>(); + if(groupNames.contains("kof-website-publishers") || groupNames.contains("identity-admins")) + { + rs.addAll(List.of("cms_page.read", "cms_page.edit", "cms.publish.hasAccess", "nothing.here.hasAccess")); + } + if(groupNames.contains("kof-website-analysts") || groupNames.contains("identity-admins")) + { + rs.addAll(List.of("kof22.analytics.hasAccess", "nothing.here.hasAccess")); + } + return (rs); + } + }; + + private AdminTestSupport t; + private AuthentikRuntime runtime; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void setUp() throws Exception + { + t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, AuthentikFeature.POLICIES)); + t.config.withHostPermissionSource(SOURCE).withCutoverGuardGroups(List.of("kof-website-publishers")); + runtime = AuthentikRuntime.forBackend(t.config.getApiBackendName()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private AccessComparison.State state() throws Exception + { + runtime.setMapperState(null); + return (AccessComparison.state(runtime, AuthentikActionUtil.forBackend(t.config.getApiBackendName()))); + } + + + + /******************************************************************************* + ** OFF until the host turns the mapper on; then the guard decides; the + ** owner can override. + *******************************************************************************/ + @Test + void statusAndGuard() throws Exception + { + assertThat(state().status()).isEqualTo(AccessComparison.Status.OFF); + assertThat(state().message()).isEqualTo("Policies are saved but NOT enforced at sign-in on this instance (mapper off)."); + + t.config.withMapperEnabled(true); + AccessComparison.State blocked = state(); + assertThat(blocked.status()).isEqualTo(AccessComparison.Status.BLOCKED); + assertThat(blocked.message()).contains("1 member(s) of kof-website-publishers").contains("operator"); + + t.config.withMapperOverride(true); + assertThat(state().status()).isEqualTo(AccessComparison.Status.OVERRIDDEN); + t.config.withMapperOverride(false); + + //////////////////////////////////////////////////////////////////// + // the static fallback for the guarded group covers today's grant // + //////////////////////////////////////////////////////////////////// + t.config.withStaticGroupPermissions(Map.of("identity-admins", List.copyOf(SOURCE.userPermissions(null, List.of("identity-admins"))))); + assertThat(state().status()).isEqualTo(AccessComparison.Status.ACTIVE); + assertThat(state().enforced()).isTrue(); + } + + + + /******************************************************************************* + ** Compare Access: today against the mapper, per active person. + *******************************************************************************/ + @Test + void compareReport() throws Exception + { + List rows = AccessComparison.run(t.config, AuthentikActionUtil.forBackend(t.config.getApiBackendName()), runtime.getPermissionCatalog()); + AccessComparison.Row operator = rows.stream().filter(r -> r.username().equals("operator")).findFirst().orElseThrow(); + assertThat(operator.guarded()).isTrue(); + assertThat(operator.removed()).contains("cms_page.read", "cms_page.edit", "kof22.analytics.hasAccess").doesNotContain("cms.publish.hasAccess"); + assertThat(operator.added()).contains("authentik.users.read.hasAccess"); + assertThat(rows).noneMatch(r -> r.username().equals("dormant") || r.username().equals("svc-publisher")); + assertThat(AccessComparison.guardFailures(rows)).extracting(AccessComparison.Row::username).containsExactly("operator"); + + RenderWidgetInput input = new RenderWidgetInput(); + input.setWidgetMetaData(t.qInstance.getWidget("authentikCompareAccess")); + TableData table = (TableData) new RenderWidgetAction().execute(input).getWidgetData(); + assertThat(table.getRows()).anyMatch(r -> String.valueOf(r.get("user")).equals("operator (guarded)") && String.valueOf(r.get("removed")).contains("cms_page.edit")); + assertThat(table.getFooterHTML()).contains("guard FAILS for 1"); + assertThat(t.qInstance.getApp("authentikAdmin").getWidgets()).contains("authentikCompareAccess"); + } + + + + /******************************************************************************* + ** The banner widget, on the group and policy screens and the Edit Policy + ** step, and the home's health row. + *******************************************************************************/ + @Test + void enforcementBanner() throws Exception + { + RenderWidgetInput input = new RenderWidgetInput(); + input.setWidgetMetaData(t.qInstance.getWidget("authentikPolicyEnforcement")); + String html = ((RawHTML) new RenderWidgetAction().execute(input).getWidgetData()).getHtml(); + assertThat(html).contains("Not enforced.").contains("NOT enforced at sign-in on this instance (mapper off)").contains("data-status=\"OFF\""); + + assertThat(t.qInstance.getTable("authentik_group").getSections()).anyMatch(s -> "authentikPolicyEnforcement".equals(s.getWidgetName())); + assertThat(t.qInstance.getTable("authentik_policy").getSections().get(0).getWidgetName()).isEqualTo("authentikPolicyEnforcement"); + assertThat(PolicyCutoverWidgets.bannerHtml(new AccessComparison.State(AccessComparison.Status.ACTIVE, "ok ", java.time.Instant.now()))).contains("Enforced.").contains("ok <b>"); + + String home = com.kingsrook.qbits.authentik.app.AuthentikHomeView.render(com.kingsrook.qbits.authentik.app.AuthentikHomeData.load(t.config)); + assertThat(home).contains("Policies at sign-in").contains("mapper off"); + } + + + + /******************************************************************************* + ** While the guard blocks, a sign-in through the mapper gets today's access + ** (and the floor); once it passes, the policies. + *******************************************************************************/ + @Test + void mapperFallsBackWhileBlocked() throws Exception + { + t.config.withMapperEnabled(true); + Map context = new HashMap<>(); + context.put(AuthentikGroupPermissionMapper.CLAIMS_CONTEXT_KEY, new JSONObject().put("email", "sam@example.test").put("groups", new JSONArray().put("kof-website-publishers"))); + + runtime.setMapperState(null); + QSession blocked = new QSession(); + new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, blocked, context); + assertThat(blocked.getPermissions()).contains("cms_page.read", "cms_page.edit", "cms.publish.hasAccess"); + + t.config.withStaticGroupPermissions(Map.of("kof-website-publishers", List.of("cms_page.read", "cms_page.edit", "cms.publish.hasAccess"))); + t.config.withCutoverGuardGroups(List.of()); + t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").put("qqq.io/policy", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"delete\"],\"Resource\":[\"cms_page\"]}]}"); + t.config.withStaticGroupPermissions(Map.of()); + runtime.invalidateCache(); + runtime.setMapperState(null); + QSession active = new QSession(); + new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, active, context); + assertThat(active.getPermissions()).contains("cms_page.delete", "cms.publish.hasAccess").doesNotContain("cms_page.edit"); + } + + + + /******************************************************************************* + ** Generate Policy From Current Access: the expressible part becomes the + ** policy, named permissions go to qqq.io/permissions, unknown ones are + ** skipped; Review shows the document before anything is saved. + *******************************************************************************/ + @Test + void generatePolicy() throws Exception + { + String analysts = t.authentik.pk("kof-website-analysts"); + RunProcessInput input = new RunProcessInput(); + input.setProcessName("authentikGeneratePolicy"); + input.addValue("recordIds", t.authentik.pk("kof-website-publishers")); + RunProcessOutput output = new RunProcessAction().execute(input); + input.setProcessUUID(output.getProcessUUID()); + input.addValue("reason", "seed from today's access"); + input.setStartAfterStep("details"); + output = new RunProcessAction().execute(input); + assertThat(output.getException()).isEmpty(); + @SuppressWarnings("unchecked") + List review = (List) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY); + assertThat(review).anyMatch(l -> l.getMessage().contains("Generated from current access: {\"PolicySpec\":\"qqq-iam\"")); + assertThat(review).anyMatch(l -> l.getMessage().contains("Skipped (this app does not define them): nothing.here.hasAccess")); + assertThat(t.changes()).isEmpty(); + + AuthentikAdmin.AdminResult result = t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-publishers"), "seed"))); + assertThat(result.ok()).as(result.message()).isTrue(); + JSONObject attributes = t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes"); + assertThat(PolicyParser.parse(attributes.get("qqq.io/policy"), PolicyFormat.DEFAULT)).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit"); + assertThat(attributes.getJSONArray("qqq.io/permissions").toList()).contains("cms.publish.hasAccess"); + + assertThat(t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-publishers"), "again"))).noChange()).isTrue(); + + t.config.withHostPermissionSource(null); + assertThat(t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, analysts, "x"))).message()).contains("no host permission source"); + } +}