feat(policy): phase 5a policy model, parser, generator and runtime mapper #14
No files matched your search
@@ -4,6 +4,11 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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.
|
||||
- **Blueprint example** for the scope mapping that puts the merged policies into the token, pinned by `PolicyClaimMappingIT` on Authentik 2026.8.3.
|
||||
|
||||
### Added (v1: full user lifecycle and group/permission management, phases 2-3)
|
||||
- **Writes through the Authentik API**, behind the features `USERS`, `GROUPS`, `MEMBERSHIP`, `CREDENTIALS` and `SESSIONS` (read-only stays the default; `readOnly` is a hard kill switch). Every change goes through `AuthentikAdmin`: it plans against live Authentik data (the guardrails of DESIGN.md 5.4), audits first, calls Authentik, posts a `custom_qqq_admin_action` event naming the operator, and finishes the audit row. `AuthentikActionUtil.write` needs a `WriteAuthorization`, which only `AuthentikGuard.authorize` can mint.
|
||||
- **Actions** (table and record actions with Review and Result steps; none in the nav): Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject.
|
||||
|
||||
@@ -103,7 +103,7 @@ Authentik puts the merged document into the token through a scope mapping. Steps
|
||||
|
||||
The mapping merges the statements of every group in `request.user.all_groups()` (direct groups and their ancestors) whose document has the right spec and version. It always emits a document, with an empty `Statement` array when the user has no policies.
|
||||
|
||||
The contract test `PolicyClaimMappingIT` runs this blueprint on Authentik 2026.8.3. It saves policies on a parent and a child group through the qbit, then runs the mapping through Authentik's property-mapping test endpoint for a member of the child group. It checks that the parser turns the claim into the union of both groups' permissions.
|
||||
The contract test `PolicyClaimMappingIT` runs this blueprint on Authentik 2026.8.3. It puts generated policies on a parent and a child group (one as a string, one as a JSON object), then runs the mapping through Authentik's property-mapping test endpoint for a member of the child group. It checks that the parser turns the claim into the union of the groups' permissions, that a document with another spec is skipped, and that a user without policies gets a valid empty document.
|
||||
|
||||
### Freshness
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
@@ -84,6 +85,11 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
private Integer socketTimeoutMillis = 30_000;
|
||||
private String expectedServerVersion = "2026.8";
|
||||
private Boolean allowInsecureHttp = false;
|
||||
private String policySpec = PolicyFormat.DEFAULT_SPEC;
|
||||
private String policyVersion = PolicyFormat.DEFAULT_VERSION;
|
||||
private String policyAttribute = PolicyFormat.DEFAULT_ATTRIBUTE;
|
||||
private String policyClaim = PolicyFormat.DEFAULT_CLAIM;
|
||||
private Boolean policyTwoPersonApproval = false;
|
||||
|
||||
|
||||
|
||||
@@ -195,6 +201,12 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
break;
|
||||
}
|
||||
}
|
||||
String policyProblem = getPolicyFormat().problem();
|
||||
if(policyProblem != null)
|
||||
{
|
||||
errors.add(policyProblem);
|
||||
}
|
||||
|
||||
if(throttleRequestsPerSecond != null && throttleRequestsPerSecond == 0)
|
||||
{
|
||||
errors.add("throttleRequestsPerSecond must be at least 1");
|
||||
@@ -1105,4 +1117,120 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policySpec: the PolicySpec this app accepts and writes (docs/POLICY-FORMAT.md).
|
||||
*******************************************************************************/
|
||||
public String getPolicySpec()
|
||||
{
|
||||
return (this.policySpec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policySpec
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withPolicySpec(String policySpec)
|
||||
{
|
||||
this.policySpec = policySpec;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyVersion: the one supported PolicyVersion (semver).
|
||||
*******************************************************************************/
|
||||
public String getPolicyVersion()
|
||||
{
|
||||
return (this.policyVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyVersion
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withPolicyVersion(String policyVersion)
|
||||
{
|
||||
this.policyVersion = policyVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyAttribute: the Authentik group attribute that holds a group's policy document.
|
||||
*******************************************************************************/
|
||||
public String getPolicyAttribute()
|
||||
{
|
||||
return (this.policyAttribute);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyAttribute
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withPolicyAttribute(String policyAttribute)
|
||||
{
|
||||
this.policyAttribute = policyAttribute;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyClaim: the token claim that holds the merged policy document.
|
||||
*******************************************************************************/
|
||||
public String getPolicyClaim()
|
||||
{
|
||||
return (this.policyClaim);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyClaim
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withPolicyClaim(String policyClaim)
|
||||
{
|
||||
this.policyClaim = policyClaim;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyTwoPersonApproval: whether every policy save needs a second person (authentik.approve).
|
||||
*******************************************************************************/
|
||||
public Boolean getPolicyTwoPersonApproval()
|
||||
{
|
||||
return (this.policyTwoPersonApproval);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyTwoPersonApproval
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withPolicyTwoPersonApproval(Boolean policyTwoPersonApproval)
|
||||
{
|
||||
this.policyTwoPersonApproval = policyTwoPersonApproval;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The policy names (spec, version, attribute, claim) as one value.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public PolicyFormat getPolicyFormat()
|
||||
{
|
||||
return (new PolicyFormat(policySpec, policyVersion, policyAttribute, policyClaim));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -141,7 +141,7 @@ public final class AuthentikSafety
|
||||
{
|
||||
List<Integer> members = new ArrayList<>(node.memberPks());
|
||||
members.remove(Integer.valueOf(userPk));
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed()));
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed(), node.policyPermissions()));
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ public final class AuthentikSafety
|
||||
*******************************************************************************/
|
||||
public static GroupGraph.Node withPermissions(GroupGraph.Node node, List<String> permissions, List<String> appAccess)
|
||||
{
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed()));
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed(), node.policyPermissions()));
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ public final class AuthentikSafety
|
||||
*******************************************************************************/
|
||||
public static GroupGraph.Node withParents(GroupGraph.Node node, List<String> parentPks)
|
||||
{
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed()));
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed(), node.policyPermissions()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -765,7 +765,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
throw (new QUserFacingException("More than " + MAX_SWEEP_OBJECTS + " Authentik groups; the group graph is not supported at this size."));
|
||||
}
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups);
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat());
|
||||
if(cache)
|
||||
{
|
||||
runtime.getGroupGraphCache().storeResult("all", graph);
|
||||
@@ -814,7 +814,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
}
|
||||
}
|
||||
List<JSONObject> groups = getAllPages("/core/groups/?include_users=true&include_parents=false&include_children=false&ordering=name", MAX_SWEEP_OBJECTS);
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups);
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat());
|
||||
if(cache)
|
||||
{
|
||||
runtime.getGroupGraphCache().storeResult("members", graph);
|
||||
|
||||
@@ -304,7 +304,7 @@ public class AuthentikDriftReport
|
||||
void checkIdentityAdmins(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
List<JSONObject> groups = client.getAllPages("/core/groups/?include_users=true&ordering=name", AuthentikActionUtil.MAX_SWEEP_OBJECTS);
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups);
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat());
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
|
||||
|
||||
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
|
||||
|
||||
+10
@@ -27,6 +27,8 @@ import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.security.BootstrapOperators;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
@@ -107,6 +109,14 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
boolean verified = claims.optBoolean("email_verified", false);
|
||||
|
||||
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, sub, email, verified));
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// the policy claim (docs/POLICY-FORMAT.md) carries the same group data //
|
||||
// as the graph read above; the union also covers an unreachable API //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
AuthentikRuntime runtime = runtime();
|
||||
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
|
||||
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format));
|
||||
if(qSession.getPermissions() != null)
|
||||
{
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
|
||||
+4
-1
@@ -17,6 +17,7 @@
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -101,7 +102,9 @@ public class EffectivePermissionResolver
|
||||
continue;
|
||||
}
|
||||
appAccess.addAll(node.appAccess());
|
||||
for(String raw : node.permissions())
|
||||
List<String> raws = new ArrayList<>(node.permissions());
|
||||
raws.addAll(node.policyPermissions());
|
||||
for(String raw : raws)
|
||||
{
|
||||
Set<String> expanded = expand(raw);
|
||||
if(expanded.isEmpty())
|
||||
|
||||
@@ -27,6 +27,9 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParser;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -42,8 +45,16 @@ public class GroupGraph
|
||||
/*******************************************************************************
|
||||
** One group.
|
||||
*******************************************************************************/
|
||||
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
|
||||
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed,
|
||||
List<String> policyPermissions)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A group without a policy document.
|
||||
*******************************************************************************/
|
||||
public Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
|
||||
{
|
||||
this(pk, name, ownSuperuser, parentPks, permissions, appAccess, memberPks, malformed, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,9 +70,25 @@ public class GroupGraph
|
||||
*******************************************************************************/
|
||||
public static GroupGraph fromGroups(Collection<JSONObject> groups)
|
||||
{
|
||||
GroupGraph graph = new GroupGraph();
|
||||
return (fromGroups(groups, PolicyFormat.DEFAULT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build from Authentik group objects, reading each group's policy document
|
||||
** (docs/POLICY-FORMAT.md) from the format's attribute. A document that does
|
||||
** not parse grants nothing and is listed in the node's malformed entries.
|
||||
*******************************************************************************/
|
||||
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format)
|
||||
{
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
GroupGraph graph = new GroupGraph();
|
||||
for(JSONObject group : groups)
|
||||
{
|
||||
JSONObject attributes = group.optJSONObject("attributes");
|
||||
List<String> malformed = malformed(attributes);
|
||||
List<String> policy = policyPermissions(attributes, f, malformed);
|
||||
graph.add(new Node(
|
||||
group.getString("pk"),
|
||||
group.optString("name", ""),
|
||||
@@ -70,7 +97,8 @@ public class GroupGraph
|
||||
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)),
|
||||
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)),
|
||||
ints(group.optJSONArray("users")),
|
||||
malformed(group.optJSONObject("attributes"))));
|
||||
malformed,
|
||||
policy));
|
||||
}
|
||||
return (graph);
|
||||
}
|
||||
@@ -257,6 +285,30 @@ public class GroupGraph
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permissions of a group's policy document; none (and a malformed entry)
|
||||
** when it does not parse.
|
||||
*******************************************************************************/
|
||||
private static List<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed)
|
||||
{
|
||||
Object document = attributes == null ? null : attributes.opt(format.attribute());
|
||||
if(document == null || JSONObject.NULL.equals(document) || "".equals(document))
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (List.copyOf(PolicyParser.parseStrict(document, format).permissions()));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
malformed.add(format.attribute() + ": " + e.getMessage());
|
||||
return (List.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import org.json.JSONObject;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentication customizer that adds the QQQ permissions granted by the
|
||||
** policy claim of the sign-in token (docs/POLICY-FORMAT.md). It needs no
|
||||
** Authentik API access: Authentik merges the user's group policies into the
|
||||
** claim at sign-in (the scope mapping in the format doc).
|
||||
**
|
||||
** Order: applySecurityKeys (a no-op hook for hosts), then permissions. The
|
||||
** permissions are added to whatever the session already holds. A missing or
|
||||
** invalid claim adds nothing (fail closed).
|
||||
*******************************************************************************/
|
||||
public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikPolicyClaimMapper.class);
|
||||
|
||||
private PolicyFormat format = PolicyFormat.DEFAULT;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use these names (the qbit config's getPolicyFormat()) instead of the
|
||||
** defaults.
|
||||
*******************************************************************************/
|
||||
public AuthentikPolicyClaimMapper withFormat(PolicyFormat format)
|
||||
{
|
||||
this.format = format == null ? PolicyFormat.DEFAULT : format;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for format.
|
||||
*******************************************************************************/
|
||||
public PolicyFormat getFormat()
|
||||
{
|
||||
return (format);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add the permissions from the policy claim.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
|
||||
{
|
||||
JSONObject claims = PolicyPermissions.claimsWithPolicy(context, format);
|
||||
JSONObject any = claims;
|
||||
if(any == null && context != null)
|
||||
{
|
||||
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ID_TOKEN_CONTEXT_KEY));
|
||||
if(any == null)
|
||||
{
|
||||
any = PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY));
|
||||
}
|
||||
}
|
||||
if(any != null)
|
||||
{
|
||||
applySecurityKeys(qSession, any);
|
||||
}
|
||||
|
||||
if(claims == null)
|
||||
{
|
||||
LOG.info("No policy claim in the token; no policy permissions added", logPair("claim", format.claim()));
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format));
|
||||
if(qSession.getPermissions() != null)
|
||||
{
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
}
|
||||
qSession.setPermissions(permissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Extension point for record locks (security keys); a no-op here. A host
|
||||
** overrides it to set security key values from its OWN explicit claims, and
|
||||
** must never infer them from group names.
|
||||
*******************************************************************************/
|
||||
protected void applySecurityKeys(QSession qSession, JSONObject claims)
|
||||
{
|
||||
////////////////////////////////////////////////////////
|
||||
// no record locks in policy format 1.0.0 (by design) //
|
||||
////////////////////////////////////////////////////////
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.Optional;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The actions a policy statement can grant, in canonical statement order. Each
|
||||
** maps to the QQQ permission suffix of the same name ({resource}.{action}).
|
||||
*******************************************************************************/
|
||||
public enum PolicyAction
|
||||
{
|
||||
HAS_ACCESS("hasAccess"),
|
||||
READ("read"),
|
||||
INSERT("insert"),
|
||||
EDIT("edit"),
|
||||
DELETE("delete");
|
||||
|
||||
/** "All five actions" in a statement's Action array. */
|
||||
public static final String WILDCARD = "*";
|
||||
|
||||
private final String jsonName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
PolicyAction(String jsonName)
|
||||
{
|
||||
this.jsonName = jsonName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The name used in documents and as the QQQ permission suffix.
|
||||
*******************************************************************************/
|
||||
public String getJsonName()
|
||||
{
|
||||
return (jsonName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The QQQ permission this action grants on a resource.
|
||||
*******************************************************************************/
|
||||
public String permission(String resource)
|
||||
{
|
||||
return (resource + "." + jsonName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up by document name (case-sensitive, like QQQ permission names).
|
||||
*******************************************************************************/
|
||||
public static Optional<PolicyAction> fromJsonName(String name)
|
||||
{
|
||||
for(PolicyAction action : values())
|
||||
{
|
||||
if(action.jsonName.equals(name))
|
||||
{
|
||||
return (Optional.of(action));
|
||||
}
|
||||
}
|
||||
return (Optional.empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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.regex.Pattern;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The names a host uses for its policy documents (docs/POLICY-FORMAT.md): the
|
||||
** PolicySpec it accepts, the one supported PolicyVersion, the Authentik group
|
||||
** attribute that holds a group's document, and the token claim that holds the
|
||||
** merged document.
|
||||
**
|
||||
** @param spec PolicySpec, e.g. "qqq-iam"
|
||||
** @param version PolicyVersion (semver), e.g. "1.0.0"
|
||||
** @param attribute group attribute key, e.g. "qqq.io/policy"
|
||||
** @param claim token claim name, e.g. "qqq_policy"
|
||||
*******************************************************************************/
|
||||
public record PolicyFormat(String spec, String version, String attribute, String claim)
|
||||
{
|
||||
/** Default PolicySpec. */
|
||||
public static final String DEFAULT_SPEC = "qqq-iam";
|
||||
|
||||
/** Default (and only) PolicyVersion of this qbit's format. */
|
||||
public static final String DEFAULT_VERSION = "1.0.0";
|
||||
|
||||
/** Default group attribute. */
|
||||
public static final String DEFAULT_ATTRIBUTE = "qqq.io/policy";
|
||||
|
||||
/** Default token claim. */
|
||||
public static final String DEFAULT_CLAIM = "qqq_policy";
|
||||
|
||||
/** The generic defaults. */
|
||||
public static final PolicyFormat DEFAULT = new PolicyFormat(DEFAULT_SPEC, DEFAULT_VERSION, DEFAULT_ATTRIBUTE, DEFAULT_CLAIM);
|
||||
|
||||
/** A valid spec name: letters, digits, dot, dash and underscore. */
|
||||
public static final Pattern SPEC_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
|
||||
|
||||
/** Semver MAJOR.MINOR.PATCH. */
|
||||
public static final Pattern VERSION_PATTERN = Pattern.compile("(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)");
|
||||
|
||||
/** A valid claim name. */
|
||||
public static final Pattern CLAIM_PATTERN = Pattern.compile("[A-Za-z_][A-Za-z0-9_.-]{0,63}");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether every name is well formed (used by the config's validate()).
|
||||
*******************************************************************************/
|
||||
public String problem()
|
||||
{
|
||||
if(spec == null || !SPEC_PATTERN.matcher(spec).matches())
|
||||
{
|
||||
return ("policySpec must be letters, digits, '.', '-' or '_': " + spec);
|
||||
}
|
||||
if(version == null || !VERSION_PATTERN.matcher(version).matches())
|
||||
{
|
||||
return ("policyVersion must be semver MAJOR.MINOR.PATCH: " + version);
|
||||
}
|
||||
if(attribute == null || attribute.isBlank())
|
||||
{
|
||||
return ("policyAttribute is required");
|
||||
}
|
||||
if(claim == null || !CLAIM_PATTERN.matcher(claim).matches())
|
||||
{
|
||||
return ("policyClaim must be a plain claim name: " + claim);
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds the canonical policy document for a set of QQQ permissions
|
||||
** (docs/POLICY-FORMAT.md, "Canonical form"): one Allow statement per granted
|
||||
** action in the order hasAccess, read, insert, edit, delete; resources sorted;
|
||||
** no "*". Every generated document is checked to round-trip through
|
||||
** PolicyParser to exactly the permissions it came from; one that does not is
|
||||
** never returned.
|
||||
*******************************************************************************/
|
||||
public final class PolicyGenerator
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PolicyGenerator()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical document for these permissions ({resource}.{action} names).
|
||||
**
|
||||
** @throws PolicyParseException when a permission is not {resource}.{action}
|
||||
** with a known action and a valid resource, or the document does not
|
||||
** round-trip
|
||||
*******************************************************************************/
|
||||
public static JSONObject generate(Collection<String> permissions, PolicyFormat format) throws PolicyParseException
|
||||
{
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
|
||||
Map<PolicyAction, Set<String>> byAction = new LinkedHashMap<>();
|
||||
for(PolicyAction action : PolicyAction.values())
|
||||
{
|
||||
byAction.put(action, new TreeSet<>());
|
||||
}
|
||||
|
||||
Set<String> expected = new TreeSet<>();
|
||||
for(String permission : permissions)
|
||||
{
|
||||
int dot = permission == null ? -1 : permission.lastIndexOf('.');
|
||||
if(dot <= 0 || dot == permission.length() - 1)
|
||||
{
|
||||
throw (new PolicyParseException("\"" + permission + "\" is not {resource}.{action}"));
|
||||
}
|
||||
String resource = permission.substring(0, dot);
|
||||
String name = permission.substring(dot + 1);
|
||||
PolicyAction action = PolicyAction.fromJsonName(name).orElseThrow(() -> new PolicyParseException("\"" + permission + "\": unknown action \"" + name + "\""));
|
||||
if(!PolicyParser.RESOURCE_PATTERN.matcher(resource).matches())
|
||||
{
|
||||
throw (new PolicyParseException("\"" + permission + "\": resource must match [A-Za-z0-9_]+"));
|
||||
}
|
||||
byAction.get(action).add(resource);
|
||||
expected.add(permission);
|
||||
}
|
||||
|
||||
JSONArray statements = new JSONArray();
|
||||
for(Map.Entry<PolicyAction, Set<String>> entry : byAction.entrySet())
|
||||
{
|
||||
if(!entry.getValue().isEmpty())
|
||||
{
|
||||
statements.put(new JSONObject()
|
||||
.put("Effect", PolicyParser.ALLOW)
|
||||
.put("Action", new JSONArray().put(entry.getKey().getJsonName()))
|
||||
.put("Resource", new JSONArray(entry.getValue())));
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject document = new JSONObject()
|
||||
.put("PolicySpec", f.spec())
|
||||
.put("PolicyVersion", f.version())
|
||||
.put("Statement", statements);
|
||||
|
||||
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f).permissions();
|
||||
if(!parsed.equals(expected))
|
||||
{
|
||||
Set<String> missing = new TreeSet<>(expected);
|
||||
missing.removeAll(parsed);
|
||||
Set<String> extra = new TreeSet<>(parsed);
|
||||
extra.removeAll(expected);
|
||||
throw (new PolicyParseException("The generated document does not round-trip. Missing: " + missing + "; unexpected: " + extra));
|
||||
}
|
||||
return (document);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-generate any valid document in canonical form (parse, then generate).
|
||||
*******************************************************************************/
|
||||
public static JSONObject canonical(Object document, PolicyFormat format) throws PolicyParseException
|
||||
{
|
||||
return (generate(PolicyParser.parseStrict(document, format).permissions(), format));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compact JSON in a fixed key order (PolicySpec, PolicyVersion, Statement;
|
||||
** Effect, Action, Resource), as stored in the group attribute.
|
||||
*******************************************************************************/
|
||||
public static String toCompact(JSONObject document)
|
||||
{
|
||||
return (write(document, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pretty JSON (2-space indent, LF, trailing newline) in the fixed key order.
|
||||
*******************************************************************************/
|
||||
public static String toPretty(JSONObject document)
|
||||
{
|
||||
return (write(document, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write in the fixed key order (org.json does not keep insertion order).
|
||||
*******************************************************************************/
|
||||
private static String write(JSONObject document, boolean pretty)
|
||||
{
|
||||
String nl = pretty ? "\n" : "";
|
||||
String i1 = pretty ? " " : "";
|
||||
String i2 = pretty ? " " : "";
|
||||
String i3 = pretty ? " " : "";
|
||||
String i4 = pretty ? " " : "";
|
||||
String sep = pretty ? ": " : ":";
|
||||
|
||||
StringBuilder sb = new StringBuilder("{").append(nl);
|
||||
sb.append(i1).append(JSONObject.quote("PolicySpec")).append(sep).append(JSONObject.quote(document.optString("PolicySpec"))).append(",").append(nl);
|
||||
sb.append(i1).append(JSONObject.quote("PolicyVersion")).append(sep).append(JSONObject.quote(document.optString("PolicyVersion"))).append(",").append(nl);
|
||||
sb.append(i1).append(JSONObject.quote("Statement")).append(sep).append("[");
|
||||
|
||||
JSONArray statements = document.optJSONArray("Statement");
|
||||
int count = statements == null ? 0 : statements.length();
|
||||
for(int s = 0; s < count; s++)
|
||||
{
|
||||
JSONObject statement = statements.getJSONObject(s);
|
||||
sb.append(s == 0 ? nl : "," + nl).append(i2).append("{").append(nl);
|
||||
sb.append(i3).append(JSONObject.quote("Effect")).append(sep).append(JSONObject.quote(statement.optString("Effect"))).append(",").append(nl);
|
||||
sb.append(i3).append(JSONObject.quote("Action")).append(sep).append(inline(statement.optJSONArray("Action"), pretty)).append(",").append(nl);
|
||||
sb.append(i3).append(JSONObject.quote("Resource")).append(sep).append("[");
|
||||
JSONArray resources = statement.optJSONArray("Resource");
|
||||
int n = resources == null ? 0 : resources.length();
|
||||
for(int r = 0; r < n; r++)
|
||||
{
|
||||
sb.append(r == 0 ? nl : "," + nl).append(i4).append(JSONObject.quote(resources.optString(r)));
|
||||
}
|
||||
sb.append(n == 0 ? "" : nl + i3).append("]").append(nl);
|
||||
sb.append(i2).append("}");
|
||||
}
|
||||
sb.append(count == 0 ? "" : nl + i1).append("]").append(nl);
|
||||
sb.append("}").append(nl);
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A short string array on one line.
|
||||
*******************************************************************************/
|
||||
private static String inline(JSONArray array, boolean pretty)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
int n = array == null ? 0 : array.length();
|
||||
for(int i = 0; i < n; i++)
|
||||
{
|
||||
sb.append(i == 0 ? "" : (pretty ? ", " : ",")).append(JSONObject.quote(array.optString(i)));
|
||||
}
|
||||
return (sb.append("]").toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A policy document that cannot be accepted; the message names the problem
|
||||
** in plain words (shown to operators in the editor's Review step).
|
||||
*******************************************************************************/
|
||||
public class PolicyParseException extends Exception
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public PolicyParseException(String message)
|
||||
{
|
||||
super(message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* 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.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parses a policy document (docs/POLICY-FORMAT.md) into QQQ permission names.
|
||||
**
|
||||
** parse() FAILS CLOSED: any problem (unknown spec, unsupported version, a
|
||||
** statement that is not Allow, an unknown action, a bad resource, malformed
|
||||
** JSON, ...) grants zero permissions and logs a warning. parseStrict() throws
|
||||
** a PolicyParseException naming the problem instead (for the editor).
|
||||
*******************************************************************************/
|
||||
public final class PolicyParser
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(PolicyParser.class);
|
||||
|
||||
/** A valid resource: a QQQ permission base name. */
|
||||
public static final Pattern RESOURCE_PATTERN = Pattern.compile("[A-Za-z0-9_]+");
|
||||
|
||||
/** The only effect in 1.0.0. */
|
||||
public static final String ALLOW = "Allow";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The result of a successful parse.
|
||||
**
|
||||
** @param permissions QQQ permission names ({resource}.{action}), sorted
|
||||
** @param resources every resource named, in document order
|
||||
*******************************************************************************/
|
||||
public record Parsed(Set<String> permissions, Set<String> resources)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PolicyParser()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permissions a document grants, or none (with a warning) when it is
|
||||
** missing or invalid in any way.
|
||||
**
|
||||
** @param document a JSONObject, a Map, or a JSON string (as stored in a group
|
||||
** attribute or a token claim); null grants nothing
|
||||
*******************************************************************************/
|
||||
public static Set<String> parse(Object document, PolicyFormat format)
|
||||
{
|
||||
if(document == null || JSONObject.NULL.equals(document))
|
||||
{
|
||||
return (Collections.emptySet());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (parseStrict(document, format).permissions());
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
LOG.warn("Policy document rejected; it grants no permissions", logPair("reason", e.getMessage()));
|
||||
return (Collections.emptySet());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse, throwing a PolicyParseException that names the first problem.
|
||||
*******************************************************************************/
|
||||
public static Parsed parseStrict(Object document, PolicyFormat format) throws PolicyParseException
|
||||
{
|
||||
JSONObject json = asObject(document);
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
|
||||
Object spec = json.opt("PolicySpec");
|
||||
if(!(spec instanceof String s) || !s.equals(f.spec()))
|
||||
{
|
||||
throw (new PolicyParseException("PolicySpec must be \"" + f.spec() + "\" (found " + describe(spec) + ")"));
|
||||
}
|
||||
Object version = json.opt("PolicyVersion");
|
||||
if(!(version instanceof String v) || !v.equals(f.version()))
|
||||
{
|
||||
throw (new PolicyParseException("PolicyVersion must be \"" + f.version() + "\" (found " + describe(version) + ")"));
|
||||
}
|
||||
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
Set<String> resources = new LinkedHashSet<>();
|
||||
Object statements = json.opt("Statement");
|
||||
if(statements == null)
|
||||
{
|
||||
return (new Parsed(permissions, resources));
|
||||
}
|
||||
if(!(statements instanceof JSONArray array))
|
||||
{
|
||||
throw (new PolicyParseException("Statement must be an array"));
|
||||
}
|
||||
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
final int number = i + 1;
|
||||
if(!(array.opt(i) instanceof JSONObject statement))
|
||||
{
|
||||
throw (new PolicyParseException("Statement " + (i + 1) + " must be an object"));
|
||||
}
|
||||
Object effect = statement.opt("Effect");
|
||||
if(!ALLOW.equals(effect))
|
||||
{
|
||||
throw (new PolicyParseException("Statement " + (i + 1) + ": Effect must be \"Allow\" (found " + describe(effect) + "); Deny is not supported yet"));
|
||||
}
|
||||
|
||||
Set<PolicyAction> actions = EnumSet.noneOf(PolicyAction.class);
|
||||
for(String name : strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action"))
|
||||
{
|
||||
if(PolicyAction.WILDCARD.equals(name))
|
||||
{
|
||||
actions.addAll(EnumSet.allOf(PolicyAction.class));
|
||||
}
|
||||
else
|
||||
{
|
||||
actions.add(PolicyAction.fromJsonName(name).orElseThrow(() -> new PolicyParseException("Statement " + number + ": unknown action \"" + name + "\"")));
|
||||
}
|
||||
}
|
||||
|
||||
for(String resource : strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource"))
|
||||
{
|
||||
if(!RESOURCE_PATTERN.matcher(resource).matches())
|
||||
{
|
||||
throw (new PolicyParseException("Statement " + (i + 1) + ": resource \"" + resource + "\" must match [A-Za-z0-9_]+"));
|
||||
}
|
||||
resources.add(resource);
|
||||
for(PolicyAction action : actions)
|
||||
{
|
||||
permissions.add(action.permission(resource));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new Parsed(permissions, resources));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The document as a JSON object.
|
||||
*******************************************************************************/
|
||||
static JSONObject asObject(Object document) throws PolicyParseException
|
||||
{
|
||||
if(document instanceof JSONObject object)
|
||||
{
|
||||
return (object);
|
||||
}
|
||||
if(document instanceof Map<?, ?> map)
|
||||
{
|
||||
return (new JSONObject(map));
|
||||
}
|
||||
if(document instanceof String text)
|
||||
{
|
||||
if(text.isBlank())
|
||||
{
|
||||
throw (new PolicyParseException("The document is empty"));
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new JSONObject(text));
|
||||
}
|
||||
catch(JSONException e)
|
||||
{
|
||||
throw (new PolicyParseException("The document is not a JSON object"));
|
||||
}
|
||||
}
|
||||
throw (new PolicyParseException("The document is not a JSON object"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An array of strings, or a PolicyParseException.
|
||||
*******************************************************************************/
|
||||
private static Set<String> strings(Object value, String what) throws PolicyParseException
|
||||
{
|
||||
if(!(value instanceof JSONArray array))
|
||||
{
|
||||
throw (new PolicyParseException(what + " must be an array of strings"));
|
||||
}
|
||||
Set<String> rs = new LinkedHashSet<>();
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
if(!(array.opt(i) instanceof String text))
|
||||
{
|
||||
throw (new PolicyParseException(what + " must be an array of strings"));
|
||||
}
|
||||
rs.add(text);
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A short description of a found value for messages (never the whole value).
|
||||
*******************************************************************************/
|
||||
private static String describe(Object value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("nothing");
|
||||
}
|
||||
if(value instanceof String text)
|
||||
{
|
||||
return ("\"" + (text.length() > 40 ? text.substring(0, 40) + "..." : text) + "\"");
|
||||
}
|
||||
return (value.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Helpers a host's own authentication customizer can call to turn the policy
|
||||
** claim of a sign-in token into QQQ permissions (docs/POLICY-FORMAT.md).
|
||||
*******************************************************************************/
|
||||
public final class PolicyPermissions
|
||||
{
|
||||
/** Customizer context key of the ID token claims (QQQ OAuth2 module). */
|
||||
public static final String ID_TOKEN_CONTEXT_KEY = "idToken";
|
||||
|
||||
/** Customizer context key of the access token claims. */
|
||||
public static final String ACCESS_TOKEN_CONTEXT_KEY = "jwtPayloadJsonObject";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PolicyPermissions()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permissions granted by the policy claim in these token claims; none
|
||||
** when the claim is missing or invalid (fail closed).
|
||||
*******************************************************************************/
|
||||
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format)
|
||||
{
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
if(claims == null || !claims.has(f.claim()))
|
||||
{
|
||||
return (Collections.emptySet());
|
||||
}
|
||||
return (PolicyParser.parse(claims.opt(f.claim()), f));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The claims that carry the policy in a customizer context: the ID token's
|
||||
** when it has the claim (Authentik puts scope-mapping claims there), else the
|
||||
** access token's (the only ones on a session resume). Null when neither has
|
||||
** the claim.
|
||||
*******************************************************************************/
|
||||
public static JSONObject claimsWithPolicy(Map<String, Object> context, PolicyFormat format)
|
||||
{
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
if(context == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(String key : new String[] { ID_TOKEN_CONTEXT_KEY, ACCESS_TOKEN_CONTEXT_KEY })
|
||||
{
|
||||
JSONObject claims = asClaims(context.get(key));
|
||||
if(claims != null && claims.has(f.claim()))
|
||||
{
|
||||
return (claims);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Claims as a JSONObject (QQQ passes a JSONObject; tests may pass a Map).
|
||||
*******************************************************************************/
|
||||
public static JSONObject asClaims(Object value)
|
||||
{
|
||||
if(value instanceof JSONObject object)
|
||||
{
|
||||
return (object);
|
||||
}
|
||||
if(value instanceof Map<?, ?> map)
|
||||
{
|
||||
return (new JSONObject(map));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,24 @@ class AuthentikQBitConfigTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void policyNames()
|
||||
{
|
||||
assertThat(AuthentikTestSupport.unitConfig().getPolicyFormat()).isEqualTo(com.kingsrook.qbits.authentik.policy.PolicyFormat.DEFAULT);
|
||||
assertThat(AuthentikTestSupport.unitConfig().getPolicyTwoPersonApproval()).isFalse();
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicySpec("acme-iam").withPolicyVersion("2.0.0").withPolicyAttribute("policies").withPolicyClaim("policies"))).isEmpty();
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicyVersion("1"))).anyMatch(e -> e.contains("policyVersion"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicySpec(""))).anyMatch(e -> e.contains("policySpec"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicyClaim("a b"))).anyMatch(e -> e.contains("policyClaim"));
|
||||
assertThat(errors(AuthentikTestSupport.unitConfig().withPolicyAttribute(null))).anyMatch(e -> e.contains("policyAttribute"));
|
||||
assertThat(AuthentikTestSupport.unitConfig().withPolicyTwoPersonApproval(true).getPolicyTwoPersonApproval()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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.contract;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
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.policy.PolicyPermissions;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pins the scope-mapping example of docs/POLICY-FORMAT.md on Authentik
|
||||
** 2026.8.3: the blueprint applies, and the mapping (run through Authentik's
|
||||
** property-mapping test endpoint) emits a claim that the qbit's parser turns
|
||||
** into the union of the user's groups' policies, including inherited parents.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(46)
|
||||
class PolicyClaimMappingIT
|
||||
{
|
||||
private static TestApi admin;
|
||||
private static String mappingPk;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll() throws Exception
|
||||
{
|
||||
AuthentikTestStack stack = AuthentikTestStack.get();
|
||||
admin = stack.admin();
|
||||
try(InputStream in = PolicyClaimMappingIT.class.getResourceAsStream("/blueprints/qqq-policy-claim.yaml"))
|
||||
{
|
||||
stack.createContentBlueprint("qqq-policy-claim", new String(in.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
mappingPk = admin.first("/propertymappings/provider/scope/?name=qqq-policy-claim").getString("pk");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the mapping for a user; the claim as Authentik would put it in a token.
|
||||
*******************************************************************************/
|
||||
private static JSONObject claimsFor(int userPk)
|
||||
{
|
||||
TestApi.Response response = admin.post("/propertymappings/all/" + mappingPk + "/test/", new JSONObject().put("user", userPk));
|
||||
assertThat(response.status()).as(response.body()).isEqualTo(200);
|
||||
JSONObject result = response.json();
|
||||
assertThat(result.getBoolean("successful")).as(result.toString()).isTrue();
|
||||
return (new JSONObject(result.getString("result")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group with a canonical policy for these permissions.
|
||||
*******************************************************************************/
|
||||
private static String group(String name, String parentPk, Object policy) throws Exception
|
||||
{
|
||||
JSONObject group = new JSONObject().put("name", name).put("attributes", new JSONObject().put("qqq.io/policy", policy));
|
||||
if(parentPk != null)
|
||||
{
|
||||
group.put("parents", new JSONArray().put(parentPk));
|
||||
}
|
||||
admin.post("/core/groups/", group);
|
||||
return (admin.groupPk(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static int user(String username, String groupPk)
|
||||
{
|
||||
admin.post("/core/users/", new JSONObject().put("username", username).put("name", username).put("type", "internal").put("path", "users"));
|
||||
int pk = admin.userPk(username);
|
||||
if(groupPk != null)
|
||||
{
|
||||
admin.post("/core/groups/" + groupPk + "/add_user/", new JSONObject().put("pk", pk));
|
||||
}
|
||||
return (pk);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void claimIsTheUnionOfInheritedGroupPolicies() throws Exception
|
||||
{
|
||||
PolicyFormat format = PolicyFormat.DEFAULT;
|
||||
String parent = group("policy-parent", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("contentApp.hasAccess"), format)));
|
||||
String child = group("policy-child", parent, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("cms_page.read", "cms_page.edit"), format)));
|
||||
group("policy-object", null, PolicyGenerator.generate(List.of("objectAttr.read"), format));
|
||||
group("policy-other-spec", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("ignored.read"), new PolicyFormat("other", "1.0.0", "x", "y"))));
|
||||
|
||||
int member = user("policy.member", child);
|
||||
admin.post("/core/groups/" + admin.groupPk("policy-object") + "/add_user/", new JSONObject().put("pk", member));
|
||||
admin.post("/core/groups/" + admin.groupPk("policy-other-spec") + "/add_user/", new JSONObject().put("pk", member));
|
||||
|
||||
JSONObject claims = claimsFor(member);
|
||||
assertThat(claims.has("qqq_policy")).isTrue();
|
||||
assertThat(PolicyPermissions.fromClaims(claims, format))
|
||||
.as("parent (inherited), child, an object-valued attribute; another spec is skipped")
|
||||
.containsExactlyInAnyOrder("contentApp.hasAccess", "cms_page.read", "cms_page.edit", "objectAttr.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No policies: the claim is still a valid, empty document.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void userWithoutPoliciesGetsAnEmptyDocument() throws Exception
|
||||
{
|
||||
JSONObject claims = claimsFor(user("policy.nobody", null));
|
||||
assertThat(PolicyParser.parseStrict(claims.get("qqq_policy"), PolicyFormat.DEFAULT).permissions()).isEmpty();
|
||||
}
|
||||
}
|
||||
+21
@@ -182,6 +182,27 @@ class AuthentikGroupPermissionMapperTest
|
||||
** Inherited authentik.* through a child group; existing permissions kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void policyClaimIsAdded()
|
||||
{
|
||||
String policy = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}]}";
|
||||
Map<String, Object> ctx = context("someone@example.test", new JSONArray());
|
||||
ctx.put("idToken", new JSONObject().put("qqq_policy", policy));
|
||||
QSession session = new QSession();
|
||||
new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, ctx);
|
||||
assertThat(session.getPermissions()).contains("cms_page.read");
|
||||
|
||||
ctx.put("idToken", new JSONObject().put("qqq_policy", policy.replace("Allow", "Deny")));
|
||||
QSession denied = new QSession();
|
||||
new AuthentikGroupPermissionMapper().customizeSession(qInstance, denied, ctx);
|
||||
assertThat(denied.getPermissions()).doesNotContain("cms_page.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void groupsBecomePermissions()
|
||||
{
|
||||
QSession session = new QSession();
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* 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.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.document;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.statement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for AuthentikPolicyClaimMapper and PolicyPermissions.
|
||||
*******************************************************************************/
|
||||
class AuthentikPolicyClaimMapperTest
|
||||
{
|
||||
private static final String READ_PAGE = document(statement("Allow", List.of("read"), List.of("cms_page"))).toString();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QSession run(AuthentikPolicyClaimMapper mapper, Map<String, Object> context)
|
||||
{
|
||||
QSession session = new QSession();
|
||||
session.setPermissions(Set.of("existing.hasAccess"));
|
||||
mapper.customizeSession(null, session, context);
|
||||
return (session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The claim as a string (the scope mapping's json.dumps) in the ID token.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stringClaimInIdToken()
|
||||
{
|
||||
QSession session = run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", READ_PAGE)));
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.hasAccess", "cms_page.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An object claim in the access token (session resume: no ID token).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void objectClaimInAccessToken()
|
||||
{
|
||||
QSession session = run(new AuthentikPolicyClaimMapper(), Map.of("jwtPayloadJsonObject", new JSONObject().put("qqq_policy", new JSONObject(READ_PAGE))));
|
||||
assertThat(session.getPermissions()).contains("cms_page.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The ID token wins when both carry the claim; the access token is used when
|
||||
** only it has the claim.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void idTokenPreferred()
|
||||
{
|
||||
String other = document(statement("Allow", List.of("edit"), List.of("other"))).toString();
|
||||
Map<String, Object> context = new HashMap<>();
|
||||
context.put("idToken", new JSONObject().put("qqq_policy", READ_PAGE));
|
||||
context.put("jwtPayloadJsonObject", new JSONObject().put("qqq_policy", other));
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), context).getPermissions()).contains("cms_page.read").doesNotContain("other.edit");
|
||||
|
||||
context.put("idToken", new JSONObject().put("email", "a@example.test"));
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), context).getPermissions()).contains("other.edit");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing or invalid claims add nothing and keep what the session had.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failsClosed()
|
||||
{
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of()).getPermissions()).containsExactly("existing.hasAccess");
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), null).getPermissions()).containsExactly("existing.hasAccess");
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", "{}"))).getPermissions()).containsExactly("existing.hasAccess");
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", "garbage"))).getPermissions()).containsExactly("existing.hasAccess");
|
||||
assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", "not claims")).getPermissions()).containsExactly("existing.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host's own names (spec, claim).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void customFormat()
|
||||
{
|
||||
PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies");
|
||||
String doc = new JSONObject(READ_PAGE).put("PolicySpec", "acme-iam").toString();
|
||||
|
||||
QSession session = run(new AuthentikPolicyClaimMapper().withFormat(custom), Map.of("idToken", Map.of("policies", doc)));
|
||||
assertThat(session.getPermissions()).contains("cms_page.read");
|
||||
assertThat(new AuthentikPolicyClaimMapper().withFormat(null).getFormat()).isEqualTo(PolicyFormat.DEFAULT);
|
||||
|
||||
assertThat(PolicyPermissions.fromClaims(new JSONObject().put("policies", doc), custom)).containsExactly("cms_page.read");
|
||||
assertThat(PolicyPermissions.fromClaims(null, custom)).isEmpty();
|
||||
assertThat(PolicyPermissions.claimsWithPolicy(null, null)).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The security-key hook runs before permissions, with the claims, even when
|
||||
** the token has no policy claim.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void securityKeyHook()
|
||||
{
|
||||
List<String> calls = new ArrayList<>();
|
||||
AuthentikPolicyClaimMapper mapper = new AuthentikPolicyClaimMapper()
|
||||
{
|
||||
@Override
|
||||
protected void applySecurityKeys(QSession qSession, JSONObject claims)
|
||||
{
|
||||
calls.add(claims.optString("tenant") + ":" + qSession.getPermissions().contains("cms_page.read"));
|
||||
}
|
||||
};
|
||||
run(mapper, Map.of("idToken", new JSONObject().put("tenant", "t1").put("qqq_policy", READ_PAGE)));
|
||||
run(mapper, Map.of("jwtPayloadJsonObject", new JSONObject().put("tenant", "t2")));
|
||||
assertThat(calls).containsExactly("t1:false", "t2:false");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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.Random;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for PolicyGenerator: canonical form and the round trip.
|
||||
*******************************************************************************/
|
||||
class PolicyGeneratorTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canonicalForm() throws Exception
|
||||
{
|
||||
JSONObject doc = PolicyGenerator.generate(List.of("cms_page.edit", "contentApp.hasAccess", "cms_page.read", "cms_media.read"), PolicyFormat.DEFAULT);
|
||||
|
||||
assertThat(PolicyGenerator.toCompact(doc)).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":["
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"hasAccess\"],\"Resource\":[\"contentApp\"]},"
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_media\",\"cms_page\"]},"
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"edit\"],\"Resource\":[\"cms_page\"]}]}");
|
||||
|
||||
assertThat(PolicyGenerator.toPretty(doc)).isEqualTo("""
|
||||
{
|
||||
"PolicySpec": "qqq-iam",
|
||||
"PolicyVersion": "1.0.0",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["hasAccess"],
|
||||
"Resource": [
|
||||
"contentApp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["read"],
|
||||
"Resource": [
|
||||
"cms_media",
|
||||
"cms_page"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["edit"],
|
||||
"Resource": [
|
||||
"cms_page"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emptyDocument() throws Exception
|
||||
{
|
||||
JSONObject doc = PolicyGenerator.generate(List.of(), PolicyFormat.DEFAULT);
|
||||
assertThat(PolicyGenerator.toCompact(doc)).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}\n".trim());
|
||||
assertThat(PolicyGenerator.toPretty(doc)).isEqualTo("{\n \"PolicySpec\": \"qqq-iam\",\n \"PolicyVersion\": \"1.0.0\",\n \"Statement\": []\n}\n");
|
||||
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT).permissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** parse(generate(x)) == x for many random permission sets, in both forms,
|
||||
** and generate(parse(doc)) is stable (canonical).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void roundTripRandomSets() throws Exception
|
||||
{
|
||||
Random random = new Random(20261013);
|
||||
List<String> resources = List.of("a", "B_2", "cms_page", "contentApp", "x9", "authentik_user", "publishPage");
|
||||
for(int run = 0; run < 500; run++)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
for(String resource : resources)
|
||||
{
|
||||
for(PolicyAction action : PolicyAction.values())
|
||||
{
|
||||
if(random.nextInt(3) == 0)
|
||||
{
|
||||
permissions.add(action.permission(resource));
|
||||
}
|
||||
}
|
||||
}
|
||||
JSONObject doc = PolicyGenerator.generate(permissions, PolicyFormat.DEFAULT);
|
||||
assertThat(PolicyParser.parseStrict(PolicyGenerator.toCompact(doc), PolicyFormat.DEFAULT).permissions()).isEqualTo(permissions);
|
||||
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT).permissions()).isEqualTo(permissions);
|
||||
assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT))).isEqualTo(PolicyGenerator.toCompact(doc));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A hand-written document with "*" and repeated statements becomes the
|
||||
** canonical one with the same grants.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canonicalizesHandWrittenDocuments() throws Exception
|
||||
{
|
||||
String hand = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":["
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"*\"],\"Resource\":[\"b\"]},"
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"read\",\"read\"],\"Resource\":[\"a\",\"b\"]}]}";
|
||||
JSONObject canonical = PolicyGenerator.canonical(hand, PolicyFormat.DEFAULT);
|
||||
assertThat(PolicyParser.parse(canonical, PolicyFormat.DEFAULT)).isEqualTo(PolicyParser.parse(hand, PolicyFormat.DEFAULT));
|
||||
assertThat(PolicyGenerator.toCompact(canonical)).doesNotContain("\"*\"");
|
||||
assertThat(canonical.getJSONArray("Statement").length()).isEqualTo(5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rejectsWhatCannotRoundTrip()
|
||||
{
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("noaction"), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class).hasMessageContaining("{resource}.{action}");
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("a."), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class);
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of(".read"), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class);
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("a.write"), PolicyFormat.DEFAULT)).hasMessageContaining("unknown action \"write\"");
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("authentik.users.read"), PolicyFormat.DEFAULT)).hasMessageContaining("[A-Za-z0-9_]+");
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(java.util.Arrays.asList((String) null), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The generator writes the host's spec and version.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void customFormat() throws Exception
|
||||
{
|
||||
PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies");
|
||||
JSONObject doc = PolicyGenerator.generate(List.of("order.read"), custom);
|
||||
assertThat(doc.getString("PolicySpec")).isEqualTo("acme-iam");
|
||||
assertThat(PolicyParser.parse(PolicyGenerator.toCompact(doc), custom)).containsExactly("order.read");
|
||||
assertThat(PolicyGenerator.generate(List.of("order.read"), null).getString("PolicySpec")).isEqualTo("qqq-iam");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.document;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.statement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group policies in the group graph and the effective-permission resolver:
|
||||
** inherited from parents, fail closed per group, checked against the catalog.
|
||||
*******************************************************************************/
|
||||
class PolicyGraphTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static JSONObject group(String pk, List<String> parents, Object policy, String attribute)
|
||||
{
|
||||
JSONObject attributes = new JSONObject();
|
||||
if(policy != null)
|
||||
{
|
||||
attributes.put(attribute, policy);
|
||||
}
|
||||
return (new JSONObject().put("pk", pk).put("name", pk).put("is_superuser", false).put("parents", new JSONArray(parents)).put("attributes", attributes).put("users", new JSONArray()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void policiesAreInheritedAndFailClosedPerGroup()
|
||||
{
|
||||
String parentPolicy = document(statement("Allow", List.of("hasAccess"), List.of("contentApp"))).toString();
|
||||
JSONObject childPolicy = document(statement("Allow", List.of("read", "edit"), List.of("cms_page")));
|
||||
String broken = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"x\"]}]}";
|
||||
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(
|
||||
group("parent", List.of(), parentPolicy, "qqq.io/policy"),
|
||||
group("child", List.of("parent"), childPolicy, "qqq.io/policy"),
|
||||
group("broken", List.of("parent"), broken, "qqq.io/policy"),
|
||||
group("empty", List.of(), "", "qqq.io/policy")));
|
||||
|
||||
assertThat(graph.byPk("child").policyPermissions()).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit");
|
||||
assertThat(graph.byPk("broken").policyPermissions()).isEmpty();
|
||||
assertThat(graph.byPk("broken").malformed()).anyMatch(m -> m.startsWith("qqq.io/policy: ") && m.contains("Deny"));
|
||||
assertThat(graph.byPk("empty").malformed()).isEmpty();
|
||||
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
|
||||
EffectivePermissionResolver.Resolution child = resolver.resolveGroups(List.of("child"));
|
||||
assertThat(child.sessionPermissions()).containsExactlyInAnyOrder("contentApp.hasAccess", "cms_page.read", "cms_page.edit");
|
||||
assertThat(child.grantedBy().get("contentApp.hasAccess")).containsExactly("parent");
|
||||
|
||||
assertThat(resolver.resolveGroups(List.of("broken")).sessionPermissions()).as("the broken group grants nothing; its parent still does").containsExactly("contentApp.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Grants outside the app's permission catalog are reported, not granted.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void catalogFiltersPolicyGrants()
|
||||
{
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(group("g", List.of(), document(statement("Allow", List.of("read"), List.of("cms_page", "gone_table"))).toString(), "qqq.io/policy")));
|
||||
EffectivePermissionResolver.Resolution resolution = new EffectivePermissionResolver(graph, List.of("cms_page.read")).resolveGroups(List.of("g"));
|
||||
assertThat(resolution.sessionPermissions()).containsExactly("cms_page.read");
|
||||
assertThat(resolution.unknown()).containsExactly("gone_table.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host's own attribute and spec.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void customFormat()
|
||||
{
|
||||
PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies");
|
||||
String doc = document(statement("Allow", List.of("read"), List.of("order"))).put("PolicySpec", "acme-iam").toString();
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(group("g", List.of(), doc, "policies")), custom);
|
||||
assertThat(graph.byPk("g").policyPermissions()).containsExactly("order.read");
|
||||
assertThat(GroupGraph.fromGroups(List.of(group("g", List.of(), doc, "policies"))).byPk("g").policyPermissions()).as("default attribute is qqq.io/policy").isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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 org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for PolicyParser: the format and every fail-closed case.
|
||||
*******************************************************************************/
|
||||
class PolicyParserTest
|
||||
{
|
||||
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A statement.
|
||||
*******************************************************************************/
|
||||
static JSONObject statement(String effect, List<String> actions, List<String> resources)
|
||||
{
|
||||
return (new JSONObject().put("Effect", effect).put("Action", new JSONArray(actions)).put("Resource", new JSONArray(resources)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A document with the default spec and version.
|
||||
*******************************************************************************/
|
||||
static JSONObject document(JSONObject... statements)
|
||||
{
|
||||
return (new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0").put("Statement", new JSONArray(List.of(statements))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void allowStatementsGrantResourceDotAction()
|
||||
{
|
||||
JSONObject doc = document(
|
||||
statement("Allow", List.of("hasAccess"), List.of("contentApp", "publishPage")),
|
||||
statement("Allow", List.of("read", "edit"), List.of("cms_page")));
|
||||
|
||||
assertThat(PolicyParser.parse(doc, FORMAT)).containsExactlyInAnyOrder(
|
||||
"contentApp.hasAccess", "publishPage.hasAccess", "cms_page.read", "cms_page.edit");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void wildcardActionIsAllFive()
|
||||
{
|
||||
assertThat(PolicyParser.parse(document(statement("Allow", List.of("*"), List.of("cms_page"))), FORMAT)).containsExactlyInAnyOrder(
|
||||
"cms_page.hasAccess", "cms_page.read", "cms_page.insert", "cms_page.edit", "cms_page.delete");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** String (as stored in an attribute or claim), JSONObject and Map all parse.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void acceptsStringObjectAndMap()
|
||||
{
|
||||
JSONObject doc = document(statement("Allow", List.of("read"), List.of("a")));
|
||||
assertThat(PolicyParser.parse(doc.toString(), FORMAT)).containsExactly("a.read");
|
||||
assertThat(PolicyParser.parse(doc, FORMAT)).containsExactly("a.read");
|
||||
assertThat(PolicyParser.parse(doc.toMap(), FORMAT)).containsExactly("a.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emptyOrMissingStatementGrantsNothingWithoutError() throws Exception
|
||||
{
|
||||
assertThat(PolicyParser.parseStrict(document(), FORMAT).permissions()).isEmpty();
|
||||
assertThat(PolicyParser.parseStrict(new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0"), FORMAT).permissions()).isEmpty();
|
||||
assertThat(PolicyParser.parseStrict(document(statement("Allow", List.of(), List.of("a"))), FORMAT).permissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every malformed document grants nothing (fail closed), and parseStrict
|
||||
** names the problem.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"",
|
||||
" ",
|
||||
"not json",
|
||||
"[]",
|
||||
"{\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}",
|
||||
"{\"PolicySpec\":\"other-spec\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"Statement\":[]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"2.0.0\",\"Statement\":[]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":{}}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[\"x\"]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"allow\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"read\",\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":\"a\"}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"write\"],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"Read\"],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[1],\"Resource\":[\"a\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"*\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"authentik.users\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"\"]}]}",
|
||||
"{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_*\"]}]}",
|
||||
})
|
||||
void malformedDocumentsFailClosed(String text)
|
||||
{
|
||||
assertThat(PolicyParser.parse(text, FORMAT)).isEmpty();
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(text, FORMAT)).isInstanceOf(PolicyParseException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One bad statement voids the whole document (no partial credit).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void oneBadStatementVoidsAll()
|
||||
{
|
||||
JSONObject doc = document(
|
||||
statement("Allow", List.of("read"), List.of("good")),
|
||||
statement("Deny", List.of("read"), List.of("other")));
|
||||
assertThat(PolicyParser.parse(doc, FORMAT)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void nullAndOtherTypesGrantNothing()
|
||||
{
|
||||
assertThat(PolicyParser.parse(null, FORMAT)).isEmpty();
|
||||
assertThat(PolicyParser.parse(JSONObject.NULL, FORMAT)).isEmpty();
|
||||
assertThat(PolicyParser.parse(42, FORMAT)).isEmpty();
|
||||
assertThat(PolicyParser.parse(new JSONArray(), FORMAT)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host keeps its own spec name and version.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void customSpecName()
|
||||
{
|
||||
PolicyFormat custom = new PolicyFormat("acme-iam", "2.1.0", "policies", "policies");
|
||||
JSONObject doc = new JSONObject().put("PolicySpec", "acme-iam").put("PolicyVersion", "2.1.0")
|
||||
.put("Statement", new JSONArray().put(statement("Allow", List.of("read"), List.of("order"))));
|
||||
|
||||
assertThat(PolicyParser.parse(doc, custom)).containsExactly("order.read");
|
||||
assertThat(PolicyParser.parse(doc, FORMAT)).as("the default spec rejects another spec").isEmpty();
|
||||
assertThat(PolicyParser.parse(doc, null)).as("null format means the default").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void messagesNameTheProblem()
|
||||
{
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(Map.of("PolicySpec", "x"), FORMAT)).hasMessageContaining("PolicySpec must be \"qqq-iam\"");
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Allow", List.of("write"), List.of("a"))), FORMAT)).hasMessageContaining("unknown action \"write\"");
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Deny", List.of("read"), List.of("a"))), FORMAT)).hasMessageContaining("Deny is not supported");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void formatProblems()
|
||||
{
|
||||
assertThat(PolicyFormat.DEFAULT.problem()).isNull();
|
||||
assertThat(new PolicyFormat("bad spec", "1.0.0", "a", "c").problem()).contains("policySpec");
|
||||
assertThat(new PolicyFormat("s", "1.0", "a", "c").problem()).contains("policyVersion");
|
||||
assertThat(new PolicyFormat("s", "1.0.0", " ", "c").problem()).contains("policyAttribute");
|
||||
assertThat(new PolicyFormat("s", "1.0.0", "a", "bad claim").problem()).contains("policyClaim");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user