feat(policy): grant table feature permissions (analyze, comment, ...) #19
No files matched your search
@@ -4,6 +4,9 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added (table feature permissions)
|
||||
- Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData
|
||||
|
||||
### Added (policy cut-over, DESIGN.md section 12.8)
|
||||
- `AuthentikHostPermissionSource` SPI (today's access) and config `mapperEnabled`, `cutoverGuardGroups`, `mapperOverride`, `staticGroupPermissions`.
|
||||
- **Enforcement status**: banner widget `authentikPolicyEnforcement` (first on group and policy records and on the Edit Policy step) and a "Policies at sign-in" row in the home's Connection card: OFF, ACTIVE, OVERRIDDEN or BLOCKED.
|
||||
|
||||
@@ -65,6 +65,10 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
|
||||
|
||||
`authentik.*` grants all of them.
|
||||
|
||||
Policies also grant table feature permissions registered through core `TableFeaturePermissionsMetaData`: `analyze` (qbit-data-analysis) and `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration), or any future registered feature. For example, `{"Effect":"Allow","Action":["analyze","comment"],"Resource":["person"]}` grants `person.analyze` and `person.comment`. The resource is the table's effective permission base name. Features must be granted explicitly: policy action `"*"` still means only `hasAccess`, `read`, `insert`, `edit`, `delete`.
|
||||
|
||||
The editor discovers features and labels through `PermissionsHelper.getAllAvailablePermissions`; they appear when the host's core version exposes them. This qbit does not compile against the unreleased metadata hook. See [the policy format](docs/POLICY-FORMAT.md) for validation and host customizer usage.
|
||||
|
||||
### Authentik side
|
||||
|
||||
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, token-key or impersonate permissions, and holds delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
|
||||
|
||||
+8
-3
@@ -1057,7 +1057,9 @@ A **policy** is a JSON document on an Authentik group that grants QQQ permission
|
||||
}
|
||||
```
|
||||
|
||||
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` = all five actions). Those are QQQ permission names, so a QQQ session that holds them passes QQQ's own permission checks. Policies cover the instance's tables, processes, apps, reports and widgets. Named permissions that are not `{resource}.{action}` (for example `authentik.users.manage`) stay in `qqq.io/permissions` (§5.6); a policy cannot grant them, because a resource cannot contain a dot.
|
||||
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` = only the five standard actions: `hasAccess`, `read`, `insert`, `edit`, `delete`). Those are QQQ permission names, so a QQQ session that holds them passes QQQ's own permission checks. Policies cover the instance's tables, processes, apps, reports and widgets. Named permissions that are not `{resource}.{action}` (for example `authentik.users.manage`) stay in `qqq.io/permissions` (§5.6); a policy cannot grant them, because a resource cannot contain a dot.
|
||||
|
||||
Table feature actions are generic strings registered in the table's permission catalog: for example `analyze` (qbit-data-analysis), or `comment`, `attach`, `tag`, `share`, `task`, `assign` (qbit-collaboration). `{"Effect":"Allow","Action":["analyze"],"Resource":["person"]}` grants `person.analyze`, checked with `session.hasPermission("person.analyze")`. A feature must exist for every resource in its statement. Unknown features reject the document. `"*"` never includes features, so existing policies do not acquire new grants when a feature is registered.
|
||||
|
||||
**Fail closed.** Any of these grants **zero** permissions from that document and logs a warning:
|
||||
- an unknown `PolicySpec` or an unsupported `PolicyVersion`
|
||||
@@ -1113,11 +1115,14 @@ The feature `POLICIES` turns on the editor, the `authentik_policy` table and the
|
||||
The **catalog** lists every app, table, process, report and protected widget in the QInstance. Each entry has its permission base name, label, kind, app, and the actions QQQ actually checks for it:
|
||||
- `hasAccess` for apps, processes, reports and widgets
|
||||
- `read`, `insert`, `edit` and `delete` for tables, limited by the table's capabilities and permission level
|
||||
- Table features from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, matched by the table's effective permission base name plus `.`. Their labels come from `AvailablePermission.permissionType`; no dependency on the unreleased `TableFeaturePermissionsMetaData` hook is required. The five standard suffixes are excluded.
|
||||
|
||||
The widget JSON keeps `actions` unchanged and adds a `features` array to each table resource, for example `"actions":["read","insert","edit","delete"],"features":[{"name":"analyze","label":"Analyze"},{"name":"comment","label":"Comment"}]`. Tables without features have `"features":[]`. Features also appear for `HAS_ACCESS` and `READ_WRITE` tables. Shared permission base names merge their features. Feature checkboxes must emit explicit action names.
|
||||
|
||||
The catalog also follows these rules:
|
||||
- Hidden processes are included. Record actions are hidden from the nav but are still permission-checked.
|
||||
- Objects that are in no app are listed under "Other".
|
||||
- Tables that use `READ_WRITE` permissions are shown with `read` only, and a note, because `write` is not in the action set yet.
|
||||
- Tables that use `READ_WRITE` permissions are shown with the standard action `read` and any registered features, and a note about their single write permission. The generic matching rule also exposes their available `write` permission as an explicit named action; `*` does not grant it.
|
||||
- Objects with a custom permission checker, or `NOT_PROTECTED`, are left out because a policy cannot grant them.
|
||||
|
||||
The **current document** is read live from the group in Authentik, the source of truth, together with the group's fingerprint for the stale check. If it differs from the last QQQ save, a notice says it was changed in Authentik.
|
||||
@@ -1145,7 +1150,7 @@ Refusals name the reason.
|
||||
- clears the caches
|
||||
|
||||
Saving an empty matrix removes the attribute. The canonical form is the reference generator's:
|
||||
- one statement per action, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`
|
||||
- one statement per action, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`, followed by feature names sorted alphabetically
|
||||
- resources sorted
|
||||
- a resource with every action is still written as five statements, not `*`, so diffs stay readable
|
||||
|
||||
|
||||
+11
-5
@@ -31,12 +31,12 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is
|
||||
| `PolicyVersion` | string, semver | Must equal the supported version (`policyVersion`, default `1.0.0`) |
|
||||
| `Statement` | array of statements | May be empty (grants nothing) |
|
||||
| `Statement[].Effect` | string | `Allow` (the only effect in 1.0.0) |
|
||||
| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*` |
|
||||
| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`, or a table feature name registered for every listed resource |
|
||||
| `Statement[].Resource` | array of strings | Each one a QQQ permission base name matching `[A-Za-z0-9_]+`: a table, process, app, report or widget name (or its custom `permissionBaseName`) |
|
||||
|
||||
## Meaning
|
||||
|
||||
Each statement grants `{resource}.{action}` for every resource and action it lists. `*` stands for all five actions. The result is the union over all statements, and over all of the user's groups.
|
||||
Each statement grants `{resource}.{action}` for every resource and action it lists. `*` stands for the five standard actions only; it never includes table features. The result is the union over all statements, and over all of the user's groups.
|
||||
|
||||
| Action | QQQ permission | Applies to |
|
||||
|---|---|---|
|
||||
@@ -45,10 +45,14 @@ Each statement grants `{resource}.{action}` for every resource and action it lis
|
||||
| `insert` | `{name}.insert` | tables |
|
||||
| `edit` | `{name}.edit` | tables |
|
||||
| `delete` | `{name}.delete` | tables |
|
||||
| registered feature (e.g. `analyze`, `comment`) | `{name}.{feature}` | tables that register that feature, including `HAS_ACCESS` and `READ_WRITE` tables |
|
||||
|
||||
Examples:
|
||||
- `{"Action": ["read"], "Resource": ["cms_page"]}` grants `cms_page.read`.
|
||||
- `{"Action": ["*"], "Resource": ["cms_page"]}` grants `cms_page.hasAccess`, `cms_page.read`, `cms_page.insert`, `cms_page.edit` and `cms_page.delete`.
|
||||
- `{"Action": ["analyze", "comment"], "Resource": ["person"]}` grants `person.analyze` and `person.comment` when both features are registered for `person`.
|
||||
|
||||
Features are generic strings, discovered by matching `PermissionsHelper.getAllAvailablePermissions(qInstance)` entries to the table's effective permission base name. Examples include `analyze` from qbit-data-analysis and `comment`, `attach`, `tag`, `share`, `task`, `assign` from qbit-collaboration. The feature label comes from `AvailablePermission.permissionType`. Every feature grant is explicit, so registering a new feature never broadens an existing `"*"` policy. This matching rule also exposes the available `write` permission of `READ_WRITE` tables as an explicit named action; it is not part of `*`.
|
||||
|
||||
A grant that QQQ never checks (for example `hasAccess` on a table with insert/edit/delete permissions) is harmless.
|
||||
|
||||
@@ -63,7 +67,7 @@ A document grants **nothing**, and the parser logs a warning, when any of these
|
||||
- `Statement` is present but not an array, or a statement is not an object
|
||||
- a statement's `Effect` is not `Allow` (including `Deny`, which is not supported yet)
|
||||
- `Action` or `Resource` is missing or not an array of strings
|
||||
- an action is not one of the six above
|
||||
- an action is neither a standard action, `*`, nor a feature registered for every listed resource
|
||||
- a resource does not match `[A-Za-z0-9_]+`
|
||||
|
||||
There is no partial credit: one bad statement voids the whole document. When the qbit reads policies from the group graph, each group's document is parsed on its own, so one bad group voids only that group. In the merged token claim, one bad statement voids the claim.
|
||||
@@ -71,7 +75,7 @@ There is no partial credit: one bad statement voids the whole document. When the
|
||||
## Canonical form
|
||||
|
||||
The qbit always writes the canonical form:
|
||||
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`
|
||||
- one statement per action that is granted, in the order `hasAccess`, `read`, `insert`, `edit`, `delete`, then feature names sorted alphabetically (existing documents retain their byte-for-byte canonical form)
|
||||
- each statement lists its resources sorted
|
||||
- no `*`
|
||||
- stored on the group as a compact JSON **string**
|
||||
@@ -99,7 +103,9 @@ Authentik puts the merged document into the token through a scope mapping. Steps
|
||||
4. Register the customizer in your app:
|
||||
- `AuthentikPolicyClaimMapper` on its own, or
|
||||
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
|
||||
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat())`.
|
||||
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
|
||||
|
||||
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog continue to accept only standard actions. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the existing available-permission API without compiling against the unreleased `TableFeaturePermissionsMetaData` hook; features become available when the host's core supplies those entries.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1092,8 +1092,8 @@ public class AuthentikAdmin
|
||||
try
|
||||
{
|
||||
String submitted = request.str(POLICY_DOCUMENT);
|
||||
newPermissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format).permissions() : new TreeSet<>();
|
||||
canonical = PolicyGenerator.generate(newPermissions, format);
|
||||
newPermissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).permissions() : new TreeSet<>();
|
||||
canonical = PolicyGenerator.generate(newPermissions, format, runtime.getPolicyCatalog());
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
@@ -1107,7 +1107,7 @@ public class AuthentikAdmin
|
||||
{
|
||||
try
|
||||
{
|
||||
oldPermissions.addAll(PolicyParser.parseStrict(stored, format).permissions());
|
||||
oldPermissions.addAll(PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).permissions());
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
@@ -1213,7 +1213,7 @@ public class AuthentikAdmin
|
||||
PolicyFormat format = config.getPolicyFormat();
|
||||
Set<String> policy = new TreeSet<>();
|
||||
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
|
||||
policy.addAll(PolicyParser.parse(stored, format));
|
||||
policy.addAll(PolicyParser.parse(stored, format, runtime.getPolicyCatalog()));
|
||||
|
||||
Set<String> expressible = runtime.getPolicyCatalog().permissionNames();
|
||||
Set<String> extras = new TreeSet<>();
|
||||
@@ -1235,7 +1235,7 @@ public class AuthentikAdmin
|
||||
}
|
||||
try
|
||||
{
|
||||
request.with(POLICY_DOCUMENT, PolicyGenerator.toCompact(PolicyGenerator.generate(policy, format)));
|
||||
request.with(POLICY_DOCUMENT, PolicyGenerator.toCompact(PolicyGenerator.generate(policy, format, runtime.getPolicyCatalog())));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
@@ -2011,8 +2011,8 @@ public class AuthentikAdmin
|
||||
try
|
||||
{
|
||||
String submitted = request.str(POLICY_DOCUMENT);
|
||||
permissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format).permissions() : new TreeSet<>();
|
||||
canonical = PolicyGenerator.generate(permissions, format);
|
||||
permissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).permissions() : new TreeSet<>();
|
||||
canonical = PolicyGenerator.generate(permissions, format, runtime.getPolicyCatalog());
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
@@ -2047,7 +2047,7 @@ public class AuthentikAdmin
|
||||
try
|
||||
{
|
||||
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissions.size(), guard.operatorEmail(),
|
||||
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format));
|
||||
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format, runtime.getPolicyCatalog()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
|
||||
@@ -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, runtime.getConfig().getPolicyFormat());
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
|
||||
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, runtime.getConfig().getPolicyFormat());
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
|
||||
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, config.getPolicyFormat());
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups, config.getPolicyFormat(), client.getRuntime().getPolicyCatalog());
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
|
||||
|
||||
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
|
||||
|
||||
+3
-1
@@ -27,6 +27,7 @@ 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.PolicyCatalog;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.security.BootstrapOperators;
|
||||
@@ -116,7 +117,8 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
AuthentikRuntime runtime = runtime();
|
||||
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
|
||||
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format));
|
||||
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format,
|
||||
runtime == null ? (qInstance == null ? null : PolicyCatalog.of(qInstance)) : runtime.getPolicyCatalog()));
|
||||
|
||||
if(runtime != null)
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ 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.PolicyCatalog;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParser;
|
||||
@@ -81,6 +82,16 @@ public class GroupGraph
|
||||
** not parse grants nothing and is listed in the node's malformed entries.
|
||||
*******************************************************************************/
|
||||
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format)
|
||||
{
|
||||
return (fromGroups(groups, format, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read group policies with the catalog of registered table features.
|
||||
*******************************************************************************/
|
||||
public static GroupGraph fromGroups(Collection<JSONObject> groups, PolicyFormat format, PolicyCatalog catalog)
|
||||
{
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
GroupGraph graph = new GroupGraph();
|
||||
@@ -88,7 +99,7 @@ public class GroupGraph
|
||||
{
|
||||
JSONObject attributes = group.optJSONObject("attributes");
|
||||
List<String> malformed = malformed(attributes);
|
||||
List<String> policy = policyPermissions(attributes, f, malformed);
|
||||
List<String> policy = policyPermissions(attributes, f, malformed, catalog);
|
||||
graph.add(new Node(
|
||||
group.getString("pk"),
|
||||
group.optString("name", ""),
|
||||
@@ -289,7 +300,7 @@ 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)
|
||||
private static List<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed, PolicyCatalog catalog)
|
||||
{
|
||||
Object document = attributes == null ? null : attributes.opt(format.attribute());
|
||||
if(document == null || JSONObject.NULL.equals(document) || "".equals(document))
|
||||
@@ -298,7 +309,7 @@ public class GroupGraph
|
||||
}
|
||||
try
|
||||
{
|
||||
return (List.copyOf(PolicyParser.parseStrict(document, format).permissions()));
|
||||
return (List.copyOf(PolicyParser.parseStrict(document, format, catalog).permissions()));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
|
||||
@@ -95,7 +95,7 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format));
|
||||
Set<String> permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format, qInstance == null ? null : PolicyCatalog.of(qInstance)));
|
||||
if(qSession.getPermissions() != null)
|
||||
{
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
|
||||
@@ -21,7 +21,8 @@ import java.util.Optional;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The actions a policy statement can grant, in canonical statement order. Each
|
||||
** The five standard actions, in canonical statement order. Table features use
|
||||
** their registered string names separately. Each standard action
|
||||
** maps to the QQQ permission suffix of the same name ({resource}.{action}).
|
||||
*******************************************************************************/
|
||||
public enum PolicyAction
|
||||
|
||||
@@ -18,6 +18,7 @@ package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -25,7 +26,9 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -74,9 +77,26 @@ public final class PolicyCatalog
|
||||
** @param label what operators see
|
||||
** @param kind app, table, process, report or widget
|
||||
** @param actions the actions QQQ checks, in canonical order
|
||||
** @param features explicitly grantable table features, sorted by name
|
||||
** @param note a plain note (e.g. why an action is missing), or null
|
||||
*******************************************************************************/
|
||||
public record Resource(String name, String label, Kind kind, List<PolicyAction> actions, String note)
|
||||
public record Resource(String name, String label, Kind kind, List<PolicyAction> actions, List<Feature> features, String note)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A resource with only standard actions.
|
||||
*******************************************************************************/
|
||||
public Resource(String name, String label, Kind kind, List<PolicyAction> actions, String note)
|
||||
{
|
||||
this(name, label, kind, actions, List.of(), note);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table feature's permission suffix and display label from QQQ.
|
||||
*******************************************************************************/
|
||||
public record Feature(String name, String label)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -118,6 +138,17 @@ public final class PolicyCatalog
|
||||
** Build the catalog of an instance.
|
||||
*******************************************************************************/
|
||||
public static PolicyCatalog of(QInstance qInstance)
|
||||
{
|
||||
return (of(qInstance, PermissionsHelper.getAllAvailablePermissions(qInstance)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build using an explicit permission source, including features registered
|
||||
** by core. This needs no dependency on the feature metadata hook itself.
|
||||
*******************************************************************************/
|
||||
public static PolicyCatalog of(QInstance qInstance, Collection<AvailablePermission> availablePermissions)
|
||||
{
|
||||
Map<String, String> appOfObject = appsOfObjects(qInstance);
|
||||
Map<String, Map<String, ResourceDraft>> byApp = new LinkedHashMap<>();
|
||||
@@ -155,7 +186,7 @@ public final class PolicyCatalog
|
||||
}
|
||||
if(rules.getLevel() == PermissionLevel.READ_WRITE_PERMISSIONS)
|
||||
{
|
||||
note = "This table uses one write permission, which policies cannot grant yet; only read is offered.";
|
||||
note = "This table uses one write permission instead of separate insert, edit and delete actions.";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -173,7 +204,20 @@ public final class PolicyCatalog
|
||||
}
|
||||
}
|
||||
}
|
||||
add(byApp, appOfObject.get("table:" + table.getName()), qInstance, table, Kind.TABLE, label(table.getLabel(), table.getName()), actions, note);
|
||||
String baseName = StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : table.getName();
|
||||
Map<String, Feature> features = new TreeMap<>();
|
||||
for(AvailablePermission permission : availablePermissions)
|
||||
{
|
||||
if(permission.getName().startsWith(baseName + "."))
|
||||
{
|
||||
String suffix = permission.getName().substring(baseName.length() + 1);
|
||||
if(!suffix.isEmpty() && PolicyAction.fromJsonName(suffix).isEmpty())
|
||||
{
|
||||
features.putIfAbsent(suffix, new Feature(suffix, permission.getPermissionType()));
|
||||
}
|
||||
}
|
||||
}
|
||||
add(byApp, appOfObject.get("table:" + table.getName()), qInstance, table, Kind.TABLE, label(table.getLabel(), table.getName()), actions, features, note);
|
||||
}
|
||||
|
||||
List<QProcessMetaData> processes = new ArrayList<>(qInstance.getProcesses().values());
|
||||
@@ -218,7 +262,7 @@ public final class PolicyCatalog
|
||||
List<Resource> resources = new ArrayList<>();
|
||||
for(ResourceDraft draft : entry.getValue().values())
|
||||
{
|
||||
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), draft.note));
|
||||
resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), List.copyOf(draft.features.values()), draft.note));
|
||||
}
|
||||
groups.add(new Group(entry.getKey(), groupLabel, resources));
|
||||
}
|
||||
@@ -237,6 +281,7 @@ public final class PolicyCatalog
|
||||
private final Kind kind;
|
||||
private final Set<String> labels = new LinkedHashSet<>();
|
||||
private final Set<PolicyAction> actions = new TreeSet<>();
|
||||
private final Map<String, Feature> features = new TreeMap<>();
|
||||
private String note;
|
||||
|
||||
|
||||
@@ -258,9 +303,20 @@ public final class PolicyCatalog
|
||||
*******************************************************************************/
|
||||
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
|
||||
Set<PolicyAction> actions, String note)
|
||||
{
|
||||
add(byApp, app, qInstance, object, kind, label, actions, Map.of(), note);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add standard and feature actions, merging shared permission base names.
|
||||
*******************************************************************************/
|
||||
private static void add(Map<String, Map<String, ResourceDraft>> byApp, String app, QInstance qInstance, MetaDataWithPermissionRules object, Kind kind, String label,
|
||||
Set<PolicyAction> actions, Map<String, Feature> features, String note)
|
||||
{
|
||||
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(object, qInstance);
|
||||
if(rules == null || rules.getLevel() == null || rules.getLevel() == PermissionLevel.NOT_PROTECTED || rules.getCustomPermissionChecker() != null || actions.isEmpty())
|
||||
if(rules == null || rules.getLevel() == null || rules.getLevel() == PermissionLevel.NOT_PROTECTED || rules.getCustomPermissionChecker() != null || (actions.isEmpty() && features.isEmpty()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -281,6 +337,7 @@ public final class PolicyCatalog
|
||||
{
|
||||
existing.labels.add(label);
|
||||
existing.actions.addAll(actions);
|
||||
existing.features.putAll(features);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -288,6 +345,7 @@ public final class PolicyCatalog
|
||||
ResourceDraft draft = new ResourceDraft(name, kind);
|
||||
draft.labels.add(label);
|
||||
draft.actions.addAll(actions);
|
||||
draft.features.putAll(features);
|
||||
draft.note = note;
|
||||
byApp.computeIfAbsent(byApp.containsKey(app) ? app : null, k -> new LinkedHashMap<>()).put(name, draft);
|
||||
}
|
||||
@@ -399,6 +457,7 @@ public final class PolicyCatalog
|
||||
{
|
||||
rs.add(action.permission(resource.name()));
|
||||
}
|
||||
resource.features().forEach(feature -> rs.add(resource.name() + "." + feature.name()));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
@@ -425,6 +484,17 @@ public final class PolicyCatalog
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this resource has an explicitly registered feature action.
|
||||
*******************************************************************************/
|
||||
public boolean hasFeature(String resourceName, String actionName)
|
||||
{
|
||||
Resource resource = get(resourceName);
|
||||
return (resource != null && resource.features().stream().anyMatch(feature -> feature.name().equals(actionName)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** As JSON for the editor widget.
|
||||
*******************************************************************************/
|
||||
@@ -443,6 +513,12 @@ public final class PolicyCatalog
|
||||
.put("label", resource.label())
|
||||
.put("kind", resource.kind().name())
|
||||
.put("actions", actions);
|
||||
if(resource.kind() == Kind.TABLE || !resource.features().isEmpty())
|
||||
{
|
||||
JSONArray features = new JSONArray();
|
||||
resource.features().forEach(feature -> features.put(new JSONObject().put("name", feature.name()).put("label", feature.label())));
|
||||
json.put("features", features);
|
||||
}
|
||||
if(resource.note() != null)
|
||||
{
|
||||
json.put("note", resource.note());
|
||||
|
||||
@@ -106,7 +106,7 @@ public final class PolicyEditorWidget
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// canonical form of what is stored (unknown resources survive) //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format)));
|
||||
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format, runtime.getPolicyCatalog())));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
@@ -117,7 +117,7 @@ public final class PolicyEditorWidget
|
||||
QRecord row = PolicyStore.find(config, groupPk);
|
||||
if(row != null)
|
||||
{
|
||||
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format)));
|
||||
data.withChangedInAuthentik(!Objects.equals(row.getValueString("authentikFingerprint"), PolicyStore.fingerprint(stored, format, runtime.getPolicyCatalog())));
|
||||
}
|
||||
return (data);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -29,7 +30,8 @@ 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;
|
||||
** action in the order hasAccess, read, insert, edit, delete, then features
|
||||
** sorted by name; 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.
|
||||
@@ -54,13 +56,24 @@ public final class PolicyGenerator
|
||||
** round-trip
|
||||
*******************************************************************************/
|
||||
public static JSONObject generate(Collection<String> permissions, PolicyFormat format) throws PolicyParseException
|
||||
{
|
||||
return (generate(permissions, format, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Generate with the catalog of explicitly grantable table features.
|
||||
*******************************************************************************/
|
||||
public static JSONObject generate(Collection<String> permissions, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
|
||||
{
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
|
||||
Map<PolicyAction, Set<String>> byAction = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> byAction = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> byFeature = new TreeMap<>();
|
||||
for(PolicyAction action : PolicyAction.values())
|
||||
{
|
||||
byAction.put(action, new TreeSet<>());
|
||||
byAction.put(action.getJsonName(), new TreeSet<>());
|
||||
}
|
||||
|
||||
Set<String> expected = new TreeSet<>();
|
||||
@@ -73,23 +86,28 @@ public final class PolicyGenerator
|
||||
}
|
||||
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(PolicyAction.fromJsonName(name).isEmpty() && (catalog == null || !catalog.hasFeature(resource, name)))
|
||||
{
|
||||
throw (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);
|
||||
Map<String, Set<String>> target = byAction.containsKey(name) ? byAction : byFeature;
|
||||
target.computeIfAbsent(name, key -> new TreeSet<>()).add(resource);
|
||||
expected.add(permission);
|
||||
}
|
||||
|
||||
JSONArray statements = new JSONArray();
|
||||
for(Map.Entry<PolicyAction, Set<String>> entry : byAction.entrySet())
|
||||
byAction.putAll(byFeature);
|
||||
for(Map.Entry<String, 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("Action", new JSONArray().put(entry.getKey()))
|
||||
.put("Resource", new JSONArray(entry.getValue())));
|
||||
}
|
||||
}
|
||||
@@ -99,7 +117,7 @@ public final class PolicyGenerator
|
||||
.put("PolicyVersion", f.version())
|
||||
.put("Statement", statements);
|
||||
|
||||
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f).permissions();
|
||||
Set<String> parsed = PolicyParser.parseStrict(toCompact(document), f, catalog).permissions();
|
||||
if(!parsed.equals(expected))
|
||||
{
|
||||
Set<String> missing = new TreeSet<>(expected);
|
||||
@@ -118,7 +136,17 @@ public final class PolicyGenerator
|
||||
*******************************************************************************/
|
||||
public static JSONObject canonical(Object document, PolicyFormat format) throws PolicyParseException
|
||||
{
|
||||
return (generate(PolicyParser.parseStrict(document, format).permissions(), format));
|
||||
return (canonical(document, format, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonicalize a document using the registered table features.
|
||||
*******************************************************************************/
|
||||
public static JSONObject canonical(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
|
||||
{
|
||||
return (generate(PolicyParser.parseStrict(document, format, catalog).permissions(), format, catalog));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ 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;
|
||||
@@ -80,6 +79,17 @@ public final class PolicyParser
|
||||
** attribute or a token claim); null grants nothing
|
||||
*******************************************************************************/
|
||||
public static Set<String> parse(Object document, PolicyFormat format)
|
||||
{
|
||||
return (parse(document, format, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse with the catalog of explicitly grantable table features; an invalid
|
||||
** document still grants nothing.
|
||||
*******************************************************************************/
|
||||
public static Set<String> parse(Object document, PolicyFormat format, PolicyCatalog catalog)
|
||||
{
|
||||
if(document == null || JSONObject.NULL.equals(document))
|
||||
{
|
||||
@@ -87,7 +97,7 @@ public final class PolicyParser
|
||||
}
|
||||
try
|
||||
{
|
||||
return (parseStrict(document, format).permissions());
|
||||
return (parseStrict(document, format, catalog).permissions());
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
@@ -102,6 +112,17 @@ public final class PolicyParser
|
||||
** Parse, throwing a PolicyParseException that names the first problem.
|
||||
*******************************************************************************/
|
||||
public static Parsed parseStrict(Object document, PolicyFormat format) throws PolicyParseException
|
||||
{
|
||||
return (parseStrict(document, format, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse standard actions and features registered for every listed resource.
|
||||
** Without a catalog only the five standard actions are accepted.
|
||||
*******************************************************************************/
|
||||
public static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
|
||||
{
|
||||
JSONObject json = asObject(document);
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
@@ -131,7 +152,6 @@ public final class PolicyParser
|
||||
|
||||
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"));
|
||||
@@ -142,29 +162,38 @@ public final class PolicyParser
|
||||
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);
|
||||
Set<String> statementResources = strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource");
|
||||
Set<String> actions = new LinkedHashSet<>();
|
||||
for(String name : strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action"))
|
||||
{
|
||||
if(PolicyAction.WILDCARD.equals(name))
|
||||
{
|
||||
actions.addAll(EnumSet.allOf(PolicyAction.class));
|
||||
for(PolicyAction action : PolicyAction.values())
|
||||
{
|
||||
actions.add(action.getJsonName());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
actions.add(PolicyAction.fromJsonName(name).orElseThrow(() -> new PolicyParseException("Statement " + number + ": unknown action \"" + name + "\"")));
|
||||
if(PolicyAction.fromJsonName(name).isEmpty()
|
||||
&& (catalog == null || statementResources.isEmpty() || !statementResources.stream().allMatch(resource -> catalog.hasFeature(resource, name))))
|
||||
{
|
||||
throw (new PolicyParseException("Statement " + (i + 1) + ": unknown action \"" + name + "\""));
|
||||
}
|
||||
actions.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
for(String resource : strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource"))
|
||||
for(String resource : statementResources)
|
||||
{
|
||||
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)
|
||||
for(String action : actions)
|
||||
{
|
||||
permissions.add(action.permission(resource));
|
||||
permissions.add(resource + "." + action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,13 +51,23 @@ public final class PolicyPermissions
|
||||
** when the claim is missing or invalid (fail closed).
|
||||
*******************************************************************************/
|
||||
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format)
|
||||
{
|
||||
return (fromClaims(claims, format, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a policy claim with the catalog of registered table features.
|
||||
*******************************************************************************/
|
||||
public static Set<String> fromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog)
|
||||
{
|
||||
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));
|
||||
return (PolicyParser.parse(claims.opt(f.claim()), f, catalog));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +68,16 @@ public final class PolicyStore
|
||||
** text. Null for no policy.
|
||||
*******************************************************************************/
|
||||
public static String fingerprint(Object attributeValue, PolicyFormat format)
|
||||
{
|
||||
return (fingerprint(attributeValue, format, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fingerprint including registered table features in the canonical form.
|
||||
*******************************************************************************/
|
||||
public static String fingerprint(Object attributeValue, PolicyFormat format, PolicyCatalog catalog)
|
||||
{
|
||||
if(attributeValue == null || JSONObject.NULL.equals(attributeValue) || "".equals(attributeValue))
|
||||
{
|
||||
@@ -76,7 +86,7 @@ public final class PolicyStore
|
||||
String text;
|
||||
try
|
||||
{
|
||||
text = PolicyGenerator.toCompact(PolicyGenerator.canonical(attributeValue, format));
|
||||
text = PolicyGenerator.toCompact(PolicyGenerator.canonical(attributeValue, format, catalog));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -27,8 +28,12 @@ import com.kingsrook.qbits.authentik.policy.PolicyEditorWidget;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParser;
|
||||
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
|
||||
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -42,10 +47,13 @@ import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@@ -98,6 +106,44 @@ class PolicyEditorTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Save, reload, resolve and compare a feature using simulated core metadata.
|
||||
** The subset rule still requires the operator to hold the explicit grant.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void featurePolicyFlowsThroughEditorAndMapper() throws Exception
|
||||
{
|
||||
List<AvailablePermission> available = new ArrayList<>(PermissionsHelper.getAllAvailablePermissions(t.qInstance));
|
||||
available.add(new AvailablePermission().withName("cms_page.analyze").withObjectType("Table").withObjectName("Pages").withPermissionType("Analyze"));
|
||||
try(MockedStatic<PermissionsHelper> helper = Mockito.mockStatic(PermissionsHelper.class, Mockito.CALLS_REAL_METHODS))
|
||||
{
|
||||
helper.when(() -> PermissionsHelper.getAllAvailablePermissions(t.qInstance)).thenReturn(available);
|
||||
String document = PAGE_READ_EDIT.replace("\"read\",\"edit\"", "\"analyze\"");
|
||||
assertThat(save("kof-website-publishers", document).message()).contains("do not hold yourself");
|
||||
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/policy", document);
|
||||
t.admin().getUtil().getRuntime().invalidateCache();
|
||||
AuthentikAdmin.AdminResult saved = save("kof-website-publishers", document);
|
||||
assertThat(saved.ok()).as(saved.message()).isTrue();
|
||||
assertThat(policyRows().get(0).getValueInteger("permissionCount")).isEqualTo(1);
|
||||
PolicyEditorWidget.Data widget = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-publishers"));
|
||||
assertThat(widget.getDocument()).contains("\"Action\": [\"analyze\"]");
|
||||
assertThat(widget.getDocumentProblem()).isNull();
|
||||
assertThat(widget.getChangedInAuthentik()).isFalse();
|
||||
assertThat(save("kof-website-publishers", document).noChange()).isTrue();
|
||||
assertThat(t.admin().getUtil().groupGraph().byName("kof-website-publishers").policyPermissions()).containsExactly("cms_page.analyze");
|
||||
assertThat(AccessComparison.run(t.config, t.admin().getUtil(), t.admin().getUtil().getRuntime().getPermissionCatalog()))
|
||||
.anyMatch(row -> row.username().equals("operator") && row.after().contains("cms_page.analyze"));
|
||||
|
||||
QSession session = new QSession();
|
||||
JSONObject claims = new JSONObject().put("groups", List.of("kof-website-publishers")).put("sub", "1")
|
||||
.put("qqq_policy", new JSONObject(document));
|
||||
new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
|
||||
assertThat(session.getPermissions()).contains("cms_page.analyze");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Save writes the canonical compact string into the CURRENT attributes, and
|
||||
** an authentik_policy row with the pretty document.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table features use generic permission suffixes from core's existing catalog
|
||||
** API. No test or production code depends on the unreleased metadata hook.
|
||||
*******************************************************************************/
|
||||
class PolicyFeaturePermissionsTest
|
||||
{
|
||||
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use an overridden permission base name to catch matching by table name.
|
||||
*******************************************************************************/
|
||||
private static QInstance instance()
|
||||
{
|
||||
QInstance instance = PolicyCatalogTest.instance();
|
||||
instance.addTable(new QTableMetaData().withName("person_records").withLabel("People").withBackendName("memory")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withPermissionBaseName("person")));
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shape returned by PermissionsHelper when core registers a feature.
|
||||
*******************************************************************************/
|
||||
private static AvailablePermission feature(String name, String label)
|
||||
{
|
||||
return (new AvailablePermission().withName(name).withObjectName("People").withObjectType("Table").withPermissionType(label));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliberately unsorted; duplicates and standard suffixes are not features.
|
||||
*******************************************************************************/
|
||||
private static List<AvailablePermission> available()
|
||||
{
|
||||
return (List.of(feature("person.comment", "Comment"), feature("person.analyze", "Analyze"), feature("person.analyze", "Analyze"),
|
||||
feature("person.read", "Read"), feature("person.insert", "Insert"), feature("person.edit", "Edit"), feature("person.delete", "Delete"),
|
||||
feature("person.hasAccess", "Access"), feature("person_extra.share", "Share"), feature("person.", "Empty"),
|
||||
feature("gate.analyze", "Analyze"), feature("legacy.comment", "Comment"), feature("legacy.write", "Write"),
|
||||
feature("shared_base.tag", "Tag"), feature("open_table.analyze", "Analyze")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A statement granting these actions on these resources.
|
||||
*******************************************************************************/
|
||||
private static JSONObject document(List<String> actions, String... resources)
|
||||
{
|
||||
return (PolicyParserTest.document(PolicyParserTest.statement("Allow", actions, List.of(resources))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Labels, overridden/shared bases, permission levels and additive widget JSON.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void catalogListsFeaturesSeparately()
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
assertThat(catalog.get("person").actions()).containsExactly(PolicyAction.READ, PolicyAction.INSERT, PolicyAction.EDIT, PolicyAction.DELETE);
|
||||
assertThat(catalog.get("person").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"), new PolicyCatalog.Feature("comment", "Comment"));
|
||||
assertThat(catalog.get("gate").features()).containsExactly(new PolicyCatalog.Feature("analyze", "Analyze"));
|
||||
assertThat(catalog.get("legacy").features()).containsExactly(new PolicyCatalog.Feature("comment", "Comment"), new PolicyCatalog.Feature("write", "Write"));
|
||||
assertThat(catalog.get("shared_base").features()).containsExactly(new PolicyCatalog.Feature("tag", "Tag"));
|
||||
assertThat(catalog.get("open_table")).isNull();
|
||||
assertThat(catalog.permissionNames()).contains("person.analyze", "person.comment", "gate.analyze", "legacy.comment", "legacy.write", "shared_base.tag")
|
||||
.doesNotContain("person.share", "person_records.analyze");
|
||||
assertThat(catalog.unknownPermissions(Set.of("person.analyze", "person.mystery"))).containsExactly("person.mystery");
|
||||
|
||||
List<JSONObject> resources = new ArrayList<>();
|
||||
catalog.toJson().forEach(group -> ((JSONObject) group).getJSONArray("resources").forEach(resource -> resources.add((JSONObject) resource)));
|
||||
JSONObject person = resources.stream().filter(resource -> resource.getString("name").equals("person")).findFirst().orElseThrow();
|
||||
assertThat(person.getJSONArray("actions").toList()).containsExactly("read", "insert", "edit", "delete");
|
||||
assertThat(person.getJSONArray("features").toList()).containsExactly(Map.of("name", "analyze", "label", "Analyze"), Map.of("name", "comment", "label", "Comment"));
|
||||
assertThat(resources.stream().filter(resource -> resource.getString("kind").equals("TABLE"))).allMatch(resource -> resource.has("features"));
|
||||
assertThat(resources.stream().filter(resource -> resource.getString("kind").equals("APP"))).noneMatch(resource -> resource.has("features"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Feature-only tables are still grantable when CRUD capabilities are absent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void featuresDoNotRequireStandardActions()
|
||||
{
|
||||
QInstance instance = instance();
|
||||
instance.getTable("person_records").withoutCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
PolicyCatalog.Resource resource = PolicyCatalog.of(instance, available()).get("person");
|
||||
assertThat(resource.actions()).isEmpty();
|
||||
assertThat(resource.features()).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every resource must register a feature; one bad pair voids the document.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void parserValidatesFeaturesPerResource() throws Exception
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
assertThat(PolicyParser.parseStrict(document(List.of("read", "analyze"), "person"), FORMAT, catalog).permissions())
|
||||
.containsExactly("person.analyze", "person.read");
|
||||
assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "gate"), FORMAT, catalog)).containsExactly("gate.analyze", "person.analyze");
|
||||
assertThat(PolicyParser.parse(document(List.of("comment", "write"), "legacy"), FORMAT, catalog)).containsExactly("legacy.comment", "legacy.write");
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(document(List.of("mystery"), "person"), FORMAT, catalog))
|
||||
.hasMessage("Statement 1: unknown action \"mystery\"");
|
||||
assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "legacy"), FORMAT, catalog)).isEmpty();
|
||||
assertThat(PolicyParser.parse(document(List.of("analyze"), "gone"), FORMAT, catalog)).isEmpty();
|
||||
assertThat(PolicyParser.parse(document(List.of("analyze")), FORMAT, catalog)).isEmpty();
|
||||
assertThat(PolicyParser.parse(document(List.of("Analyze"), "person"), FORMAT, catalog)).isEmpty();
|
||||
assertThat(PolicyParser.parse(document(List.of("analyze"), "person"), FORMAT)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing wildcard grants remain the five standard actions forever.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void wildcardDoesNotGrantFeatures()
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
assertThat(PolicyParser.parse(document(List.of("*"), "person"), FORMAT, catalog))
|
||||
.containsExactlyInAnyOrder("person.hasAccess", "person.read", "person.insert", "person.edit", "person.delete");
|
||||
assertThat(PolicyParser.parse(document(List.of("*", "analyze"), "person"), FORMAT, catalog)).hasSize(6).contains("person.analyze").doesNotContain("person.comment");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard ordering stays byte-identical; sorted features follow it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void generatorRoundTripsFeatures() throws Exception
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
List<String> permissions = List.of("person.comment", "person.read", "person.analyze", "gate.analyze");
|
||||
JSONObject generated = PolicyGenerator.generate(permissions, FORMAT, catalog);
|
||||
String compact = PolicyGenerator.toCompact(generated);
|
||||
assertThat(compact).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":["
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"person\"]},"
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"analyze\"],\"Resource\":[\"gate\",\"person\"]},"
|
||||
+ "{\"Effect\":\"Allow\",\"Action\":[\"comment\"],\"Resource\":[\"person\"]}]}");
|
||||
assertThat(PolicyParser.parseStrict(compact, FORMAT, catalog).permissions()).containsExactlyInAnyOrderElementsOf(permissions);
|
||||
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(generated), FORMAT, catalog).permissions()).containsExactlyInAnyOrderElementsOf(permissions);
|
||||
assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(PolicyGenerator.toPretty(generated), FORMAT, catalog))).isEqualTo(compact);
|
||||
assertThat(PolicyStore.fingerprint(compact, FORMAT, catalog)).isEqualTo(PolicyStore.fingerprint(PolicyGenerator.toPretty(generated), FORMAT, catalog));
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("person.mystery"), FORMAT, catalog)).hasMessageContaining("unknown action \"mystery\"");
|
||||
assertThatThrownBy(() -> PolicyGenerator.generate(List.of("legacy.analyze"), FORMAT, catalog)).hasMessageContaining("unknown action \"analyze\"");
|
||||
List<String> standard = List.of("person.read", "person.edit", "gate.hasAccess");
|
||||
assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(standard, FORMAT, catalog))).isEqualTo(PolicyGenerator.toCompact(PolicyGenerator.generate(standard, FORMAT)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The real mapper discovers simulated core entries and adds their exact names.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void claimMapperUsesCoreCatalog()
|
||||
{
|
||||
QInstance instance = instance();
|
||||
try(MockedStatic<PermissionsHelper> helper = Mockito.mockStatic(PermissionsHelper.class, Mockito.CALLS_REAL_METHODS))
|
||||
{
|
||||
helper.when(() -> PermissionsHelper.getAllAvailablePermissions(instance)).thenReturn(available());
|
||||
QSession session = new QSession();
|
||||
session.setPermissions(Set.of("existing.read"));
|
||||
JSONObject claims = new JSONObject().put(FORMAT.claim(), document(List.of("analyze"), "person"));
|
||||
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
|
||||
assertThat(session.getPermissions()).containsExactlyInAnyOrder("existing.read", "person.analyze");
|
||||
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance))).containsExactly("person.analyze");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inherited feature grants reach resolution, attribution and access comparison.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void comparisonIncludesInheritedFeatures() throws Exception
|
||||
{
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance(), available());
|
||||
JSONObject parent = new JSONObject().put("pk", "parent").put("name", "Analysts")
|
||||
.put("attributes", new JSONObject().put(FORMAT.attribute(), document(List.of("analyze"), "person")));
|
||||
JSONObject child = new JSONObject().put("pk", "child").put("name", "Staff").put("parents", new JSONArray(List.of("parent")))
|
||||
.put("users", new JSONArray(List.of(1)));
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(parent, child), FORMAT, catalog);
|
||||
EffectivePermissionResolver.Resolution resolution = new EffectivePermissionResolver(graph, catalog.permissionNames()).resolveUser(1);
|
||||
assertThat(resolution.sessionPermissions()).containsExactly("person.analyze");
|
||||
assertThat(resolution.grantedBy().get("person.analyze")).containsExactly("Analysts");
|
||||
assertThat(resolution.unknown()).isEmpty();
|
||||
|
||||
AuthentikActionUtil util = Mockito.mock(AuthentikActionUtil.class);
|
||||
Mockito.when(util.groupGraphWithMembers()).thenReturn(graph);
|
||||
Mockito.when(util.allUsers()).thenReturn(List.of(new JSONObject().put("pk", 1).put("username", "person").put("type", "internal")));
|
||||
AuthentikQBitConfig config = new AuthentikQBitConfig().withCutoverGuardGroups(List.of("Staff"));
|
||||
AccessComparison.Row added = AccessComparison.run(config, util, catalog.permissionNames()).get(0);
|
||||
assertThat(added.after()).containsExactly("person.analyze");
|
||||
assertThat(added.added()).containsExactly("person.analyze");
|
||||
config.withHostPermissionSource((email, groups) -> Set.of("person.analyze"));
|
||||
AccessComparison.Row unchanged = AccessComparison.run(config, util, catalog.permissionNames()).get(0);
|
||||
assertThat(unchanged.before()).isEqualTo(unchanged.after());
|
||||
assertThat(unchanged.removed()).isEmpty();
|
||||
assertThat(AccessComparison.guardFailures(List.of(unchanged))).isEmpty();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user