feat(policy): full access ("*") statement and maintenance policy API #32
No files matched your search
@@ -4,6 +4,11 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- `AuthentikQBitConfig.fullAccessHostPermissions` lets a host declare permission names core cannot enumerate (custom checkers, named permissions); full access expands to them at use time, for the instance holding that runtime's backend only.
|
||||
- `AuthentikMaintenance.applyPolicy` lets reviewed host run-once jobs apply a policy to an exact group name through the existing audited Save Policy path. Dedicated maintenance identities, full-access expansion, canonical no-ops, context restoration, stale checks and optional two-person approval are covered; system-session writes remain forbidden. No HTTP route or process is registered.
|
||||
|
||||
- Explicit full-access policy statement (`Action: ["*"], Resource: ["*"]`) resolves against all current core and qbit permissions, including features, READ_WRITE `write`, identity names and future resources. Added canonical round trips, complete-set save authorization, last-admin removal protection and the editor `fullAccess` payload contract; named-resource wildcards retain their five-action meaning.
|
||||
|
||||
- Edit Policy supplies per-member preview data for up to 200 active, in-scope direct and descendant members, with relevant direct group names, base permissions excluding only the edited policy, a truncation flag, and the configured mapper-enabled flag. The Next UI can compute effective permissions and additions/removals locally while preserving inherited, named, static and bootstrap grants.
|
||||
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
|
||||
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
|
||||
|
||||
+12
-6
@@ -1085,16 +1085,18 @@ 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 (`*` = 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.
|
||||
Each `Allow` statement grants `{resource}.{action}` for every listed resource and action (`*` on a named resource = 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); individual policy resources cannot contain a dot. Full access includes these named permissions in session form.
|
||||
|
||||
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.
|
||||
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. `Action: ["*"]` on a named resource never includes features, so those statements do not acquire new grants when a feature is registered.
|
||||
|
||||
**Full access.** `{"Effect":"Allow","Action":["*"],"Resource":["*"]}` grants every name from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, the qbit permission catalog (including hidden record actions), and all `IdentityPermission` session names. This includes table features, READ_WRITE `write`, custom permission names enumerated by core, and future resources. Expansion happens at permission computation, never by replacing the stored statement with today's list. `Resource "*"` requires exactly `Action ["*"]`; other combinations reject the whole document. Other statements are still validated. Canonical full access is exactly one wildcard statement; enumerating all current grants never invents it. See [Full access](POLICY-FORMAT.md#full-access) for exact inclusion rules.
|
||||
|
||||
**Fail closed.** Any of these grants **zero** permissions from that document and logs a warning:
|
||||
- an unknown `PolicySpec` or an unsupported `PolicyVersion`
|
||||
- a document that is not a JSON object, or a `Statement` that is not an array
|
||||
- a statement whose `Effect` is not `Allow`
|
||||
- an unknown action
|
||||
- a resource that does not match `[A-Za-z0-9_]+`
|
||||
- a resource that does not match `[A-Za-z0-9_]+`, except the full-access `*`
|
||||
- any other parse error
|
||||
|
||||
This is stricter than the reference implementation in two places. The reference ignores unknown actions, while this qbit rejects the whole document. `Deny` is not supported yet, so a `Deny` statement also rejects the document; ignoring it would grant more than its author intended.
|
||||
@@ -1143,7 +1145,7 @@ 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.
|
||||
- Table features from `PermissionsHelper.getAllAvailablePermissions(qInstance)`, matched by the table's effective permission base name plus `.`. Their labels come from `AvailablePermission.permissionType`; registered `TableFeaturePermissionsMetaData` features appear through this core API. 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.
|
||||
|
||||
@@ -1153,6 +1155,8 @@ The catalog also follows these rules:
|
||||
- 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.
|
||||
|
||||
**Full-access UI contract (Next implementation in a separate PR).** The widget payload adds `fullAccess: boolean`, true only when the current valid document contains the full-access statement, false for an absent or invalid document. Next must show a prominent **Full access (all current and future resources)** checkbox initialized from this field. While checked, disable the permissions matrix and emit a document with the payload's `spec` and `version` and exactly `Statement: [{"Effect":"Allow","Action":["*"],"Resource":["*"]}]` through the existing `outputFieldName`. Do not enumerate the matrix into explicit permissions. Unchecking removes that statement and re-enables the matrix; the emitted document then contains only explicit selected grants. `fullAccess` is display state, not a separately trusted save input: the server derives authority from the submitted document. Member `basePermissions` already expands full-access policies from other groups; the UI must label full access as including future resources, not infer completeness from the matrix alone.
|
||||
|
||||
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.
|
||||
|
||||
**Preview for a member.** On editor load only, under `authentik.policies.manage`, the payload adds `members` (up to 200 active, in-scope members of this group or its descendants, sorted by username), `membersTruncated` (true when more eligible people exist), and `mapperEnabled` from config so the UI can say "not enforced at sign-in". Each member has `username`, `label` (`Full Name (username)`), `viaGroups` (sorted direct group names that are this group or its descendants), and `basePermissions` (sorted session permissions with only this group's policy document excluded). Named `qqq.io/permissions`, other groups' and ancestors' policies, wildcard expansion, static direct-group fallbacks and bootstrap-subject grants still count. The Next UI combines that base with the current and edited documents to preview effective permissions and additions/removals locally.
|
||||
@@ -1169,7 +1173,7 @@ The screen works like this:
|
||||
- regenerates it in canonical form and checks that parse(generate(x)) = x (the round-trip invariant)
|
||||
- flags unknown resources
|
||||
- shows the permissions added and removed, and how many members are affected
|
||||
- applies the subset rule (§5.4 rule 4): the operator must hold every **added** permission
|
||||
- applies the subset rule (§5.4 rule 4): the operator must hold every **added** permission; for full access they must hold its **entire current expansion** (their own effective full-access policy satisfies this). Review and audit summaries say **Full access (all current and future resources)**. Removing full access also preserves the last qualifying identity administrator.
|
||||
|
||||
Refusals name the reason.
|
||||
|
||||
@@ -1179,11 +1183,13 @@ Refusals name the reason.
|
||||
- writes the `authentik_admin_action` row and the Authentik event
|
||||
- clears the caches
|
||||
|
||||
Saving an empty matrix removes the attribute. The canonical form is the reference generator's:
|
||||
Saving an empty matrix removes the attribute. Full access canonicalizes to one wildcard statement. Other documents retain the reference generator's canonical form:
|
||||
- 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
|
||||
|
||||
**Maintenance API.** Reviewed Java code in the host repository, typically a run-once job such as `KOF22_RUN_ONCE`, may call `AuthentikMaintenance.applyPolicy(apiBackendName, groupName, policyJson, operatorName, reason)`. This is a plain Java API with no registered process or route; hosts must not expose it over HTTP. It resolves exactly one group by name and uses the same audited Save Policy plan and execution, including stale, scope, superuser ancestry, read-only and feature checks. A temporary non-system session grants `authentik.policies.manage` plus the document's permissions (the complete current expansion for full access); its `maintenance:` or `run-once:` operator name and the required reason are recorded verbatim. The caller's context is restored. Equivalent policies return `NO_CHANGE` without a write or audit row; `policyTwoPersonApproval` queues approval instead of applying. `AuthentikMaintenance.fullAccessDocument(config)` generates the canonical compact full-access document for the host's spec/version. System sessions remain forbidden from writing directly.
|
||||
|
||||
### 12.5 Full user and group CRUD (revision 5 additions)
|
||||
|
||||
| Action | Table / slot | Permission | Feature | Confirmation | Authentik |
|
||||
|
||||
+49
-7
@@ -32,11 +32,11 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is
|
||||
| `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`, `*`, 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`) |
|
||||
| `Statement[].Resource` | array of strings | Either `*` with exactly `Action: ["*"]`, or 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 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.
|
||||
Each statement grants `{resource}.{action}` for every resource and action it lists. `Action: ["*"]` on a named resource stands for the five standard actions only; it never includes table features. The full-access statement below is the explicit exception. The result is the union over all statements, and over all of the user's groups.
|
||||
|
||||
| Action | QQQ permission | Applies to |
|
||||
|---|---|---|
|
||||
@@ -52,11 +52,51 @@ Examples:
|
||||
- `{"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 `*`.
|
||||
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`. For named resources every feature grant is explicit, so registering a new feature never broadens an existing `Action: ["*"]` statement on a named resource. 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.
|
||||
|
||||
**Not in 1.0.0:** `Deny`, wildcard or prefix resources, conditions, and field-level rules. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots. They stay in the group attribute `qqq.io/permissions`.
|
||||
**Not in 1.0.0:** `Deny`, prefix resources, wildcard resources other than the full-access form below, conditions, and field-level rules. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots. Explicit named grants stay in the group attribute `qqq.io/permissions`; full access includes them in session form.
|
||||
|
||||
## Full access
|
||||
|
||||
The exact full-access statement is:
|
||||
|
||||
```json
|
||||
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
|
||||
```
|
||||
|
||||
It grants **Full access (all current and future resources)**. A complete document is:
|
||||
|
||||
```json
|
||||
{
|
||||
"PolicySpec": "qqq-iam",
|
||||
"PolicyVersion": "1.0.0",
|
||||
"Statement": [
|
||||
{"Effect":"Allow","Action":["*"],"Resource":["*"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Full access expands when permissions are computed (sign-in, effective-permission resolution, access comparison and member preview), using the current QQQ instance. The document and cached group graph retain a symbolic marker; no expanded permission list is stored in the document or cached for full access. Adding a table, process, app, report, widget or table feature therefore requires no policy edit. Existing signed-in sessions receive the new set when their permissions are computed again.
|
||||
|
||||
The expansion is the union of:
|
||||
|
||||
- **Every `AvailablePermission.name` returned by `PermissionsHelper.getAllAvailablePermissions(qInstance)`**, without limiting names to the editor's resource syntax. Core uses effective permission base names and permission levels: table `hasAccess`, `read`, `insert`, `edit`, `delete`, READ_WRITE `write`, registered table feature names, and process/app/report/protected-widget `hasAccess`. Names supplied by custom permission checkers are included when those checkers expose them through this API; arbitrary permissions that core does not enumerate are not inferred.
|
||||
- **The qbit permission catalog**, including policy-catalog permissions for hidden tables and processes (core skips hidden tables/processes), plus any additional catalog names supplied to the resolver.
|
||||
- **Host-declared permission names** from `AuthentikQBitConfig.withFullAccessHostPermissions(qInstance -> names)`, for each runtime whose API backend is in the instance. Use it for names that core cannot enumerate: custom permission checkers and named permissions such as `cdn.purge` or `email.send`. The function runs at every expansion, so names the host adds later are covered without a policy edit. Without it, full access does not grant those names.
|
||||
- **Every `IdentityPermission` session name**, such as `authentik.users.manage.hasAccess`, `authentik.permissions.manage.hasAccess` and `authentik.policies.manage.hasAccess`. This covers the entire qbit identity catalog, even when a particular feature is not enabled.
|
||||
|
||||
This grants QQQ permissions, not Authentik superuser status. Existing scope, protected-account, audit, approval, last-admin and cut-over checks still apply.
|
||||
|
||||
Examples and boundaries:
|
||||
|
||||
- `{"Effect":"Allow","Action":["*"],"Resource":["*"]}` includes table features, READ_WRITE `write`, named identity permissions and future resources.
|
||||
- `{"Effect":"Allow","Action":["*"],"Resource":["cms_page"]}` still grants only the five standard actions for `cms_page`; it does not grant features or `write`.
|
||||
- `{"Effect":"Allow","Action":["read"],"Resource":["*"]}` is invalid. So is `Action: ["*", "read"]` with `Resource: ["*"]`. The error says that `Resource "*" requires Action ["*"]`.
|
||||
- A full-access statement may coexist with other valid statements (or named resources in the same statement). Canonicalization drops the redundant grants. Every statement and resource is validated first: an invalid statement anywhere still rejects the entire document, even beside full access.
|
||||
|
||||
Saving a full-access document requires the operator to hold **every permission in its current expansion**, even when the target already has full access. An operator whose own effective policy is full access meets that check. Review and audit summaries use **Full access (all current and future resources)**. Removing full access cannot remove the last qualifying identity administrator.
|
||||
|
||||
## Parsing fails closed
|
||||
|
||||
@@ -68,13 +108,15 @@ A document grants **nothing**, and the parser logs a warning, when any of these
|
||||
- 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 neither a standard action, `*`, nor a feature registered for every listed resource
|
||||
- a resource does not match `[A-Za-z0-9_]+`
|
||||
- a resource does not match `[A-Za-z0-9_]+`, except `*` with exactly `Action: ["*"]`
|
||||
|
||||
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.
|
||||
|
||||
## Canonical form
|
||||
|
||||
The qbit always writes the canonical form:
|
||||
A document containing full access canonicalizes to exactly one full-access statement. A generated permission set produces full access only when it explicitly contains the internal full-access marker; enumerating all current permissions never creates future grants.
|
||||
|
||||
For other documents the qbit writes the existing canonical form:
|
||||
- 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 `*`
|
||||
@@ -105,7 +147,7 @@ Authentik puts the merged document into the token through a scope mapping. Steps
|
||||
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
|
||||
- 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 `TableFeaturePermissionsMetaData` hook (absent from the pinned RC.5 release); features become available when the host's core supplies those entries.
|
||||
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog accept standard actions and full access; `PolicyPermissions.fromClaims` uses the current `QContext` instance for full-access expansion when no catalog is supplied. Raw `PolicyParser` results are symbolic and must be resolved before entering a session. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the available-permission API from the pinned RC.6 core; registered `TableFeaturePermissionsMetaData` features appear in that API.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
private java.util.Map<String, List<String>> staticGroupPermissions = new java.util.LinkedHashMap<>();
|
||||
@JsonIgnore
|
||||
private com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource hostPermissionSource;
|
||||
private java.util.function.Function<QInstance, java.util.Collection<String>> fullAccessHostPermissions;
|
||||
|
||||
|
||||
|
||||
@@ -1431,6 +1432,30 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fullAccessHostPermissions: the host's own permission names
|
||||
** (custom checkers, named permissions such as "cdn.purge") that QQQ
|
||||
** metadata cannot list. Full access expands to these as well, computed at
|
||||
** use time from the current instance. Null means none.
|
||||
*******************************************************************************/
|
||||
public java.util.function.Function<QInstance, java.util.Collection<String>> getFullAccessHostPermissions()
|
||||
{
|
||||
return (this.fullAccessHostPermissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fullAccessHostPermissions.
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withFullAccessHostPermissions(java.util.function.Function<QInstance, java.util.Collection<String>> fullAccessHostPermissions)
|
||||
{
|
||||
this.fullAccessHostPermissions = fullAccessHostPermissions;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokensEnabled.
|
||||
|
||||
@@ -82,6 +82,16 @@ public class AuthentikRuntime
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every registered runtime (a snapshot).
|
||||
*******************************************************************************/
|
||||
public static java.util.List<AuthentikRuntime> all()
|
||||
{
|
||||
return (java.util.List.copyOf(BY_BACKEND.values()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find the runtime for an API backend name.
|
||||
*******************************************************************************/
|
||||
@@ -152,10 +162,11 @@ public class AuthentikRuntime
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ permission names that qqq.io/permissions may grant: every permission
|
||||
** the instance's metadata defines (PermissionsHelper), computed once.
|
||||
** the instance's metadata defines (PermissionsHelper), computed per instance.
|
||||
*******************************************************************************/
|
||||
public java.util.Set<String> getPermissionCatalog()
|
||||
{
|
||||
PolicyCatalog currentPolicyCatalog = getPolicyCatalog();
|
||||
if(permissionCatalog == null)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
|
||||
@@ -164,7 +175,7 @@ public class AuthentikRuntime
|
||||
return (java.util.Set.of());
|
||||
}
|
||||
java.util.Set<String> catalog = new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
|
||||
catalog.addAll(getPolicyCatalog().permissionNames());
|
||||
catalog.addAll(currentPolicyCatalog.permissionNames());
|
||||
permissionCatalog = catalog;
|
||||
}
|
||||
return (permissionCatalog);
|
||||
@@ -173,19 +184,20 @@ public class AuthentikRuntime
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a policy can grant in the current instance (built once, lazily: the
|
||||
** What a policy can grant in the current instance (built lazily per instance: the
|
||||
** instance is complete by the first request).
|
||||
*******************************************************************************/
|
||||
public PolicyCatalog getPolicyCatalog()
|
||||
{
|
||||
if(policyCatalog == null)
|
||||
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
|
||||
if(policyCatalog == null || policyCatalog.getQInstance() != qInstance)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
|
||||
if(qInstance == null)
|
||||
{
|
||||
return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance()));
|
||||
}
|
||||
policyCatalog = PolicyCatalog.of(qInstance);
|
||||
permissionCatalog = null;
|
||||
}
|
||||
return (policyCatalog);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.kingsrook.qbits.authentik.policy.PolicyFormat;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParser;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyStore;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
@@ -328,7 +329,8 @@ public class AuthentikAdmin
|
||||
/*******************************************************************************
|
||||
** The operator, resolved once per instance from live Authentik data. When
|
||||
** the operator is not an Authentik user (static or development sessions),
|
||||
** the session's own permissions stand in. bootstrapOperatorSubjects additionally
|
||||
** the session's own permissions stand in. Maintenance sessions are local job
|
||||
** identities and never resolved as Authentik users. bootstrapOperatorSubjects additionally
|
||||
** hold every authentik.* permission.
|
||||
*******************************************************************************/
|
||||
public Operator operator() throws QException
|
||||
@@ -339,12 +341,13 @@ public class AuthentikAdmin
|
||||
}
|
||||
|
||||
String email = guard.operatorEmail();
|
||||
boolean bootstrap = email != null && com.kingsrook.qbits.authentik.security.BootstrapOperators.matchesUniqueUser(config.getResolvedBootstrapSubjects(), util, email);
|
||||
boolean maintenance = AuthentikMaintenance.isMaintenanceSession(QContext.getQSession());
|
||||
boolean bootstrap = !maintenance && email != null && com.kingsrook.qbits.authentik.security.BootstrapOperators.matchesUniqueUser(config.getResolvedBootstrapSubjects(), util, email);
|
||||
Integer pk = null;
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
Set<String> appAccess = new TreeSet<>();
|
||||
|
||||
JSONObject user = email == null ? null : findUserByEmail(email);
|
||||
JSONObject user = maintenance || email == null ? null : findUserByEmail(email);
|
||||
if(user != null)
|
||||
{
|
||||
pk = user.optInt("pk");
|
||||
@@ -1307,12 +1310,21 @@ public class AuthentikAdmin
|
||||
return (plan.refuse("These are not permissions of this app: " + String.join(", ", unknownNew) + ". Pick them in the editor."));
|
||||
}
|
||||
|
||||
if(added.isEmpty() && removed.isEmpty() && storedProblem == null && extrasNew.isEmpty())
|
||||
boolean fullAccess = newPermissions.contains(PolicyPermissions.FULL_ACCESS);
|
||||
boolean noChange = added.isEmpty() && removed.isEmpty() && storedProblem == null && extrasNew.isEmpty();
|
||||
if(noChange && !fullAccess)
|
||||
{
|
||||
return (plan.noChange("Nothing to change."));
|
||||
}
|
||||
|
||||
Set<String> needed = new TreeSet<>(added);
|
||||
if(fullAccess)
|
||||
{
|
||||
needed = resolver().expand(PolicyPermissions.FULL_ACCESS);
|
||||
}
|
||||
else if(oldPermissions.contains(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
needed.removeAll(resolver().expand(PolicyPermissions.FULL_ACCESS));
|
||||
}
|
||||
for(String extra : extrasNew)
|
||||
{
|
||||
needed.addAll(resolver().expand(extra));
|
||||
@@ -1323,6 +1335,24 @@ public class AuthentikAdmin
|
||||
return (plan.refuse("You cannot grant permissions you do not hold yourself: " + String.join(", ", missing)));
|
||||
}
|
||||
|
||||
if(noChange)
|
||||
{
|
||||
return (plan.noChange("Nothing to change."));
|
||||
}
|
||||
|
||||
if(removed.contains(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
GroupGraph.Node current = membersGraph().byPk(request.targetKey());
|
||||
GroupGraph simulated = AuthentikSafety.withNodes(membersGraph(), new GroupGraph.Node(current.pk(), current.name(),
|
||||
current.ownSuperuser(), current.parentPks(), current.permissions(), current.appAccess(), current.memberPks(),
|
||||
current.malformed(), List.copyOf(newPermissions)));
|
||||
if(AuthentikSafety.removesLastAdmin(adminsNow(), AuthentikSafety.qualifyingAdmins(simulated,
|
||||
new EffectivePermissionResolver(simulated, runtime.getPermissionCatalog()), userFacts())))
|
||||
{
|
||||
return (plan.refuse("Removing this policy would leave nobody able to manage memberships and permissions."));
|
||||
}
|
||||
}
|
||||
|
||||
plan.withChange(policySummary(oldPermissions), policySummary(newPermissions));
|
||||
if(request.bool("generated"))
|
||||
{
|
||||
@@ -1338,11 +1368,11 @@ public class AuthentikAdmin
|
||||
}
|
||||
if(!added.isEmpty())
|
||||
{
|
||||
plan.getNotes().add("Adds: " + abbreviate(added));
|
||||
plan.getNotes().add("Adds: " + (added.contains(PolicyPermissions.FULL_ACCESS) ? PolicyPermissions.FULL_ACCESS_LABEL : abbreviate(added)));
|
||||
}
|
||||
if(!removed.isEmpty())
|
||||
{
|
||||
plan.getNotes().add("Removes: " + abbreviate(removed));
|
||||
plan.getNotes().add("Removes: " + (removed.contains(PolicyPermissions.FULL_ACCESS) ? PolicyPermissions.FULL_ACCESS_LABEL : abbreviate(removed)));
|
||||
}
|
||||
if(storedProblem != null)
|
||||
{
|
||||
@@ -1355,7 +1385,8 @@ public class AuthentikAdmin
|
||||
}
|
||||
JSONArray users = group.optJSONArray("users");
|
||||
plan.getNotes().add((users == null ? 0 : users.length()) + " member(s), and the members of groups below it, get the change at their next sign-in or token refresh.");
|
||||
plan.getNotes().add(canonical.getJSONArray("Statement").length() + " statement(s), " + newPermissions.size() + " permission(s); the document round-trips.");
|
||||
int permissionCount = fullAccess ? resolver().expand(PolicyPermissions.FULL_ACCESS).size() : newPermissions.size();
|
||||
plan.getNotes().add(canonical.getJSONArray("Statement").length() + " statement(s), " + permissionCount + " current permission(s); the document round-trips.");
|
||||
|
||||
if(Boolean.TRUE.equals(config.getPolicyTwoPersonApproval()) && !request.bool("approved"))
|
||||
{
|
||||
@@ -1655,6 +1686,10 @@ public class AuthentikAdmin
|
||||
*******************************************************************************/
|
||||
private static String policySummary(Set<String> permissions)
|
||||
{
|
||||
if(permissions.contains(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
return (PolicyPermissions.FULL_ACCESS_LABEL);
|
||||
}
|
||||
return (permissions.isEmpty() ? "no policy" : permissions.size() + " permission(s): " + abbreviate(permissions));
|
||||
}
|
||||
|
||||
@@ -2221,7 +2256,8 @@ public class AuthentikAdmin
|
||||
|
||||
try
|
||||
{
|
||||
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissions.size(), guard.operatorEmail(),
|
||||
int permissionCount = permissions.contains(PolicyPermissions.FULL_ACCESS) ? resolver().expand(PolicyPermissions.FULL_ACCESS).size() : permissions.size();
|
||||
PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissionCount, guard.operatorEmail(),
|
||||
permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format, runtime.getPolicyCatalog()));
|
||||
}
|
||||
catch(Exception e)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParser;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audited policy maintenance for reviewed host Java code, typically a run-once
|
||||
** job. This API deliberately creates its own authority; never expose it through
|
||||
** an HTTP route or a user-invokable process. No route or process is registered.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikMaintenance
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AuthentikMaintenance()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply a policy to exactly one group by name through the normal Save Policy
|
||||
** plan and execute path. Requires an initialized QContext with the host's
|
||||
** QInstance. The caller's context is restored on success, refusal or failure.
|
||||
**
|
||||
** The operator must start with maintenance: or run-once: and have a nonblank
|
||||
** suffix. Its name and the required reason are recorded verbatim. A dedicated
|
||||
** non-system session holds policies.manage and the document's current grants.
|
||||
** Existing scope, protection, readOnly, feature and stale checks still apply.
|
||||
** Equivalent policies return NO_CHANGE without a write or an audit row.
|
||||
** With policyTwoPersonApproval enabled, a change queues an approval instead
|
||||
** of writing to Authentik; this API never bypasses second-person approval.
|
||||
*******************************************************************************/
|
||||
public static AuthentikAdmin.AdminResult applyPolicy(String apiBackendName, String groupName, String policyJson, String operatorName, String reason) throws QException
|
||||
{
|
||||
if(operatorName == null || !(operatorName.startsWith("maintenance:") || operatorName.startsWith("run-once:"))
|
||||
|| operatorName.substring(operatorName.indexOf(':') + 1).isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("The operator name must start with maintenance: or run-once: and name the job."));
|
||||
}
|
||||
if(!StringUtils.hasContent(reason))
|
||||
{
|
||||
throw (new QUserFacingException("A reason is required for every change."));
|
||||
}
|
||||
if(!StringUtils.hasContent(groupName))
|
||||
{
|
||||
throw (new QUserFacingException("A group name is required."));
|
||||
}
|
||||
|
||||
CapturedContext caller = QContext.capture();
|
||||
try
|
||||
{
|
||||
MaintenanceSession session = new MaintenanceSession();
|
||||
session.setUser(new QUser().withIdReference(operatorName).withFullName("Maintenance (" + operatorName + ")"));
|
||||
session.setPermissions(new TreeSet<>(Set.of(IdentityPermission.POLICIES_MANAGE.getSessionPermission())));
|
||||
QContext.init(caller.qInstance(), session);
|
||||
|
||||
AuthentikAdmin admin = AuthentikAdmin.forBackend(apiBackendName);
|
||||
AuthentikRuntime runtime = admin.getUtil().getRuntime();
|
||||
runtime.getGuard().authorize(AdminAction.SAVE_POLICY);
|
||||
Set<String> grants = StringUtils.hasContent(policyJson)
|
||||
? PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog()).permissions() : Set.of();
|
||||
session.getPermissions().addAll(grants.contains(PolicyPermissions.FULL_ACCESS)
|
||||
? PolicyPermissions.allPermissions(caller.qInstance(), runtime.getPermissionCatalog()) : grants);
|
||||
|
||||
runtime.invalidateCache();
|
||||
List<JSONObject> groups = admin.getUtil().getAllPages("/core/groups/?name=" + URLEncoder.encode(groupName, StandardCharsets.UTF_8)
|
||||
+ "&include_users=true&include_parents=true&include_children=true", 2);
|
||||
if(groups.size() != 1 || !groupName.equals(groups.get(0).optString("name")))
|
||||
{
|
||||
throw (new QUserFacingException("Expected exactly one Authentik group named " + groupName + "."));
|
||||
}
|
||||
JSONObject group = groups.get(0);
|
||||
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), reason)
|
||||
.with(AuthentikAdmin.POLICY_DOCUMENT, policyJson)
|
||||
.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
|
||||
return (admin.execute(request));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
throw (new QUserFacingException("The policy is not valid: " + e.getMessage()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(caller);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical compact full-access document using the host's spec and version.
|
||||
*******************************************************************************/
|
||||
public static String fullAccessDocument(AuthentikQBitConfig config) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (PolicyGenerator.toCompact(PolicyGenerator.generate(Set.of(PolicyPermissions.FULL_ACCESS), config.getPolicyFormat())));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
throw (new QException("Could not generate the full-access policy.", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recognize only sessions created here, never an ordinary user's name prefix.
|
||||
*******************************************************************************/
|
||||
static boolean isMaintenanceSession(QSession session)
|
||||
{
|
||||
return (session instanceof MaintenanceSession);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A local job identity, never an Authentik user or a QSystemUserSession.
|
||||
*******************************************************************************/
|
||||
private static final class MaintenanceSession extends QSession
|
||||
{
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -121,7 +121,7 @@ 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,
|
||||
runtime == null ? (qInstance == null ? null : PolicyCatalog.of(qInstance)) : runtime.getPolicyCatalog()));
|
||||
qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance)));
|
||||
|
||||
if(runtime != null)
|
||||
{
|
||||
@@ -327,7 +327,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
{
|
||||
AuthentikActionUtil client = client(qInstance);
|
||||
GroupGraph graph = client.groupGraph();
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog());
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog(), qInstance);
|
||||
List<String> pks = new ArrayList<>();
|
||||
for(String name : groupNames)
|
||||
{
|
||||
|
||||
+21
-1
@@ -25,14 +25,18 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Turns groups (and their ancestors) into QQQ permissions (DESIGN.md
|
||||
** section 5.6). Pure: give it a GroupGraph and a permission catalog.
|
||||
** section 5.6). Full access uses the explicit instance or current QContext.
|
||||
**
|
||||
** Rules:
|
||||
** - A full-access policy marker expands to all current instance/catalog names.
|
||||
** - A group confers the union of qqq.io/permissions on itself and every
|
||||
** ancestor ("inherited parents").
|
||||
** - "authentik.*" expands to every identity permission; an identity short
|
||||
@@ -47,6 +51,7 @@ public class EffectivePermissionResolver
|
||||
{
|
||||
private final GroupGraph graph;
|
||||
private final Set<String> catalog;
|
||||
private final QInstance qInstance;
|
||||
|
||||
|
||||
|
||||
@@ -68,8 +73,19 @@ public class EffectivePermissionResolver
|
||||
** null to pass non-identity names through unchecked
|
||||
*******************************************************************************/
|
||||
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog)
|
||||
{
|
||||
this(graph, catalog, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve against an explicit sign-in instance, or the current QContext.
|
||||
*******************************************************************************/
|
||||
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog, QInstance qInstance)
|
||||
{
|
||||
this.graph = graph;
|
||||
this.qInstance = qInstance;
|
||||
this.catalog = catalog == null ? null : new TreeSet<>(catalog);
|
||||
if(this.catalog != null)
|
||||
{
|
||||
@@ -160,6 +176,10 @@ public class EffectivePermissionResolver
|
||||
return (rs);
|
||||
}
|
||||
String name = raw.trim();
|
||||
if(name.equals(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
return (PolicyPermissions.allPermissions(qInstance == null ? QContext.getQInstance() : qInstance, catalog));
|
||||
}
|
||||
|
||||
if(name.equals(IdentityPermission.WILDCARD))
|
||||
{
|
||||
|
||||
@@ -113,14 +113,16 @@ public final class PolicyCatalog
|
||||
|
||||
private final List<Group> groups;
|
||||
private final Map<String, Resource> byName;
|
||||
private final QInstance qInstance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
private PolicyCatalog(List<Group> groups)
|
||||
private PolicyCatalog(List<Group> groups, QInstance qInstance)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.groups = groups;
|
||||
this.byName = new LinkedHashMap<>();
|
||||
for(Group group : groups)
|
||||
@@ -256,7 +258,17 @@ public final class PolicyCatalog
|
||||
}
|
||||
groups.add(new Group(entry.getKey(), groupLabel, resources));
|
||||
}
|
||||
return (new PolicyCatalog(groups));
|
||||
return (new PolicyCatalog(groups, qInstance));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The source instance, used for live full-access expansion, not a snapshot.
|
||||
*******************************************************************************/
|
||||
public QInstance getQInstance()
|
||||
{
|
||||
return (qInstance);
|
||||
}
|
||||
|
||||
|
||||
@@ -464,7 +476,7 @@ public final class PolicyCatalog
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(String permission : permissions)
|
||||
{
|
||||
if(!known.contains(permission))
|
||||
if(!known.contains(permission) && !PolicyPermissions.FULL_ACCESS.equals(permission))
|
||||
{
|
||||
rs.add(permission);
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ public final class PolicyEditorWidget
|
||||
// canonical form of what is stored (unknown resources survive) //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
data.withDocument(PolicyGenerator.toPretty(PolicyGenerator.canonical(stored, format, runtime.getPolicyCatalog())));
|
||||
data.withFullAccess(PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).permissions().contains(PolicyPermissions.FULL_ACCESS));
|
||||
}
|
||||
catch(PolicyParseException e)
|
||||
{
|
||||
@@ -230,6 +231,28 @@ public final class PolicyEditorWidget
|
||||
private List<Map<String, Object>> members = List.of();
|
||||
private Boolean membersTruncated = false;
|
||||
private Boolean mapperEnabled;
|
||||
private Boolean fullAccess = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the current valid document grants all current and future resources.
|
||||
*******************************************************************************/
|
||||
public Boolean getFullAccess()
|
||||
{
|
||||
return (fullAccess);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fullAccess.
|
||||
*******************************************************************************/
|
||||
public Data withFullAccess(Boolean fullAccess)
|
||||
{
|
||||
this.fullAccess = fullAccess;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import org.json.JSONObject;
|
||||
** (docs/POLICY-FORMAT.md, "Canonical form"): one Allow statement per granted
|
||||
** 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
|
||||
** full access is a single Action/Resource "*" statement. Every document is checked to round-trip through
|
||||
** PolicyParser to exactly the permissions it came from; one that does not is
|
||||
** never returned.
|
||||
*******************************************************************************/
|
||||
@@ -68,6 +68,17 @@ public final class PolicyGenerator
|
||||
public static JSONObject generate(Collection<String> permissions, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
|
||||
{
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
if(permissions.contains(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
JSONObject document = new JSONObject().put("PolicySpec", f.spec()).put("PolicyVersion", f.version())
|
||||
.put("Statement", new JSONArray().put(new JSONObject().put("Effect", PolicyParser.ALLOW)
|
||||
.put("Action", new JSONArray().put("*")).put("Resource", new JSONArray().put("*"))));
|
||||
if(!PolicyParser.parseStrict(toCompact(document), f, catalog).permissions().equals(Set.of(PolicyPermissions.FULL_ACCESS)))
|
||||
{
|
||||
throw (new PolicyParseException("The full-access document does not round-trip"));
|
||||
}
|
||||
return (document);
|
||||
}
|
||||
|
||||
Map<String, Set<String>> byAction = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> byFeature = new TreeMap<>();
|
||||
|
||||
@@ -53,7 +53,7 @@ public final class PolicyParser
|
||||
/*******************************************************************************
|
||||
** The result of a successful parse.
|
||||
**
|
||||
** @param permissions QQQ permission names ({resource}.{action}), sorted
|
||||
** @param permissions QQQ permission names, or the symbolic FULL_ACCESS marker
|
||||
** @param resources every resource named, in document order
|
||||
*******************************************************************************/
|
||||
public record Parsed(Set<String> permissions, Set<String> resources)
|
||||
@@ -120,7 +120,7 @@ public final class PolicyParser
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse standard actions and features registered for every listed resource.
|
||||
** Without a catalog only the five standard actions are accepted.
|
||||
** Without a catalog only standard actions and full access are accepted.
|
||||
*******************************************************************************/
|
||||
public static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
|
||||
{
|
||||
@@ -163,8 +163,13 @@ public final class PolicyParser
|
||||
}
|
||||
|
||||
Set<String> statementResources = strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource");
|
||||
Set<String> statementActions = strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action");
|
||||
if(statementResources.contains("*") && !statementActions.equals(Set.of("*")))
|
||||
{
|
||||
throw (new PolicyParseException("Statement " + (i + 1) + ": Resource \"*\" requires Action [\"*\"] (full access)"));
|
||||
}
|
||||
Set<String> actions = new LinkedHashSet<>();
|
||||
for(String name : strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action"))
|
||||
for(String name : statementActions)
|
||||
{
|
||||
if(PolicyAction.WILDCARD.equals(name))
|
||||
{
|
||||
@@ -186,6 +191,12 @@ public final class PolicyParser
|
||||
|
||||
for(String resource : statementResources)
|
||||
{
|
||||
if(resource.equals("*"))
|
||||
{
|
||||
permissions.add(PolicyPermissions.FULL_ACCESS);
|
||||
resources.add(resource);
|
||||
continue;
|
||||
}
|
||||
if(!RESOURCE_PATTERN.matcher(resource).matches())
|
||||
{
|
||||
throw (new PolicyParseException("Statement " + (i + 1) + ": resource \"" + resource + "\" must match [A-Za-z0-9_]+"));
|
||||
@@ -197,7 +208,7 @@ public final class PolicyParser
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new Parsed(permissions, resources));
|
||||
return (new Parsed(permissions.contains(PolicyPermissions.FULL_ACCESS) ? Set.of(PolicyPermissions.FULL_ACCESS) : permissions, resources));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
@@ -29,6 +35,12 @@ import org.json.JSONObject;
|
||||
*******************************************************************************/
|
||||
public final class PolicyPermissions
|
||||
{
|
||||
/** Internal parse marker, never a session permission or persisted permission list. */
|
||||
public static final String FULL_ACCESS = "<full-access>";
|
||||
|
||||
/** Operator-facing description of the wildcard statement. */
|
||||
public static final String FULL_ACCESS_LABEL = "Full access (all current and future resources)";
|
||||
|
||||
/** Customizer context key of the ID token claims (QQQ OAuth2 module). */
|
||||
public static final String ID_TOKEN_CONTEXT_KEY = "idToken";
|
||||
|
||||
@@ -67,7 +79,61 @@ public final class PolicyPermissions
|
||||
{
|
||||
return (Collections.emptySet());
|
||||
}
|
||||
return (PolicyParser.parse(claims.opt(f.claim()), f, catalog));
|
||||
Set<String> parsed = PolicyParser.parse(claims.opt(f.claim()), f, catalog);
|
||||
return (parsed.contains(FULL_ACCESS)
|
||||
? allPermissions(catalog == null ? QContext.getQInstance() : catalog.getQInstance(), Set.of()) : parsed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expand full access at use time, including core names, the qbit catalog
|
||||
** (hidden record actions included), and every identity session permission.
|
||||
** No expanded set is cached, so instance additions and reloads are visible.
|
||||
*******************************************************************************/
|
||||
public static Set<String> allPermissions(QInstance qInstance, Collection<String> additional)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
if(additional != null)
|
||||
{
|
||||
permissions.addAll(additional);
|
||||
}
|
||||
if(qInstance != null)
|
||||
{
|
||||
PermissionsHelper.getAllAvailablePermissions(qInstance).forEach(permission -> permissions.add(permission.getName()));
|
||||
permissions.addAll(PolicyCatalog.of(qInstance).permissionNames());
|
||||
permissions.addAll(hostPermissions(qInstance));
|
||||
}
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
permissions.add(permission.getSessionPermission());
|
||||
}
|
||||
permissions.remove(FULL_ACCESS);
|
||||
return (permissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The host-declared permission names (config fullAccessHostPermissions) of
|
||||
** every Authentik runtime whose API backend is in this instance.
|
||||
*******************************************************************************/
|
||||
static Set<String> hostPermissions(QInstance qInstance)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(com.kingsrook.qbits.authentik.AuthentikRuntime runtime : com.kingsrook.qbits.authentik.AuthentikRuntime.all())
|
||||
{
|
||||
com.kingsrook.qbits.authentik.AuthentikQBitConfig config = runtime.getConfig();
|
||||
if(config.getFullAccessHostPermissions() != null && config.getApiBackendName() != null && qInstance.getBackend(config.getApiBackendName()) != null)
|
||||
{
|
||||
java.util.Collection<String> names = config.getFullAccessHostPermissions().apply(qInstance);
|
||||
if(names != null)
|
||||
{
|
||||
names.stream().filter(n -> n != null && !n.isBlank()).forEach(rs::add);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host maintenance uses the audited Save Policy path with a local job identity.
|
||||
*******************************************************************************/
|
||||
class AuthentikMaintenanceTest
|
||||
{
|
||||
private static final String GROUP = "kof-website-analysts";
|
||||
private static final String OPERATOR = "run-once:KOF22_RUN_ONCE";
|
||||
private static final String REASON = " owner request 2026-10-14: full-access policy for kof22-admins ";
|
||||
|
||||
private AdminTestSupport t;
|
||||
private String fullAccess;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A real qbit instance backed by the in-memory Authentik API and audit store.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.GROUPS, AuthentikFeature.POLICIES));
|
||||
fullAccess = AuthentikMaintenance.fullAccessDocument(t.config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A system job gets a dedicated, fully expanded session. The audit precedes
|
||||
** PATCH, the event identifies the job, and an equivalent document is a no-op.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appliesFullAccessAuditedAndIdempotently() throws Exception
|
||||
{
|
||||
QContext.setQSession(new QSystemUserSession());
|
||||
QContext.getActionStack().push(new QueryInput());
|
||||
QContext.enterAuthorizedActionScope();
|
||||
CapturedContext caller = QContext.capture();
|
||||
Set<String> all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog());
|
||||
t.authentik.user(10, "collision", OPERATOR, "internal", true);
|
||||
t.config.withBootstrapOperatorSubjects("10");
|
||||
FixtureActionUtil.handler = request ->
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
assertThat(session).isNotInstanceOf(QSystemUserSession.class);
|
||||
assertThat(session.getUser().getIdReference()).isEqualTo(OPERATOR);
|
||||
assertThat(session.getUser().getFullName()).isEqualTo("Maintenance (" + OPERATOR + ")");
|
||||
assertThat(session.getPermissions()).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
assertThat(QContext.getActionStack()).isNotSameAs(caller.actionStack());
|
||||
assertThat(QContext.getAuthorizedActionScopeDepth()).isZero();
|
||||
if(request.getMethod().equals("PATCH"))
|
||||
{
|
||||
try
|
||||
{
|
||||
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
|
||||
assertThat(row.getValueString("status")).isEqualTo("RUNNING"));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new AssertionError(e));
|
||||
}
|
||||
}
|
||||
return (t.authentik.handle(request));
|
||||
};
|
||||
|
||||
AuthentikAdmin.AdminResult result = apply(GROUP, fullAccess);
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
assertThat(result.noChange()).isFalse();
|
||||
assertThat(result.pending()).isFalse();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
assertThat(t.authentik.groupByName(GROUP).getJSONObject("attributes").getString(t.config.getPolicyAttribute())).isEqualTo(fullAccess);
|
||||
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("operator")).isEqualTo(OPERATOR);
|
||||
assertThat(row.getValueString("reason")).isEqualTo(REASON);
|
||||
assertThat(row.getValueString("status")).isEqualTo("SUCCEEDED");
|
||||
});
|
||||
assertThat(t.authentik.events).singleElement().satisfies(event ->
|
||||
{
|
||||
assertThat(event.getString("action")).isEqualTo("custom_qqq_admin_action");
|
||||
assertThat(event.getJSONObject("context").getString("operator")).isEqualTo(OPERATOR);
|
||||
assertThat(event.getJSONObject("context").getString("reason")).isEqualTo(REASON);
|
||||
});
|
||||
assertThat(result.eventPk()).isNotBlank();
|
||||
int writes = t.writes().size();
|
||||
assertThat(apply(GROUP, new JSONObject(fullAccess).toString(2)).noChange()).isTrue();
|
||||
assertThat(t.writes()).hasSize(writes);
|
||||
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).hasSize(1);
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
assertThat(FixtureActionUtil.REQUESTS).noneMatch(request -> request.getURI().getPath().contains("/core/users/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary policies need only their grants and policies.manage, and honor
|
||||
** the configured spec/version. The maintenance: prefix is accepted too.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appliesNamedPolicyWithLimitedSession() throws Exception
|
||||
{
|
||||
t.config.withPolicySpec("host-iam");
|
||||
String document = AuthentikMaintenance.fullAccessDocument(t.config).replace("\"Action\":[\"*\"]", "\"Action\":[\"read\"]")
|
||||
.replace("\"Resource\":[\"*\"]", "\"Resource\":[\"cms_page\"]");
|
||||
FixtureActionUtil.handler = request ->
|
||||
{
|
||||
assertThat(QContext.getQSession().getPermissions()).containsExactlyInAnyOrder("cms_page.read", IdentityPermission.POLICIES_MANAGE.getSessionPermission());
|
||||
return (t.authentik.handle(request));
|
||||
};
|
||||
AuthentikAdmin.AdminResult result = AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), GROUP, document, "maintenance:policy", REASON);
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
assertThat(t.authentik.groupByName(GROUP).getJSONObject("attributes").getString(t.config.getPolicyAttribute())).contains("host-iam", "cms_page");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Superuser ancestry remains protected, including when the policy matches.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesSuperuserDescendant() throws Exception
|
||||
{
|
||||
t.authentik.group("protected-child", false, List.of(), List.of(), "authentik Admins");
|
||||
t.authentik.groupByName("protected-child").getJSONObject("attributes").put(t.config.getPolicyAttribute(), fullAccess);
|
||||
CapturedContext caller = QContext.capture();
|
||||
AuthentikAdmin.AdminResult result = apply("protected-child", fullAccess);
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).contains("Superuser groups");
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact names must resolve uniquely; neither missing nor ambiguous is safe.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesMissingAndAmbiguousGroups() throws Exception
|
||||
{
|
||||
CapturedContext caller = QContext.capture();
|
||||
assertThatThrownBy(() -> apply("kof-website", fullAccess)).hasMessageContaining("exactly one");
|
||||
t.authentik.group("duplicate", false, List.of(), List.of());
|
||||
t.authentik.groupByName("duplicate").put("name", GROUP);
|
||||
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("exactly one");
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid inputs fail without contacting Authentik or altering the caller.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validatesInputs() throws Exception
|
||||
{
|
||||
CapturedContext caller = QContext.capture();
|
||||
for(String name : new String[] { null, "", "operator@example.test", "run-once:", "maintenance: " })
|
||||
{
|
||||
assertThatThrownBy(() -> AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), GROUP, fullAccess, name, REASON))
|
||||
.hasMessageContaining("operator name");
|
||||
}
|
||||
assertThatThrownBy(() -> AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), GROUP, fullAccess, OPERATOR, " "))
|
||||
.hasMessageContaining("reason");
|
||||
assertThatThrownBy(() -> apply(" ", fullAccess)).hasMessageContaining("group name");
|
||||
assertThatThrownBy(() -> apply(GROUP, "{bad json")).hasMessageContaining("policy is not valid");
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The kill switch and feature gate are applied even to maintenance jobs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesReadOnlyAndDisabledPolicies() throws Exception
|
||||
{
|
||||
CapturedContext caller = QContext.capture();
|
||||
t.config.withReadOnly(true);
|
||||
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("read-only");
|
||||
t.config.withReadOnly(false).withFeatures(EnumSet.of(AuthentikFeature.READ));
|
||||
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("POLICIES");
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Approval remains a second person's decision; maintenance only queues it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void queuesTwoPersonApproval() throws Exception
|
||||
{
|
||||
t.config.withPolicyTwoPersonApproval(true);
|
||||
CapturedContext caller = QContext.capture();
|
||||
AuthentikAdmin.AdminResult result = apply(GROUP, fullAccess);
|
||||
assertThat(result.ok()).isTrue();
|
||||
assertThat(result.pending()).isTrue();
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
|
||||
assertThat(row.getValueString("status")).isEqualTo("PENDING_APPROVAL"));
|
||||
assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("requester")).isEqualTo(OPERATOR);
|
||||
assertThat(row.getValueString("reason")).isEqualTo(REASON);
|
||||
});
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A change after the name lookup is caught when execute plans against live data.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesStaleGroup() throws Exception
|
||||
{
|
||||
FixtureActionUtil.handler = request ->
|
||||
{
|
||||
if(request.getURI().getPath().equals("/api/v3/core/groups/"))
|
||||
{
|
||||
var response = t.authentik.handle(request);
|
||||
t.authentik.groupByName(GROUP).getJSONObject("attributes").put("qqq.io/changed", true);
|
||||
return (response);
|
||||
}
|
||||
return (t.authentik.handle(request));
|
||||
};
|
||||
AuthentikAdmin.AdminResult result = apply(GROUP, fullAccess);
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).contains("was changed");
|
||||
assertThat(t.writes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed audit persistence prevents the write and still restores the caller.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void auditFailurePreventsWrite() throws Exception
|
||||
{
|
||||
CapturedContext caller = QContext.capture();
|
||||
t.qInstance.getTables().remove(t.config.applyTablePrefix(AuthentikLocalTables.ADMIN_ACTION));
|
||||
assertThatThrownBy(() -> apply(GROUP, fullAccess)).isInstanceOf(QException.class);
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct system writes are still refused, even with a previously minted permit.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void systemSessionStillCannotWrite() throws Exception
|
||||
{
|
||||
var util = t.admin().getUtil();
|
||||
var authorization = util.getRuntime().getGuard().authorize(AdminAction.SAVE_POLICY);
|
||||
QSystemUserSession system = new QSystemUserSession();
|
||||
system.setUser(QContext.getQSession().getUser());
|
||||
QContext.setQSession(system);
|
||||
assertThatThrownBy(() -> util.getRuntime().getGuard().authorize(AdminAction.SAVE_POLICY)).hasMessageContaining("System sessions");
|
||||
assertThatThrownBy(() -> util.getRuntime().getGuard().checkWrite(AuthentikTables.get(AuthentikTables.GROUP), "update")).hasMessageContaining("System sessions");
|
||||
assertThatThrownBy(() -> util.write(authorization, "PATCH", "/core/groups/" + t.authentik.pk(GROUP) + "/", new JSONObject()))
|
||||
.hasMessageContaining("System sessions");
|
||||
assertThat(t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(GROUP), REASON)
|
||||
.with(AuthentikAdmin.POLICY_DOCUMENT, fullAccess)).ok()).isFalse();
|
||||
assertThat(t.writes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply using the production public API.
|
||||
*******************************************************************************/
|
||||
private AuthentikAdmin.AdminResult apply(String group, String document) throws QException
|
||||
{
|
||||
return (AuthentikMaintenance.applyPolicy(t.config.getApiBackendName(), group, document, OPERATOR, REASON));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect the real local audit and approval tables.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows(String table) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput();
|
||||
input.setTableName(t.config.applyTablePrefix(table));
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
@@ -30,6 +32,7 @@ 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.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.permissions.AccessComparison;
|
||||
import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
@@ -66,12 +69,97 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
*******************************************************************************/
|
||||
class PolicyEditorTest
|
||||
{
|
||||
private static final String FULL_ACCESS = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"*\"],\"Resource\":[\"*\"]}]}";
|
||||
private static final String PAGE_READ_EDIT = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"cms_page\"]}]}";
|
||||
|
||||
private AdminTestSupport t;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full access requires every current grant, even when some were already in
|
||||
** the target policy. An operator holding all grants may save it explicitly.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fullAccessSubsetRuleAndEditorFlag() throws Exception
|
||||
{
|
||||
String group = "kof-website-analysts";
|
||||
assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getFullAccess()).isFalse();
|
||||
assertThat(save(group, FULL_ACCESS).message()).contains("do not hold yourself", "cms_page.delete");
|
||||
assertThat(t.changes()).isEmpty();
|
||||
Set<String> all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog());
|
||||
Set<String> almostAll = new TreeSet<>(all);
|
||||
almostAll.remove("cms_page.delete");
|
||||
t.config.withHostPermissionSource((email, groups) -> almostAll);
|
||||
assertThat(save(group, FULL_ACCESS).message()).contains("do not hold yourself", "cms_page.delete");
|
||||
t.config.withHostPermissionSource((email, groups) -> all);
|
||||
AdminPlan plan = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(group), "because")
|
||||
.with(AuthentikAdmin.POLICY_DOCUMENT, FULL_ACCESS));
|
||||
assertThat(plan.isRunnable()).as(plan.getMessage()).isTrue();
|
||||
assertThat(plan.getAfter()).isEqualTo(PolicyPermissions.FULL_ACCESS_LABEL);
|
||||
assertThat(save(group, FULL_ACCESS).ok()).isTrue();
|
||||
assertThat(t.authentik.groupByName(group).getJSONObject("attributes").getString("qqq.io/policy")).isEqualTo(FULL_ACCESS);
|
||||
PolicyEditorWidget.Data widget = PolicyEditorWidget.load(t.config, t.authentik.pk(group));
|
||||
assertThat(widget.getFullAccess()).isTrue();
|
||||
assertThat(new JSONObject(widget).getBoolean("fullAccess")).isTrue();
|
||||
assertThat(policyRows().get(0).getValueInteger("permissionCount")).isEqualTo(all.size());
|
||||
assertThat(widget.getDocumentProblem()).isNull();
|
||||
assertThat(widget.getDocument()).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
t.config.withHostPermissionSource((email, groups) -> Set.of());
|
||||
assertThat(save(group, FULL_ACCESS).message()).contains("do not hold yourself");
|
||||
assertThat(save(group, PAGE_READ_EDIT).ok()).isTrue();
|
||||
assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getFullAccess()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A full-access operator can grant full access. Both mapper paths, comparison
|
||||
** and preview bases expand it, including grants outside the editor matrix.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fullAccessGraphClaimsComparisonAndMemberPreview() throws Exception
|
||||
{
|
||||
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/policy", FULL_ACCESS);
|
||||
assertThat(save("kof-website-analysts", FULL_ACCESS).ok()).isTrue();
|
||||
t.authentik.user(10, "full-member", "full@example.test", "internal", true, "kof-website-analysts", "kof-website-publishers");
|
||||
Set<String> all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog());
|
||||
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper();
|
||||
assertThat(mapper.resolve(t.qInstance, List.of("kof-website-analysts"), "10", "full@example.test", true))
|
||||
.containsAll(all).containsAll(PermissionsHelper.getAllAvailablePermissionNames(t.qInstance)).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
t.config.withMapperEnabled(true).withMapperOverride(true);
|
||||
QSession session = new QSession();
|
||||
JSONObject claims = new JSONObject().put("groups", List.of()).put("sub", "10").put("qqq_policy", new JSONObject(FULL_ACCESS));
|
||||
mapper.customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims));
|
||||
assertThat(session.getPermissions()).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
assertThat(AccessComparison.run(t.config, t.admin().getUtil(), t.admin().getUtil().getRuntime().getPermissionCatalog()))
|
||||
.anyMatch(row -> row.username().equals("full-member") && row.after().containsAll(all));
|
||||
Map<String, Object> member = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-publishers")).getMembers().stream()
|
||||
.filter(row -> row.get("username").equals("full-member")).findFirst().orElseThrow();
|
||||
assertThat((List<String>) member.get("basePermissions")).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
Map<String, Object> excluded = PolicyEditorWidget.load(t.config, t.authentik.pk("kof-website-analysts")).getMembers().stream()
|
||||
.filter(row -> row.get("username").equals("full-member")).findFirst().orElseThrow();
|
||||
assertThat((List<String>) excluded.get("basePermissions")).doesNotContain("cms_page.delete");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Removing a full-access policy must not remove the last qualifying admin.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fullAccessPolicyProtectsLastAdmin() throws Exception
|
||||
{
|
||||
JSONObject attributes = t.authentik.groupByName("identity-admins").getJSONObject("attributes");
|
||||
attributes.put("qqq.io/permissions", List.of()).remove("qqq.io/policy");
|
||||
t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", FULL_ACCESS);
|
||||
t.authentik.user(10, "last-admin", "last@example.test", "internal", true, "kof-website-analysts");
|
||||
assertThat(save("kof-website-analysts", "").message()).contains("leave nobody");
|
||||
assertThat(t.changes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The operator's own group carries a policy, so the subset rule lets them
|
||||
** grant cms_page read/edit and content access (but not delete).
|
||||
@@ -352,6 +440,7 @@ class PolicyEditorTest
|
||||
PolicyEditorWidget.Data broken = PolicyEditorWidget.load(t.config, groupPk);
|
||||
assertThat(broken.getDocumentProblem()).contains("not a JSON object");
|
||||
assertThat(broken.getDocument()).isNull();
|
||||
assertThat(broken.getFullAccess()).isFalse();
|
||||
|
||||
assertThat(PolicyEditorWidget.load(t.config, null).getGroupLabel()).isNull();
|
||||
assertThatThrownBy(() -> PolicyEditorWidget.load(t.config, "../x")).hasMessageContaining("Not a group id");
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
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.permissions.TableFeaturePermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full access stays symbolic in documents and expands against live metadata.
|
||||
*******************************************************************************/
|
||||
class PolicyFullAccessTest
|
||||
{
|
||||
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact full-access statement in a valid document envelope.
|
||||
*******************************************************************************/
|
||||
private static JSONObject document()
|
||||
{
|
||||
return (PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("*"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mixed documents still validate every statement before dropping redundancy.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void parserAcceptsOnlyExplicitFullAccess() throws Exception
|
||||
{
|
||||
JSONObject document = document();
|
||||
assertThat(PolicyParser.parseStrict(document, FORMAT).permissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
|
||||
document.getJSONArray("Statement").put(PolicyParserTest.statement("Allow", List.of("read"), List.of("cms_page")));
|
||||
assertThat(PolicyParser.parseStrict(document, FORMAT).permissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
|
||||
document.getJSONArray("Statement").put(PolicyParserTest.statement("Deny", List.of("read"), List.of("cms_page")));
|
||||
assertThat(PolicyParser.parse(document, FORMAT)).isEmpty();
|
||||
for(List<String> actions : List.of(List.of("read"), List.of("*", "read"), List.<String>of()))
|
||||
{
|
||||
JSONObject invalid = PolicyParserTest.document(PolicyParserTest.statement("Allow", actions, List.of("*")));
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(invalid, FORMAT)).hasMessageContaining("requires Action");
|
||||
assertThat(PolicyParser.parse(invalid, FORMAT)).isEmpty();
|
||||
}
|
||||
JSONObject badResource = PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("*", "bad.resource")));
|
||||
assertThat(PolicyParser.parse(badResource, FORMAT)).isEmpty();
|
||||
JSONObject mixedResources = PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("*", "cms_page")));
|
||||
assertThat(PolicyParser.parse(mixedResources, FORMAT)).containsExactly(PolicyPermissions.FULL_ACCESS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The marker alone creates the wildcard; enumerated grants never do.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canonicalFormAndRoundTrip() throws Exception
|
||||
{
|
||||
JSONObject mixed = document();
|
||||
mixed.getJSONArray("Statement").put(PolicyParserTest.statement("Allow", List.of("*"), List.of("cms_page")));
|
||||
String expected = PolicyGenerator.toCompact(document());
|
||||
assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(mixed, FORMAT))).isEqualTo(expected);
|
||||
JSONObject generated = PolicyGenerator.generate(Set.of(PolicyPermissions.FULL_ACCESS, "cms_page.read"), FORMAT);
|
||||
assertThat(PolicyGenerator.toCompact(generated)).isEqualTo(expected);
|
||||
assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(generated), FORMAT).permissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
|
||||
Set<String> fiveActions = PolicyParser.parse(PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("cms_page"))), FORMAT);
|
||||
assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(fiveActions, FORMAT))).doesNotContain("[\"*\"]");
|
||||
assertThat(PolicyCatalog.of(PolicyCatalogTest.instance()).unknownPermissions(Set.of(PolicyPermissions.FULL_ACCESS))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An already parsed graph and catalog cover later features and processes.
|
||||
** The member's grants must be a superset of the actual core API's output.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void resolverAndClaimsExpandCurrentAndFutureInstancePermissions()
|
||||
{
|
||||
QInstance instance = PolicyCatalogTest.instance();
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance);
|
||||
JSONObject parent = new JSONObject().put("pk", "parent").put("name", "Owners")
|
||||
.put("attributes", new JSONObject().put(FORMAT.attribute(), document()));
|
||||
JSONObject child = new JSONObject().put("pk", "child").put("name", "Staff").put("parents", List.of("parent")).put("users", List.of(7));
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(parent, child), FORMAT, catalog);
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, catalog.permissionNames(), instance);
|
||||
assertThat(resolver.resolveUser(7).sessionPermissions()).doesNotContain("cms_page.analyze", "futureProcess.hasAccess");
|
||||
|
||||
TableFeaturePermissionsMetaData.ofOrWithNew(instance).withPermission(new TableFeaturePermission().withSuffix("analyze").withLabel("Analyze")
|
||||
.withAppliesTo((qi, table) -> table.getName().equals("cms_page")));
|
||||
instance.addProcess(new QProcessMetaData().withName("futureProcess")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
Set<String> permissions = resolver.resolveUser(7).sessionPermissions();
|
||||
assertThat(permissions).containsAll(PermissionsHelper.getAllAvailablePermissionNames(instance))
|
||||
.contains("cms_page.analyze", "futureProcess.hasAccess", "legacy.write", "cms.publish.hasAccess", "publishPage.hasAccess",
|
||||
"content.hasAccess", "pageReport.hasAccess", "pageStats.hasAccess")
|
||||
.doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
assertThat(permissions).contains(permission.getSessionPermission());
|
||||
}
|
||||
assertThat(resolver.resolveUser(7).grantedBy().get("futureProcess.hasAccess")).containsExactly("Owners");
|
||||
assertThat(resolver.resolveGroups(List.of("child"), "parent").sessionPermissions()).isEmpty();
|
||||
assertThat(graph.byPk("parent").policyPermissions()).containsExactly(PolicyPermissions.FULL_ACCESS);
|
||||
|
||||
JSONObject claims = new JSONObject().put(FORMAT.claim(), document());
|
||||
assertThat(PolicyPermissions.fromClaims(claims, FORMAT, catalog)).isEqualTo(permissions);
|
||||
QSession session = new QSession();
|
||||
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
|
||||
assertThat(session.getPermissions()).isEqualTo(permissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QContext-based consumers see a replacement instance without a new graph.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contextReloadAndCatalogOnlyResolution()
|
||||
{
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(new JSONObject().put("pk", "owners").put("name", "Owners")
|
||||
.put("attributes", new JSONObject().put(FORMAT.attribute(), document()))), FORMAT);
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, Set.of("host.extra"));
|
||||
QInstance original = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
QInstance sample = PolicyCatalogTest.instance();
|
||||
original.addBackend(sample.getBackend("memory"));
|
||||
original.addTable(sample.getTable("cms_page"));
|
||||
QContext.init(original, new QSession());
|
||||
try
|
||||
{
|
||||
AuthentikRuntime runtime = new AuthentikRuntime(new AuthentikQBitConfig());
|
||||
assertThat(runtime.getPermissionCatalog()).contains("cms_page.read");
|
||||
assertThat(resolver.resolveGroups(List.of("owners")).sessionPermissions()).contains("cms_page.read", "host.extra");
|
||||
QInstance replacement = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
replacement.addBackend(sample.getBackend("memory"));
|
||||
replacement.addTable(sample.getTable("orphan"));
|
||||
QContext.init(replacement, new QSession());
|
||||
assertThat(runtime.getPermissionCatalog()).contains("orphan.read").doesNotContain("cms_page.read");
|
||||
assertThat(resolver.resolveGroups(List.of("owners")).sessionPermissions()).contains("host.extra").doesNotContain("cms_page.read");
|
||||
assertThat(PolicyPermissions.fromClaims(new JSONObject().put(FORMAT.claim(), document()), FORMAT))
|
||||
.contains(IdentityPermission.USERS_READ.getSessionPermission()).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
assertThat(PolicyPermissions.allPermissions(null, null)).hasSize(IdentityPermission.values().length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-declared names (custom checkers QQQ metadata cannot list) join the
|
||||
** expansion for an instance holding that runtime's backend, are computed at
|
||||
** use time, and never leak into another instance.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostDeclaredPermissionsJoinFullAccessForTheirInstanceOnly()
|
||||
{
|
||||
AuthentikQBitConfig config = new AuthentikQBitConfig().withTableNamePrefix("hostdecl");
|
||||
List<String> names = new java.util.ArrayList<>(List.of("host.purge"));
|
||||
config.withFullAccessHostPermissions(qInstance -> names);
|
||||
AuthentikRuntime.register(new AuthentikRuntime(config));
|
||||
|
||||
QInstance mine = new QInstance();
|
||||
mine.addBackend(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData().withName(config.getApiBackendName()));
|
||||
assertThat(PolicyPermissions.allPermissions(mine, Set.of())).contains("host.purge");
|
||||
|
||||
names.add("host.later");
|
||||
assertThat(PolicyPermissions.allPermissions(mine, Set.of())).contains("host.purge", "host.later");
|
||||
|
||||
assertThat(PolicyPermissions.allPermissions(new QInstance(), Set.of())).doesNotContain("host.purge", "host.later");
|
||||
config.withFullAccessHostPermissions(qInstance -> null);
|
||||
assertThat(PolicyPermissions.allPermissions(mine, Set.of())).doesNotContain("host.purge");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user