From 6f9ec41f35d3b732120854910fe0de87a1ed7d7d Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 17:28:33 -0600 Subject: [PATCH 01/14] docs(policy): specify format 1.1.0 (Deny, prefix wildcards, named permissions) Spec and shared golden vectors only; no parser change yet. - Effect Allow|Deny; result = union(Allow) minus union(Deny) across all of a user's groups, Deny wins, no ordering, expanded against the live instance so a Deny also covers future resources. - Prefix resources (utils_*): at least two characters, literal part ends in an underscore, wildcard last. Deny with Action * means every action including features; Allow with Action * stays the five standard actions. - Permission[] key for exact dotted/host permission names under Allow and Deny, so Deny can remove what full access includes. - Zero-match prefix, feature or named permission is rejected at save for both effects. Unknown statement keys (Condition) reject the document. - One invalid document among a user's groups voids all of them: dropping a bad group could drop a Deny and escalate access. - Canonicalization never drops a Deny. - policy-vectors-1.1.0.json: 27 vectors over a 16-name universe, to be run by this parser, the authentik-cd claim validator and Forge's tests. Co-Authored-By: Claude Sonnet 5.5 --- docs/POLICY-FORMAT.md | 43 +++- src/test/resources/policy-vectors-1.1.0.json | 240 +++++++++++++++++++ 2 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 src/test/resources/policy-vectors-1.1.0.json diff --git a/docs/POLICY-FORMAT.md b/docs/POLICY-FORMAT.md index 76bfc2d..841c455 100644 --- a/docs/POLICY-FORMAT.md +++ b/docs/POLICY-FORMAT.md @@ -1,6 +1,6 @@ # QQQ policy format -Version 1.0.0 of the policy documents that `qbit-authentik` reads and writes. The design is in [DESIGN.md §12](DESIGN.md#12-policies-iam-style-permission-statements-revision-5). +Version 1.1.0 of the policy documents that `qbit-authentik` reads and writes. The design is in [DESIGN.md §12](DESIGN.md#12-policies-iam-style-permission-statements-revision-5). A **policy** grants QQQ permissions to the members of an Authentik group. It is stored on the group as an attribute. At sign-in, the policies of all the user's groups, including inherited parent groups, become QQQ session permissions. @@ -9,7 +9,7 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is ```json { "PolicySpec": "qqq-iam", - "PolicyVersion": "1.0.0", + "PolicyVersion": "1.1.0", "Statement": [ { "Effect": "Allow", @@ -28,11 +28,12 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is | Field | Type | Rule | |---|---|---| | `PolicySpec` | string | Must equal the app's configured spec name (`policySpec`, default `qqq-iam`) | -| `PolicyVersion` | string, semver | Must equal the supported version (`policyVersion`, default `1.0.0`) | +| `PolicyVersion` | string, semver | Must equal the supported version (`policyVersion`, default `1.1.0`) | | `Statement` | array of statements | May be empty (grants nothing) | -| `Statement[].Effect` | string | `Allow` (the only effect in 1.0.0) | -| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`, or a table feature name registered for every listed resource | -| `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`) | +| `Statement[].Effect` | string | `Allow` or `Deny` | +| `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`, or a table feature name registered for every listed resource (for a prefix resource: for at least one match) | +| `Statement[].Resource` | array of strings | `*` with exactly `Action: ["*"]`; a prefix such as `utils_*`; or a QQQ permission base name matching `[A-Za-z0-9_]+`: a table, process, app, report or widget name (or its custom `permissionBaseName`) | +| `Statement[].Permission` | array of strings | Instead of `Action` and `Resource`: exact permission names, including dotted names such as `authentik.users.manage.hasAccess` or `cdn.purge` | ## Meaning @@ -56,7 +57,23 @@ Features are generic strings, discovered by matching `PermissionsHelper.getAllAv 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`, 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. +**Not in 1.1.0:** conditions and field-level rules. Any statement key other than `Effect`, `Action`, `Resource` and `Permission` rejects the document, because a parser that silently dropped a `Condition` would turn a conditional statement into an unconditional one. Named permissions such as `authentik.users.manage` cannot be written as a resource, because they contain dots; use the `Permission` key (next section). Explicit named grants may also stay in the group attribute `qqq.io/permissions`; full access includes them in session form. + +## Deny, prefixes and named permissions (1.1.0) + +**Evaluation.** Across all of a user's groups (direct and inherited), the result is the union of every `Allow` expansion minus the union of every `Deny` expansion. A `Deny` anywhere wins. There is no ordering, no specificity and no per-group precedence, so any result is explained by listing the statements that matched. Both sets are expanded against the live QQQ instance at sign-in, so a `Deny` on `billing_*` also covers a billing table added later. + +**Prefix resources.** `utils_*` matches every resource whose base name starts with `utils_`: the part before the final `.action` of a permission name, containing no dot. It does not match `utilsboard`. A prefix must have at least two characters before the `*`, end its literal part in `_`, and have the `*` as the last character; `u*`, `utils*` and `*_log` reject the document. `Action: ["*"]` on a prefix means the five standard actions for each match, exactly as on a named resource. A feature action grants only on matches that register it, and a statement whose feature action matches no resource is rejected at save. + +**`Deny` and `*`.** For `Allow`, `Action: ["*"]` is the five standard actions only. For `Deny` it means **every action, including table features and `write`**, for each match. Otherwise denying `utils_log` under full access would leave `utils_log.analyze` granted. A `Deny` is therefore always at least as broad as the `Allow` it is written to cancel. `Resource: ["*"]` still requires `Action: ["*"]`, for `Deny` as for `Allow`. + +**Named permissions.** A statement may carry `Permission: [...]` instead of `Action` and `Resource`, giving exact permission names. It works under `Allow` and `Deny`, and is the only way to deny a dotted permission such as `authentik.users.manage.hasAccess` or a host name such as `cdn.purge` that full access includes. Mixing `Permission` with `Action` or `Resource` in one statement rejects the document. + +**Zero matches.** At sign-in a prefix or named permission that matches nothing expands to nothing. When an operator saves a document, any prefix, feature or named permission that matches nothing in the current instance is rejected ("matches nothing"), for `Allow` and for `Deny`. A mistyped `Deny` that silently does nothing is the dangerous case, so it is refused at the point where someone can fix it. + +**Editing.** Adding a `Deny` is always allowed to an operator with policy management. Removing a `Deny` is a grant: the operator must hold every permission it was withholding and must not be denied any of them themselves. + +The golden vectors in `src/test/resources/policy-vectors-1.1.0.json` define these rules over a small fixed permission universe. Every implementation of the grammar must pass them: this qbit's parser, the Authentik claim validator in the identity CD repository, and Forge's catalog tests. ## Full access @@ -71,7 +88,7 @@ It grants **Full access (all current and future resources)**. A complete documen ```json { "PolicySpec": "qqq-iam", - "PolicyVersion": "1.0.0", + "PolicyVersion": "1.1.0", "Statement": [ {"Effect":"Allow","Action":["*"],"Resource":["*"]} ] @@ -105,16 +122,18 @@ A document grants **nothing**, and the parser logs a warning, when any of these - `PolicySpec` is missing or is not the configured name - `PolicyVersion` is missing or is not the supported version - `Statement` is present but not an array, or a statement is not an object -- a statement's `Effect` is not `Allow` (including `Deny`, which is not supported yet) +- a statement's `Effect` is neither `Allow` nor `Deny` +- a statement has a key other than `Effect`, `Action`, `Resource` and `Permission`, or mixes `Permission` with `Action`/`Resource` +- a wildcard resource is not a valid prefix - `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_]+`, 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. +There is no partial credit: one bad statement voids the whole document. A user's documents are evaluated together, and **one invalid document among a user's groups voids all of them: the user gets no permissions.** This is deliberate. With `Deny` in the format, dropping only the bad group could drop a `Deny` while other groups' `Allow` statements survive, which would silently escalate access. The same rule applies to the merged token claim: one bad statement voids the claim. ## 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. +Canonicalization never drops a `Deny`. A document containing full access canonicalizes to one full-access `Allow` statement followed by its `Deny` statements (and no other `Allow`); the redundant `Allow` grants are dropped, the `Deny` statements are kept. 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) @@ -131,7 +150,7 @@ Before every save, the qbit checks the round trip: parsing the generated documen | Group attribute holding a group's document | `qqq.io/policy` | `policyAttribute` | | Token claim holding the merged document | `qqq_policy` | `policyClaim` | | Spec name | `qqq-iam` | `policySpec` | -| Version | `1.0.0` | `policyVersion` | +| Version | `1.1.0` | `policyVersion` | An app that already stores documents of this shape under its own spec name, attribute and claim sets those four values and keeps its data unchanged. The parser accepts the attribute or claim as a JSON string or as a JSON object. diff --git a/src/test/resources/policy-vectors-1.1.0.json b/src/test/resources/policy-vectors-1.1.0.json new file mode 100644 index 0000000..8648889 --- /dev/null +++ b/src/test/resources/policy-vectors-1.1.0.json @@ -0,0 +1,240 @@ +{ + "comment": [ + "Golden vectors for policy format 1.1.0 (docs/POLICY-FORMAT.md). Every implementation of the grammar runs these:", + "the qbit parser, the Authentik claim validator in greater-goods-authentik-cd, and the Forge catalog tests.", + "'universe' is the complete set of permission names that exist. Permission base name = text before the last dot", + "(authentik.users.manage.hasAccess has base authentik.users.manage; cdn.purge has no action and is a named permission).", + "'documents' are the Statement arrays of every group the user is in (the merged claim). 'expect.permissions' is compared as a set.", + "mode 'runtime' = what happens at sign-in; mode 'save' = extra checks the editor runs before writing (zero-match rules).", + "A vector with valid=false means the WHOLE merged claim grants nothing." + ], + "spec": "qqq-iam", + "version": "1.1.0", + "universe": [ + "utils_helper.hasAccess", + "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete", "utils_log.analyze", + "utilsboard.hasAccess", + "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", + "billing_report.hasAccess", + "authentik.users.manage.hasAccess", + "cdn.purge" + ], + "vectors": [ + { + "name": "allow-exact-resource", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["read"],"Resource":["billing_invoice"]}]], + "expect": {"valid": true, "permissions": ["billing_invoice.read"]} + }, + { + "name": "prefix-hasAccess-matches-only-the-underscore-prefix", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["hasAccess"],"Resource":["utils_*"]}]], + "expect": {"valid": true, "permissions": ["utils_helper.hasAccess", "utils_log.hasAccess"]} + }, + { + "name": "prefix-star-is-five-standard-actions-not-features", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["*"],"Resource":["utils_*"]}]], + "expect": {"valid": true, "permissions": [ + "utils_helper.hasAccess", + "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete" + ]} + }, + { + "name": "prefix-feature-grants-only-where-registered", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["analyze"],"Resource":["utils_*"]}]], + "expect": {"valid": true, "permissions": ["utils_log.analyze"]} + }, + { + "name": "deny-subtracts-from-allow-on-same-resource", + "mode": "runtime", + "documents": [[ + {"Effect":"Allow","Action":["*"],"Resource":["billing_invoice"]}, + {"Effect":"Deny","Action":["delete"],"Resource":["billing_invoice"]} + ]], + "expect": {"valid": true, "permissions": [ + "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit" + ]} + }, + { + "name": "deny-in-another-group-wins", + "mode": "runtime", + "documents": [ + [{"Effect":"Allow","Action":["*"],"Resource":["billing_*"]}], + [{"Effect":"Deny","Action":["*"],"Resource":["billing_report"]}] + ], + "expect": {"valid": true, "permissions": [ + "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete" + ]} + }, + { + "name": "deny-order-does-not-matter", + "mode": "runtime", + "documents": [ + [{"Effect":"Deny","Action":["*"],"Resource":["billing_report"]}], + [{"Effect":"Allow","Action":["*"],"Resource":["billing_*"]}] + ], + "expect": {"valid": true, "permissions": [ + "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete" + ]} + }, + { + "name": "full-access-minus-denied-prefix-everything-except-billing", + "mode": "runtime", + "documents": [[ + {"Effect":"Allow","Action":["*"],"Resource":["*"]}, + {"Effect":"Deny","Action":["*"],"Resource":["billing_*"]} + ]], + "expect": {"valid": true, "permissions": [ + "utils_helper.hasAccess", + "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete", "utils_log.analyze", + "utilsboard.hasAccess", + "authentik.users.manage.hasAccess", + "cdn.purge" + ]} + }, + { + "name": "deny-star-removes-features-too", + "mode": "runtime", + "documents": [[ + {"Effect":"Allow","Action":["*"],"Resource":["*"]}, + {"Effect":"Deny","Action":["*"],"Resource":["utils_log"]} + ]], + "expect": {"valid": true, "permissions": [ + "utils_helper.hasAccess", + "utilsboard.hasAccess", + "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", + "billing_report.hasAccess", + "authentik.users.manage.hasAccess", + "cdn.purge" + ]} + }, + { + "name": "allow-star-does-not-grant-features-but-full-access-does", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["*"],"Resource":["utils_log"]}]], + "expect": {"valid": true, "permissions": [ + "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete" + ]} + }, + { + "name": "named-permission-deny-removes-it-from-full-access", + "mode": "runtime", + "documents": [[ + {"Effect":"Allow","Action":["*"],"Resource":["*"]}, + {"Effect":"Deny","Permission":["cdn.purge", "authentik.users.manage.hasAccess"]} + ]], + "expect": {"valid": true, "permissions": [ + "utils_helper.hasAccess", + "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete", "utils_log.analyze", + "utilsboard.hasAccess", + "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", + "billing_report.hasAccess" + ]} + }, + { + "name": "named-permission-allow", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Permission":["cdn.purge"]}]], + "expect": {"valid": true, "permissions": ["cdn.purge"]} + }, + { + "name": "deny-only-document-grants-nothing", + "mode": "runtime", + "documents": [[{"Effect":"Deny","Action":["*"],"Resource":["billing_*"]}]], + "expect": {"valid": true, "permissions": []} + }, + { + "name": "empty-statement-array-grants-nothing", + "mode": "runtime", + "documents": [[]], + "expect": {"valid": true, "permissions": []} + }, + { + "name": "zero-match-prefix-is-empty-at-runtime", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["*"],"Resource":["zzz_*"]}]], + "expect": {"valid": true, "permissions": []} + }, + { + "name": "zero-match-allow-prefix-rejected-at-save", + "mode": "save", + "documents": [[{"Effect":"Allow","Action":["*"],"Resource":["zzz_*"]}]], + "expect": {"valid": false, "error": "matches nothing"} + }, + { + "name": "zero-match-deny-prefix-rejected-at-save", + "mode": "save", + "documents": [[{"Effect":"Deny","Action":["*"],"Resource":["zzz_*"]}]], + "expect": {"valid": false, "error": "matches nothing"} + }, + { + "name": "unknown-named-permission-rejected-at-save", + "mode": "save", + "documents": [[{"Effect":"Deny","Permission":["cdn.nuke"]}]], + "expect": {"valid": false, "error": "matches nothing"} + }, + { + "name": "prefix-too-short-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["read"],"Resource":["u*"]}]], + "expect": {"valid": false, "error": "prefix"} + }, + { + "name": "prefix-without-underscore-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["read"],"Resource":["utils*"]}]], + "expect": {"valid": false, "error": "prefix"} + }, + { + "name": "wildcard-not-at-end-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["read"],"Resource":["*_log"]}]], + "expect": {"valid": false, "error": "prefix"} + }, + { + "name": "star-resource-requires-star-action-for-deny-too", + "mode": "runtime", + "documents": [[{"Effect":"Deny","Action":["read"],"Resource":["*"]}]], + "expect": {"valid": false, "error": "requires"} + }, + { + "name": "permission-and-resource-in-one-statement-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["read"],"Resource":["billing_invoice"],"Permission":["cdn.purge"]}]], + "expect": {"valid": false, "error": "Permission"} + }, + { + "name": "unknown-effect-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Maybe","Action":["read"],"Resource":["billing_invoice"]}]], + "expect": {"valid": false, "error": "Effect"} + }, + { + "name": "one-bad-statement-voids-the-document", + "mode": "runtime", + "documents": [[ + {"Effect":"Allow","Action":["read"],"Resource":["billing_invoice"]}, + {"Effect":"Allow","Action":["read"],"Resource":["bad name"]} + ]], + "expect": {"valid": false, "error": "resource"} + }, + { + "name": "one-bad-group-voids-the-whole-merged-claim", + "mode": "runtime", + "documents": [ + [{"Effect":"Allow","Action":["*"],"Resource":["*"]}], + [{"Effect":"Deny","Action":["*"],"Resource":["billing_*"]}, {"Effect":"Deny","Action":["read"],"Resource":["bad name"]}] + ], + "expect": {"valid": false, "error": "resource"} + }, + { + "name": "other-unknown-statement-keys-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["read"],"Resource":["billing_invoice"],"Condition":{"IpAddress":"1.2.3.4"}}]], + "expect": {"valid": false, "error": "Condition"} + } + ] +} -- 2.54.0 From 9c76ff389e7e5c661ee813747597fa8d75eea244 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 18:01:26 -0600 Subject: [PATCH 02/14] docs(policy): vectors and spec for hyphenated names, one universe, host names, claim shape Permission names are dot-separated segments of [A-Za-z0-9_-] (bare names allowed); Resource stays [A-Za-z0-9_]+ and utils_* prefixes. Every Allow expands against one universe, host names are not features, a standard action a resource lacks is dropped, and the claim is one merged document. New vectors: hyphenated and bare Permission names, invalid names, hyphenated Resource, host name as Action, dropped action, not-in-universe name. Co-Authored-By: Claude Sonnet 5.5 --- docs/POLICY-FORMAT.md | 24 ++++-- src/test/resources/policy-vectors-1.1.0.json | 85 +++++++++++++++++++- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/docs/POLICY-FORMAT.md b/docs/POLICY-FORMAT.md index 841c455..b806a77 100644 --- a/docs/POLICY-FORMAT.md +++ b/docs/POLICY-FORMAT.md @@ -33,11 +33,11 @@ A **policy** grants QQQ permissions to the members of an Authentik group. It is | `Statement[].Effect` | string | `Allow` or `Deny` | | `Statement[].Action` | array of strings | Each one of `hasAccess`, `read`, `insert`, `edit`, `delete`, `*`, or a table feature name registered for every listed resource (for a prefix resource: for at least one match) | | `Statement[].Resource` | array of strings | `*` with exactly `Action: ["*"]`; a prefix such as `utils_*`; or a QQQ permission base name matching `[A-Za-z0-9_]+`: a table, process, app, report or widget name (or its custom `permissionBaseName`) | -| `Statement[].Permission` | array of strings | Instead of `Action` and `Resource`: exact permission names, including dotted names such as `authentik.users.manage.hasAccess` or `cdn.purge` | +| `Statement[].Permission` | array of strings | Instead of `Action` and `Resource`: exact permission names, dot-separated segments of `[A-Za-z0-9_-]` (hyphens allowed here, not in `Resource`; a bare name with no dot is allowed). Dotted names such as `authentik.users.manage.hasAccess`, `cdn.purge` or `document-generation.print` are granted only this way | ## Meaning -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. +Each statement grants `{resource}.{action}` for every resource and action it lists, **that exists** (see "One universe" below): a standard action the resource does not have is silently dropped, so `Action ["hasAccess", "read"]` on a process grants only `hasAccess`. `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 | |---|---|---| @@ -69,9 +69,17 @@ A grant that QQQ never checks (for example `hasAccess` on a table with insert/ed **Named permissions.** A statement may carry `Permission: [...]` instead of `Action` and `Resource`, giving exact permission names. It works under `Allow` and `Deny`, and is the only way to deny a dotted permission such as `authentik.users.manage.hasAccess` or a host name such as `cdn.purge` that full access includes. Mixing `Permission` with `Action` or `Resource` in one statement rejects the document. -**Zero matches.** At sign-in a prefix or named permission that matches nothing expands to nothing. When an operator saves a document, any prefix, feature or named permission that matches nothing in the current instance is rejected ("matches nothing"), for `Allow` and for `Deny`. A mistyped `Deny` that silently does nothing is the dangerous case, so it is refused at the point where someone can fix it. +**One universe.** Every Allow expansion (an exact `Resource` and `Action`, a `Permission` name, a prefix, and full access) is computed against the same set of permission names: core `getAllAvailablePermissions`, plus the qbit catalog including hidden tables and processes, plus host-declared names, plus every `IdentityPermission` session name. A prefix matches the base name (the text before the final `.action`) of any entry in that set. An Allow of a name that is not in the set grants nothing at runtime. A `Deny` is a predicate over names and needs no universe entry to apply. -**Editing.** Adding a `Deny` is always allowed to an operator with policy management. Removing a `Deny` is a grant: the operator must hold every permission it was withholding and must not be denied any of them themselves. +**Host-declared names are not features.** `Action` grants only the standard actions and the table features registered for that resource. Dotted service names (`alerts.admin`, `email.send`, `echo.use`) are never features; they are granted only through `Permission`. A host declares them with `PolicyPermissions.registerHostPermissions(qInstance -> names)`, which needs no Authentik runtime (so a host that runs only the policy mapper keeps its names), or with `AuthentikQBitConfig.withFullAccessHostPermissions`. The two are unioned and evaluated at every expansion. + +**Claim shape.** The claim is ONE merged document, a JSON object or a JSON string, exactly what the claim expression emits. An array of documents is rejected. + +**Zero matches.** At sign-in a prefix or named permission that matches nothing expands to nothing. When an operator saves a document, any prefix, feature or named permission that matches nothing in the current instance is rejected ("matches nothing"), for `Allow` and for `Deny`. A `Deny` of a named resource none of whose listed actions exists is rejected the same way (this is stricter than the golden vectors require). A mistyped `Deny` that silently does nothing is the dangerous case, so it is refused at the point where someone can fix it. + +**Editing.** Adding a `Deny` is always allowed to an operator with policy management. Removing or narrowing a `Deny` is a grant: the operator must hold every permission it was withholding and must not be denied any of them themselves. Removing a person from a group, or a group from a parent, releases the `Deny` statements of that group and above it, so it needs the same check. Saving a policy that contains a `Deny` also runs the last-administrator check. + +**What a Deny subtracts.** A `Deny` removes what the user's groups grant through policy documents and through `qqq.io/permissions`. It does not subtract host-owned floors: the static group fallbacks and the bootstrap-operator identity permissions. The golden vectors in `src/test/resources/policy-vectors-1.1.0.json` define these rules over a small fixed permission universe. Every implementation of the grammar must pass them: this qbit's parser, the Authentik claim validator in the identity CD repository, and Forge's catalog tests. @@ -125,7 +133,9 @@ A document grants **nothing**, and the parser logs a warning, when any of these - a statement's `Effect` is neither `Allow` nor `Deny` - a statement has a key other than `Effect`, `Action`, `Resource` and `Permission`, or mixes `Permission` with `Action`/`Resource` - a wildcard resource is not a valid prefix -- `Action` or `Resource` is missing or not an array of strings +- `Action` or `Resource` is missing or not an array of strings (a statement with `Permission` has neither) +- a `Permission` name is not dot-separated segments of `[A-Za-z0-9_-]` +- the claim is not one JSON object or JSON string (an array of documents is rejected) - an action is neither a standard action, `*`, nor a feature registered for every listed resource - a resource does not match `[A-Za-z0-9_]+`, except `*` with exactly `Action: ["*"]` @@ -156,7 +166,7 @@ An app that already stores documents of this shape under its own spec name, attr ## Getting policies into the token -Authentik puts the merged document into the token through a scope mapping. Steps: +Authentik puts the merged document into the token through a scope mapping. The example blueprint voids the whole claim (it emits a document the qbit rejects) when any of the user's documents under the configured spec is unparsable, not an object, has another version or a non-array `Statement`; only a document that declares another spec is skipped. Steps: 1. Apply the example blueprint, [`src/test/resources/blueprints/qqq-policy-claim.yaml`](../src/test/resources/blueprints/qqq-policy-claim.yaml). Edit its spec, version, attribute and claim name to match your config. 2. Add the mapping to the OAuth2 provider's `property_mappings`. It uses the scope `profile`, which every QQQ sign-in requests. @@ -166,6 +176,8 @@ 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 expanded catalog and permission universe are cached per `QInstance` (the last few instances, keyed by a digest of table permission rules, supplemental metadata and object names); `PolicyCatalog.clearCache()` forgets them. Host-declared names are never cached. + 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. diff --git a/src/test/resources/policy-vectors-1.1.0.json b/src/test/resources/policy-vectors-1.1.0.json index 8648889..0aea0c3 100644 --- a/src/test/resources/policy-vectors-1.1.0.json +++ b/src/test/resources/policy-vectors-1.1.0.json @@ -4,7 +4,10 @@ "the qbit parser, the Authentik claim validator in greater-goods-authentik-cd, and the Forge catalog tests.", "'universe' is the complete set of permission names that exist. Permission base name = text before the last dot", "(authentik.users.manage.hasAccess has base authentik.users.manage; cdn.purge has no action and is a named permission).", + "'hostNames' are the universe entries that are host-declared service names (not table features): they are granted only through the Permission key, never as an Action.", "'documents' are the Statement arrays of every group the user is in (the merged claim). 'expect.permissions' is compared as a set.", + "Resource allows [A-Za-z0-9_]+ and utils_* prefixes (no hyphen); Permission names are dot-separated segments of [A-Za-z0-9_-] (a bare name has no dot).", + "Every Allow, exact names included, expands against the universe: a name that is not in it grants nothing at runtime.", "mode 'runtime' = what happens at sign-in; mode 'save' = extra checks the editor runs before writing (zero-match rules).", "A vector with valid=false means the WHOLE merged claim grants nothing." ], @@ -17,8 +20,11 @@ "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", "billing_report.hasAccess", "authentik.users.manage.hasAccess", - "cdn.purge" + "cdn.purge", + "document-generation.print", + "ping" ], + "hostNames": ["authentik.users.manage.hasAccess", "cdn.purge", "document-generation.print", "ping"], "vectors": [ { "name": "allow-exact-resource", @@ -92,7 +98,7 @@ "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete", "utils_log.analyze", "utilsboard.hasAccess", "authentik.users.manage.hasAccess", - "cdn.purge" + "cdn.purge", "document-generation.print", "ping" ]} }, { @@ -108,7 +114,7 @@ "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", "billing_report.hasAccess", "authentik.users.manage.hasAccess", - "cdn.purge" + "cdn.purge", "document-generation.print", "ping" ]} }, { @@ -131,7 +137,7 @@ "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete", "utils_log.analyze", "utilsboard.hasAccess", "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", - "billing_report.hasAccess" + "billing_report.hasAccess", "document-generation.print", "ping" ]} }, { @@ -146,6 +152,77 @@ "documents": [[{"Effect":"Deny","Action":["*"],"Resource":["billing_*"]}]], "expect": {"valid": true, "permissions": []} }, + { + "name": "permission-hyphenated-name-allow", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Permission":["document-generation.print"]}]], + "expect": {"valid": true, "permissions": ["document-generation.print"]} + }, + { + "name": "permission-bare-name-allow", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Permission":["ping"]}]], + "expect": {"valid": true, "permissions": ["ping"]} + }, + { + "name": "permission-hyphenated-deny-removes-it-from-full-access", + "mode": "runtime", + "documents": [[ + {"Effect":"Allow","Action":["*"],"Resource":["*"]}, + {"Effect":"Deny","Permission":["document-generation.print", "ping"]} + ]], + "expect": {"valid": true, "permissions": [ + "utils_helper.hasAccess", + "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete", "utils_log.analyze", + "utilsboard.hasAccess", + "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", + "billing_report.hasAccess", + "authentik.users.manage.hasAccess", + "cdn.purge" + ]} + }, + { + "name": "permission-not-in-universe-grants-nothing-at-runtime", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Permission":["cdn.nuke"]}]], + "expect": {"valid": true, "permissions": []} + }, + { + "name": "standard-action-the-resource-lacks-is-dropped", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["hasAccess","read"],"Resource":["billing_report"]}]], + "expect": {"valid": true, "permissions": ["billing_report.hasAccess"]} + }, + { + "name": "host-name-is-not-a-feature", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["purge"],"Resource":["cdn"]}]], + "expect": {"valid": false, "error": "unknown action"} + }, + { + "name": "permission-with-space-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Permission":["bad name.x"]}]], + "expect": {"valid": false, "error": "permission"} + }, + { + "name": "permission-with-empty-segment-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Deny","Permission":["cdn..purge"]}]], + "expect": {"valid": false, "error": "permission"} + }, + { + "name": "permission-with-leading-dot-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Permission":[".purge"]}]], + "expect": {"valid": false, "error": "permission"} + }, + { + "name": "hyphen-is-not-allowed-in-a-resource", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["read"],"Resource":["document-generation"]}]], + "expect": {"valid": false, "error": "resource"} + }, { "name": "empty-statement-array-grants-nothing", "mode": "runtime", -- 2.54.0 From 1ca28ecce61a17c13182d06b4e61ddd593eade7d Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 18:09:24 -0600 Subject: [PATCH 03/14] feat(policy): format 1.1.0 typed rules (Deny, prefix resources, named permissions) PolicyParser returns typed PolicyRules (Allow and Deny terms, or void) instead of permission names, so a Deny or prefix cannot be read as a grant. Result = union(Allow) minus union(Deny) across all of a user's groups; one invalid policy voids every group of the user. Allow expands against one live universe; Deny is a predicate. Canonicalization never drops a Deny. Editor: save-time zero-match checks, effective-permission subset rule, release of Deny on removal, last-admin check. Host names register without an Authentik runtime; PolicyCatalog is cached per QInstance. Default PolicyVersion is 1.1.0. Co-Authored-By: Claude Sonnet 5.5 --- .../qbits/authentik/admin/AuthentikAdmin.java | 201 ++++++--- .../authentik/admin/AuthentikSafety.java | 16 +- .../AuthentikGroupPermissionMapper.java | 56 ++- .../EffectivePermissionResolver.java | 147 ++++++- .../authentik/permissions/GroupGraph.java | 24 +- .../policy/AuthentikPolicyClaimMapper.java | 2 +- .../qbits/authentik/policy/PolicyCatalog.java | 168 +++++++- .../authentik/policy/PolicyEditorWidget.java | 38 +- .../qbits/authentik/policy/PolicyFormat.java | 4 +- .../authentik/policy/PolicyGenerator.java | 240 +++++++---- .../qbits/authentik/policy/PolicyParser.java | 357 +++++++++++++--- .../authentik/policy/PolicyPermissions.java | 128 +++++- .../qbits/authentik/policy/PolicyRules.java | 399 ++++++++++++++++++ .../qbits/authentik/policy/PolicyTerm.java | 176 ++++++++ .../authentik/policy/PolicyUniverse.java | 51 +++ .../processes/AccessEditorWidget.java | 11 +- 16 files changed, 1762 insertions(+), 256 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java create mode 100644 src/main/java/com/kingsrook/qbits/authentik/policy/PolicyTerm.java create mode 100644 src/main/java/com/kingsrook/qbits/authentik/policy/PolicyUniverse.java diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java index abed392..fd9a8c5 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java @@ -23,6 +23,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -30,6 +31,7 @@ import java.util.Set; import java.util.TreeSet; import java.util.UUID; import java.util.regex.Pattern; +import java.util.stream.Collectors; import com.kingsrook.qbits.authentik.AuthentikFeature; import com.kingsrook.qbits.authentik.AuthentikQBitConfig; import com.kingsrook.qbits.authentik.AuthentikRuntime; @@ -43,6 +45,9 @@ 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.PolicyRules; +import com.kingsrook.qbits.authentik.policy.PolicyTerm; +import com.kingsrook.qbits.authentik.policy.PolicyUniverse; import com.kingsrook.qbits.authentik.policy.PolicyPermissions; import com.kingsrook.qbits.authentik.policy.PolicyStore; import com.kingsrook.qbits.authentik.security.AuthentikGuard; @@ -1361,11 +1366,41 @@ public class AuthentikAdmin return (plan.refuse("This is the last person who can manage memberships and permissions. Add another administrator first.")); } } + String released = checkDenyRelease(node); + if(released != null) + { + return (plan.refuse(released)); + } return (plan.withChange("member of " + node.name(), "not a member")); } + /******************************************************************************* + ** Removing someone from a group, or a group from a parent, releases every + ** permission a Deny in that group (or above it) was withholding, so it is a + ** grant: the operator must hold all of them (the subset rule for Deny). + ** Conservative: it does not credit a Deny the person keeps through another + ** group. Returns the refusal text, or null. + *******************************************************************************/ + String checkDenyRelease(GroupGraph.Node node) throws QException + { + PolicyUniverse universe = PolicyUniverse.of(resolver().universe().names()); + Set withheld = new TreeSet<>(); + for(GroupGraph.Node held : graph().selfAndAncestors(node.pk())) + { + withheld.addAll(held.policy().withheld(universe)); + } + Set missing = AuthentikSafety.missing(withheld, operator().permissions()); + if(missing.isEmpty()) + { + return (null); + } + return ("A Deny in " + node.name() + " (or above it) withholds permissions you do not hold yourself, so removing it from the person or group would grant them: " + abbreviate(missing)); + } + + + /******************************************************************************* ** Create a group: name, parents (names), permissions and app access. *******************************************************************************/ @@ -1489,6 +1524,15 @@ public class AuthentikAdmin if(!new LinkedHashSet<>(newParents).equals(new LinkedHashSet<>(currentParents))) { + for(String oldParentPk : currentParents) + { + GroupGraph.Node oldParent = newParents.contains(oldParentPk) ? null : graph().byPk(oldParentPk); + String released = oldParent == null ? null : checkDenyRelease(oldParent); + if(released != null) + { + return (plan.refuse(released)); + } + } GroupGraph.Node current = membersGraph().byPk(node.pk()); GroupGraph simulated = AuthentikSafety.withNodes(membersGraph(), AuthentikSafety.withParents(current, newParents)); Set before = adminsNow(); @@ -1547,7 +1591,7 @@ public class AuthentikAdmin GroupGraph.Node node = membersGraph().byPk(ids.get(0)); if(group == null || node == null || !node.memberPks().equals(List.of(Integer.valueOf(userPk))) || !node.parentPks().isEmpty() || !AuthentikSafety.descendantPks(membersGraph(), node.pk()).isEmpty() || node.ownSuperuser() - || !node.policyPermissions().isEmpty() || !node.appAccess().isEmpty() || !node.malformed().isEmpty() + || !node.policy().isEmpty() || node.policy().isVoid() || !node.appAccess().isEmpty() || !node.malformed().isEmpty() || (group.optJSONArray("roles") != null && !group.getJSONArray("roles").isEmpty())) { throw (new QUserFacingException("This user's direct-access group was shared or changed outside this editor. Resolve it in Authentik first.")); @@ -1686,27 +1730,44 @@ public class AuthentikAdmin Set extrasNew = new TreeSet<>(AuthentikSafety.parseList(request.str(EXTRA_PERMISSIONS))); extrasNew.removeAll(oldNamed); - Set newPermissions; - JSONObject canonical; + ///////////////////////////////////////////////////////////////////////// + // everything below compares EFFECTIVE permissions (Allow minus Deny) // + // of the old and new document against one live universe: adding a Deny // + // never needs a grant, removing or narrowing a Deny is a grant // + ///////////////////////////////////////////////////////////////////////// + PolicyUniverse universe = PolicyUniverse.of(resolver().universe().names()); + PolicyRules newRules; + Set newNamed; + JSONObject canonical; try { String submitted = request.str(POLICY_DOCUMENT); - newPermissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).permissions() : new TreeSet<>(); - canonical = PolicyGenerator.generate(newPermissions, format, runtime.getPolicyCatalog()); + if(StringUtils.hasContent(submitted)) + { + PolicyParser.Parsed parsed = PolicyParser.parseForSave(submitted, format, runtime.getPolicyCatalog(), universe); + newRules = parsed.rules(); + newNamed = parsed.named(); + } + else + { + newRules = PolicyRules.EMPTY; + newNamed = Set.of(); + } + canonical = PolicyGenerator.generate(newRules, format, runtime.getPolicyCatalog()); } catch(PolicyParseException e) { return (plan.refuse("The policy is not valid: " + e.getMessage())); } - Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute()); - Set oldPermissions = new TreeSet<>(); - String storedProblem = null; + Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute()); + PolicyRules oldRules = PolicyRules.EMPTY; + String storedProblem = null; if(stored != null && !JSONObject.NULL.equals(stored) && !"".equals(stored)) { try { - oldPermissions.addAll(PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).permissions()); + oldRules = PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).rules(); } catch(PolicyParseException e) { @@ -1720,32 +1781,32 @@ public class AuthentikAdmin return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again.")); } - Set added = new TreeSet<>(newPermissions); - added.removeAll(oldPermissions); - Set removed = new TreeSet<>(oldPermissions); - removed.removeAll(newPermissions); + Set effectiveNew = newRules.resolve(universe); + Set effectiveOld = oldRules.resolve(universe); + Set added = new TreeSet<>(effectiveNew); + added.removeAll(effectiveOld); + Set removed = new TreeSet<>(effectiveOld); + removed.removeAll(effectiveNew); + Set released = new TreeSet<>(oldRules.withheld(universe)); + released.removeAll(newRules.withheld(universe)); - Set unknownNew = runtime.getPolicyCatalog().unknownPermissions(added); + Set exactAdded = exactAllowed(newRules); + exactAdded.removeAll(exactAllowed(oldRules)); + exactAdded.removeAll(newNamed); + Set unknownNew = runtime.getPolicyCatalog().unknownPermissions(exactAdded); if(!unknownNew.isEmpty()) { return (plan.refuse("These are not permissions of this app: " + String.join(", ", unknownNew) + ". Pick them in the editor.")); } - boolean fullAccess = newPermissions.contains(PolicyPermissions.FULL_ACCESS); - boolean noChange = added.isEmpty() && removed.isEmpty() && storedProblem == null && extrasNew.isEmpty(); + boolean fullAccess = newRules.hasFullAccess(); + boolean noChange = newRules.equals(oldRules) && storedProblem == null && extrasNew.isEmpty(); if(noChange && !fullAccess) { return (plan.noChange("Nothing to change.")); } - Set 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)); - } + Set needed = new TreeSet<>(fullAccess ? effectiveNew : added); + needed.addAll(released); for(String extra : extrasNew) { needed.addAll(resolver().expand(extra)); @@ -1761,20 +1822,18 @@ public class AuthentikAdmin return (plan.noChange("Nothing to change.")); } - if(removed.contains(PolicyPermissions.FULL_ACCESS)) + if(newRules.hasDeny() || !removed.isEmpty() || oldRules.hasFullAccess()) { 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))); + GroupGraph simulated = AuthentikSafety.withNodes(membersGraph(), AuthentikSafety.withPolicy(current, newRules)); 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.")); + return (plan.refuse("Saving this policy would leave nobody able to manage memberships and permissions.")); } } - plan.withChange(policySummary(oldPermissions), policySummary(newPermissions)); + plan.withChange(policySummary(oldRules), policySummary(newRules)); if(request.bool("generated")) { plan.getNotes().add("Generated from current access: " + PolicyGenerator.toCompact(canonical)); @@ -1789,25 +1848,34 @@ public class AuthentikAdmin } if(!added.isEmpty()) { - plan.getNotes().add("Adds: " + (added.contains(PolicyPermissions.FULL_ACCESS) ? PolicyPermissions.FULL_ACCESS_LABEL : abbreviate(added))); + plan.getNotes().add("Adds: " + (fullAccess && !oldRules.hasFullAccess() ? PolicyPermissions.FULL_ACCESS_LABEL : abbreviate(added))); } if(!removed.isEmpty()) { - plan.getNotes().add("Removes: " + (removed.contains(PolicyPermissions.FULL_ACCESS) ? PolicyPermissions.FULL_ACCESS_LABEL : abbreviate(removed))); + plan.getNotes().add("Removes: " + (oldRules.hasFullAccess() && !fullAccess ? PolicyPermissions.FULL_ACCESS_LABEL : abbreviate(removed))); + } + List newDenials = new ArrayList<>(newRules.denyLabels()); + newDenials.removeAll(oldRules.denyLabels()); + if(!newDenials.isEmpty()) + { + plan.getNotes().add("Denies (wins over every grant, in every group): " + abbreviate(newDenials)); + } + if(!released.isEmpty()) + { + plan.getNotes().add("Stops withholding (members of this group and below can get these from other groups): " + abbreviate(released)); } if(storedProblem != null) { plan.getNotes().add("The policy stored in Authentik was not valid (" + storedProblem + "); saving replaces it."); } - Set keptUnknown = runtime.getPolicyCatalog().unknownPermissions(newPermissions); + Set keptUnknown = runtime.getPolicyCatalog().unknownPermissions(exactAllowed(newRules).stream().filter(n -> !newNamed.contains(n)).collect(Collectors.toCollection(TreeSet::new))); if(!keptUnknown.isEmpty()) { plan.getNotes().add("Kept although this app does not define them: " + abbreviate(keptUnknown)); } JSONArray users = group.optJSONArray("users"); plan.getNotes().add((users == null ? 0 : users.length()) + " member(s), and the members of groups below it, get the change at their next sign-in or token refresh."); - 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."); + plan.getNotes().add(canonical.getJSONArray("Statement").length() + " statement(s), " + effectiveNew.size() + " current permission(s); the document round-trips."); if(Boolean.TRUE.equals(config.getPolicyTwoPersonApproval()) && !request.bool("approved")) { @@ -1818,6 +1886,25 @@ public class AuthentikAdmin + /******************************************************************************* + ** The exact permission names of the Allow side (prefix and full access + ** terms are symbolic and not listed). + *******************************************************************************/ + private static Set exactAllowed(PolicyRules rules) + { + Set rs = new TreeSet<>(); + for(PolicyTerm term : rules.allowTerms()) + { + if(term instanceof PolicyTerm.Exact exact) + { + rs.add(exact.name()); + } + } + return (rs); + } + + + /******************************************************************************* ** Fill a Save Policy request from what members of the group get TODAY from ** the host (DESIGN.md section 12.8): the policy-expressible permissions @@ -1838,9 +1925,13 @@ public class AuthentikAdmin return (request.with("generateProblem", "That group no longer exists.")); } PolicyFormat format = config.getPolicyFormat(); - Set policy = new TreeSet<>(); Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute()); - policy.addAll(PolicyParser.parse(stored, format, runtime.getPolicyCatalog())); + PolicyRules existing = PolicyParser.parse(stored, format, runtime.getPolicyCatalog()); + if(existing.isVoid()) + { + existing = PolicyRules.EMPTY; + } + Set policy = new LinkedHashSet<>(existing.allowTerms()); Set expressible = runtime.getPolicyCatalog().permissionNames(); Set extras = new TreeSet<>(); @@ -1849,7 +1940,7 @@ public class AuthentikAdmin { if(expressible.contains(permission)) { - policy.add(permission); + policy.add(new PolicyTerm.Exact(permission)); } else if(!resolver().expand(permission).isEmpty()) { @@ -1862,7 +1953,7 @@ public class AuthentikAdmin } try { - request.with(POLICY_DOCUMENT, PolicyGenerator.toCompact(PolicyGenerator.generate(policy, format, runtime.getPolicyCatalog()))); + request.with(POLICY_DOCUMENT, PolicyGenerator.toCompact(PolicyGenerator.generate(PolicyRules.of(policy, existing.denyTerms()), format, runtime.getPolicyCatalog()))); } catch(PolicyParseException e) { @@ -2105,13 +2196,19 @@ public class AuthentikAdmin /******************************************************************************* ** "n permission(s): a, b, ..." for the audit before/after. *******************************************************************************/ - private static String policySummary(Set permissions) + private static String policySummary(PolicyRules rules) { - if(permissions.contains(PolicyPermissions.FULL_ACCESS)) + if(rules.isEmpty()) { - return (PolicyPermissions.FULL_ACCESS_LABEL); + return ("no policy"); } - return (permissions.isEmpty() ? "no policy" : permissions.size() + " permission(s): " + abbreviate(permissions)); + String deny = rules.hasDeny() ? "; Deny: " + abbreviate(rules.denyLabels()) : ""; + if(rules.hasFullAccess()) + { + return (PolicyPermissions.FULL_ACCESS_LABEL + deny); + } + List allow = rules.allowLabels(); + return ((allow.isEmpty() ? "no grants" : allow.size() + " permission(s): " + abbreviate(allow)) + deny); } @@ -2119,7 +2216,7 @@ public class AuthentikAdmin /******************************************************************************* ** The first 12 names, then "and n more". *******************************************************************************/ - private static String abbreviate(Set names) + private static String abbreviate(Collection names) { List list = new ArrayList<>(names); if(list.size() <= 12) @@ -2726,13 +2823,13 @@ public class AuthentikAdmin { AdminRequest request = plan.getRequest(); PolicyFormat format = config.getPolicyFormat(); - Set permissions; + PolicyRules rules; JSONObject canonical; try { String submitted = request.str(POLICY_DOCUMENT); - permissions = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).permissions() : new TreeSet<>(); - canonical = PolicyGenerator.generate(permissions, format, runtime.getPolicyCatalog()); + rules = StringUtils.hasContent(submitted) ? PolicyParser.parseStrict(submitted, format, runtime.getPolicyCatalog()).rules() : PolicyRules.EMPTY; + canonical = PolicyGenerator.generate(rules, format, runtime.getPolicyCatalog()); } catch(PolicyParseException e) { @@ -2754,7 +2851,7 @@ public class AuthentikAdmin named.addAll(extras); putList(attributes, AuthentikJson.ATTR_PERMISSIONS, new ArrayList<>(named)); } - if(permissions.isEmpty()) + if(rules.isEmpty()) { attributes.remove(format.attribute()); } @@ -2766,16 +2863,16 @@ public class AuthentikAdmin try { - int permissionCount = permissions.contains(PolicyPermissions.FULL_ACCESS) ? resolver().expand(PolicyPermissions.FULL_ACCESS).size() : permissions.size(); + int permissionCount = rules.resolve(resolver().universe()).size(); PolicyStore.save(config, request.targetKey(), fresh.optString("name"), canonical, permissionCount, guard.operatorEmail(), - permissions.isEmpty() ? null : PolicyStore.fingerprint(compact, format, runtime.getPolicyCatalog())); + rules.isEmpty() ? null : PolicyStore.fingerprint(compact, format, runtime.getPolicyCatalog())); } catch(Exception e) { LOG.warn("The policy was saved to Authentik but its QQQ record could not be written", logPair("group", request.targetKey()), logPair("error", e.getMessage())); return ("Policy saved to Authentik, but its QQQ record could not be written: " + e.getMessage()); } - return (permissions.isEmpty() ? "Policy removed from the group in Authentik." : "Policy saved to Authentik."); + return (rules.isEmpty() ? "Policy removed from the group in Authentik." : "Policy saved to Authentik."); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java index 185bacc..dd20443 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java @@ -141,7 +141,7 @@ public final class AuthentikSafety { List members = new ArrayList<>(node.memberPks()); members.remove(Integer.valueOf(userPk)); - return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed(), node.policyPermissions())); + return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed(), node.policy())); } @@ -151,7 +151,7 @@ public final class AuthentikSafety *******************************************************************************/ public static GroupGraph.Node withPermissions(GroupGraph.Node node, List permissions, List appAccess) { - return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed(), node.policyPermissions())); + return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), permissions, appAccess, node.memberPks(), node.malformed(), node.policy())); } @@ -161,7 +161,17 @@ public final class AuthentikSafety *******************************************************************************/ public static GroupGraph.Node withParents(GroupGraph.Node node, List parentPks) { - return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed(), node.policyPermissions())); + return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), parentPks, node.permissions(), node.appAccess(), node.memberPks(), node.malformed(), node.policy())); + } + + + + /******************************************************************************* + ** A copy of a node with other policy rules (to simulate saving a policy). + *******************************************************************************/ + public static GroupGraph.Node withPolicy(GroupGraph.Node node, com.kingsrook.qbits.authentik.policy.PolicyRules rules) + { + return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), node.memberPks(), node.malformed(), rules)); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java index 4235372..cc60643 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java @@ -33,6 +33,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikRedactor; import com.kingsrook.qbits.authentik.policy.PolicyCatalog; import com.kingsrook.qbits.authentik.policy.PolicyFormat; import com.kingsrook.qbits.authentik.policy.PolicyPermissions; +import com.kingsrook.qbits.authentik.policy.PolicyRules; import com.kingsrook.qbits.authentik.security.BootstrapOperators; import com.kingsrook.qbits.authentik.security.IdentityPermission; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; @@ -122,16 +123,30 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust String sub = claims.optString("sub", "").trim(); boolean verified = claims.optBoolean("email_verified", false); - Set permissions = new TreeSet<>(resolve(qInstance, groups, sub, email, verified)); + Detailed detailed = resolveDetailed(qInstance, groups, sub, email, verified); + Set permissions = new TreeSet<>(detailed.floor()); ////////////////////////////////////////////////////////////////////////// // the policy claim (docs/POLICY-FORMAT.md) carries the same group data // - // as the graph read above; the union also covers an unreachable API // + // as the graph read above; the union also covers an unreachable API. // + // A Deny from either source removes the grant from both, and an // + // invalid policy in either voids the user's policy grants. // ////////////////////////////////////////////////////////////////////////// AuthentikRuntime runtime = runtime(); PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat(); - permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format, - qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance))); + PolicyCatalog catalog = qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance); + PolicyRules claim = PolicyPermissions.rulesFromClaims(PolicyPermissions.claimsWithPolicy(context, format), format, catalog); + if(claim.isVoid() || detailed.denials().isVoid()) + { + LOG.warn("An invalid policy voids this user's policy grants", logPair("source", claim.isVoid() ? "token claim" : "group graph")); + } + else + { + Set fromPolicies = new TreeSet<>(detailed.fromGroups()); + fromPolicies.addAll(claim.resolve(PolicyPermissions.universe(qInstance == null ? PolicyPermissions.instanceOf(catalog) : qInstance, Set.of()))); + fromPolicies.removeIf(permission -> claim.denies(permission) || detailed.denials().denies(permission)); + permissions.addAll(fromPolicies); + } if(runtime != null) { @@ -316,6 +331,35 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust ** email_verified). Bootstrap operators are matched on immutable ids only. *******************************************************************************/ public Set resolve(QInstance qInstance, Collection groupNames, String sub, String email, boolean emailVerified) + { + Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified); + Set rs = new TreeSet<>(detailed.floor()); + rs.addAll(detailed.fromGroups()); + return (rs); + } + + + + /******************************************************************************* + ** What resolve() found, kept apart so a Deny and an invalid policy can be + ** applied across sources. + ** + ** @param floor static group permissions and bootstrap identity permissions + ** (host-owned, not subtracted by a policy Deny) + ** @param fromGroups the permissions the groups' policies and qqq.io/permissions + ** grant, with Deny already applied; empty when voided + ** @param denials the Deny side of the user's policies (void when any is invalid) + *******************************************************************************/ + private record Detailed(Set floor, Set fromGroups, PolicyRules denials) + { + } + + + + /******************************************************************************* + ** Resolve into floor, group-derived grants and denials. + *******************************************************************************/ + private Detailed resolveDetailed(QInstance qInstance, Collection groupNames, String sub, String email, boolean emailVerified) { Set rs = new TreeSet<>(); for(String group : groupNames) @@ -352,13 +396,13 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust { LOG.info("Group attributes name permissions this app does not have", logPair("unknown", resolution.unknown())); } - rs.addAll(resolution.sessionPermissions()); + return (new Detailed(rs, new TreeSet<>(resolution.sessionPermissions()), resolution.denials())); } catch(Exception e) { LOG.warn("Authentik groups could not be read at sign-in; granting only the static fallbacks", logPair("error", AuthentikRedactor.redactText(e.getMessage(), runtime == null ? null : runtime.getConfig().getResolvedApiToken()))); } - return (rs); + return (new Detailed(rs, new TreeSet<>(), PolicyRules.EMPTY)); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java index bce0ff7..20c805e 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java @@ -26,6 +26,9 @@ import java.util.Map; import java.util.Set; import java.util.TreeSet; import com.kingsrook.qbits.authentik.policy.PolicyPermissions; +import com.kingsrook.qbits.authentik.policy.PolicyRules; +import com.kingsrook.qbits.authentik.policy.PolicyTerm; +import com.kingsrook.qbits.authentik.policy.PolicyUniverse; import com.kingsrook.qbits.authentik.security.IdentityPermission; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; @@ -36,7 +39,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance; ** 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. + ** - Policies are typed rules (policy.PolicyRules). Allow terms expand against + ** the live universe (full access, prefix resources); Deny terms subtract + ** from EVERYTHING the user's groups grant, qqq.io/permissions included. A + ** Deny anywhere wins; there is no ordering. + ** - ONE invalid policy among the groups (and ancestors) voids all of them: + ** the user gets no permissions and no app access. Dropping only the bad + ** group could drop a Deny while other groups' Allow statements survive. + ** - Full access 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 @@ -52,14 +62,33 @@ public class EffectivePermissionResolver private final GroupGraph graph; private final Set catalog; private final QInstance qInstance; + private final PolicyUniverse universe; /******************************************************************************* ** Result for a set of groups. *******************************************************************************/ - public record Resolution(Set sessionPermissions, Map> grantedBy, Set unknown, Set appAccess) + public record Resolution(Set sessionPermissions, Map> grantedBy, Set unknown, Set appAccess, PolicyRules denials) { + /******************************************************************************* + ** A resolution with no policy denials. + *******************************************************************************/ + public Resolution(Set sessionPermissions, Map> grantedBy, Set unknown, Set appAccess) + { + this(sessionPermissions, grantedBy, unknown, appAccess, PolicyRules.EMPTY); + } + + + + /******************************************************************************* + ** Whether an invalid policy voided this user's access (the permissions are + ** empty and the denials are void). + *******************************************************************************/ + public boolean voided() + { + return (denials.isVoid()); + } } @@ -84,6 +113,18 @@ public class EffectivePermissionResolver *******************************************************************************/ public EffectivePermissionResolver(GroupGraph graph, Collection catalog, QInstance qInstance) { + this(graph, catalog, qInstance, null); + } + + + + /******************************************************************************* + ** Resolve with an explicit permission universe (the test seam for golden + ** vectors); null means the live universe of the instance or QContext. + *******************************************************************************/ + public EffectivePermissionResolver(GroupGraph graph, Collection catalog, QInstance qInstance, PolicyUniverse universe) + { + this.universe = universe; this.graph = graph; this.qInstance = qInstance; this.catalog = catalog == null ? null : new TreeSet<>(catalog); @@ -118,6 +159,8 @@ public class EffectivePermissionResolver Map> grantedBy = new LinkedHashMap<>(); Set unknown = new TreeSet<>(); Set appAccess = new TreeSet<>(); + List policies = new ArrayList<>(); + PolicyUniverse live = memoized(universe()); Set visited = new LinkedHashSet<>(); for(String pk : groupPks) @@ -129,27 +172,94 @@ public class EffectivePermissionResolver continue; } appAccess.addAll(node.appAccess()); - List raws = new ArrayList<>(node.permissions()); + for(String raw : node.permissions()) + { + grant(expand(raw), raw, node, permissions, grantedBy, unknown); + } if(!node.pk().equals(excludePolicyOfGroupPk)) { - raws.addAll(node.policyPermissions()); - } - for(String raw : raws) - { - Set expanded = expand(raw); - if(expanded.isEmpty()) + policies.add(node.policy()); + for(PolicyTerm term : node.policy().allowTerms()) { - unknown.add(raw); - } - for(String permission : expanded) - { - permissions.add(permission); - grantedBy.computeIfAbsent(permission, k -> new TreeSet<>()).add(node.name()); + grant(term.expand(live), term instanceof PolicyTerm.Exact exact ? exact.name() : null, node, permissions, grantedBy, unknown); } } } } - return (new Resolution(permissions, grantedBy, unknown, appAccess)); + + //////////////////////////////////////////////////////////////////////// + // one invalid policy voids every group of the user; a Deny anywhere // + // removes what any source (policy or qqq.io/permissions) granted // + //////////////////////////////////////////////////////////////////////// + PolicyRules denials = PolicyRules.merge(policies).denialsOnly(); + if(denials.isVoid()) + { + return (new Resolution(new TreeSet<>(), new LinkedHashMap<>(), unknown, new TreeSet<>(), denials)); + } + permissions.removeIf(denials::denies); + grantedBy.keySet().removeIf(denials::denies); + return (new Resolution(permissions, grantedBy, unknown, appAccess, denials)); + } + + + + /******************************************************************************* + ** Record what a node granted; a name that expanded to nothing is unknown. + *******************************************************************************/ + private static void grant(Set expanded, String raw, GroupGraph.Node node, Set permissions, Map> grantedBy, Set unknown) + { + if(expanded.isEmpty() && raw != null) + { + unknown.add(raw); + } + for(String permission : expanded) + { + permissions.add(permission); + grantedBy.computeIfAbsent(permission, k -> new TreeSet<>()).add(node.name()); + } + } + + + + /******************************************************************************* + ** The universe symbolic terms expand against: the explicit one, else every + ** current permission of the instance (never cached across calls). + *******************************************************************************/ + public PolicyUniverse universe() + { + if(universe != null) + { + return (universe); + } + return (() -> PolicyPermissions.allPermissions(qInstance == null ? QContext.getQInstance() : qInstance, catalog)); + } + + + + /******************************************************************************* + ** Evaluate a universe at most once. + *******************************************************************************/ + private static PolicyUniverse memoized(PolicyUniverse source) + { + List> cache = new ArrayList<>(); + return (() -> + { + if(cache.isEmpty()) + { + cache.add(source.names()); + } + return (cache.get(0)); + }); + } + + + + /******************************************************************************* + ** Every permission full access grants now (the live universe). + *******************************************************************************/ + public Set fullAccessPermissions() + { + return (new TreeSet<>(universe().names())); } @@ -176,11 +286,6 @@ 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)) { for(IdentityPermission permission : IdentityPermission.values()) diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/GroupGraph.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/GroupGraph.java index 0cd0ed0..881e46f 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/GroupGraph.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/GroupGraph.java @@ -31,6 +31,7 @@ import com.kingsrook.qbits.authentik.policy.PolicyCatalog; import com.kingsrook.qbits.authentik.policy.PolicyFormat; import com.kingsrook.qbits.authentik.policy.PolicyParseException; import com.kingsrook.qbits.authentik.policy.PolicyParser; +import com.kingsrook.qbits.authentik.policy.PolicyRules; import org.json.JSONArray; import org.json.JSONObject; @@ -47,14 +48,14 @@ public class GroupGraph ** One group. *******************************************************************************/ public record Node(String pk, String name, boolean ownSuperuser, List parentPks, List permissions, List appAccess, List memberPks, List malformed, - List policyPermissions) + PolicyRules policy) { /******************************************************************************* ** A group without a policy document. *******************************************************************************/ public Node(String pk, String name, boolean ownSuperuser, List parentPks, List permissions, List appAccess, List memberPks, List malformed) { - this(pk, name, ownSuperuser, parentPks, permissions, appAccess, memberPks, malformed, List.of()); + this(pk, name, ownSuperuser, parentPks, permissions, appAccess, memberPks, malformed, PolicyRules.EMPTY); } } @@ -82,7 +83,10 @@ public class GroupGraph /******************************************************************************* ** Build from Authentik group objects, reading each group's policy document ** (docs/POLICY-FORMAT.md) from the format's attribute. A document that does - ** not parse grants nothing and is listed in the node's malformed entries. + ** not parse is listed in the node's malformed entries and its rules are VOID: + ** the void spreads to every user who has the group, direct or inherited + ** (see EffectivePermissionResolver), because dropping only the bad group + ** could drop a Deny while other groups' Allow statements survive. *******************************************************************************/ public static GroupGraph fromGroups(Collection groups, PolicyFormat format) { @@ -106,7 +110,7 @@ public class GroupGraph graph.userAccessOwners.put(group.getString("pk"), attributes.optString(USER_ACCESS_ATTRIBUTE)); } List malformed = malformed(attributes); - List policy = policyPermissions(attributes, f, malformed, catalog); + PolicyRules policy = policyRules(attributes, f, malformed, catalog); graph.add(new Node( group.getString("pk"), group.optString("name", ""), @@ -320,24 +324,24 @@ public class GroupGraph /******************************************************************************* - ** The permissions of a group's policy document; none (and a malformed entry) - ** when it does not parse. + ** The typed rules of a group's policy document: empty without one, void + ** (and a malformed entry) when it does not parse. *******************************************************************************/ - private static List policyPermissions(JSONObject attributes, PolicyFormat format, List malformed, PolicyCatalog catalog) + private static PolicyRules policyRules(JSONObject attributes, PolicyFormat format, List malformed, PolicyCatalog catalog) { Object document = attributes == null ? null : attributes.opt(format.attribute()); if(document == null || JSONObject.NULL.equals(document) || "".equals(document)) { - return (List.of()); + return (PolicyRules.EMPTY); } try { - return (List.copyOf(PolicyParser.parseStrict(document, format, catalog).permissions())); + return (PolicyParser.parseStrict(document, format, catalog).rules()); } catch(PolicyParseException e) { malformed.add(format.attribute() + ": " + e.getMessage()); - return (List.of()); + return (PolicyRules.voided(e.getMessage())); } } diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java b/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java index 54ff7bd..c65055e 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java @@ -119,7 +119,7 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz protected void applySecurityKeys(QSession qSession, JSONObject claims) { //////////////////////////////////////////////////////// - // no record locks in policy format 1.0.0 (by design) // + // no record locks in policy format 1.1.0 (by design) // //////////////////////////////////////////////////////// } } diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java index df1e8b1..06f15d0 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java @@ -111,17 +111,40 @@ public final class PolicyCatalog + private static final int CACHE_SIZE = 4; + + /** The last few instances' catalogs, so sign-in does not rebuild one per call. */ + private static final Map CACHE = new LinkedHashMap<>(8, 0.75f, true) + { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) + { + return (size() > CACHE_SIZE); + } + }; + private final List groups; private final Map byName; private final QInstance qInstance; + private final Set availableNames; + + + + /******************************************************************************* + ** A cached catalog with the metadata fingerprint it was built from. + *******************************************************************************/ + private record Cached(String fingerprint, PolicyCatalog catalog) + { + } /******************************************************************************* ** Constructor. *******************************************************************************/ - private PolicyCatalog(List groups, QInstance qInstance) + private PolicyCatalog(List groups, QInstance qInstance, Set availableNames) { + this.availableNames = Set.copyOf(availableNames); this.qInstance = qInstance; this.groups = groups; this.byName = new LinkedHashMap<>(); @@ -140,6 +163,68 @@ public final class PolicyCatalog ** Build the catalog of an instance. *******************************************************************************/ public static PolicyCatalog of(QInstance qInstance) + { + String fingerprint = fingerprint(qInstance); + synchronized(CACHE) + { + Cached cached = CACHE.get(qInstance); + if(cached != null && cached.fingerprint().equals(fingerprint)) + { + return (cached.catalog()); + } + } + PolicyCatalog built = build(qInstance); + synchronized(CACHE) + { + CACHE.put(qInstance, new Cached(fingerprint, built)); + } + return (built); + } + + + + /******************************************************************************* + ** Forget every cached catalog (a host that changes table features in place + ** without adding or removing metadata calls this). + *******************************************************************************/ + public static void clearCache() + { + synchronized(CACHE) + { + CACHE.clear(); + } + } + + + + /******************************************************************************* + ** A cheap digest of what the catalog is built from: object names, table + ** permission rules and supplemental (feature) metadata keys. A change in + ** any of them rebuilds the catalog on the next use. + *******************************************************************************/ + static String fingerprint(QInstance qInstance) + { + StringBuilder sb = new StringBuilder(); + new TreeMap<>(qInstance.getTables()).forEach((name, table) -> + { + QPermissionRules rules = table.getPermissionRules(); + sb.append("t:").append(name).append('|') + .append(rules == null ? "-" : rules.getLevel() + ":" + rules.getPermissionBaseName() + ":" + rules.getCustomPermissionChecker()).append('|') + .append(table.getSupplementalMetaData() == null ? "" : new TreeSet<>(table.getSupplementalMetaData().keySet())).append(';'); + }); + sb.append("p:").append(new TreeSet<>(qInstance.getProcesses().keySet())); + sb.append("a:").append(new TreeSet<>(qInstance.getApps().keySet())); + sb.append("r:").append(new TreeSet<>(qInstance.getReports().keySet())); + sb.append("w:").append(new TreeSet<>(qInstance.getWidgets().keySet())); + return (sb.toString()); + } + + + + /******************************************************************************* + ** Build the catalog of an instance without the cache. + *******************************************************************************/ + public static PolicyCatalog build(QInstance qInstance) { Collection availablePermissions = PermissionsHelper.getAllAvailablePermissions(qInstance); Map appOfObject = appsOfObjects(qInstance); @@ -258,7 +343,61 @@ public final class PolicyCatalog } groups.add(new Group(entry.getKey(), groupLabel, resources)); } - return (new PolicyCatalog(groups, qInstance)); + Set available = new TreeSet<>(); + availablePermissions.forEach(permission -> available.add(permission.getName())); + return (new PolicyCatalog(groups, qInstance, available)); + } + + + + /******************************************************************************* + ** A catalog of a flat set of permission names ({base}.{action}): bases that + ** are valid resources become resources, standard actions become actions and + ** every other action a feature. The seam for tests and golden vectors; it + ** has no instance, so it never expands full access by itself. + *******************************************************************************/ + public static PolicyCatalog ofPermissionNames(Collection names) + { + return (ofPermissionNames(names, List.of())); + } + + + + /******************************************************************************* + ** As ofPermissionNames, but the host-declared names are in no resource: a + ** dotted service name is never a table feature (granted only through the + ** Permission key). + *******************************************************************************/ + public static PolicyCatalog ofPermissionNames(Collection names, Collection hostNames) + { + Map drafts = new TreeMap<>(); + for(String permission : names) + { + if(hostNames.contains(permission)) + { + continue; + } + int dot = permission.lastIndexOf('.'); + if(dot <= 0 || dot == permission.length() - 1) + { + continue; + } + String base = permission.substring(0, dot); + String action = permission.substring(dot + 1); + if(!PolicyParser.RESOURCE_PATTERN.matcher(base).matches()) + { + continue; + } + ResourceDraft draft = drafts.computeIfAbsent(base, key -> new ResourceDraft(key, Kind.TABLE)); + draft.labels.add(base); + PolicyAction.fromJsonName(action).ifPresentOrElse(draft.actions::add, () -> draft.features.put(action, new Feature(action, action))); + } + List resources = new ArrayList<>(); + for(ResourceDraft draft : drafts.values()) + { + resources.add(new Resource(draft.name, String.join(" / ", draft.labels), draft.kind, new ArrayList<>(draft.actions), List.copyOf(draft.features.values()), null)); + } + return (new PolicyCatalog(drafts.isEmpty() ? List.of() : List.of(new Group(null, OTHER, resources)), null, new TreeSet<>(names))); } @@ -476,7 +615,7 @@ public final class PolicyCatalog Set rs = new TreeSet<>(); for(String permission : permissions) { - if(!known.contains(permission) && !PolicyPermissions.FULL_ACCESS.equals(permission)) + if(!known.contains(permission)) { rs.add(permission); } @@ -497,6 +636,29 @@ public final class PolicyCatalog + /******************************************************************************* + ** The names core reports as available permissions (the catalog source), which + ** the universe starts from. + *******************************************************************************/ + public Set availableNames() + { + return (availableNames); + } + + + + /******************************************************************************* + ** Whether at least one resource whose name starts with the prefix registers + ** this feature action (a prefix resource's feature rule). + *******************************************************************************/ + public boolean hasFeatureUnderPrefix(String prefix, String actionName) + { + return (byName.values().stream().anyMatch(resource -> resource.name().startsWith(prefix) + && resource.features().stream().anyMatch(feature -> feature.name().equals(actionName)))); + } + + + /******************************************************************************* ** As JSON for the editor widget. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java index 10c292e..021dd31 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyEditorWidget.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.TreeSet; import java.util.stream.Collectors; import com.kingsrook.qbits.authentik.AuthentikQBitConfig; import com.kingsrook.qbits.authentik.AuthentikRuntime; @@ -127,9 +128,18 @@ 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)); - data.withUnknownPermissions(new ArrayList<>(runtime.getPolicyCatalog().unknownPermissions( - PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()).permissions()))); + PolicyParser.Parsed parsed = PolicyParser.parseStrict(stored, format, runtime.getPolicyCatalog()); + data.withFullAccess(parsed.rules().hasFullAccess()); + data.withDenied(parsed.rules().denyLabels()); + Set exact = new TreeSet<>(); + for(PolicyTerm term : parsed.rules().allowTerms()) + { + if(term instanceof PolicyTerm.Exact e && !parsed.named().contains(e.name())) + { + exact.add(e.name()); + } + } + data.withUnknownPermissions(new ArrayList<>(runtime.getPolicyCatalog().unknownPermissions(exact))); } catch(PolicyParseException e) { @@ -247,6 +257,28 @@ public final class PolicyEditorWidget private Boolean membersTruncated = false; private Boolean mapperEnabled; private Boolean fullAccess = false; + private List denied = List.of(); + + + + /******************************************************************************* + ** Operator-facing labels of the Deny terms of the stored document. + *******************************************************************************/ + public List getDenied() + { + return (denied); + } + + + + /******************************************************************************* + ** Fluent setter for denied. + *******************************************************************************/ + public Data withDenied(List denied) + { + this.denied = denied; + return (this); + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyFormat.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyFormat.java index e1498a9..848354e 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyFormat.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyFormat.java @@ -36,8 +36,8 @@ public record PolicyFormat(String spec, String version, String attribute, String /** Default PolicySpec. */ public static final String DEFAULT_SPEC = "qqq-iam"; - /** Default (and only) PolicyVersion of this qbit's format. */ - public static final String DEFAULT_VERSION = "1.0.0"; + /** Default PolicyVersion of this qbit's format (1.1.0: Deny, prefix resources, named permissions). */ + public static final String DEFAULT_VERSION = "1.1.0"; /** Default group attribute. */ public static final String DEFAULT_ATTRIBUTE = "qqq.io/policy"; diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyGenerator.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyGenerator.java index e5c5c16..50a9918 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyGenerator.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyGenerator.java @@ -28,13 +28,10 @@ import org.json.JSONObject; /******************************************************************************* - ** Builds the canonical policy document for a set of QQQ permissions - ** (docs/POLICY-FORMAT.md, "Canonical form"): one Allow statement per granted - ** action in the order hasAccess, read, insert, edit, delete, then features - ** sorted by name; resources sorted; - ** 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. + ** Writes the canonical policy document (docs/POLICY-FORMAT.md, "Canonical + ** form") for typed rules, and compact or pretty JSON in a fixed key order. + ** Canonicalization NEVER drops a Deny; it drops only Allow terms that full + ** access makes redundant. *******************************************************************************/ public final class PolicyGenerator { @@ -49,11 +46,11 @@ public final class PolicyGenerator /******************************************************************************* - ** The canonical document for these permissions ({resource}.{action} names). + ** The canonical document that allows exactly these permission names (and + ** full access when the set holds the internal FULL_ACCESS marker). Names + ** that are not {resource}.{action} are written as named permissions. ** - ** @throws PolicyParseException when a permission is not {resource}.{action} - ** with a known action and a valid resource, or the document does not - ** round-trip + ** @throws PolicyParseException when the document does not round-trip *******************************************************************************/ public static JSONObject generate(Collection permissions, PolicyFormat format) throws PolicyParseException { @@ -67,81 +64,158 @@ public final class PolicyGenerator *******************************************************************************/ public static JSONObject generate(Collection 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> byAction = new LinkedHashMap<>(); - Map> byFeature = new TreeMap<>(); - for(PolicyAction action : PolicyAction.values()) - { - byAction.put(action.getJsonName(), new TreeSet<>()); - } - - Set expected = new TreeSet<>(); for(String permission : permissions) { - int dot = permission == null ? -1 : permission.lastIndexOf('.'); - if(dot <= 0 || dot == permission.length() - 1) + if(permission == null) { - throw (new PolicyParseException("\"" + permission + "\" is not {resource}.{action}")); + throw (new PolicyParseException("null is not a permission name")); } - String resource = permission.substring(0, dot); - String name = permission.substring(dot + 1); - if(PolicyAction.fromJsonName(name).isEmpty() && (catalog == null || !catalog.hasFeature(resource, name))) - { - throw (new PolicyParseException("\"" + permission + "\": unknown action \"" + name + "\"")); - } - if(!PolicyParser.RESOURCE_PATTERN.matcher(resource).matches()) - { - throw (new PolicyParseException("\"" + permission + "\": resource must match [A-Za-z0-9_]+")); - } - Map> target = byAction.containsKey(name) ? byAction : byFeature; - target.computeIfAbsent(name, key -> new TreeSet<>()).add(resource); - expected.add(permission); + } + return (generate(PolicyRules.allowing(permissions), format, catalog)); + } + + + + /******************************************************************************* + ** The canonical document for typed rules. + ** + ** @throws PolicyParseException when the rules are void or the document does + ** not round-trip (parsing it must give back exactly the same rules) + *******************************************************************************/ + public static JSONObject generate(PolicyRules rules, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException + { + PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format; + if(rules.isVoid()) + { + throw (new PolicyParseException("An invalid policy cannot be written: " + rules.voidReason())); } JSONArray statements = new JSONArray(); - byAction.putAll(byFeature); - for(Map.Entry> entry : byAction.entrySet()) + if(rules.hasFullAccess()) { - if(!entry.getValue().isEmpty()) - { - statements.put(new JSONObject() - .put("Effect", PolicyParser.ALLOW) - .put("Action", new JSONArray().put(entry.getKey())) - .put("Resource", new JSONArray(entry.getValue()))); - } + statements.put(statement(PolicyParser.ALLOW, new JSONArray().put("*"), new JSONArray().put("*"))); } + else + { + addStatements(statements, PolicyParser.ALLOW, rules.allowTerms(), catalog); + } + addStatements(statements, PolicyParser.DENY, rules.denyTerms(), catalog); JSONObject document = new JSONObject() .put("PolicySpec", f.spec()) .put("PolicyVersion", f.version()) .put("Statement", statements); - Set parsed = PolicyParser.parseStrict(toCompact(document), f, catalog).permissions(); - if(!parsed.equals(expected)) + PolicyRules parsed = PolicyParser.parseStrict(toCompact(document), f, catalog).rules(); + if(!parsed.equals(rules)) { - Set missing = new TreeSet<>(expected); - missing.removeAll(parsed); - Set extra = new TreeSet<>(parsed); - extra.removeAll(expected); - throw (new PolicyParseException("The generated document does not round-trip. Missing: " + missing + "; unexpected: " + extra)); + throw (new PolicyParseException("The generated document does not round-trip: " + rules + " became " + parsed)); } return (document); } + /******************************************************************************* + ** The statements of one effect: per-action statements in the order hasAccess, + ** read, insert, edit, delete then features by name, each with sorted + ** resources; Deny additionally has an all-actions statement first; names + ** that cannot be written as {resource}.{action} go in a Permission + ** statement last. + *******************************************************************************/ + private static void addStatements(JSONArray statements, String effect, Set terms, PolicyCatalog catalog) + { + boolean isDeny = PolicyParser.DENY.equals(effect); + Set all = new TreeSet<>(); + Map> byAction = new LinkedHashMap<>(); + Map> byFeature = new TreeMap<>(); + Set named = new TreeSet<>(); + for(PolicyAction action : PolicyAction.values()) + { + byAction.put(action.getJsonName(), new TreeSet<>()); + } + + for(PolicyTerm term : terms) + { + if(term instanceof PolicyTerm.Everything) + { + all.add("*"); + } + else if(term instanceof PolicyTerm.Base base) + { + all.add(base.base()); + } + else if(term instanceof PolicyTerm.Prefix prefix) + { + if(prefix.action() == null) + { + all.add(prefix.prefix() + "*"); + } + else + { + bucket(byAction, byFeature, prefix.action()).add(prefix.prefix() + "*"); + } + } + else if(term instanceof PolicyTerm.Exact exact) + { + int dot = exact.name().lastIndexOf('.'); + String base = dot > 0 ? exact.name().substring(0, dot) : ""; + String action = dot > 0 ? exact.name().substring(dot + 1) : ""; + boolean written = PolicyParser.RESOURCE_PATTERN.matcher(base).matches() + && (PolicyAction.fromJsonName(action).isPresent() || (catalog != null && catalog.hasFeature(base, action))); + if(written) + { + bucket(byAction, byFeature, action).add(base); + } + else + { + named.add(exact.name()); + } + } + } + + if(isDeny && !all.isEmpty()) + { + statements.put(statement(effect, new JSONArray().put("*"), new JSONArray(all))); + } + byAction.putAll(byFeature); + for(Map.Entry> entry : byAction.entrySet()) + { + if(!entry.getValue().isEmpty()) + { + statements.put(statement(effect, new JSONArray().put(entry.getKey()), new JSONArray(entry.getValue()))); + } + } + if(!named.isEmpty()) + { + statements.put(new JSONObject().put("Effect", effect).put("Permission", new JSONArray(named))); + } + } + + + + /******************************************************************************* + ** The resource set of an action: the five standard actions keep their + ** order, features sort after them. + *******************************************************************************/ + private static Set bucket(Map> byAction, Map> byFeature, String action) + { + Map> target = byAction.containsKey(action) ? byAction : byFeature; + return (target.computeIfAbsent(action, key -> new TreeSet<>())); + } + + + + /******************************************************************************* + ** An Action and Resource statement. + *******************************************************************************/ + private static JSONObject statement(String effect, JSONArray actions, JSONArray resources) + { + return (new JSONObject().put("Effect", effect).put("Action", actions).put("Resource", resources)); + } + + + /******************************************************************************* ** Re-generate any valid document in canonical form (parse, then generate). *******************************************************************************/ @@ -157,14 +231,15 @@ public final class PolicyGenerator *******************************************************************************/ public static JSONObject canonical(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException { - return (generate(PolicyParser.parseStrict(document, format, catalog).permissions(), format, catalog)); + return (generate(PolicyParser.parseStrict(document, format, catalog).rules(), format, catalog)); } /******************************************************************************* ** Compact JSON in a fixed key order (PolicySpec, PolicyVersion, Statement; - ** Effect, Action, Resource), as stored in the group attribute. + ** Effect, Action, Resource or Effect, Permission), as stored in the group + ** attribute. *******************************************************************************/ public static String toCompact(JSONObject document) { @@ -192,7 +267,6 @@ public final class PolicyGenerator String i1 = pretty ? " " : ""; String i2 = pretty ? " " : ""; String i3 = pretty ? " " : ""; - String i4 = pretty ? " " : ""; String sep = pretty ? ": " : ":"; StringBuilder sb = new StringBuilder("{").append(nl); @@ -207,15 +281,15 @@ public final class PolicyGenerator JSONObject statement = statements.getJSONObject(s); sb.append(s == 0 ? nl : "," + nl).append(i2).append("{").append(nl); sb.append(i3).append(JSONObject.quote("Effect")).append(sep).append(JSONObject.quote(statement.optString("Effect"))).append(",").append(nl); - sb.append(i3).append(JSONObject.quote("Action")).append(sep).append(inline(statement.optJSONArray("Action"), pretty)).append(",").append(nl); - sb.append(i3).append(JSONObject.quote("Resource")).append(sep).append("["); - JSONArray resources = statement.optJSONArray("Resource"); - int n = resources == null ? 0 : resources.length(); - for(int r = 0; r < n; r++) + if(statement.has("Permission")) { - sb.append(r == 0 ? nl : "," + nl).append(i4).append(JSONObject.quote(resources.optString(r))); + sb.append(i3).append(JSONObject.quote("Permission")).append(sep).append(list(statement.optJSONArray("Permission"), pretty, i3)).append(nl); + } + else + { + sb.append(i3).append(JSONObject.quote("Action")).append(sep).append(inline(statement.optJSONArray("Action"), pretty)).append(",").append(nl); + sb.append(i3).append(JSONObject.quote("Resource")).append(sep).append(list(statement.optJSONArray("Resource"), pretty, i3)).append(nl); } - sb.append(n == 0 ? "" : nl + i3).append("]").append(nl); sb.append(i2).append("}"); } sb.append(count == 0 ? "" : nl + i1).append("]").append(nl); @@ -225,6 +299,24 @@ public final class PolicyGenerator + /******************************************************************************* + ** A string array with one entry per line when pretty. + *******************************************************************************/ + private static String list(JSONArray array, boolean pretty, String indent) + { + String nl = pretty ? "\n" : ""; + String in = pretty ? indent + " " : ""; + StringBuilder sb = new StringBuilder("["); + int n = array == null ? 0 : array.length(); + for(int i = 0; i < n; i++) + { + sb.append(i == 0 ? nl : "," + nl).append(in).append(JSONObject.quote(array.optString(i))); + } + return (sb.append(n == 0 ? "" : nl + indent).append("]").toString()); + } + + + /******************************************************************************* ** A short string array on one line. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java index ab3d41c..15871f4 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java @@ -17,11 +17,13 @@ package com.kingsrook.qbits.authentik.policy; -import java.util.Collections; +import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; -import java.util.TreeSet; +import java.util.regex.Matcher; import java.util.regex.Pattern; import com.kingsrook.qqq.backend.core.logging.QLogger; import org.json.JSONArray; @@ -31,12 +33,16 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* - ** Parses a policy document (docs/POLICY-FORMAT.md) into QQQ permission names. + ** Parses policy documents (docs/POLICY-FORMAT.md) into typed PolicyRules, + ** never into plain permission names: a Deny or a prefix cannot be mistaken + ** for a grant. ** - ** parse() FAILS CLOSED: any problem (unknown spec, unsupported version, a - ** statement that is not Allow, an unknown action, a bad resource, malformed - ** JSON, ...) grants zero permissions and logs a warning. parseStrict() throws - ** a PolicyParseException naming the problem instead (for the editor). + ** parse() FAILS CLOSED: any problem (unknown spec, unsupported version, an + ** unknown effect or key, an unknown action, a bad resource or prefix, + ** malformed JSON, ...) gives void rules, which grant nothing, and logs a + ** warning. parseStrict() throws a PolicyParseException naming the problem + ** instead (for the editor). parseAll() merges the documents of one user and + ** voids the whole merge when any one is invalid. *******************************************************************************/ public final class PolicyParser { @@ -45,18 +51,30 @@ public final class PolicyParser /** A valid resource: a QQQ permission base name. */ public static final Pattern RESOURCE_PATTERN = Pattern.compile("[A-Za-z0-9_]+"); - /** The only effect in 1.0.0. */ + /** A valid prefix resource: at least two characters before the "*", the literal part ending in an underscore. */ + public static final Pattern PREFIX_PATTERN = Pattern.compile("([A-Za-z0-9_]+_)\\*"); + + /** A valid exact permission name: dot-separated segments of [A-Za-z0-9_-]; a bare name has no dot. */ + public static final Pattern PERMISSION_PATTERN = Pattern.compile("[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)*"); + + /** The effect that grants. */ public static final String ALLOW = "Allow"; + /** The effect that withholds; it always wins. */ + public static final String DENY = "Deny"; + + private static final Set STATEMENT_KEYS = Set.of("Effect", "Action", "Resource", "Permission"); + /******************************************************************************* ** The result of a successful parse. ** - ** @param permissions QQQ permission names, or the symbolic FULL_ACCESS marker - ** @param resources every resource named, in document order + ** @param rules the typed Allow and Deny terms + ** @param resources every resource named, in document order + ** @param named exact names given through the Permission key *******************************************************************************/ - public record Parsed(Set permissions, Set resources) + public record Parsed(PolicyRules rules, Set resources, Set named) { } @@ -72,13 +90,13 @@ public final class PolicyParser /******************************************************************************* - ** The permissions a document grants, or none (with a warning) when it is - ** missing or invalid in any way. + ** The rules of a document, or void rules (with a warning) when it is + ** invalid in any way. A null or JSON-null document is valid and empty. ** ** @param document a JSONObject, a Map, or a JSON string (as stored in a group - ** attribute or a token claim); null grants nothing + ** attribute or a token claim) *******************************************************************************/ - public static Set parse(Object document, PolicyFormat format) + public static PolicyRules parse(Object document, PolicyFormat format) { return (parse(document, format, null)); } @@ -87,27 +105,43 @@ public final class PolicyParser /******************************************************************************* ** Parse with the catalog of explicitly grantable table features; an invalid - ** document still grants nothing. + ** document gives void rules. *******************************************************************************/ - public static Set parse(Object document, PolicyFormat format, PolicyCatalog catalog) + public static PolicyRules parse(Object document, PolicyFormat format, PolicyCatalog catalog) { if(document == null || JSONObject.NULL.equals(document)) { - return (Collections.emptySet()); + return (PolicyRules.EMPTY); } try { - return (parseStrict(document, format, catalog).permissions()); + return (parseStrict(document, format, catalog).rules()); } catch(PolicyParseException e) { - LOG.warn("Policy document rejected; it grants no permissions", logPair("reason", e.getMessage())); - return (Collections.emptySet()); + LOG.warn("Policy document rejected; it voids the user's policies", logPair("reason", e.getMessage())); + return (PolicyRules.voided(e.getMessage())); } } + /******************************************************************************* + ** Parse every document of one user (the policies of their groups, direct + ** and inherited) and merge. ONE invalid document voids the lot. + *******************************************************************************/ + public static PolicyRules parseAll(Collection documents, PolicyFormat format, PolicyCatalog catalog) + { + List all = new ArrayList<>(); + for(Object document : documents) + { + all.add(parse(document, format, catalog)); + } + return (PolicyRules.merge(all)); + } + + + /******************************************************************************* ** Parse, throwing a PolicyParseException that names the first problem. *******************************************************************************/ @@ -124,8 +158,35 @@ public final class PolicyParser *******************************************************************************/ public static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException { - JSONObject json = asObject(document); - PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format; + return (parseStrict(document, format, catalog, null)); + } + + + + /******************************************************************************* + ** The editor's save-time parse: as parseStrict, and additionally refuses a + ** prefix, a named permission, or a Deny of a named resource that matches + ** nothing in the universe (a mistyped Deny that silently does nothing is + ** the dangerous case). + *******************************************************************************/ + public static Parsed parseForSave(Object document, PolicyFormat format, PolicyCatalog catalog, PolicyUniverse universe) throws PolicyParseException + { + if(universe == null) + { + throw (new IllegalArgumentException("A universe is required for save-time checks")); + } + return (parseStrict(document, format, catalog, universe)); + } + + + + /******************************************************************************* + ** The shared parse; a non-null universe turns on the save-time checks. + *******************************************************************************/ + private static Parsed parseStrict(Object document, PolicyFormat format, PolicyCatalog catalog, PolicyUniverse universe) throws PolicyParseException + { + JSONObject json = asObject(document); + PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format; Object spec = json.opt("PolicySpec"); if(!(spec instanceof String s) || !s.equals(f.spec())) @@ -138,12 +199,14 @@ public final class PolicyParser throw (new PolicyParseException("PolicyVersion must be \"" + f.version() + "\" (found " + describe(version) + ")")); } - Set permissions = new TreeSet<>(); - Set resources = new LinkedHashSet<>(); - Object statements = json.opt("Statement"); + Set allow = new LinkedHashSet<>(); + Set deny = new LinkedHashSet<>(); + Set resources = new LinkedHashSet<>(); + Set named = new LinkedHashSet<>(); + Object statements = json.opt("Statement"); if(statements == null) { - return (new Parsed(permissions, resources)); + return (new Parsed(PolicyRules.EMPTY, resources, Set.of())); } if(!(statements instanceof JSONArray array)) { @@ -156,59 +219,211 @@ public final class PolicyParser { throw (new PolicyParseException("Statement " + (i + 1) + " must be an object")); } - Object effect = statement.opt("Effect"); - if(!ALLOW.equals(effect)) - { - throw (new PolicyParseException("Statement " + (i + 1) + ": Effect must be \"Allow\" (found " + describe(effect) + "); Deny is not supported yet")); - } + parseStatement("Statement " + (i + 1), statement, catalog, universe, allow, deny, resources, named); + } + return (new Parsed(PolicyRules.of(allow, deny), resources, named)); + } - Set statementResources = strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource"); - Set statementActions = strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action"); - if(statementResources.contains("*") && !statementActions.equals(Set.of("*"))) + + + /******************************************************************************* + ** One statement into the Allow or Deny terms. + *******************************************************************************/ + private static void parseStatement(String where, JSONObject statement, PolicyCatalog catalog, PolicyUniverse universe, + Set allow, Set deny, Set resources, Set named) throws PolicyParseException + { + for(String key : statement.keySet()) + { + if(!STATEMENT_KEYS.contains(key)) { - throw (new PolicyParseException("Statement " + (i + 1) + ": Resource \"*\" requires Action [\"*\"] (full access)")); + throw (new PolicyParseException(where + ": unknown key \"" + shorten(key) + "\" (only Effect, Action, Resource and Permission are supported)")); } - Set actions = new LinkedHashSet<>(); - for(String name : statementActions) + } + Object effect = statement.opt("Effect"); + if(!ALLOW.equals(effect) && !DENY.equals(effect)) + { + throw (new PolicyParseException(where + ": Effect must be \"Allow\" or \"Deny\" (found " + describe(effect) + ")")); + } + boolean isDeny = DENY.equals(effect); + Set target = isDeny ? deny : allow; + + if(statement.has("Permission")) + { + if(statement.has("Action") || statement.has("Resource")) { - if(PolicyAction.WILDCARD.equals(name)) + throw (new PolicyParseException(where + ": Permission cannot be combined with Action or Resource")); + } + for(String name : strings(statement.opt("Permission"), where + ": Permission")) + { + if(!PERMISSION_PATTERN.matcher(name).matches()) { - for(PolicyAction action : PolicyAction.values()) - { - actions.add(action.getJsonName()); - } + throw (new PolicyParseException(where + ": permission \"" + shorten(name) + "\" must be dot-separated names of [A-Za-z0-9_-]")); + } + if(universe != null && !universe.names().contains(name)) + { + throw (new PolicyParseException(where + ": permission \"" + name + "\" matches nothing in this app")); + } + target.add(new PolicyTerm.Exact(name)); + named.add(name); + } + return; + } + + Set statementResources = strings(statement.opt("Resource"), where + ": Resource"); + Set statementActions = strings(statement.opt("Action"), where + ": Action"); + if(statementResources.contains("*") && !statementActions.equals(Set.of("*"))) + { + throw (new PolicyParseException(where + ": Resource \"*\" requires Action [\"*\"] (full access)")); + } + for(String resource : statementResources) + { + if(!resource.equals("*") && resource.indexOf('*') >= 0 && !PREFIX_PATTERN.matcher(resource).matches()) + { + throw (new PolicyParseException(where + ": \"" + shorten(resource) + "\" is not a valid prefix (at least two characters before the \"*\", the literal part ending in \"_\", and the \"*\" last)")); + } + } + checkActions(where, statementActions, statementResources, catalog); + + boolean star = statementActions.contains(PolicyAction.WILDCARD); + Set listed = concreteActions(statementActions); + for(String resource : statementResources) + { + if(resource.equals("*")) + { + target.add(new PolicyTerm.Everything()); + resources.add(resource); + continue; + } + Matcher prefixMatch = PREFIX_PATTERN.matcher(resource); + if(prefixMatch.matches()) + { + String prefix = prefixMatch.group(1); + resources.add(resource); + if(isDeny && star) + { + target.add(new PolicyTerm.Prefix(prefix, null)); } else { - if(PolicyAction.fromJsonName(name).isEmpty() - && (catalog == null || statementResources.isEmpty() || !statementResources.stream().allMatch(resource -> catalog.hasFeature(resource, name)))) + for(String action : listed) { - throw (new PolicyParseException("Statement " + (i + 1) + ": unknown action \"" + name + "\"")); + target.add(new PolicyTerm.Prefix(prefix, action)); } - actions.add(name); + } + if(universe != null && !prefixMatchesSomething(universe, prefix, listed, isDeny && star)) + { + throw (new PolicyParseException(where + ": prefix \"" + resource + "\" matches nothing in this app")); + } + continue; + } + if(!RESOURCE_PATTERN.matcher(resource).matches()) + { + throw (new PolicyParseException(where + ": resource \"" + shorten(resource) + "\" must match [A-Za-z0-9_]+")); + } + resources.add(resource); + if(isDeny && star) + { + target.add(new PolicyTerm.Base(resource)); + } + else + { + for(String action : listed) + { + target.add(new PolicyTerm.Exact(resource + "." + action)); } } - - for(String resource : statementResources) + if(isDeny && universe != null && !namedResourceExists(universe, resource, statementActions)) { - 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_]+")); - } - resources.add(resource); - for(String action : actions) - { - permissions.add(resource + "." + action); - } + throw (new PolicyParseException(where + ": Deny of \"" + resource + "\" matches nothing in this app")); } } - return (new Parsed(permissions.contains(PolicyPermissions.FULL_ACCESS) ? Set.of(PolicyPermissions.FULL_ACCESS) : permissions, resources)); + } + + + + /******************************************************************************* + ** The concrete action names of a statement: "*" is the five standard + ** actions (a Deny gets a Base or Prefix term for everything else besides, + ** added by the caller). + *******************************************************************************/ + private static Set concreteActions(Set statementActions) + { + Set actions = new LinkedHashSet<>(); + for(String name : statementActions) + { + if(PolicyAction.WILDCARD.equals(name)) + { + for(PolicyAction action : PolicyAction.values()) + { + actions.add(action.getJsonName()); + } + } + else + { + actions.add(name); + } + } + return (actions); + } + + + + /******************************************************************************* + ** Every action must be standard, "*", or a feature registered for every + ** listed resource (for a prefix: for at least one match). + *******************************************************************************/ + private static void checkActions(String where, Set statementActions, Set statementResources, PolicyCatalog catalog) throws PolicyParseException + { + for(String name : statementActions) + { + if(PolicyAction.WILDCARD.equals(name) || PolicyAction.fromJsonName(name).isPresent()) + { + continue; + } + boolean registered = catalog != null && !statementResources.isEmpty() && statementResources.stream().allMatch(resource -> + { + Matcher prefixMatch = PREFIX_PATTERN.matcher(resource); + return (prefixMatch.matches() ? catalog.hasFeatureUnderPrefix(prefixMatch.group(1), name) : catalog.hasFeature(resource, name)); + }); + if(!registered) + { + throw (new PolicyParseException(where + ": unknown action \"" + shorten(name) + "\"")); + } + } + } + + + + /******************************************************************************* + ** Whether a prefix matches any name of the universe, for the listed actions + ** (or any action). + *******************************************************************************/ + private static boolean prefixMatchesSomething(PolicyUniverse universe, String prefix, Set actions, boolean anyAction) + { + if(anyAction) + { + return (!new PolicyTerm.Prefix(prefix, null).expand(universe).isEmpty()); + } + return (actions.stream().anyMatch(action -> !new PolicyTerm.Prefix(prefix, action).expand(universe).isEmpty())); + } + + + + /******************************************************************************* + ** Whether the universe has any permission of a named resource for the + ** listed actions ("*" counts as any action). + *******************************************************************************/ + private static boolean namedResourceExists(PolicyUniverse universe, String resource, Set statementActions) + { + boolean any = statementActions.contains(PolicyAction.WILDCARD); + for(String name : universe.names()) + { + if(new PolicyTerm.Base(resource).matches(name) && (any || statementActions.contains(name.substring(name.lastIndexOf('.') + 1)))) + { + return (true); + } + } + return (false); } @@ -269,6 +484,16 @@ public final class PolicyParser + /******************************************************************************* + ** A name shortened for messages (never the whole value). + *******************************************************************************/ + private static String shorten(String text) + { + return (text.length() > 40 ? text.substring(0, 40) + "..." : text); + } + + + /******************************************************************************* ** A short description of a found value for messages (never the whole value). *******************************************************************************/ @@ -280,7 +505,7 @@ public final class PolicyParser } if(value instanceof String text) { - return ("\"" + (text.length() > 40 ? text.substring(0, 40) + "..." : text) + "\""); + return ("\"" + shorten(text) + "\""); } return (value.getClass().getSimpleName()); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java index 4b16e15..37e9c05 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java @@ -19,11 +19,13 @@ package com.kingsrook.qbits.authentik.policy; import java.util.Collection; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; 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; @@ -38,6 +40,8 @@ public final class PolicyPermissions /** Internal parse marker, never a session permission or persisted permission list. */ public static final String FULL_ACCESS = ""; + private static final List>> REGISTERED_HOST_PERMISSIONS = new CopyOnWriteArrayList<>(); + /** Operator-facing description of the wildcard statement. */ public static final String FULL_ACCESS_LABEL = "Full access (all current and future resources)"; @@ -70,18 +74,56 @@ public final class PolicyPermissions /******************************************************************************* - ** Read a policy claim with the catalog of registered table features. + ** Read a policy claim with the catalog of registered table features: + ** Allow expansions minus Deny coverage, expanded against the live instance + ** now. None when the claim is missing or invalid (fail closed). *******************************************************************************/ public static Set fromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog) + { + PolicyRules rules = rulesFromClaims(claims, format, catalog); + if(rules.isVoid() || rules.isEmpty()) + { + return (Collections.emptySet()); + } + return (rules.resolve(universe(instanceOf(catalog), Set.of()))); + } + + + + /******************************************************************************* + ** The typed rules of the policy claim: empty when the claim is missing, + ** void when it is invalid. The mapper uses these to apply Deny to every + ** source of a session, not only to the claim. + *******************************************************************************/ + public static PolicyRules rulesFromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog) { PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format; if(claims == null || !claims.has(f.claim())) { - return (Collections.emptySet()); + return (PolicyRules.EMPTY); } - Set parsed = PolicyParser.parse(claims.opt(f.claim()), f, catalog); - return (parsed.contains(FULL_ACCESS) - ? allPermissions(catalog == null ? QContext.getQInstance() : catalog.getQInstance(), Set.of()) : parsed); + return (PolicyParser.parse(claims.opt(f.claim()), f, catalog)); + } + + + + /******************************************************************************* + ** The instance a catalog was built from, else the current QContext's. + *******************************************************************************/ + public static QInstance instanceOf(PolicyCatalog catalog) + { + return (catalog == null || catalog.getQInstance() == null ? QContext.getQInstance() : catalog.getQInstance()); + } + + + + /******************************************************************************* + ** The live universe of an instance (allPermissions, evaluated when a + ** symbolic term needs it, never cached). + *******************************************************************************/ + public static PolicyUniverse universe(QInstance qInstance, Collection additional) + { + return (() -> allPermissions(qInstance, additional)); } @@ -100,10 +142,15 @@ public final class PolicyPermissions } if(qInstance != null) { - PermissionsHelper.getAllAvailablePermissions(qInstance).forEach(permission -> permissions.add(permission.getName())); - permissions.addAll(PolicyCatalog.of(qInstance).permissionNames()); + PolicyCatalog catalog = PolicyCatalog.of(qInstance); + permissions.addAll(catalog.availableNames()); + permissions.addAll(catalog.permissionNames()); permissions.addAll(hostPermissions(qInstance)); } + else + { + permissions.addAll(registeredHostPermissions(null)); + } for(IdentityPermission permission : IdentityPermission.values()) { permissions.add(permission.getSessionPermission()); @@ -120,17 +167,13 @@ public final class PolicyPermissions *******************************************************************************/ static Set hostPermissions(QInstance qInstance) { - Set rs = new TreeSet<>(); + Set rs = registeredHostPermissions(qInstance); 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 names = config.getFullAccessHostPermissions().apply(qInstance); - if(names != null) - { - names.stream().filter(n -> n != null && !n.isBlank()).forEach(rs::add); - } + addNames(rs, config.getFullAccessHostPermissions().apply(qInstance)); } } return (rs); @@ -138,6 +181,63 @@ public final class PolicyPermissions + /******************************************************************************* + ** The names of every registered host-name function (no runtime needed). + *******************************************************************************/ + private static Set registeredHostPermissions(QInstance qInstance) + { + Set rs = new TreeSet<>(); + for(Function> supplier : REGISTERED_HOST_PERMISSIONS) + { + addNames(rs, supplier.apply(qInstance)); + } + return (rs); + } + + + + /******************************************************************************* + ** Register a host's permission names (custom checkers, named permissions such + ** as cdn.purge) for the policy universe WITHOUT any Authentik runtime, so a + ** host that runs only the policy mapper (user management off) keeps its + ** names. The function runs at every expansion, so names added later are + ** covered. Registering the same function twice has no extra effect. + ** Names declared through AuthentikQBitConfig.withFullAccessHostPermissions + ** still apply, and the two are unioned. + *******************************************************************************/ + public static void registerHostPermissions(Function> supplier) + { + if(supplier != null && !REGISTERED_HOST_PERMISSIONS.contains(supplier)) + { + REGISTERED_HOST_PERMISSIONS.add(supplier); + } + } + + + + /******************************************************************************* + ** Forget every registered host-name function (tests, hot reload). + *******************************************************************************/ + public static void clearRegisteredHostPermissions() + { + REGISTERED_HOST_PERMISSIONS.clear(); + } + + + + /******************************************************************************* + ** Add non-blank names. + *******************************************************************************/ + private static void addNames(Set target, Collection names) + { + if(names != null) + { + names.stream().filter(n -> n != null && !n.isBlank()).forEach(target::add); + } + } + + + /******************************************************************************* ** The claims that carry the policy in a customizer context: the ID token's ** when it has the claim (Authentik puts scope-mapping claims there), else the diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java new file mode 100644 index 0000000..e8440e7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java @@ -0,0 +1,399 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.policy; + + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + + +/******************************************************************************* + ** The typed result of parsing policy documents: Allow terms and Deny terms, + ** or VOID when any document was invalid. There is no way to read these as a + ** plain set of permission names: the only ways to get permissions are + ** resolve() (Allow minus Deny, against a universe) and, for tests and + ** documents known to be plain, exactGrants(), which refuses to answer when a + ** Deny or a symbolic term is present. + ** + ** The rules of docs/POLICY-FORMAT.md: result = union(Allow expansions) minus + ** union(Deny expansions); a Deny anywhere wins; no ordering. Merging rules + ** (several groups, several documents) voids the lot when any one is void. + *******************************************************************************/ +public final class PolicyRules +{ + /** Nothing allowed, nothing denied, valid. */ + public static final PolicyRules EMPTY = new PolicyRules(Set.of(), Set.of(), null); + + private final Set allow; + private final Set deny; + private final String voidReason; + + + + /******************************************************************************* + ** Constructor. Full access makes every other Allow redundant. + *******************************************************************************/ + private PolicyRules(Set allow, Set deny, String voidReason) + { + this.voidReason = voidReason; + this.deny = Set.copyOf(deny); + this.allow = allow.contains(new PolicyTerm.Everything()) ? Set.of(new PolicyTerm.Everything()) : Set.copyOf(allow); + } + + + + /******************************************************************************* + ** Rules from terms. + *******************************************************************************/ + public static PolicyRules of(Collection allow, Collection deny) + { + return (new PolicyRules(new LinkedHashSet<>(allow), new LinkedHashSet<>(deny), null)); + } + + + + /******************************************************************************* + ** Allow exactly these permission names (and full access when the set holds + ** the internal FULL_ACCESS marker). The one bridge from plain name sets, + ** used to generate documents; it never carries a Deny. + *******************************************************************************/ + public static PolicyRules allowing(Collection names) + { + Set allow = new LinkedHashSet<>(); + for(String name : names) + { + allow.add(PolicyPermissions.FULL_ACCESS.equals(name) ? new PolicyTerm.Everything() : new PolicyTerm.Exact(name)); + } + return (new PolicyRules(allow, Set.of(), null)); + } + + + + /******************************************************************************* + ** Rules that grant nothing and void every set they are merged into. + *******************************************************************************/ + public static PolicyRules voided(String reason) + { + return (new PolicyRules(Set.of(), Set.of(), reason == null || reason.isBlank() ? "invalid policy" : reason)); + } + + + + /******************************************************************************* + ** Merge the rules of every document of one user. ONE void member voids the + ** result: dropping only a bad group could drop a Deny while the other + ** groups' Allow statements survive. + *******************************************************************************/ + public static PolicyRules merge(Collection all) + { + Set allow = new LinkedHashSet<>(); + Set deny = new LinkedHashSet<>(); + for(PolicyRules rules : all) + { + if(rules.isVoid()) + { + return (voided(rules.voidReason)); + } + allow.addAll(rules.allow); + deny.addAll(rules.deny); + } + return (new PolicyRules(allow, deny, null)); + } + + + + /******************************************************************************* + ** Whether a document was invalid (the user gets nothing). + *******************************************************************************/ + public boolean isVoid() + { + return (voidReason != null); + } + + + + /******************************************************************************* + ** Why the rules are void, or null. + *******************************************************************************/ + public String voidReason() + { + return (voidReason); + } + + + + /******************************************************************************* + ** Whether no Allow and no Deny term exists. + *******************************************************************************/ + public boolean isEmpty() + { + return (allow.isEmpty() && deny.isEmpty()); + } + + + + /******************************************************************************* + ** Whether the Allow side is full access (all current and future resources). + *******************************************************************************/ + public boolean hasFullAccess() + { + return (allow.contains(new PolicyTerm.Everything())); + } + + + + /******************************************************************************* + ** Whether any Deny term exists. + *******************************************************************************/ + public boolean hasDeny() + { + return (!deny.isEmpty()); + } + + + + /******************************************************************************* + ** The Allow terms. + *******************************************************************************/ + public Set allowTerms() + { + return (allow); + } + + + + /******************************************************************************* + ** The Deny terms. + *******************************************************************************/ + public Set denyTerms() + { + return (deny); + } + + + + /******************************************************************************* + ** Only the Deny side (and the void state): what to subtract from sources + ** that are not policy documents, such as static fallbacks. + *******************************************************************************/ + public PolicyRules denialsOnly() + { + return (new PolicyRules(Set.of(), deny, voidReason)); + } + + + + /******************************************************************************* + ** Whether a Deny term covers this permission. A void rule set denies + ** everything (fail closed). + *******************************************************************************/ + public boolean denies(String permission) + { + if(isVoid()) + { + return (true); + } + for(PolicyTerm term : deny) + { + if(term.matches(permission)) + { + return (true); + } + } + return (false); + } + + + + /******************************************************************************* + ** The Allow expansion alone (before Deny), against the universe. Empty + ** when void. Callers that hold other sources must still subtract denies(). + *******************************************************************************/ + public Set allowed(PolicyUniverse universe) + { + Set rs = new TreeSet<>(); + if(isVoid()) + { + return (rs); + } + for(PolicyTerm term : allow) + { + rs.addAll(term.expand(universe)); + } + return (rs); + } + + + + /******************************************************************************* + ** What these rules grant: Allow expansions minus Deny coverage. Empty when + ** void. + *******************************************************************************/ + public Set resolve(PolicyUniverse universe) + { + Set rs = allowed(universe); + rs.removeIf(this::denies); + return (rs); + } + + + + /******************************************************************************* + ** The permission names the Deny side withholds: names in the universe or + ** granted by an Allow that a Deny term covers. Removing a Deny releases + ** these (the subset rule). + *******************************************************************************/ + public Set withheld(PolicyUniverse universe) + { + Set rs = new TreeSet<>(); + if(isVoid() || deny.isEmpty()) + { + return (rs); + } + Set candidates = new TreeSet<>(universe.names()); + candidates.addAll(allowed(universe)); + for(String name : candidates) + { + if(denies(name)) + { + rs.add(name); + } + } + return (rs); + } + + + + /******************************************************************************* + ** The exact permission names of rules that are plain: no Deny, no prefix, + ** no full access. Throws rather than silently ignoring a Deny or a + ** symbolic term, so a caller that cannot handle them fails loudly. Void + ** rules give the empty set. + *******************************************************************************/ + public Set exactGrants() + { + Set rs = new TreeSet<>(); + if(isVoid()) + { + return (rs); + } + if(!deny.isEmpty()) + { + throw (new IllegalStateException("The policy has a Deny; resolve() it instead of reading names")); + } + for(PolicyTerm term : allow) + { + if(!(term instanceof PolicyTerm.Exact exact)) + { + throw (new IllegalStateException("The policy has a symbolic term; resolve() it instead of reading names")); + } + rs.add(exact.name()); + } + return (rs); + } + + + + /******************************************************************************* + ** Operator-facing labels of the Deny terms, sorted ("billing_*: all + ** actions", "billing_invoice.delete"). + *******************************************************************************/ + public List denyLabels() + { + return (labels(deny)); + } + + + + /******************************************************************************* + ** Operator-facing labels of the Allow terms, sorted. + *******************************************************************************/ + public List allowLabels() + { + return (labels(allow)); + } + + + + /******************************************************************************* + ** Sorted labels of terms. + *******************************************************************************/ + private static List labels(Set terms) + { + List rs = new ArrayList<>(); + for(PolicyTerm term : terms) + { + if(term instanceof PolicyTerm.Exact exact) + { + rs.add(exact.name()); + } + else if(term instanceof PolicyTerm.Everything) + { + rs.add("* (all current and future resources)"); + } + else if(term instanceof PolicyTerm.Base base) + { + rs.add(base.base() + ": all actions"); + } + else if(term instanceof PolicyTerm.Prefix prefix) + { + rs.add(prefix.prefix() + "*: " + (prefix.action() == null ? "all actions" : prefix.action())); + } + } + rs.sort(String::compareTo); + return (rs); + } + + + + /******************************************************************************* + ** Same terms and same void state. + *******************************************************************************/ + @Override + public boolean equals(Object other) + { + return (other instanceof PolicyRules rules && allow.equals(rules.allow) && deny.equals(rules.deny) && Objects.equals(voidReason, rules.voidReason)); + } + + + + /******************************************************************************* + ** Hash of the terms. + *******************************************************************************/ + @Override + public int hashCode() + { + return (Objects.hash(allow, deny, voidReason)); + } + + + + /******************************************************************************* + ** Never prints terms into logs beyond their count. + *******************************************************************************/ + @Override + public String toString() + { + return (isVoid() ? "PolicyRules[void]" : "PolicyRules[allow=" + allow.size() + ", deny=" + deny.size() + "]"); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyTerm.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyTerm.java new file mode 100644 index 0000000..205e059 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyTerm.java @@ -0,0 +1,176 @@ +/* + * 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.Set; + + +/******************************************************************************* + ** One symbolic term of a parsed policy. A term is NOT a permission name: it + ** can only be turned into permissions through PolicyRules, which applies Deny + ** across all terms. Allow terms expand against a PolicyUniverse; Deny terms + ** are predicates over permission names, so a Deny is at least as broad as + ** what it names whether or not the name exists in the universe. + *******************************************************************************/ +public sealed interface PolicyTerm +{ + + /******************************************************************************* + ** Whether this term covers the permission name (the Deny test). + *******************************************************************************/ + boolean matches(String permission); + + + + /******************************************************************************* + ** The permission names this term grants (the Allow expansion). + *******************************************************************************/ + Set expand(PolicyUniverse universe); + + + + /******************************************************************************* + ** An exact permission name: "billing_invoice.read", "cdn.purge". It grants + ** only when the universe has it (an action a resource does not have is + ** silently dropped); as a Deny it matches by name, universe or not. + *******************************************************************************/ + record Exact(String name) implements PolicyTerm + { + /******************************************************************************* + ** {@inheritDoc} + *******************************************************************************/ + @Override + public boolean matches(String permission) + { + return (name.equals(permission)); + } + + + + /******************************************************************************* + ** {@inheritDoc} + *******************************************************************************/ + @Override + public Set expand(PolicyUniverse universe) + { + return (universe.names().contains(name) ? Set.of(name) : Set.of()); + } + } + + + + /******************************************************************************* + ** Every permission that exists (Resource "*"). As Deny, every name. + *******************************************************************************/ + record Everything() implements PolicyTerm + { + /******************************************************************************* + ** {@inheritDoc} + *******************************************************************************/ + @Override + public boolean matches(String permission) + { + return (true); + } + + + + /******************************************************************************* + ** {@inheritDoc} + *******************************************************************************/ + @Override + public Set expand(PolicyUniverse universe) + { + return (universe.names()); + } + } + + + + /******************************************************************************* + ** Every action of one named resource, features included (Deny "*" on a + ** named resource). Never an Allow term. + *******************************************************************************/ + record Base(String base) implements PolicyTerm + { + /******************************************************************************* + ** {@inheritDoc} + *******************************************************************************/ + @Override + public boolean matches(String permission) + { + int dot = permission == null ? -1 : permission.lastIndexOf('.'); + return (dot > 0 && permission.substring(0, dot).equals(base)); + } + + + + /******************************************************************************* + ** {@inheritDoc} + *******************************************************************************/ + @Override + public Set expand(PolicyUniverse universe) + { + return (Set.of()); + } + } + + + + /******************************************************************************* + ** A prefix resource ("utils_" for "utils_*") with one action, or every + ** action when action is null (Deny "*"). Matches names whose base (the + ** text before the last dot) starts with the prefix and has no dot. + *******************************************************************************/ + record Prefix(String prefix, String action) implements PolicyTerm + { + /******************************************************************************* + ** {@inheritDoc} + *******************************************************************************/ + @Override + public boolean matches(String permission) + { + int dot = permission == null ? -1 : permission.lastIndexOf('.'); + if(dot <= 0) + { + return (false); + } + String base = permission.substring(0, dot); + return (base.startsWith(prefix) && base.indexOf('.') < 0 && (action == null || action.equals(permission.substring(dot + 1)))); + } + + + + /******************************************************************************* + ** {@inheritDoc} + *******************************************************************************/ + @Override + public Set expand(PolicyUniverse universe) + { + Set rs = new java.util.TreeSet<>(); + for(String name : universe.names()) + { + if(matches(name)) + { + rs.add(name); + } + } + return (rs); + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyUniverse.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyUniverse.java new file mode 100644 index 0000000..aa26cd2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyUniverse.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.authentik.policy; + + +import java.util.Collection; +import java.util.Set; +import java.util.TreeSet; + + +/******************************************************************************* + ** The flat set of QQQ permission names that exist right now, which symbolic + ** policy terms (full access, prefix resources) expand against. Evaluated + ** lazily so a policy with only exact names never needs the instance. + ** + ** This is also the test seam: the golden vectors give a fixed flat universe. + *******************************************************************************/ +@FunctionalInterface +public interface PolicyUniverse +{ + + /******************************************************************************* + ** Every permission name that exists, in session form. + *******************************************************************************/ + Set names(); + + + + /******************************************************************************* + ** A fixed universe. + *******************************************************************************/ + static PolicyUniverse of(Collection names) + { + Set copy = new TreeSet<>(names); + return (() -> copy); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/processes/AccessEditorWidget.java b/src/main/java/com/kingsrook/qbits/authentik/processes/AccessEditorWidget.java index 5bed1fd..c202d01 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/processes/AccessEditorWidget.java +++ b/src/main/java/com/kingsrook/qbits/authentik/processes/AccessEditorWidget.java @@ -131,7 +131,16 @@ public final class AccessEditorWidget node.permissions().forEach(value -> selected.add(Map.of("id", value, "label", value))); var inherited = new EffectivePermissionResolver(graph, runtime.getPermissionCatalog()).resolveGroups(node.parentPks()); item.put("inherited", inherited.grantedBy()); - item.put("policyPermissions", node.policyPermissions()); + /////////////////////////////////////////////////////////////////////// + // the group's own policy, resolved (Allow minus Deny) for display; // + // the Deny terms and the void state travel separately so the UI // + // never shows a denied permission as granted // + /////////////////////////////////////////////////////////////////////// + EffectivePermissionResolver own = new EffectivePermissionResolver(graph, runtime.getPermissionCatalog()); + item.put("policyPermissions", node.policy().resolve(own.universe()).stream().sorted().toList()); + item.put("policyFullAccess", node.policy().hasFullAccess()); + item.put("policyDeny", node.policy().denyLabels()); + item.put("policyInvalid", node.policy().isVoid()); } if(userAccess) { -- 2.54.0 From 33e75b6d45c5500c69e009738e7259330529b641 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 18:09:24 -0600 Subject: [PATCH 04/14] test(policy): golden vectors, Deny properties, consumer tests; blueprint, changelog, upgrade notes Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + UPGRADE.md | 8 + .../authentik/admin/PolicyCutoverTest.java | 5 +- .../authentik/admin/PolicyEditorTest.java | 78 ++++- .../authentik/admin/UserGroupCrudTest.java | 2 +- .../contract/PolicyClaimMappingIT.java | 5 +- .../contract/QbitWriteContractIT.java | 9 +- .../AuthentikGroupPermissionMapperTest.java | 47 ++- .../EffectivePermissionResolverTest.java | 5 +- .../AuthentikPolicyClaimMapperTest.java | 24 +- .../authentik/policy/PolicyDenyTest.java | 303 ++++++++++++++++++ .../policy/PolicyFeaturePermissionsTest.java | 33 +- .../policy/PolicyFullAccessTest.java | 22 +- .../authentik/policy/PolicyGeneratorTest.java | 35 +- .../authentik/policy/PolicyGraphTest.java | 20 +- .../authentik/policy/PolicyParserTest.java | 80 ++--- .../authentik/policy/PolicyTestSupport.java | 51 +++ .../authentik/policy/PolicyVectorsTest.java | 209 ++++++++++++ .../blueprints/qqq-policy-claim.yaml | 23 +- 19 files changed, 841 insertions(+), 119 deletions(-) create mode 100644 src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java create mode 100644 src/test/java/com/kingsrook/qbits/authentik/policy/PolicyTestSupport.java create mode 100644 src/test/java/com/kingsrook/qbits/authentik/policy/PolicyVectorsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index bcbb1d5..60580db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document of the configured spec. Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). Deny does not subtract static group fallbacks or bootstrap identity permissions. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`. - Fix: native **Create Group** now obeys the two-person gate. With `twoPersonApproval=true`, creating a group whose permissions expand to any `authentik.*` permission queues an `authentik_approval_request` for a second person instead of persisting the grant immediately; the same identity-permission expansion and second-person condition as Edit Group apply, nothing is written before approval, self-approval is refused and a decided request cannot be approved again. Previously a new group with `authentik.users.read` was created without approval. - **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255). - Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: `, `malformed ` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was. diff --git a/UPGRADE.md b/UPGRADE.md index 08ac884..2867487 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,13 @@ # Upgrading qbit-authentik +## Policy format 1.1.0 (unreleased) + +1. The default `PolicyVersion` is `1.1.0`. Documents stored as `1.0.0` stop parsing (they grant nothing, and one invalid document voids ALL of a user's policies). Either rewrite the stored documents and the claim expression to `1.1.0`, or keep `policyVersion("1.0.0")` in the config and the claim expression. +2. The example claim blueprint now emits a document the qbit rejects when any of a user's documents under your spec is unparsable, has another version or has a non-array `Statement`. Update the blueprint copy in your identity repository, and run `src/test/resources/policy-vectors-1.1.0.json` against any claim validator you own. +3. Code that read `PolicyParser.parse`/`parseStrict(...).permissions()` or `GroupGraph.Node.policyPermissions()` must move to `PolicyRules` (`resolve(universe)`, `hasFullAccess()`, `denyLabels()`); there is deliberately no plain-name view of a Deny. +4. Hosts that keep custom or service permission names (`email.send`, `echo.use`) declare them with `PolicyPermissions.registerHostPermissions(qInstance -> names)`; no Authentik runtime is needed. They are granted only through `Permission: [...]`. +5. The Next UI policy editor payloads gain `denied` (policy editor widget) and `policyFullAccess`, `policyDeny`, `policyInvalid` (access editor widget); `policyPermissions` is now the resolved list (Allow minus Deny), never the internal full-access marker. + ## Table help bundle (unreleased) Nothing to do. The qbit now applies `help/qbit-authentik.yaml` to the Identity app and its tables at the end of `produce()`, filling only help slots the host has not set. There is no schema change and nothing to seed or include in a Liquibase changelog: the API-backed tables are live Authentik data, and the four local tables fill as people use the actions. diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java index b69ba76..22c92fa 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java @@ -33,6 +33,7 @@ import com.kingsrook.qbits.authentik.permissions.AuthentikHostPermissionSource; import com.kingsrook.qbits.authentik.policy.PolicyCutoverWidgets; import com.kingsrook.qbits.authentik.policy.PolicyFormat; import com.kingsrook.qbits.authentik.policy.PolicyParser; +import com.kingsrook.qbits.authentik.policy.PolicyTestSupport; import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; @@ -199,7 +200,7 @@ class PolicyCutoverTest t.config.withStaticGroupPermissions(Map.of("kof-website-publishers", List.of("cms_page.read", "cms_page.edit", "cms.publish.hasAccess"))); t.config.withCutoverGuardGroups(List.of()); t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").put("qqq.io/policy", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"delete\"],\"Resource\":[\"cms_page\"]}]}"); + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"delete\"],\"Resource\":[\"cms_page\"]}]}"); t.config.withStaticGroupPermissions(Map.of()); runtime.invalidateCache(); runtime.setMapperState(null); @@ -237,7 +238,7 @@ class PolicyCutoverTest AuthentikAdmin.AdminResult result = t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-publishers"), "seed"))); assertThat(result.ok()).as(result.message()).isTrue(); JSONObject attributes = t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes"); - assertThat(PolicyParser.parse(attributes.get("qqq.io/policy"), PolicyFormat.DEFAULT)).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit"); + assertThat(PolicyTestSupport.names(PolicyParser.parse(attributes.get("qqq.io/policy"), PolicyFormat.DEFAULT))).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit"); assertThat(attributes.getJSONArray("qqq.io/permissions").toList()).contains("cms.publish.hasAccess"); assertThat(t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-publishers"), "again"))).noChange()).isTrue(); diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java index 87d5bf2..d458d90 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java @@ -33,6 +33,7 @@ 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.policy.PolicyTestSupport; import com.kingsrook.qbits.authentik.permissions.AccessComparison; import com.kingsrook.qbits.authentik.permissions.AuthentikGroupPermissionMapper; import com.kingsrook.qbits.authentik.security.IdentityPermission; @@ -69,8 +70,8 @@ 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 static final String FULL_ACCESS = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"*\"],\"Resource\":[\"*\"]}]}"; + private static final String PAGE_READ_EDIT = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"cms_page\"]}]}"; private AdminTestSupport t; @@ -113,6 +114,61 @@ class PolicyEditorTest + /******************************************************************************* + ** Adding a Deny needs no grants; removing it is a grant (the operator must hold + ** what it withheld); a Deny that matches nothing is refused at save; removing + ** a person from the group that carries it is the same grant. + *******************************************************************************/ + @Test + void denyIsFreeToAddAndRemovingItIsAGrant() throws Exception + { + String group = "kof-website-analysts"; + String deny = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"*\"],\"Resource\":[\"cms_page\"]}]}"; + String none = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[]}"; + t.config.withHostPermissionSource((email, groups) -> Set.of()); + + AuthentikAdmin.AdminResult added = save(group, deny); + assertThat(added.ok()).as(added.message()).isTrue(); + assertThat(PolicyEditorWidget.load(t.config, t.authentik.pk(group)).getDenied()).containsExactly("cms_page: all actions"); + assertThat(t.authentik.groupByName(group).getJSONObject("attributes").getString("qqq.io/policy")).contains("\"Effect\":\"Deny\""); + + assertThat(save(group, none).message()).as("removing a Deny is a grant").contains("do not hold yourself", "cms_page.delete"); + assertThat(t.authentik.groupByName(group).getJSONObject("attributes").getString("qqq.io/policy")).contains("\"Effect\":\"Deny\""); + + Set all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog()); + t.config.withHostPermissionSource((email, groups) -> all); + assertThat(save(group, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"*\"],\"Resource\":[\"zzz_*\"]}]}") + .message()).contains("matches nothing"); + assertThat(save(group, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"*\"],\"Resource\":[\"cms_pgae\"]}]}") + .message()).contains("matches nothing"); + assertThat(save(group, none).ok()).isTrue(); + } + + + + /******************************************************************************* + ** Taking a person out of the group that carries a Deny releases what it + ** withheld, so it needs the same grants. + *******************************************************************************/ + @Test + void removingAMemberFromADenyGroupIsAGrant() throws Exception + { + String group = "kof-website-publishers"; + String deny = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"*\"],\"Resource\":[\"cms_page\"]}]}"; + t.authentik.user(8, "member8", "member8@example.test", "internal", true, group); + t.config.withHostPermissionSource((email, groups) -> Set.of()); + assertThat(save(group, deny).ok()).isTrue(); + AuthentikAdmin.AdminResult refused = t.admin().execute(AdminRequest.of(AdminAction.REMOVE_MEMBER, t.authentik.pk(group) + ":8", "because")); + assertThat(refused.message()).contains("withholds permissions you do not hold yourself", "cms_page.delete"); + assertThat(t.authentik.groupNamesOf(8)).contains(group); + + Set all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog()); + t.config.withHostPermissionSource((email, groups) -> all); + assertThat(t.admin().execute(AdminRequest.of(AdminAction.REMOVE_MEMBER, t.authentik.pk(group) + ":8", "because")).ok()).isTrue(); + } + + + /******************************************************************************* ** A full-access operator can grant full access. Both mapper paths, comparison ** and preview bases expand it, including grants outside the editor matrix. @@ -220,7 +276,7 @@ class PolicyEditorTest assertThat(widget.getDocumentProblem()).isNull(); assertThat(widget.getChangedInAuthentik()).isFalse(); assertThat(save("kof-website-publishers", document).noChange()).isTrue(); - assertThat(t.admin().getUtil().groupGraph().byName("kof-website-publishers").policyPermissions()).containsExactly("cms_page.analyze"); + assertThat(PolicyTestSupport.names(t.admin().getUtil().groupGraph().byName("kof-website-publishers").policy())).containsExactly("cms_page.analyze"); assertThat(AccessComparison.run(t.config, t.admin().getUtil(), t.admin().getUtil().getRuntime().getPermissionCatalog())) .anyMatch(row -> row.username().equals("operator") && row.after().contains("cms_page.analyze")); @@ -250,7 +306,7 @@ class PolicyEditorTest JSONObject attributes = t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes"); assertThat(attributes.getString("note")).isEqualTo("set in the Authentik UI"); assertThat(attributes.getString("qqq.io/policy")).isEqualTo(PolicyGenerator.toCompact(PolicyGenerator.canonical(PAGE_READ_EDIT, PolicyFormat.DEFAULT))); - assertThat(PolicyParser.parse(attributes.get("qqq.io/policy"), PolicyFormat.DEFAULT)).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit"); + assertThat(PolicyTestSupport.names(PolicyParser.parse(attributes.get("qqq.io/policy"), PolicyFormat.DEFAULT))).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit"); List rows = policyRows(); assertThat(rows).hasSize(1); @@ -263,7 +319,7 @@ class PolicyEditorTest // the effective permissions of the group now include them // ////////////////////////////////////////////////////////////// t.admin().getUtil().getRuntime().invalidateCache(); - assertThat(t.admin().getUtil().groupGraph().byName("kof-website-analysts").policyPermissions()).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit"); + assertThat(PolicyTestSupport.names(t.admin().getUtil().groupGraph().byName("kof-website-analysts").policy())).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit"); /////////////////////////////////////////////////////////////////// // saving it again is no change; removing it drops the attribute // @@ -285,9 +341,9 @@ class PolicyEditorTest @Test void refusals() throws Exception { - assertThat(save("kof-website-analysts", "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}]}").message()) - .contains("not valid").contains("Deny"); - assertThat(save("kof-website-analysts", "{\"PolicySpec\":\"other\",\"PolicyVersion\":\"1.0.0\"}").message()).contains("PolicySpec"); + assertThat(save("kof-website-analysts", "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Maybe\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}]}").message()) + .contains("not valid").contains("Effect"); + assertThat(save("kof-website-analysts", "{\"PolicySpec\":\"other\",\"PolicyVersion\":\"1.1.0\"}").message()).contains("PolicySpec"); assertThat(save("kof-website-analysts", PAGE_READ_EDIT.replace("cms_page", "no_such_table")).message()).contains("not permissions of this app").contains("no_such_table.read"); assertThat(save("kof-website-analysts", PAGE_READ_EDIT.replace("\"edit\"", "\"delete\"")).message()).contains("do not hold yourself").contains("cms_page.delete"); assertThat(save("authentik Admins", PAGE_READ_EDIT).message()).contains("Superuser groups"); @@ -309,10 +365,10 @@ class PolicyEditorTest @Test void keptUnknownsAndInvalidStoredDocuments() throws Exception { - String withGone = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\"]}]}"; + String withGone = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\"]}]}"; t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", withGone); AdminPlan plan = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because") - .with(AuthentikAdmin.POLICY_DOCUMENT, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\",\"cms_page\"]}]}")); + .with(AuthentikAdmin.POLICY_DOCUMENT, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\",\"cms_page\"]}]}")); assertThat(plan.isRunnable()).as(plan.getMessage()).isTrue(); assertThat(plan.getNotes()).anyMatch(n -> n.startsWith("Adds: cms_page.read")).anyMatch(n -> n.contains("Kept although") && n.contains("gone_table.read")); @@ -394,7 +450,7 @@ class PolicyEditorTest input.setStartAfterStep("review"); output = new RunProcessAction().execute(input); assertThat(output.getException()).isEmpty(); - assertThat(PolicyParser.parse(t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").get("qqq.io/policy"), PolicyFormat.DEFAULT)) + assertThat(PolicyTestSupport.names(PolicyParser.parse(t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").get("qqq.io/policy"), PolicyFormat.DEFAULT))) .containsExactlyInAnyOrder("cms_page.read", "cms_page.edit"); //////////////////////////////////////////////////////// diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/UserGroupCrudTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/UserGroupCrudTest.java index abc3870..9e28fdf 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/UserGroupCrudTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/UserGroupCrudTest.java @@ -169,7 +169,7 @@ class UserGroupCrudTest assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, analysts, "cleanup").with("confirmText", "kof-website-analysts")).message()).contains("child groups (child-of-analysts)"); String child = t.authentik.pk("child-of-analysts"); - PolicyStore.save(t.config, child, "child-of-analysts", new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0").put("Statement", new JSONArray()), 0, "x", null); + PolicyStore.save(t.config, child, "child-of-analysts", new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0").put("Statement", new JSONArray()), 0, "x", null); AuthentikAdmin.AdminResult deleted = run(AdminRequest.of(AdminAction.DELETE_GROUP, child, "cleanup").with("confirmText", "child-of-analysts")); assertThat(deleted.ok()).as(deleted.message()).isTrue(); assertThat(t.authentik.groups).doesNotContainKey(child); diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/PolicyClaimMappingIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/PolicyClaimMappingIT.java index b117377..a4c16fa 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/PolicyClaimMappingIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/PolicyClaimMappingIT.java @@ -24,6 +24,7 @@ import com.kingsrook.qbits.authentik.RequiresDockerCondition; import com.kingsrook.qbits.authentik.policy.PolicyFormat; import com.kingsrook.qbits.authentik.policy.PolicyGenerator; import com.kingsrook.qbits.authentik.policy.PolicyParser; +import com.kingsrook.qbits.authentik.policy.PolicyTestSupport; import com.kingsrook.qbits.authentik.policy.PolicyPermissions; import org.json.JSONArray; import org.json.JSONObject; @@ -122,7 +123,7 @@ class PolicyClaimMappingIT String parent = group("policy-parent", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("contentApp.hasAccess"), format))); String child = group("policy-child", parent, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("cms_page.read", "cms_page.edit"), format))); group("policy-object", null, PolicyGenerator.generate(List.of("objectAttr.read"), format)); - group("policy-other-spec", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("ignored.read"), new PolicyFormat("other", "1.0.0", "x", "y")))); + group("policy-other-spec", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("ignored.read"), new PolicyFormat("other", "1.1.0", "x", "y")))); int member = user("policy.member", child); admin.post("/core/groups/" + admin.groupPk("policy-object") + "/add_user/", new JSONObject().put("pk", member)); @@ -144,6 +145,6 @@ class PolicyClaimMappingIT void userWithoutPoliciesGetsAnEmptyDocument() throws Exception { JSONObject claims = claimsFor(user("policy.nobody", null)); - assertThat(PolicyParser.parseStrict(claims.get("qqq_policy"), PolicyFormat.DEFAULT).permissions()).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(claims.get("qqq_policy"), PolicyFormat.DEFAULT).rules())).isEmpty(); } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java index 1fa1239..90eb89a 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java @@ -33,6 +33,7 @@ import com.kingsrook.qbits.authentik.admin.AdminAction; import com.kingsrook.qbits.authentik.admin.AdminRequest; import com.kingsrook.qbits.authentik.admin.AuthentikAdmin; import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; +import com.kingsrook.qbits.authentik.policy.PolicyTestSupport; import com.kingsrook.qbits.authentik.security.IdentityPermission; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -73,7 +74,7 @@ class QbitWriteContractIT private static final String OPERATOR_EMAIL = "wr.operator@example.test"; /** The operator's own policy: they may grant read and edit on wr_page. */ - private static final String WR_POLICY = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"wr_page\"]}]}"; + private static final String WR_POLICY = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"wr_page\"]}]}"; /** The phase 2/3 permissions of DESIGN.md section 5.1 (plus view_flow to resolve the invitation flow by slug). */ private static final List ROLE_PERMISSIONS = List.of( @@ -321,7 +322,7 @@ class QbitWriteContractIT { admin.post("/core/groups/", new JSONObject().put("name", "wr-policy").put("attributes", new JSONObject().put("owner-note", "set in the Authentik UI"))); String pk = admin.groupPk("wr-policy"); - String document = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"delete\"],\"Resource\":[\"wr_page\"]}]}"; + String document = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"delete\"],\"Resource\":[\"wr_page\"]}]}"; AuthentikAdmin.AdminResult escalation = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, document)); assertThat(escalation.ok()).isFalse(); @@ -333,8 +334,8 @@ class QbitWriteContractIT JSONObject attributes = admin.get("/core/groups/" + pk + "/").json().getJSONObject("attributes"); assertThat(attributes.getString("owner-note")).isEqualTo("set in the Authentik UI"); - assertThat(attributes.getString("qqq.io/policy")).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"wr_page\"]}]}"); - assertThat(AuthentikActionUtil.forBackend(config.getApiBackendName()).groupGraph().byPk(pk).policyPermissions()).containsExactly("wr_page.read"); + assertThat(attributes.getString("qqq.io/policy")).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"wr_page\"]}]}"); + assertThat(PolicyTestSupport.names(AuthentikActionUtil.forBackend(config.getApiBackendName()).groupGraph().byPk(pk).policy())).containsExactly("wr_page.read"); AuthentikAdmin.AdminResult cleared = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, "")); assertThat(cleared.ok()).as(cleared.message()).isTrue(); diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java index 43a55e5..9503271 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java @@ -48,6 +48,28 @@ import static org.assertj.core.api.Assertions.assertThat; *******************************************************************************/ class AuthentikGroupPermissionMapperTest { + /******************************************************************************* + ** The test names these policies grant exist in the universe (a grant of a + ** name that does not exist is dropped), declared the way a host does. + *******************************************************************************/ + @org.junit.jupiter.api.BeforeEach + void declareNames() + { + com.kingsrook.qbits.authentik.policy.PolicyPermissions.registerHostPermissions(names -> java.util.List.of("cms_page.read", "other.edit")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @org.junit.jupiter.api.AfterEach + void forgetNames() + { + com.kingsrook.qbits.authentik.policy.PolicyPermissions.clearRegisteredHostPermissions(); + } + + private QInstance qInstance; private AuthentikQBitConfig config; @@ -360,7 +382,7 @@ class AuthentikGroupPermissionMapperTest @Test void policyClaimIsAdded() { - String policy = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}]}"; + String policy = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}]}"; Map ctx = context("someone@example.test", new JSONArray()); ctx.put("idToken", new JSONObject().put("qqq_policy", policy)); QSession session = new QSession(); @@ -375,6 +397,29 @@ class AuthentikGroupPermissionMapperTest + /******************************************************************************* + ** A Deny in the claim wins over the Allow in the same claim, and an invalid + ** claim grants no policy permissions at all. + *******************************************************************************/ + @Test + void claimDenyWinsAndInvalidClaimVoids() + { + String head = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":["; + String allow = "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}"; + String deny = "{\"Effect\":\"Deny\",\"Action\":[\"*\"],\"Resource\":[\"cms_page\"]}"; + String bad = "{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"bad name\"]}"; + for(String statements : List.of(allow + "," + deny, allow + "," + bad)) + { + Map ctx = context("someone@example.test", new JSONArray()); + ctx.put("idToken", new JSONObject().put("qqq_policy", head + statements + "]}")); + QSession session = new QSession(); + new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, ctx); + assertThat(session.getPermissions()).doesNotContain("cms_page.read"); + } + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java index 9560002..a5c25ea 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java @@ -19,6 +19,7 @@ package com.kingsrook.qbits.authentik.permissions; import java.util.List; import java.util.Set; +import com.kingsrook.qbits.authentik.policy.PolicyRules; import com.kingsrook.qbits.authentik.security.IdentityPermission; import org.json.JSONArray; import org.json.JSONObject; @@ -183,8 +184,8 @@ class EffectivePermissionResolverTest void excludesOnlyTheSelectedPolicy() { GroupGraph graph = new GroupGraph() - .add(new GroupGraph.Node("parent", "Parent", false, List.of(), List.of("cms.publish"), List.of("app"), List.of(), List.of(), List.of("cms.*", "missing"))) - .add(new GroupGraph.Node("child", "Child", false, List.of("parent"), List.of(), List.of(), List.of(), List.of(), List.of("cms.edit"))); + .add(new GroupGraph.Node("parent", "Parent", false, List.of(), List.of("cms.publish"), List.of("app"), List.of(), List.of(), PolicyRules.allowing(List.of("cms.delete", "missing")))) + .add(new GroupGraph.Node("child", "Child", false, List.of("parent"), List.of(), List.of(), List.of(), List.of(), PolicyRules.allowing(List.of("cms.edit")))); EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, CATALOG); EffectivePermissionResolver.Resolution base = resolver.resolveGroups(List.of("child", "parent"), "parent"); assertThat(base.sessionPermissions()).containsExactly("cms.edit", "cms.publish"); diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapperTest.java index 97482a5..deaf1cf 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapperTest.java @@ -35,6 +35,28 @@ import static org.assertj.core.api.Assertions.assertThat; *******************************************************************************/ class AuthentikPolicyClaimMapperTest { + /******************************************************************************* + ** The test names these policies grant exist in the universe (a grant of a + ** name that does not exist is dropped), declared the way a host does. + *******************************************************************************/ + @org.junit.jupiter.api.BeforeEach + void declareNames() + { + com.kingsrook.qbits.authentik.policy.PolicyPermissions.registerHostPermissions(names -> java.util.List.of("cms_page.read", "other.edit")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @org.junit.jupiter.api.AfterEach + void forgetNames() + { + com.kingsrook.qbits.authentik.policy.PolicyPermissions.clearRegisteredHostPermissions(); + } + + private static final String READ_PAGE = document(statement("Allow", List.of("read"), List.of("cms_page"))).toString(); @@ -116,7 +138,7 @@ class AuthentikPolicyClaimMapperTest @Test void customFormat() { - PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies"); + PolicyFormat custom = new PolicyFormat("acme-iam", "1.1.0", "policies", "policies"); String doc = new JSONObject(READ_PAGE).put("PolicySpec", "acme-iam").toString(); QSession session = run(new AuthentikPolicyClaimMapper().withFormat(custom), Map.of("idToken", Map.of("policies", doc))); diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java new file mode 100644 index 0000000..d529511 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java @@ -0,0 +1,303 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.policy; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.TreeSet; +import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver; +import com.kingsrook.qbits.authentik.permissions.GroupGraph; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Properties of Deny, prefix resources and the universe: order independence, + ** idempotent canonicalization that never drops a Deny, one bad group voiding + ** the user, host names without a runtime, the catalog cache, claim shape. + *******************************************************************************/ +class PolicyDenyTest +{ + private static final PolicyFormat FORMAT = new PolicyFormat("qqq-iam", "1.1.0", "qqq.io/policy", "qqq_policy"); + private static final Set NAMES = Set.of("billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", + "billing_report.hasAccess", "utils_log.hasAccess", "utils_log.read", "utils_log.analyze", "cdn.purge", "document-generation.print"); + private static final Set HOST = Set.of("cdn.purge", "document-generation.print"); + private static final PolicyCatalog CATALOG = PolicyCatalog.ofPermissionNames(NAMES, HOST); + private static final PolicyUniverse UNIVERSE = PolicyUniverse.of(NAMES); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void cleanUp() + { + PolicyPermissions.clearRegisteredHostPermissions(); + } + + + + /******************************************************************************* + ** One statement as JSON text. + *******************************************************************************/ + private static JSONObject stmt(String effect, String action, String... resources) + { + return (new JSONObject().put("Effect", effect).put("Action", new JSONArray().put(action)).put("Resource", new JSONArray(resources))); + } + + + + /******************************************************************************* + ** A document of statements. + *******************************************************************************/ + private static JSONObject doc(List statements) + { + return (new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0").put("Statement", new JSONArray(statements))); + } + + + + /******************************************************************************* + ** The statements every property below shuffles. + *******************************************************************************/ + private static List statements() + { + return (new ArrayList<>(List.of( + stmt("Allow", "*", "*"), + stmt("Deny", "*", "billing_*"), + stmt("Deny", "read", "utils_log"), + stmt("Allow", "read", "billing_invoice"), + new JSONObject().put("Effect", "Deny").put("Permission", new JSONArray(List.of("cdn.purge"))), + stmt("Deny", "*", "utils_log")))); + } + + + + /******************************************************************************* + ** Statement order, document order and splitting across groups never change + ** the result. + *******************************************************************************/ + @Test + void orderDoesNotMatter() throws Exception + { + Set expected = PolicyParser.parseStrict(doc(statements()), FORMAT, CATALOG).rules().resolve(UNIVERSE); + assertThat(expected).containsExactlyInAnyOrder("document-generation.print"); + Random random = new Random(42); + for(int i = 0; i < 60; i++) + { + List shuffled = statements(); + Collections.shuffle(shuffled, random); + int split = random.nextInt(shuffled.size() + 1); + PolicyRules merged = PolicyRules.merge(List.of( + PolicyParser.parseStrict(doc(shuffled.subList(0, split)), FORMAT, CATALOG).rules(), + PolicyParser.parseStrict(doc(shuffled.subList(split, shuffled.size())), FORMAT, CATALOG).rules())); + assertThat(merged.resolve(UNIVERSE)).isEqualTo(expected); + } + } + + + + /******************************************************************************* + ** Canonicalizing twice equals canonicalizing once, keeps every Deny, and + ** parses back to the same rules. + *******************************************************************************/ + @Test + void canonicalizationIsIdempotentAndKeepsEveryDeny() throws Exception + { + Random random = new Random(7); + for(int i = 0; i < 40; i++) + { + List shuffled = statements(); + Collections.shuffle(shuffled, random); + JSONObject input = doc(shuffled); + PolicyRules rules = PolicyParser.parseStrict(input, FORMAT, CATALOG).rules(); + JSONObject once = PolicyGenerator.canonical(input, FORMAT, CATALOG); + JSONObject twice = PolicyGenerator.canonical(once, FORMAT, CATALOG); + assertThat(PolicyGenerator.toCompact(twice)).isEqualTo(PolicyGenerator.toCompact(once)); + assertThat(PolicyParser.parseStrict(once, FORMAT, CATALOG).rules()).isEqualTo(rules); + assertThat(rules.denyTerms()).hasSize(4); + assertThat(PolicyGenerator.toCompact(once)).contains("\"Effect\":\"Deny\""); + } + JSONObject full = PolicyGenerator.canonical(doc(statements()), FORMAT, CATALOG); + assertThat(full.getJSONArray("Statement").getJSONObject(0).getString("Effect")).as("one full-access Allow first").isEqualTo("Allow"); + assertThat(PolicyGenerator.toCompact(full)).doesNotContain("\"read\"],\"Resource\":[\"billing_invoice\"]},{\"Effect\":\"Allow\""); + long allows = java.util.stream.IntStream.range(0, full.getJSONArray("Statement").length()) + .filter(n -> "Allow".equals(full.getJSONArray("Statement").getJSONObject(n).getString("Effect"))).count(); + assertThat(allows).as("no other Allow beside full access").isEqualTo(1); + } + + + + /******************************************************************************* + ** The typed model cannot be read as names when it holds a Deny or a symbol. + *******************************************************************************/ + @Test + void denyCannotBeReadAsPlainNames() throws Exception + { + PolicyRules rules = PolicyParser.parseStrict(doc(statements()), FORMAT, CATALOG).rules(); + assertThatThrownBy(rules::exactGrants).isInstanceOf(IllegalStateException.class); + assertThat(PolicyRules.voided("x").exactGrants()).isEmpty(); + assertThat(PolicyRules.voided("x").denies("anything.read")).as("void denies everything").isTrue(); + } + + + + /******************************************************************************* + ** One bad group among a user's groups voids all of them, Deny or not. + *******************************************************************************/ + @Test + void oneBadGroupVoidsTheUser() + { + JSONObject good = doc(List.of(stmt("Allow", "*", "billing_invoice"))); + JSONObject bad = doc(List.of(stmt("Deny", "read", "bad name"))); + List groups = List.of( + new JSONObject().put("pk", "1").put("name", "good").put("users", new JSONArray().put(9)).put("attributes", new JSONObject().put(FORMAT.attribute(), good.toString())), + new JSONObject().put("pk", "2").put("name", "bad").put("users", new JSONArray().put(9)).put("attributes", new JSONObject().put(FORMAT.attribute(), bad.toString())), + new JSONObject().put("pk", "3").put("name", "other").put("users", new JSONArray().put(10)).put("attributes", new JSONObject().put(FORMAT.attribute(), good.toString()))); + GroupGraph graph = GroupGraph.fromGroups(groups, FORMAT, CATALOG); + EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null, null, UNIVERSE); + assertThat(resolver.resolveUser(9).sessionPermissions()).isEmpty(); + assertThat(resolver.resolveUser(9).voided()).isTrue(); + assertThat(resolver.resolveUser(10).sessionPermissions()).hasSize(5); + } + + + + /******************************************************************************* + ** A Deny in one group removes what another group grants, including grants + ** from qqq.io/permissions and from a parent. + *******************************************************************************/ + @Test + void denyRemovesEveryKindOfGrant() + { + JSONObject allowDoc = doc(List.of(stmt("Allow", "*", "billing_*"))); + JSONObject denyDoc = doc(List.of(stmt("Deny", "*", "billing_invoice"))); + List groups = List.of( + new JSONObject().put("pk", "p").put("name", "parent").put("users", new JSONArray()).put("attributes", new JSONObject().put(FORMAT.attribute(), allowDoc.toString())), + new JSONObject().put("pk", "c").put("name", "child").put("users", new JSONArray().put(1)).put("parents", new JSONArray().put("p")) + .put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("utils_log.read"))), + new JSONObject().put("pk", "d").put("name", "deny").put("users", new JSONArray().put(1)).put("attributes", new JSONObject().put(FORMAT.attribute(), denyDoc.toString()).put("qqq.io/permissions", new JSONArray().put("billing_invoice.read")))); + GroupGraph graph = GroupGraph.fromGroups(groups, FORMAT, CATALOG); + EffectivePermissionResolver.Resolution resolution = new EffectivePermissionResolver(graph, NAMES, null, UNIVERSE).resolveUser(1); + assertThat(resolution.sessionPermissions()).containsExactlyInAnyOrder("billing_report.hasAccess", "utils_log.read"); + assertThat(resolution.grantedBy()).doesNotContainKey("billing_invoice.read"); + } + + + + /******************************************************************************* + ** Host-declared names reach the universe with no Authentik runtime and no + ** instance configuration, and are granted only through Permission. + *******************************************************************************/ + @Test + void hostNamesWorkWithoutARuntime() throws Exception + { + assertThat(PolicyPermissions.allPermissions(null, Set.of())).doesNotContain("email.send"); + PolicyPermissions.registerHostPermissions(instance -> List.of("email.send", "echo.use", " ")); + assertThat(PolicyPermissions.allPermissions(null, Set.of())).contains("email.send", "echo.use"); + assertThat(PolicyPermissions.allPermissions(new QInstance(), Set.of())).contains("email.send", "echo.use"); + + PolicyUniverse live = PolicyPermissions.universe(null, Set.of()); + JSONObject allowed = doc(List.of(new JSONObject().put("Effect", "Allow").put("Permission", new JSONArray(List.of("email.send", "alerts.admin"))))); + assertThat(PolicyParser.parseStrict(allowed, FORMAT, CATALOG).rules().resolve(live)).as("alerts.admin is not in the universe").containsExactly("email.send"); + assertThatThrownBy(() -> PolicyParser.parseForSave(allowed, FORMAT, CATALOG, live)).hasMessageContaining("matches nothing"); + } + + + + /******************************************************************************* + ** A dotted host name is never a table feature. + *******************************************************************************/ + @Test + void hostNamesAreNotFeatures() + { + PolicyCatalog catalog = PolicyCatalog.ofPermissionNames(Set.of("email.send", "email_log.read"), Set.of("email.send")); + assertThatThrownBy(() -> PolicyParser.parseStrict(doc(List.of(stmt("Allow", "send", "email"))), FORMAT, catalog)).hasMessageContaining("unknown action"); + assertThat(catalog.hasFeature("email", "send")).isFalse(); + } + + + + /******************************************************************************* + ** One merged document, as an object or as a string; an array is rejected. + *******************************************************************************/ + @Test + void claimIsOneMergedDocument() + { + JSONObject one = doc(List.of(stmt("Allow", "read", "billing_invoice"))); + PolicyRules fromObject = PolicyPermissions.rulesFromClaims(new JSONObject().put("qqq_policy", one), FORMAT, CATALOG); + PolicyRules fromString = PolicyPermissions.rulesFromClaims(new JSONObject().put("qqq_policy", one.toString()), FORMAT, CATALOG); + assertThat(fromObject.isVoid()).isFalse(); + assertThat(fromObject).isEqualTo(fromString); + assertThat(PolicyPermissions.rulesFromClaims(new JSONObject().put("qqq_policy", new JSONArray().put(one).put(one)), FORMAT, CATALOG).isVoid()).isTrue(); + assertThat(PolicyPermissions.rulesFromClaims(new JSONObject().put("qqq_policy", new JSONArray().put(one).toString()), FORMAT, CATALOG).isVoid()).isTrue(); + } + + + + /******************************************************************************* + ** The cached catalog and universe equal an uncached build; a metadata change + ** is picked up; clearing the cache forces a rebuild. + *******************************************************************************/ + @Test + void cachedCatalogEqualsUncached() + { + PolicyCatalog.clearCache(); + QInstance instance = PolicyCatalogTest.instance(); + PolicyCatalog first = PolicyCatalog.of(instance); + assertThat(PolicyCatalog.of(instance)).as("second call is a cache hit").isSameAs(first); + PolicyCatalog uncached = PolicyCatalog.build(instance); + assertThat(uncached).isNotSameAs(first); + assertThat(uncached.permissionNames()).isEqualTo(first.permissionNames()); + assertThat(uncached.availableNames()).isEqualTo(first.availableNames()); + assertThat(uncached.toJson().toString()).isEqualTo(first.toJson().toString()); + + Set withCache = PolicyPermissions.allPermissions(instance, Set.of()); + PolicyCatalog.clearCache(); + assertThat(PolicyPermissions.allPermissions(instance, Set.of())).isEqualTo(new TreeSet<>(withCache)); + + PolicyCatalog before = PolicyCatalog.of(instance); + instance.addProcess(new com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData().withName("freshProcess").withLabel("Fresh") + .withPermissionRules(new com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules() + .withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.HAS_ACCESS_PERMISSION))); + assertThat(PolicyPermissions.allPermissions(instance, Set.of())).as("a new process is visible through the cache").contains("freshProcess.hasAccess"); + assertThat(PolicyCatalog.of(instance)).isNotSameAs(before); + } + + + + /******************************************************************************* + ** Save-time zero-match rules, including a Deny of a missing named resource. + *******************************************************************************/ + @Test + void saveRejectsZeroMatchDeny() throws Exception + { + assertThatThrownBy(() -> PolicyParser.parseForSave(doc(List.of(stmt("Deny", "*", "billng_invoice"))), FORMAT, CATALOG, UNIVERSE)).hasMessageContaining("matches nothing"); + assertThatThrownBy(() -> PolicyParser.parseForSave(doc(List.of(stmt("Deny", "delete", "billing_report"))), FORMAT, CATALOG, UNIVERSE)).hasMessageContaining("matches nothing"); + assertThat(PolicyParser.parseForSave(doc(List.of(stmt("Deny", "delete", "billing_invoice"))), FORMAT, CATALOG, UNIVERSE).rules().hasDeny()).isTrue(); + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyFeaturePermissionsTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyFeaturePermissionsTest.java index 0771495..fbbe2bf 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyFeaturePermissionsTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyFeaturePermissionsTest.java @@ -147,17 +147,17 @@ class PolicyFeaturePermissionsTest void parserValidatesFeaturesPerResource() throws Exception { PolicyCatalog catalog = PolicyCatalog.of(instance()); - assertThat(PolicyParser.parseStrict(document(List.of("read", "analyze"), "person"), FORMAT, catalog).permissions()) + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(document(List.of("read", "analyze"), "person"), FORMAT, catalog).rules())) .containsExactly("person.analyze", "person.read"); - assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "gate"), FORMAT, catalog)).containsExactly("gate.analyze", "person.analyze"); - assertThat(PolicyParser.parse(document(List.of("comment", "write"), "legacy"), FORMAT, catalog)).containsExactly("legacy.comment", "legacy.write"); + assertThat(PolicyTestSupport.names(PolicyParser.parse(document(List.of("analyze"), "person", "gate"), FORMAT, catalog))).containsExactly("gate.analyze", "person.analyze"); + assertThat(PolicyTestSupport.names(PolicyParser.parse(document(List.of("comment", "write"), "legacy"), FORMAT, catalog))).containsExactly("legacy.comment", "legacy.write"); assertThatThrownBy(() -> PolicyParser.parseStrict(document(List.of("mystery"), "person"), FORMAT, catalog)) .hasMessage("Statement 1: unknown action \"mystery\""); - assertThat(PolicyParser.parse(document(List.of("analyze"), "person", "legacy"), FORMAT, catalog)).isEmpty(); - assertThat(PolicyParser.parse(document(List.of("analyze"), "gone"), FORMAT, catalog)).isEmpty(); - assertThat(PolicyParser.parse(document(List.of("analyze")), FORMAT, catalog)).isEmpty(); - assertThat(PolicyParser.parse(document(List.of("Analyze"), "person"), FORMAT, catalog)).isEmpty(); - assertThat(PolicyParser.parse(document(List.of("analyze"), "person"), FORMAT)).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(document(List.of("analyze"), "person", "legacy"), FORMAT, catalog))).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(document(List.of("analyze"), "gone"), FORMAT, catalog))).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(document(List.of("analyze")), FORMAT, catalog))).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(document(List.of("Analyze"), "person"), FORMAT, catalog))).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(document(List.of("analyze"), "person"), FORMAT))).isEmpty(); } @@ -169,9 +169,9 @@ class PolicyFeaturePermissionsTest void wildcardDoesNotGrantFeatures() { PolicyCatalog catalog = PolicyCatalog.of(instance()); - assertThat(PolicyParser.parse(document(List.of("*"), "person"), FORMAT, catalog)) + assertThat(PolicyTestSupport.names(PolicyParser.parse(document(List.of("*"), "person"), FORMAT, catalog))) .containsExactlyInAnyOrder("person.hasAccess", "person.read", "person.insert", "person.edit", "person.delete"); - assertThat(PolicyParser.parse(document(List.of("*", "analyze"), "person"), FORMAT, catalog)).hasSize(6).contains("person.analyze").doesNotContain("person.comment"); + assertThat(PolicyTestSupport.names(PolicyParser.parse(document(List.of("*", "analyze"), "person"), FORMAT, catalog))).hasSize(6).contains("person.analyze").doesNotContain("person.comment"); } @@ -186,16 +186,19 @@ class PolicyFeaturePermissionsTest List permissions = List.of("person.comment", "person.read", "person.analyze", "gate.analyze"); JSONObject generated = PolicyGenerator.generate(permissions, FORMAT, catalog); String compact = PolicyGenerator.toCompact(generated); - assertThat(compact).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[" + assertThat(compact).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[" + "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"person\"]}," + "{\"Effect\":\"Allow\",\"Action\":[\"analyze\"],\"Resource\":[\"gate\",\"person\"]}," + "{\"Effect\":\"Allow\",\"Action\":[\"comment\"],\"Resource\":[\"person\"]}]}"); - assertThat(PolicyParser.parseStrict(compact, FORMAT, catalog).permissions()).containsExactlyInAnyOrderElementsOf(permissions); - assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(generated), FORMAT, catalog).permissions()).containsExactlyInAnyOrderElementsOf(permissions); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(compact, FORMAT, catalog).rules())).containsExactlyInAnyOrderElementsOf(permissions); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(PolicyGenerator.toPretty(generated), FORMAT, catalog).rules())).containsExactlyInAnyOrderElementsOf(permissions); assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(PolicyGenerator.toPretty(generated), FORMAT, catalog))).isEqualTo(compact); assertThat(PolicyStore.fingerprint(compact, FORMAT, catalog)).isEqualTo(PolicyStore.fingerprint(PolicyGenerator.toPretty(generated), FORMAT, catalog)); - assertThatThrownBy(() -> PolicyGenerator.generate(List.of("person.mystery"), FORMAT, catalog)).hasMessageContaining("unknown action \"mystery\""); - assertThatThrownBy(() -> PolicyGenerator.generate(List.of("legacy.analyze"), FORMAT, catalog)).hasMessageContaining("unknown action \"analyze\""); + /////////////////////////////////////////////////////////////////////////// + // a name that is not a registered feature is written as a named permission // + /////////////////////////////////////////////////////////////////////////// + assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("person.mystery"), FORMAT, catalog))).contains("\"Permission\":[\"person.mystery\"]"); + assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("legacy.analyze"), FORMAT, catalog))).contains("\"Permission\":[\"legacy.analyze\"]"); List standard = List.of("person.read", "person.edit", "gate.hasAccess"); assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(standard, FORMAT, catalog))).isEqualTo(PolicyGenerator.toCompact(PolicyGenerator.generate(standard, FORMAT))); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyFullAccessTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyFullAccessTest.java index 9334767..de71142 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyFullAccessTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyFullAccessTest.java @@ -68,21 +68,24 @@ class PolicyFullAccessTest void parserAcceptsOnlyExplicitFullAccess() throws Exception { JSONObject document = document(); - assertThat(PolicyParser.parseStrict(document, FORMAT).permissions()).containsExactly(PolicyPermissions.FULL_ACCESS); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(document, FORMAT).rules())).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); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(document, FORMAT).rules())).containsExactly(PolicyPermissions.FULL_ACCESS); document.getJSONArray("Statement").put(PolicyParserTest.statement("Deny", List.of("read"), List.of("cms_page"))); - assertThat(PolicyParser.parse(document, FORMAT)).isEmpty(); + PolicyRules withDeny = PolicyParser.parse(document, FORMAT); + assertThat(withDeny.hasFullAccess()).isTrue(); + assertThat(withDeny.hasDeny()).isTrue(); + assertThat(withDeny.denies("cms_page.read")).isTrue(); for(List actions : List.of(List.of("read"), List.of("*", "read"), List.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(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(invalid, FORMAT))).isEmpty(); } JSONObject badResource = PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("*", "bad.resource"))); - assertThat(PolicyParser.parse(badResource, FORMAT)).isEmpty(); + assertThat(PolicyTestSupport.names(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); + assertThat(PolicyTestSupport.names(PolicyParser.parse(mixedResources, FORMAT))).containsExactly(PolicyPermissions.FULL_ACCESS); } @@ -99,10 +102,9 @@ class PolicyFullAccessTest 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 fiveActions = PolicyParser.parse(PolicyParserTest.document(PolicyParserTest.statement("Allow", List.of("*"), List.of("cms_page"))), FORMAT); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(PolicyGenerator.toPretty(generated), FORMAT).rules())).containsExactly(PolicyPermissions.FULL_ACCESS); + Set fiveActions = PolicyTestSupport.names(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(); } @@ -138,7 +140,7 @@ class PolicyFullAccessTest } 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); + assertThat(PolicyTestSupport.names(graph.byPk("parent").policy())).containsExactly(PolicyPermissions.FULL_ACCESS); JSONObject claims = new JSONObject().put(FORMAT.claim(), document()); assertThat(PolicyPermissions.fromClaims(claims, FORMAT, catalog)).isEqualTo(permissions); diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyGeneratorTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyGeneratorTest.java index 33cbd7d..bc49185 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyGeneratorTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyGeneratorTest.java @@ -41,7 +41,7 @@ class PolicyGeneratorTest { JSONObject doc = PolicyGenerator.generate(List.of("cms_page.edit", "contentApp.hasAccess", "cms_page.read", "cms_media.read"), PolicyFormat.DEFAULT); - assertThat(PolicyGenerator.toCompact(doc)).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[" + assertThat(PolicyGenerator.toCompact(doc)).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[" + "{\"Effect\":\"Allow\",\"Action\":[\"hasAccess\"],\"Resource\":[\"contentApp\"]}," + "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_media\",\"cms_page\"]}," + "{\"Effect\":\"Allow\",\"Action\":[\"edit\"],\"Resource\":[\"cms_page\"]}]}"); @@ -49,7 +49,7 @@ class PolicyGeneratorTest assertThat(PolicyGenerator.toPretty(doc)).isEqualTo(""" { "PolicySpec": "qqq-iam", - "PolicyVersion": "1.0.0", + "PolicyVersion": "1.1.0", "Statement": [ { "Effect": "Allow", @@ -87,9 +87,9 @@ class PolicyGeneratorTest void emptyDocument() throws Exception { JSONObject doc = PolicyGenerator.generate(List.of(), PolicyFormat.DEFAULT); - assertThat(PolicyGenerator.toCompact(doc)).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}\n".trim()); - assertThat(PolicyGenerator.toPretty(doc)).isEqualTo("{\n \"PolicySpec\": \"qqq-iam\",\n \"PolicyVersion\": \"1.0.0\",\n \"Statement\": []\n}\n"); - assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT).permissions()).isEmpty(); + assertThat(PolicyGenerator.toCompact(doc)).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[]}\n".trim()); + assertThat(PolicyGenerator.toPretty(doc)).isEqualTo("{\n \"PolicySpec\": \"qqq-iam\",\n \"PolicyVersion\": \"1.1.0\",\n \"Statement\": []\n}\n"); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT).rules())).isEmpty(); } @@ -117,8 +117,8 @@ class PolicyGeneratorTest } } JSONObject doc = PolicyGenerator.generate(permissions, PolicyFormat.DEFAULT); - assertThat(PolicyParser.parseStrict(PolicyGenerator.toCompact(doc), PolicyFormat.DEFAULT).permissions()).isEqualTo(permissions); - assertThat(PolicyParser.parseStrict(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT).permissions()).isEqualTo(permissions); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(PolicyGenerator.toCompact(doc), PolicyFormat.DEFAULT).rules())).isEqualTo(permissions); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT).rules())).isEqualTo(permissions); assertThat(PolicyGenerator.toCompact(PolicyGenerator.canonical(PolicyGenerator.toPretty(doc), PolicyFormat.DEFAULT))).isEqualTo(PolicyGenerator.toCompact(doc)); } } @@ -132,11 +132,11 @@ class PolicyGeneratorTest @Test void canonicalizesHandWrittenDocuments() throws Exception { - String hand = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[" + String hand = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[" + "{\"Effect\":\"Allow\",\"Action\":[\"*\"],\"Resource\":[\"b\"]}," + "{\"Effect\":\"Allow\",\"Action\":[\"read\",\"read\"],\"Resource\":[\"a\",\"b\"]}]}"; JSONObject canonical = PolicyGenerator.canonical(hand, PolicyFormat.DEFAULT); - assertThat(PolicyParser.parse(canonical, PolicyFormat.DEFAULT)).isEqualTo(PolicyParser.parse(hand, PolicyFormat.DEFAULT)); + assertThat(PolicyTestSupport.names(PolicyParser.parse(canonical, PolicyFormat.DEFAULT))).isEqualTo(PolicyTestSupport.names(PolicyParser.parse(hand, PolicyFormat.DEFAULT))); assertThat(PolicyGenerator.toCompact(canonical)).doesNotContain("\"*\""); assertThat(canonical.getJSONArray("Statement").length()).isEqualTo(5); } @@ -147,13 +147,18 @@ class PolicyGeneratorTest ** *******************************************************************************/ @Test - void rejectsWhatCannotRoundTrip() + void rejectsWhatCannotRoundTrip() throws Exception { - assertThatThrownBy(() -> PolicyGenerator.generate(List.of("noaction"), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class).hasMessageContaining("{resource}.{action}"); + /////////////////////////////////////////////////////////////////////////// + // names that are not {resource}.{action} become named permissions; names // + // that cannot be a valid permission name are rejected // + /////////////////////////////////////////////////////////////////////////// + assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("noaction"), PolicyFormat.DEFAULT))).contains("\"Permission\":[\"noaction\"]"); + assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("a.write"), PolicyFormat.DEFAULT))).contains("\"Permission\":[\"a.write\"]"); + assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("authentik.users.read"), PolicyFormat.DEFAULT))).contains("\"Permission\":[\"authentik.users.read\"]"); assertThatThrownBy(() -> PolicyGenerator.generate(List.of("a."), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class); assertThatThrownBy(() -> PolicyGenerator.generate(List.of(".read"), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class); - assertThatThrownBy(() -> PolicyGenerator.generate(List.of("a.write"), PolicyFormat.DEFAULT)).hasMessageContaining("unknown action \"write\""); - assertThatThrownBy(() -> PolicyGenerator.generate(List.of("authentik.users.read"), PolicyFormat.DEFAULT)).hasMessageContaining("[A-Za-z0-9_]+"); + assertThatThrownBy(() -> PolicyGenerator.generate(List.of("bad name.read"), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class); assertThatThrownBy(() -> PolicyGenerator.generate(java.util.Arrays.asList((String) null), PolicyFormat.DEFAULT)).isInstanceOf(PolicyParseException.class); } @@ -165,10 +170,10 @@ class PolicyGeneratorTest @Test void customFormat() throws Exception { - PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies"); + PolicyFormat custom = new PolicyFormat("acme-iam", "1.1.0", "policies", "policies"); JSONObject doc = PolicyGenerator.generate(List.of("order.read"), custom); assertThat(doc.getString("PolicySpec")).isEqualTo("acme-iam"); - assertThat(PolicyParser.parse(PolicyGenerator.toCompact(doc), custom)).containsExactly("order.read"); + assertThat(PolicyTestSupport.names(PolicyParser.parse(PolicyGenerator.toCompact(doc), custom))).containsExactly("order.read"); assertThat(PolicyGenerator.generate(List.of("order.read"), null).getString("PolicySpec")).isEqualTo("qqq-iam"); } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyGraphTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyGraphTest.java index 0bb0671..e6b9221 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyGraphTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyGraphTest.java @@ -58,7 +58,7 @@ class PolicyGraphTest { String parentPolicy = document(statement("Allow", List.of("hasAccess"), List.of("contentApp"))).toString(); JSONObject childPolicy = document(statement("Allow", List.of("read", "edit"), List.of("cms_page"))); - String broken = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"x\"]}]}"; + String broken = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Maybe\",\"Action\":[\"read\"],\"Resource\":[\"x\"]}]}"; GroupGraph graph = GroupGraph.fromGroups(List.of( group("parent", List.of(), parentPolicy, "qqq.io/policy"), @@ -66,17 +66,19 @@ class PolicyGraphTest group("broken", List.of("parent"), broken, "qqq.io/policy"), group("empty", List.of(), "", "qqq.io/policy"))); - assertThat(graph.byPk("child").policyPermissions()).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit"); - assertThat(graph.byPk("broken").policyPermissions()).isEmpty(); - assertThat(graph.byPk("broken").malformed()).anyMatch(m -> m.startsWith("qqq.io/policy: ") && m.contains("Deny")); + assertThat(PolicyTestSupport.names(graph.byPk("child").policy())).containsExactlyInAnyOrder("cms_page.read", "cms_page.edit"); + assertThat(PolicyTestSupport.names(graph.byPk("broken").policy())).isEmpty(); + assertThat(graph.byPk("broken").malformed()).anyMatch(m -> m.startsWith("qqq.io/policy: ") && m.contains("Effect")); assertThat(graph.byPk("empty").malformed()).isEmpty(); - EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null); + EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null, null, PolicyUniverse.of(List.of("contentApp.hasAccess", "cms_page.read", "cms_page.edit"))); EffectivePermissionResolver.Resolution child = resolver.resolveGroups(List.of("child")); assertThat(child.sessionPermissions()).containsExactlyInAnyOrder("contentApp.hasAccess", "cms_page.read", "cms_page.edit"); assertThat(child.grantedBy().get("contentApp.hasAccess")).containsExactly("parent"); - assertThat(resolver.resolveGroups(List.of("broken")).sessionPermissions()).as("the broken group grants nothing; its parent still does").containsExactly("contentApp.hasAccess"); + assertThat(resolver.resolveGroups(List.of("broken")).sessionPermissions()).as("an invalid policy voids the user: not even the parent's grant survives").isEmpty(); + assertThat(resolver.resolveGroups(List.of("broken")).voided()).isTrue(); + assertThat(resolver.resolveGroups(List.of("child", "broken")).sessionPermissions()).as("one bad group voids every group of the user").isEmpty(); } @@ -101,10 +103,10 @@ class PolicyGraphTest @Test void customFormat() { - PolicyFormat custom = new PolicyFormat("acme-iam", "1.0.0", "policies", "policies"); + PolicyFormat custom = new PolicyFormat("acme-iam", "1.1.0", "policies", "policies"); String doc = document(statement("Allow", List.of("read"), List.of("order"))).put("PolicySpec", "acme-iam").toString(); GroupGraph graph = GroupGraph.fromGroups(List.of(group("g", List.of(), doc, "policies")), custom); - assertThat(graph.byPk("g").policyPermissions()).containsExactly("order.read"); - assertThat(GroupGraph.fromGroups(List.of(group("g", List.of(), doc, "policies"))).byPk("g").policyPermissions()).as("default attribute is qqq.io/policy").isEmpty(); + assertThat(PolicyTestSupport.names(graph.byPk("g").policy())).containsExactly("order.read"); + assertThat(PolicyTestSupport.names(GroupGraph.fromGroups(List.of(group("g", List.of(), doc, "policies"))).byPk("g").policy())).as("default attribute is qqq.io/policy").isEmpty(); } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java index f204e1a..59a5f41 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java @@ -52,7 +52,7 @@ class PolicyParserTest *******************************************************************************/ static JSONObject document(JSONObject... statements) { - return (new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0").put("Statement", new JSONArray(List.of(statements)))); + return (new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0").put("Statement", new JSONArray(List.of(statements)))); } @@ -67,7 +67,7 @@ class PolicyParserTest statement("Allow", List.of("hasAccess"), List.of("contentApp", "publishPage")), statement("Allow", List.of("read", "edit"), List.of("cms_page"))); - assertThat(PolicyParser.parse(doc, FORMAT)).containsExactlyInAnyOrder( + assertThat(PolicyTestSupport.names(PolicyParser.parse(doc, FORMAT))).containsExactlyInAnyOrder( "contentApp.hasAccess", "publishPage.hasAccess", "cms_page.read", "cms_page.edit"); } @@ -79,7 +79,7 @@ class PolicyParserTest @Test void wildcardActionIsAllFive() { - assertThat(PolicyParser.parse(document(statement("Allow", List.of("*"), List.of("cms_page"))), FORMAT)).containsExactlyInAnyOrder( + assertThat(PolicyTestSupport.names(PolicyParser.parse(document(statement("Allow", List.of("*"), List.of("cms_page"))), FORMAT))).containsExactlyInAnyOrder( "cms_page.hasAccess", "cms_page.read", "cms_page.insert", "cms_page.edit", "cms_page.delete"); } @@ -92,9 +92,9 @@ class PolicyParserTest void acceptsStringObjectAndMap() { JSONObject doc = document(statement("Allow", List.of("read"), List.of("a"))); - assertThat(PolicyParser.parse(doc.toString(), FORMAT)).containsExactly("a.read"); - assertThat(PolicyParser.parse(doc, FORMAT)).containsExactly("a.read"); - assertThat(PolicyParser.parse(doc.toMap(), FORMAT)).containsExactly("a.read"); + assertThat(PolicyTestSupport.names(PolicyParser.parse(doc.toString(), FORMAT))).containsExactly("a.read"); + assertThat(PolicyTestSupport.names(PolicyParser.parse(doc, FORMAT))).containsExactly("a.read"); + assertThat(PolicyTestSupport.names(PolicyParser.parse(doc.toMap(), FORMAT))).containsExactly("a.read"); } @@ -105,9 +105,9 @@ class PolicyParserTest @Test void emptyOrMissingStatementGrantsNothingWithoutError() throws Exception { - assertThat(PolicyParser.parseStrict(document(), FORMAT).permissions()).isEmpty(); - assertThat(PolicyParser.parseStrict(new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0"), FORMAT).permissions()).isEmpty(); - assertThat(PolicyParser.parseStrict(document(statement("Allow", List.of(), List.of("a"))), FORMAT).permissions()).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(document(), FORMAT).rules())).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0"), FORMAT).rules())).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(document(statement("Allow", List.of(), List.of("a"))), FORMAT).rules())).isEmpty(); } @@ -122,29 +122,29 @@ class PolicyParserTest " ", "not json", "[]", - "{\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}", - "{\"PolicySpec\":\"other-spec\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[]}", + "{\"PolicyVersion\":\"1.1.0\",\"Statement\":[]}", + "{\"PolicySpec\":\"other-spec\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[]}", "{\"PolicySpec\":\"qqq-iam\",\"Statement\":[]}", "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"2.0.0\",\"Statement\":[]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":{}}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[\"x\"]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"allow\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"read\",\"Resource\":[\"a\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":\"a\"}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"write\"],\"Resource\":[\"a\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"Read\"],\"Resource\":[\"a\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[1],\"Resource\":[\"a\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"*\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"authentik.users\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"\"]}]}", - "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_*\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":{}}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[\"x\"]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Maybe\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"allow\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"read\",\"Resource\":[\"a\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":\"a\"}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"write\"],\"Resource\":[\"a\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"Read\"],\"Resource\":[\"a\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[1],\"Resource\":[\"a\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"*\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"authentik.users\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"\"]}]}", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms*\"]}]}", }) void malformedDocumentsFailClosed(String text) { - assertThat(PolicyParser.parse(text, FORMAT)).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(text, FORMAT))).isEmpty(); assertThatThrownBy(() -> PolicyParser.parseStrict(text, FORMAT)).isInstanceOf(PolicyParseException.class); } @@ -158,8 +158,8 @@ class PolicyParserTest { JSONObject doc = document( statement("Allow", List.of("read"), List.of("good")), - statement("Deny", List.of("read"), List.of("other"))); - assertThat(PolicyParser.parse(doc, FORMAT)).isEmpty(); + statement("Maybe", List.of("read"), List.of("other"))); + assertThat(PolicyTestSupport.names(PolicyParser.parse(doc, FORMAT))).isEmpty(); } @@ -170,10 +170,10 @@ class PolicyParserTest @Test void nullAndOtherTypesGrantNothing() { - assertThat(PolicyParser.parse(null, FORMAT)).isEmpty(); - assertThat(PolicyParser.parse(JSONObject.NULL, FORMAT)).isEmpty(); - assertThat(PolicyParser.parse(42, FORMAT)).isEmpty(); - assertThat(PolicyParser.parse(new JSONArray(), FORMAT)).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(null, FORMAT))).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(JSONObject.NULL, FORMAT))).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(42, FORMAT))).isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(new JSONArray(), FORMAT))).isEmpty(); } @@ -188,9 +188,9 @@ class PolicyParserTest JSONObject doc = new JSONObject().put("PolicySpec", "acme-iam").put("PolicyVersion", "2.1.0") .put("Statement", new JSONArray().put(statement("Allow", List.of("read"), List.of("order")))); - assertThat(PolicyParser.parse(doc, custom)).containsExactly("order.read"); - assertThat(PolicyParser.parse(doc, FORMAT)).as("the default spec rejects another spec").isEmpty(); - assertThat(PolicyParser.parse(doc, null)).as("null format means the default").isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(doc, custom))).containsExactly("order.read"); + assertThat(PolicyTestSupport.names(PolicyParser.parse(doc, FORMAT))).as("the default spec rejects another spec").isEmpty(); + assertThat(PolicyTestSupport.names(PolicyParser.parse(doc, null))).as("null format means the default").isEmpty(); } @@ -203,7 +203,7 @@ class PolicyParserTest { assertThatThrownBy(() -> PolicyParser.parseStrict(Map.of("PolicySpec", "x"), FORMAT)).hasMessageContaining("PolicySpec must be \"qqq-iam\""); assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Allow", List.of("write"), List.of("a"))), FORMAT)).hasMessageContaining("unknown action \"write\""); - assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Deny", List.of("read"), List.of("a"))), FORMAT)).hasMessageContaining("Deny is not supported"); + assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Maybe", List.of("read"), List.of("a"))), FORMAT)).hasMessageContaining("Effect must be"); } @@ -215,9 +215,9 @@ class PolicyParserTest void formatProblems() { assertThat(PolicyFormat.DEFAULT.problem()).isNull(); - assertThat(new PolicyFormat("bad spec", "1.0.0", "a", "c").problem()).contains("policySpec"); + assertThat(new PolicyFormat("bad spec", "1.1.0", "a", "c").problem()).contains("policySpec"); assertThat(new PolicyFormat("s", "1.0", "a", "c").problem()).contains("policyVersion"); - assertThat(new PolicyFormat("s", "1.0.0", " ", "c").problem()).contains("policyAttribute"); - assertThat(new PolicyFormat("s", "1.0.0", "a", "bad claim").problem()).contains("policyClaim"); + assertThat(new PolicyFormat("s", "1.1.0", " ", "c").problem()).contains("policyAttribute"); + assertThat(new PolicyFormat("s", "1.1.0", "a", "bad claim").problem()).contains("policyClaim"); } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyTestSupport.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyTestSupport.java new file mode 100644 index 0000000..7a84df5 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyTestSupport.java @@ -0,0 +1,51 @@ +/* + * 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.Set; +import java.util.TreeSet; + + +/******************************************************************************* + ** Test helper: the pre-1.1.0 view of plain rules (a set of names, or the + ** FULL_ACCESS marker for full access). It throws for a Deny or a prefix, so a + ** test that uses it cannot silently ignore one. + *******************************************************************************/ +public final class PolicyTestSupport +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private PolicyTestSupport() + { + } + + + + /******************************************************************************* + ** The names of plain rules; the marker alone for full access; empty for void. + *******************************************************************************/ + public static Set names(PolicyRules rules) + { + if(rules.hasFullAccess() && !rules.hasDeny()) + { + return (new TreeSet<>(Set.of(PolicyPermissions.FULL_ACCESS))); + } + return (rules.exactGrants()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyVectorsTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyVectorsTest.java new file mode 100644 index 0000000..87b2316 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyVectorsTest.java @@ -0,0 +1,209 @@ +/* + * 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.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; +import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver; +import com.kingsrook.qbits.authentik.permissions.GroupGraph; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + + +/******************************************************************************* + ** Runs every golden vector of policy-vectors-1.1.0.json (docs/POLICY-FORMAT.md) + ** through the real parser and resolver, in two ways: directly (parse each + ** document, merge, resolve) and through the group graph and the effective + ** permission resolver (one group per document, the user in all of them). The + ** only seam is the flat universe. + *******************************************************************************/ +class PolicyVectorsTest +{ + private static final PolicyFormat FORMAT = new PolicyFormat("qqq-iam", "1.1.0", "qqq.io/policy", "qqq_policy"); + + + + /******************************************************************************* + ** One dynamic test per vector and path. + *******************************************************************************/ + @TestFactory + Stream vectors() throws IOException + { + JSONObject file = load(); + Set universe = strings(file.getJSONArray("universe")); + Set hostNames = strings(file.getJSONArray("hostNames")); + PolicyCatalog catalog = PolicyCatalog.ofPermissionNames(universe, hostNames); + PolicyUniverse live = PolicyUniverse.of(universe); + assertThat(file.getString("version")).isEqualTo(PolicyFormat.DEFAULT_VERSION); + + List tests = new ArrayList<>(); + JSONArray vectors = file.getJSONArray("vectors"); + for(int i = 0; i < vectors.length(); i++) + { + JSONObject vector = vectors.getJSONObject(i); + String name = vector.getString("name"); + tests.add(dynamicTest(name + " (parser)", () -> checkParser(vector, catalog, live))); + if("runtime".equals(vector.getString("mode"))) + { + tests.add(dynamicTest(name + " (graph resolver)", () -> checkGraph(vector, catalog, live))); + } + } + return (tests.stream()); + } + + + + /******************************************************************************* + ** Parse each document (save mode adds the zero-match checks), merge, resolve. + *******************************************************************************/ + private void checkParser(JSONObject vector, PolicyCatalog catalog, PolicyUniverse universe) throws Exception + { + boolean save = "save".equals(vector.getString("mode")); + JSONObject expect = vector.getJSONObject("expect"); + List rules = new ArrayList<>(); + String problem = null; + for(JSONObject document : documents(vector)) + { + try + { + rules.add(save ? PolicyParser.parseForSave(document, FORMAT, catalog, universe).rules() : PolicyParser.parseStrict(document, FORMAT, catalog).rules()); + } + catch(PolicyParseException e) + { + problem = e.getMessage(); + break; + } + } + if(expect.getBoolean("valid")) + { + assertThat(problem).as("unexpected rejection").isNull(); + PolicyRules merged = PolicyRules.merge(rules); + assertThat(merged.isVoid()).isFalse(); + if(expect.has("permissions")) + { + assertThat(merged.resolve(universe)).containsExactlyInAnyOrderElementsOf(strings(expect.getJSONArray("permissions"))); + } + } + else + { + assertThat(problem).as("should be rejected").isNotNull().contains(expect.getString("error")); + /////////////////////////////////////////////////////////// + // the non-throwing parse voids, and a void merge voids // + /////////////////////////////////////////////////////////// + List lenient = new ArrayList<>(); + for(JSONObject document : documents(vector)) + { + lenient.add(PolicyParser.parse(document, FORMAT, catalog)); + } + if(!save) + { + PolicyRules merged = PolicyRules.merge(lenient); + assertThat(merged.isVoid()).isTrue(); + assertThat(merged.resolve(universe)).isEmpty(); + } + } + } + + + + /******************************************************************************* + ** The same vector through GroupGraph and EffectivePermissionResolver: one + ** group per document, the user a member of all of them. + *******************************************************************************/ + private void checkGraph(JSONObject vector, PolicyCatalog catalog, PolicyUniverse universe) throws Exception + { + JSONObject expect = vector.getJSONObject("expect"); + List groups = new ArrayList<>(); + int n = 0; + for(JSONObject document : documents(vector)) + { + n++; + groups.add(new JSONObject().put("pk", "g" + n).put("name", "group-" + n).put("users", new JSONArray().put(7)) + .put("attributes", new JSONObject().put(FORMAT.attribute(), document.toString()))); + } + GroupGraph graph = GroupGraph.fromGroups(groups, FORMAT, catalog); + EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null, null, universe); + EffectivePermissionResolver.Resolution resolution = resolver.resolveUser(7); + if(expect.getBoolean("valid")) + { + assertThat(resolution.voided()).isFalse(); + assertThat(resolution.sessionPermissions()).containsExactlyInAnyOrderElementsOf(strings(expect.getJSONArray("permissions"))); + } + else + { + assertThat(resolution.voided()).as("one bad group voids the user").isTrue(); + assertThat(resolution.sessionPermissions()).isEmpty(); + assertThat(resolution.appAccess()).isEmpty(); + } + } + + + + /******************************************************************************* + ** The vector's documents, each a full policy document. + *******************************************************************************/ + private static List documents(JSONObject vector) + { + List rs = new ArrayList<>(); + JSONArray docs = vector.getJSONArray("documents"); + for(int i = 0; i < docs.length(); i++) + { + rs.add(new JSONObject().put("PolicySpec", FORMAT.spec()).put("PolicyVersion", FORMAT.version()).put("Statement", docs.getJSONArray(i))); + } + return (rs); + } + + + + /******************************************************************************* + ** The vectors file. + *******************************************************************************/ + private static JSONObject load() throws IOException + { + try(InputStream in = PolicyVectorsTest.class.getResourceAsStream("/policy-vectors-1.1.0.json")) + { + assertThat(in).isNotNull(); + return (new JSONObject(new String(in.readAllBytes(), StandardCharsets.UTF_8))); + } + } + + + + /******************************************************************************* + ** A JSON array of strings. + *******************************************************************************/ + private static Set strings(JSONArray array) + { + Set rs = new TreeSet<>(); + for(int i = 0; i < array.length(); i++) + { + rs.add(array.getString(i)); + } + return (rs); + } +} diff --git a/src/test/resources/blueprints/qqq-policy-claim.yaml b/src/test/resources/blueprints/qqq-policy-claim.yaml index fe9debf..a8721c1 100644 --- a/src/test/resources/blueprints/qqq-policy-claim.yaml +++ b/src/test/resources/blueprints/qqq-policy-claim.yaml @@ -28,10 +28,15 @@ entries: import json spec = "qqq-iam" - version = "1.0.0" + version = "1.1.0" attribute = "qqq.io/policy" + # One invalid document of ours voids the WHOLE claim (the qbit rejects the + # claim and the user gets nothing): with Deny in the format, skipping a bad + # group could drop a Deny while other groups' Allow statements survive. + # Only a document that declares another spec is skipped; it is not ours. statements = [] + bad = False for group in request.user.all_groups(): raw = (group.attributes or {}).get(attribute) if raw is None or raw == "": @@ -39,13 +44,19 @@ entries: try: document = json.loads(raw) if isinstance(raw, str) else raw except (ValueError, TypeError): - continue + bad = True + break if not isinstance(document, dict): - continue - if document.get("PolicySpec") != spec or document.get("PolicyVersion") != version: + bad = True + break + if document.get("PolicySpec") != spec: continue found = document.get("Statement") - if isinstance(found, list): - statements.extend(found) + if document.get("PolicyVersion") != version or not isinstance(found, list): + bad = True + break + statements.extend(found) + if bad: + return {"qqq_policy": json.dumps({"PolicySpec": spec, "PolicyVersion": "invalid", "Statement": []})} return {"qqq_policy": json.dumps({"PolicySpec": spec, "PolicyVersion": version, "Statement": statements})} -- 2.54.0 From 86aa863c7e1ad8765f3a239c83b9800ec21b6910 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 18:54:02 -0600 Subject: [PATCH 05/14] docs(policy): vectors, spec and blueprint for review fixes Deny of a prefix or named resource covers its dotted host names; empty Resource/Action/Permission arrays reject; the blueprint voids on any document under the attribute that is not exactly our spec (skipping other specs is an opt-in) and on duplicate keys. Spec: what a Deny subtracts, void is not empty, operators, host customizer API, blueprint known differences. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- docs/POLICY-FORMAT.md | 17 +++-- .../blueprints/qqq-policy-claim.yaml | 29 ++++++-- src/test/resources/policy-vectors-1.1.0.json | 71 +++++++++++++++++-- 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60580db..221955e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] -- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document of the configured spec. Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). Deny does not subtract static group fallbacks or bootstrap identity permissions. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`. +- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document under the configured attribute, a differently-specced one included (skipping other specs is an explicit opt-in). Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). Deny does not subtract static group fallbacks, bootstrap identity permissions, or the host source while the cut-over guard blocks. Review fixes: operators are credited with a floor permission only if their own policies do not deny it; the configured policy attribute is reserved against Edit Group/User attributes; un-voiding an invalid policy is a release; a Deny a person keeps deliberately no longer trips the cut-over guard; a Deny of a prefix or named resource also covers its dotted host names; empty Resource/Action/Permission arrays and malformed host names are rejected; Save Policy requires `expectedFingerprint`; `PolicyResult` / `resultFromClaims` / `resolveResult` replace the deprecated plain-set APIs. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`. - Fix: native **Create Group** now obeys the two-person gate. With `twoPersonApproval=true`, creating a group whose permissions expand to any `authentik.*` permission queues an `authentik_approval_request` for a second person instead of persisting the grant immediately; the same identity-permission expansion and second-person condition as Edit Group apply, nothing is written before approval, self-approval is refused and a decided request cannot be approved again. Previously a new group with `authentik.users.read` was created without approval. - **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255). - Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: `, `malformed ` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was. diff --git a/docs/POLICY-FORMAT.md b/docs/POLICY-FORMAT.md index b806a77..e9033b7 100644 --- a/docs/POLICY-FORMAT.md +++ b/docs/POLICY-FORMAT.md @@ -63,7 +63,7 @@ A grant that QQQ never checks (for example `hasAccess` on a table with insert/ed **Evaluation.** Across all of a user's groups (direct and inherited), the result is the union of every `Allow` expansion minus the union of every `Deny` expansion. A `Deny` anywhere wins. There is no ordering, no specificity and no per-group precedence, so any result is explained by listing the statements that matched. Both sets are expanded against the live QQQ instance at sign-in, so a `Deny` on `billing_*` also covers a billing table added later. -**Prefix resources.** `utils_*` matches every resource whose base name starts with `utils_`: the part before the final `.action` of a permission name, containing no dot. It does not match `utilsboard`. A prefix must have at least two characters before the `*`, end its literal part in `_`, and have the `*` as the last character; `u*`, `utils*` and `*_log` reject the document. `Action: ["*"]` on a prefix means the five standard actions for each match, exactly as on a named resource. A feature action grants only on matches that register it, and a statement whose feature action matches no resource is rejected at save. +**Prefix resources.** `utils_*` matches every resource whose base name starts with `utils_`: the part before the final `.action` of a permission name, containing no dot. It does not match `utilsboard`. An `Allow` prefix grants only names whose base has no dot. A `Deny` of a prefix (or of a named resource) is wider: it also covers the dotted host names that resource owns, those whose first dot-separated segment matches, so `Deny * billing_*` removes `billing_audit.export.run` that full access would otherwise grant. A prefix must have at least two characters before the `*`, end its literal part in `_`, and have the `*` as the last character; `u*`, `utils*` and `*_log` reject the document. `Action: ["*"]` on a prefix means the five standard actions for each match, exactly as on a named resource. A feature action grants only on matches that register it, and a statement whose feature action matches no resource is rejected at save. **`Deny` and `*`.** For `Allow`, `Action: ["*"]` is the five standard actions only. For `Deny` it means **every action, including table features and `write`**, for each match. Otherwise denying `utils_log` under full access would leave `utils_log.analyze` granted. A `Deny` is therefore always at least as broad as the `Allow` it is written to cancel. `Resource: ["*"]` still requires `Action: ["*"]`, for `Deny` as for `Allow`. @@ -79,7 +79,15 @@ A grant that QQQ never checks (for example `hasAccess` on a table with insert/ed **Editing.** Adding a `Deny` is always allowed to an operator with policy management. Removing or narrowing a `Deny` is a grant: the operator must hold every permission it was withholding and must not be denied any of them themselves. Removing a person from a group, or a group from a parent, releases the `Deny` statements of that group and above it, so it needs the same check. Saving a policy that contains a `Deny` also runs the last-administrator check. -**What a Deny subtracts.** A `Deny` removes what the user's groups grant through policy documents and through `qqq.io/permissions`. It does not subtract host-owned floors: the static group fallbacks and the bootstrap-operator identity permissions. +**What a Deny subtracts.** A `Deny` removes what the user's groups grant through policy documents and through `qqq.io/permissions`, and, while policies are enforced, the permissions an earlier customizer already put on the session. It does NOT subtract: +- the static group fallbacks and the bootstrap-operator identity permissions (host-owned floors); +- the host permission source and the static floor while the cut-over guard is blocking: then sign-in grants today's access and no policy, Deny included, is applied. A Deny that removes a permission from a guard-group member is a deliberate removal, so it never trips the guard by itself; an invalid policy keeps it blocking and the enforcement widget says so. + +**Void is not empty.** An invalid document voids the user: they get no permissions. Because a void group might have held a `Deny`, un-voiding is a release: removing a member from a void group, removing a parent link to it, or saving a valid document over an invalid stored one requires the operator to hold every permission that exists. Generate Policy refuses to run over an invalid stored document instead of discarding its `Deny`. + +**Operators.** For every subset-rule check an operator is credited with a permission only if their own policies grant it, or a host floor (static fallback, host permission source, bootstrap) offers it AND their own policies do not deny it. An operator denied a permission can neither grant it nor release a `Deny` on it. Save Policy must carry the `expectedFingerprint` of the group as reviewed. + +**Host customizers.** `PolicyPermissions.fromClaims` and `AuthentikGroupPermissionMapper.resolve` return plain sets and are deprecated: a set cannot tell void from absent or carry a `Deny` to the host's other sources. Use `PolicyPermissions.resultFromClaims(claims, format, catalog)` (or `AuthentikGroupPermissionMapper.resolveResult`), which returns a `PolicyResult` (`isVoid()`, `isAbsent()`, the typed rules and what they grant), and add the host's own service permissions through `result.apply(otherPermissions)`: it subtracts every `Deny` from them and returns nothing when the policy is void. The golden vectors in `src/test/resources/policy-vectors-1.1.0.json` define these rules over a small fixed permission universe. Every implementation of the grammar must pass them: this qbit's parser, the Authentik claim validator in the identity CD repository, and Forge's catalog tests. @@ -135,6 +143,7 @@ A document grants **nothing**, and the parser logs a warning, when any of these - a wildcard resource is not a valid prefix - `Action` or `Resource` is missing or not an array of strings (a statement with `Permission` has neither) - a `Permission` name is not dot-separated segments of `[A-Za-z0-9_-]` +- a statement has an empty `Resource`, `Action` or `Permission` array (it would do nothing, which for a `Deny` is the dangerous case) - the claim is not one JSON object or JSON string (an array of documents is rejected) - an action is neither a standard action, `*`, nor a feature registered for every listed resource - a resource does not match `[A-Za-z0-9_]+`, except `*` with exactly `Action: ["*"]` @@ -166,7 +175,7 @@ An app that already stores documents of this shape under its own spec name, attr ## Getting policies into the token -Authentik puts the merged document into the token through a scope mapping. The example blueprint voids the whole claim (it emits a document the qbit rejects) when any of the user's documents under the configured spec is unparsable, not an object, has another version or a non-array `Statement`; only a document that declares another spec is skipped. Steps: +Authentik puts the merged document into the token through a scope mapping. The example blueprint voids the whole claim (it emits a document the qbit rejects) when any document found under the configured attribute is unparsable, has a duplicate key, is not an object, carries a `PolicySpec` that is not exactly the configured one (missing, mistyped, another case, a trailing space), has another version or a non-array `Statement`. Skipping documents of another spec is an explicit opt-in (`skip_other_specs = True`, for an attribute shared with other applications) and is off by default. Known difference: the qbit reads JSON with org.json (lenient syntax, the last duplicate key wins) and the blueprint with Python json (strict syntax, duplicate keys rejected); a document only one of them accepts voids the claim on the stricter side. `BlueprintClaimExpressionTest` runs the blueprint expression against these cases. Steps: 1. Apply the example blueprint, [`src/test/resources/blueprints/qqq-policy-claim.yaml`](../src/test/resources/blueprints/qqq-policy-claim.yaml). Edit its spec, version, attribute and claim name to match your config. 2. Add the mapping to the OAuth2 provider's `property_mappings`. It uses the scope `profile`, which every QQQ sign-in requests. @@ -176,7 +185,7 @@ Authentik puts the merged document into the token through a scope mapping. The e - `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or - your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`. -The expanded catalog and permission universe are cached per `QInstance` (the last few instances, keyed by a digest of table permission rules, supplemental metadata and object names); `PolicyCatalog.clearCache()` forgets them. Host-declared names are never cached. +The expanded catalog and permission universe are cached per `QInstance` (the last few instances, keyed by a digest of table permission rules, supplemental metadata and object names); `PolicyCatalog.clearCache()` forgets them. Host-declared names are never cached. Host names must be dot-separated segments of `[A-Za-z0-9_-]`; a function that returns anything else fails the expansion loudly. The cache key also covers the permission rules of processes, apps, reports and widgets, hidden flags and the registered table-feature list; a feature's applies-to predicate is opaque, so a host that changes only that in place calls `clearCache()`. 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. diff --git a/src/test/resources/blueprints/qqq-policy-claim.yaml b/src/test/resources/blueprints/qqq-policy-claim.yaml index a8721c1..296bdb9 100644 --- a/src/test/resources/blueprints/qqq-policy-claim.yaml +++ b/src/test/resources/blueprints/qqq-policy-claim.yaml @@ -31,10 +31,24 @@ entries: version = "1.1.0" attribute = "qqq.io/policy" - # One invalid document of ours voids the WHOLE claim (the qbit rejects the - # claim and the user gets nothing): with Deny in the format, skipping a bad - # group could drop a Deny while other groups' Allow statements survive. - # Only a document that declares another spec is skipped; it is not ours. + # Set to True ONLY when other applications store their own documents under + # the SAME attribute. Off by default: a document found under the attribute + # whose PolicySpec is not exactly the configured one (missing, mistyped, + # another case, a trailing space) is treated as ours and bad, because + # skipping it could silently drop a Deny. + skip_other_specs = False + + # One invalid document voids the WHOLE claim (the qbit rejects it and the + # user gets nothing): with Deny in the format, skipping a bad group could + # drop a Deny while other groups' Allow statements survive. + def no_duplicate_keys(pairs): + seen = {} + for key, value in pairs: + if key in seen: + raise ValueError("duplicate key") + seen[key] = value + return seen + statements = [] bad = False for group in request.user.all_groups(): @@ -42,7 +56,7 @@ entries: if raw is None or raw == "": continue try: - document = json.loads(raw) if isinstance(raw, str) else raw + document = json.loads(raw, object_pairs_hook=no_duplicate_keys) if isinstance(raw, str) else raw except (ValueError, TypeError): bad = True break @@ -50,7 +64,10 @@ entries: bad = True break if document.get("PolicySpec") != spec: - continue + if skip_other_specs and isinstance(document.get("PolicySpec"), str): + continue + bad = True + break found = document.get("Statement") if document.get("PolicyVersion") != version or not isinstance(found, list): bad = True diff --git a/src/test/resources/policy-vectors-1.1.0.json b/src/test/resources/policy-vectors-1.1.0.json index 0aea0c3..fe0fc61 100644 --- a/src/test/resources/policy-vectors-1.1.0.json +++ b/src/test/resources/policy-vectors-1.1.0.json @@ -5,6 +5,7 @@ "'universe' is the complete set of permission names that exist. Permission base name = text before the last dot", "(authentik.users.manage.hasAccess has base authentik.users.manage; cdn.purge has no action and is a named permission).", "'hostNames' are the universe entries that are host-declared service names (not table features): they are granted only through the Permission key, never as an Action.", + "A Deny of a wildcard or named resource also covers the dotted host names that resource owns (billing_* covers billing_audit.export.run); an Allow prefix never grants them.", "'documents' are the Statement arrays of every group the user is in (the merged claim). 'expect.permissions' is compared as a set.", "Resource allows [A-Za-z0-9_]+ and utils_* prefixes (no hyphen); Permission names are dot-separated segments of [A-Za-z0-9_-] (a bare name has no dot).", "Every Allow, exact names included, expands against the universe: a name that is not in it grants nothing at runtime.", @@ -22,9 +23,10 @@ "authentik.users.manage.hasAccess", "cdn.purge", "document-generation.print", - "ping" + "ping", + "billing_audit.export.run" ], - "hostNames": ["authentik.users.manage.hasAccess", "cdn.purge", "document-generation.print", "ping"], + "hostNames": ["authentik.users.manage.hasAccess", "cdn.purge", "document-generation.print", "ping", "billing_audit.export.run"], "vectors": [ { "name": "allow-exact-resource", @@ -114,7 +116,7 @@ "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", "billing_report.hasAccess", "authentik.users.manage.hasAccess", - "cdn.purge", "document-generation.print", "ping" + "billing_audit.export.run", "cdn.purge", "document-generation.print", "ping" ]} }, { @@ -137,7 +139,7 @@ "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete", "utils_log.analyze", "utilsboard.hasAccess", "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", - "billing_report.hasAccess", "document-generation.print", "ping" + "billing_report.hasAccess", "billing_audit.export.run", "document-generation.print", "ping" ]} }, { @@ -178,7 +180,7 @@ "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", "billing_report.hasAccess", "authentik.users.manage.hasAccess", - "cdn.purge" + "billing_audit.export.run", "cdn.purge" ]} }, { @@ -223,6 +225,65 @@ "documents": [[{"Effect":"Allow","Action":["read"],"Resource":["document-generation"]}]], "expect": {"valid": false, "error": "resource"} }, + { + "name": "deny-prefix-covers-dotted-host-names", + "mode": "runtime", + "documents": [[ + {"Effect":"Allow","Action":["*"],"Resource":["*"]}, + {"Effect":"Deny","Action":["*"],"Resource":["billing_*"]} + ]], + "expect": {"valid": true, "permissions": [ + "utils_helper.hasAccess", + "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete", "utils_log.analyze", + "utilsboard.hasAccess", + "authentik.users.manage.hasAccess", + "cdn.purge", "document-generation.print", "ping" + ]} + }, + { + "name": "deny-named-resource-covers-dotted-host-names", + "mode": "runtime", + "documents": [[ + {"Effect":"Allow","Action":["*"],"Resource":["*"]}, + {"Effect":"Deny","Action":["*"],"Resource":["billing_audit"]} + ]], + "expect": {"valid": true, "permissions": [ + "utils_helper.hasAccess", + "utils_log.hasAccess", "utils_log.read", "utils_log.insert", "utils_log.edit", "utils_log.delete", "utils_log.analyze", + "utilsboard.hasAccess", + "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", + "billing_report.hasAccess", + "authentik.users.manage.hasAccess", + "cdn.purge", "document-generation.print", "ping" + ]} + }, + { + "name": "allow-prefix-does-not-grant-dotted-host-names", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":["*"],"Resource":["billing_*"]}]], + "expect": {"valid": true, "permissions": [ + "billing_invoice.hasAccess", "billing_invoice.read", "billing_invoice.insert", "billing_invoice.edit", "billing_invoice.delete", + "billing_report.hasAccess" + ]} + }, + { + "name": "empty-resource-array-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Deny","Action":["*"],"Resource":[]}]], + "expect": {"valid": false, "error": "empty"} + }, + { + "name": "empty-permission-array-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Deny","Permission":[]}]], + "expect": {"valid": false, "error": "empty"} + }, + { + "name": "empty-action-array-rejected", + "mode": "runtime", + "documents": [[{"Effect":"Allow","Action":[],"Resource":["billing_invoice"]}]], + "expect": {"valid": false, "error": "empty"} + }, { "name": "empty-statement-array-grants-nothing", "mode": "runtime", -- 2.54.0 From a6ac960f3a918e1c8f5f59fb6ed21f5ce6c812f7 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 18:54:02 -0600 Subject: [PATCH 06/14] fix(policy): close fail-open edges found in review Operator credit never includes a name their own policies deny; the configured policy attribute is reserved against attribute edits; un-voiding an invalid policy is a release; a deliberate Deny no longer trips the cut-over guard and void is shown; a group missing from the graph voids; PolicyResult typed API with apply(); Deny of a prefix covers dotted host names; empty arrays and bad host names reject; Save Policy requires expectedFingerprint; the catalog cache key covers all rule sources and the runtime shares the cached catalog. Co-Authored-By: Claude Sonnet 5.5 --- .../qbits/authentik/AuthentikRuntime.java | 18 +- .../qbits/authentik/admin/AuthentikAdmin.java | 75 ++-- .../permissions/AccessComparison.java | 17 +- .../AuthentikGroupPermissionMapper.java | 53 ++- .../policy/AuthentikPolicyClaimMapper.java | 12 +- .../qbits/authentik/policy/PolicyCatalog.java | 28 +- .../policy/PolicyCutoverWidgets.java | 7 +- .../qbits/authentik/policy/PolicyParser.java | 11 +- .../authentik/policy/PolicyPermissions.java | 34 +- .../qbits/authentik/policy/PolicyResult.java | 88 +++++ .../qbits/authentik/policy/PolicyRules.java | 11 +- .../qbits/authentik/policy/PolicyTerm.java | 48 ++- .../processes/AuthentikProcesses.java | 4 +- .../authentik/admin/PolicyCutoverTest.java | 69 ++++ .../authentik/admin/PolicyEditorTest.java | 341 +++++++++++++++++- .../contract/PolicyClaimMappingIT.java | 15 +- .../contract/QbitWriteContractIT.java | 17 +- .../AuthentikGroupPermissionMapperTest.java | 32 +- .../AuthentikPolicyClaimMapperTest.java | 37 +- .../policy/BlueprintClaimExpressionTest.java | 224 ++++++++++++ .../authentik/policy/PolicyDenyTest.java | 106 +++++- .../authentik/policy/PolicyParserTest.java | 2 +- .../sync/AuthentikMirrorSnapshotTest.java | 20 + 23 files changed, 1189 insertions(+), 80 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java create mode 100644 src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java index ec73380..c4abb19 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java @@ -190,13 +190,19 @@ public class AuthentikRuntime public PolicyCatalog getPolicyCatalog() { com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance(); - if(policyCatalog == null || policyCatalog.getQInstance() != qInstance) + if(qInstance == null) { - if(qInstance == null) - { - return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance())); - } - policyCatalog = PolicyCatalog.of(qInstance); + return (PolicyCatalog.of(new com.kingsrook.qqq.backend.core.model.metadata.QInstance())); + } + /////////////////////////////////////////////////////////////////////// + // always the shared per-instance catalog (PolicyCatalog.of), so the // + // graph path and the claim path see the same one, rebuilt when the // + // metadata it is built from changes // + /////////////////////////////////////////////////////////////////////// + PolicyCatalog current = PolicyCatalog.of(qInstance); + if(current != policyCatalog) + { + policyCatalog = current; permissionCatalog = null; } return (policyCatalog); diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java index fd9a8c5..b611eec 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java @@ -139,8 +139,11 @@ public class AuthentikAdmin ** @param permissions effective QQQ permissions (session form) ** @param appAccess app keys the operator can sign in to ** @param bootstrap listed in bootstrapOperatorSubjects + ** @param denials the operator's own policy Deny rules (void when one of their + ** policies is invalid): a name they are denied is never credited + ** to them, whatever floor (static, host source, bootstrap) offers it *******************************************************************************/ - public record Operator(String email, Integer pk, Set permissions, Set appAccess, boolean bootstrap) + public record Operator(String email, Integer pk, Set permissions, Set appAccess, boolean bootstrap, PolicyRules denials) { } @@ -357,6 +360,8 @@ public class AuthentikAdmin Integer pk = null; Set permissions = new TreeSet<>(); Set appAccess = new TreeSet<>(); + Set floorCredit = new TreeSet<>(); + PolicyRules denials = PolicyRules.EMPTY; JSONObject user; if(deferredInvitation != null) @@ -386,20 +391,22 @@ public class AuthentikAdmin EffectivePermissionResolver.Resolution resolution = resolver().resolveGroups(groupPks); permissions.addAll(resolution.sessionPermissions()); appAccess.addAll(resolution.appAccess()); + denials = resolution.denials(); - ////////////////////////////////////////////////////////////////////// - // what the operator holds today through the host (and the static // - // fallback) counts for the subset rule too (DESIGN.md 12.8) // - ////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////// + // what the operator holds today through the host (and the static // + // fallback) counts for the subset rule too (DESIGN.md 12.8), but // + // only where their own policies do not deny it // + /////////////////////////////////////////////////////////////////////// List groupNames = new ArrayList<>(); for(int i = 0; groups != null && i < groups.length(); i++) { groupNames.add(groups.getJSONObject(i).optString("name")); } - permissions.addAll(com.kingsrook.qbits.authentik.permissions.AccessComparison.staticPermissions(config, groupNames)); + floorCredit.addAll(com.kingsrook.qbits.authentik.permissions.AccessComparison.staticPermissions(config, groupNames)); if(config.getHostPermissionSource() != null) { - permissions.addAll(config.getHostPermissionSource().userPermissions(email, groupNames)); + floorCredit.addAll(config.getHostPermissionSource().userPermissions(email, groupNames)); } } else if(QContext.getQSession() != null && QContext.getQSession().getPermissions() != null) @@ -411,10 +418,17 @@ public class AuthentikAdmin { for(IdentityPermission permission : IdentityPermission.values()) { - permissions.add(permission.getSessionPermission()); + floorCredit.add(permission.getSessionPermission()); } } - operator = new Operator(email, pk, permissions, appAccess, bootstrap); + for(String name : floorCredit) + { + if(!denials.denies(name)) + { + permissions.add(name); + } + } + operator = new Operator(email, pk, permissions, appAccess, bootstrap, denials); return (operator); } @@ -1772,11 +1786,16 @@ public class AuthentikAdmin catch(PolicyParseException e) { storedProblem = e.getMessage(); + oldRules = PolicyRules.voided(e.getMessage()); } } String fingerprint = request.str("expectedFingerprint"); - if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.groupFingerprint(group))) + if(!StringUtils.hasContent(fingerprint)) + { + return (plan.refuse("expectedFingerprint is required: Save Policy must carry the fingerprint of the group as the operator reviewed it.")); + } + if(!fingerprint.equals(AuthentikSafety.groupFingerprint(group))) { return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again.")); } @@ -1919,17 +1938,21 @@ public class AuthentikAdmin { return (request.with("generateProblem", "This app has no host permission source, so there is no current access to generate from.")); } - JSONObject group = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false"); + JSONObject group = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=true&include_parents=true&include_children=false"); if(group == null) { return (request.with("generateProblem", "That group no longer exists.")); } + if(!StringUtils.hasContent(request.str("expectedFingerprint"))) + { + request.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group)); + } PolicyFormat format = config.getPolicyFormat(); Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute()); PolicyRules existing = PolicyParser.parse(stored, format, runtime.getPolicyCatalog()); if(existing.isVoid()) { - existing = PolicyRules.EMPTY; + return (request.with("generateProblem", "The group's stored policy is not valid (" + existing.voidReason() + "), so Generate cannot tell which Deny statements it holds. Fix or remove it in the policy editor first.")); } Set policy = new LinkedHashSet<>(existing.allowTerms()); @@ -2059,7 +2082,7 @@ public class AuthentikAdmin JSONObject submitted = parseOtherAttributes(request.str(OTHER_ATTRIBUTES)); for(String key : submitted.keySet()) { - if(isReservedAttribute(key)) + if(isReservedAttribute(key, config.getPolicyFormat().attribute())) { return ("The attribute " + key + " is reserved; it is set by Edit Permissions, Edit Policy or Authentik itself."); } @@ -2083,14 +2106,14 @@ public class AuthentikAdmin ** removed), or null for no change. Blank means "leave them as they are" ** ({} removes them all). A null current compares against nothing. *******************************************************************************/ - private static String changedAttributeKeys(AdminRequest request, JSONObject current) throws QUserFacingException + private String changedAttributeKeys(AdminRequest request, JSONObject current) throws QUserFacingException { if(!StringUtils.hasContent(request.str(OTHER_ATTRIBUTES))) { return (null); } JSONObject submitted = parseOtherAttributes(request.str(OTHER_ATTRIBUTES)); - JSONObject others = otherAttributes(current); + JSONObject others = otherAttributes(current, config.getPolicyFormat().attribute()); Set changed = new TreeSet<>(); for(String key : submitted.keySet()) { @@ -2115,14 +2138,14 @@ public class AuthentikAdmin ** The current attributes with the non-reserved part replaced by the ** submitted object (reserved keys are kept as they are). *******************************************************************************/ - static JSONObject mergeOtherAttributes(JSONObject current, String submittedText) throws QUserFacingException + static JSONObject mergeOtherAttributes(JSONObject current, String submittedText, String policyAttribute) throws QUserFacingException { JSONObject rs = new JSONObject(); if(current != null) { for(String key : current.keySet()) { - if(isReservedAttribute(key)) + if(isReservedAttribute(key, policyAttribute)) { rs.put(key, current.get(key)); } @@ -2131,7 +2154,7 @@ public class AuthentikAdmin JSONObject submitted = parseOtherAttributes(submittedText); for(String key : submitted.keySet()) { - if(!isReservedAttribute(key)) + if(!isReservedAttribute(key, policyAttribute)) { rs.put(key, submitted.get(key)); } @@ -2144,14 +2167,14 @@ public class AuthentikAdmin /******************************************************************************* ** The non-reserved attributes, as shown on Edit User and Edit Group. *******************************************************************************/ - public static JSONObject otherAttributes(JSONObject attributes) + public static JSONObject otherAttributes(JSONObject attributes, String policyAttribute) { JSONObject rs = new JSONObject(); if(attributes != null) { for(String key : attributes.keySet()) { - if(!isReservedAttribute(key)) + if(!isReservedAttribute(key, policyAttribute)) { rs.put(key, attributes.get(key)); } @@ -2163,11 +2186,13 @@ public class AuthentikAdmin /******************************************************************************* - ** Keys QQQ manages elsewhere (qqq.io/) or Authentik owns (goauthentik.io/). + ** Keys QQQ manages elsewhere (qqq.io/ and the configured policy attribute, + ** which has its own parse check, subset rule and history) or Authentik owns + ** (goauthentik.io/). *******************************************************************************/ - static boolean isReservedAttribute(String key) + static boolean isReservedAttribute(String key, String policyAttribute) { - return (key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX) || key.startsWith("goauthentik.io/")); + return (key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX) || key.startsWith("goauthentik.io/") || key.equals(policyAttribute)); } @@ -2639,7 +2664,7 @@ public class AuthentikAdmin if(changedAttributeKeys(request, null) != null) { JSONObject fresh = util.getObject(AuthentikTables.get(AuthentikTables.USER), request.targetKey()); - body.put("attributes", mergeOtherAttributes(fresh == null ? null : fresh.optJSONObject("attributes"), request.str(OTHER_ATTRIBUTES))); + body.put("attributes", mergeOtherAttributes(fresh == null ? null : fresh.optJSONObject("attributes"), request.str(OTHER_ATTRIBUTES), config.getPolicyFormat().attribute())); } util.write(auth, "PATCH", "/core/users/" + safeKey(request.targetKey()) + "/", body); return ("User updated."); @@ -2714,7 +2739,7 @@ public class AuthentikAdmin { runtime.invalidateCache(); JSONObject fresh = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false"); - body.put("attributes", mergeOtherAttributes(fresh == null ? null : fresh.optJSONObject("attributes"), request.str(OTHER_ATTRIBUTES))); + body.put("attributes", mergeOtherAttributes(fresh == null ? null : fresh.optJSONObject("attributes"), request.str(OTHER_ATTRIBUTES), config.getPolicyFormat().attribute())); } if(request.params().containsKey("resolvedParentPks")) { diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java index 4282166..82b278d 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java @@ -55,10 +55,12 @@ public final class AccessComparison /******************************************************************************* - ** One person. + ** One person. removed excludes what a policy Deny deliberately takes away + ** (unless an invalid policy voided the person, which removes everything and + ** keeps the guard blocking); voided says an invalid policy voids their access. *******************************************************************************/ public record Row(int pk, String username, String email, List groups, Set before, Set after, - Set added, Set removed, boolean guarded) + Set added, Set removed, boolean guarded, boolean voided) { } @@ -136,8 +138,13 @@ public final class AccessComparison added.removeAll(before); Set removed = new TreeSet<>(before); removed.removeAll(after); + EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(groupPks); + if(!resolution.voided()) + { + removed.removeIf(resolution.denials()::denies); + } boolean guarded = names.stream().anyMatch(n -> config.getCutoverGuardGroups() != null && config.getCutoverGuardGroups().contains(n)); - rows.add(new Row(pk, user.optString("username"), email, names, before, after, added, removed, guarded)); + rows.add(new Row(pk, user.optString("username"), email, names, before, after, added, removed, guarded, resolution.voided())); } return (rows); } @@ -252,9 +259,11 @@ public final class AccessComparison + String.join(", ", config.getCutoverGuardGroups()) + " loses a permission).", Instant.now())); } List names = failures.stream().map(Row::username).limit(5).toList(); + long voided = failures.stream().filter(Row::voided).count(); return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard found " + failures.size() + " member(s) of " + String.join(", ", config.getCutoverGuardGroups()) + " who would lose permissions (" + String.join(", ", names) - + "). Sign-in keeps today's access until the policies cover it, or the owner overrides the guard.", Instant.now())); + + ")" + (voided == 0 ? "" : "; " + voided + " of them have an invalid policy that voids their access") + + ". Sign-in keeps today's access (Deny statements are not applied to it) until the policies cover it, or the owner overrides the guard.", Instant.now())); } catch(Exception e) { diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java index cc60643..b1c4d38 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java @@ -33,6 +33,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikRedactor; import com.kingsrook.qbits.authentik.policy.PolicyCatalog; import com.kingsrook.qbits.authentik.policy.PolicyFormat; import com.kingsrook.qbits.authentik.policy.PolicyPermissions; +import com.kingsrook.qbits.authentik.policy.PolicyResult; import com.kingsrook.qbits.authentik.policy.PolicyRules; import com.kingsrook.qbits.authentik.security.BootstrapOperators; import com.kingsrook.qbits.authentik.security.IdentityPermission; @@ -125,6 +126,8 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust Detailed detailed = resolveDetailed(qInstance, groups, sub, email, verified); Set permissions = new TreeSet<>(detailed.floor()); + boolean enforced = true; + PolicyRules denials = detailed.denials(); ////////////////////////////////////////////////////////////////////////// // the policy claim (docs/POLICY-FORMAT.md) carries the same group data // @@ -136,12 +139,14 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat(); PolicyCatalog catalog = qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance); PolicyRules claim = PolicyPermissions.rulesFromClaims(PolicyPermissions.claimsWithPolicy(context, format), format, catalog); - if(claim.isVoid() || detailed.denials().isVoid()) + boolean voided = claim.isVoid() || detailed.denials().isVoid(); + if(voided) { LOG.warn("An invalid policy voids this user's policy grants", logPair("source", claim.isVoid() ? "token claim" : "group graph")); } else { + denials = PolicyRules.merge(List.of(claim.denialsOnly(), detailed.denials().denialsOnly())); Set fromPolicies = new TreeSet<>(detailed.fromGroups()); fromPolicies.addAll(claim.resolve(PolicyPermissions.universe(qInstance == null ? PolicyPermissions.instanceOf(catalog) : qInstance, Set.of()))); fromPolicies.removeIf(permission -> claim.denies(permission) || detailed.denials().denies(permission)); @@ -167,6 +172,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust { state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now()); } + enforced = state.enforced(); if(!state.enforced()) { Set floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups)); @@ -192,7 +198,21 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust } if(qSession.getPermissions() != null) { - permissions.addAll(qSession.getPermissions()); + /////////////////////////////////////////////////////////////////////// + // permissions an earlier customizer put on the session: while the // + // policies are enforced a Deny removes them and an invalid policy // + // voids them; while the cut-over guard blocks, nothing is subtracted // + /////////////////////////////////////////////////////////////////////// + Set existing = new TreeSet<>(qSession.getPermissions()); + if(enforced && voided) + { + existing.clear(); + } + else if(enforced) + { + existing.removeIf(denials::denies); + } + permissions.addAll(existing); } qSession.setPermissions(permissions); com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims); @@ -329,7 +349,11 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust /******************************************************************************* ** The permissions for a user's group names and token identity (sub, email, ** email_verified). Bootstrap operators are matched on immutable ids only. + ** + ** @deprecated a plain set cannot tell void from absent or carry a Deny to the + ** host's other sources; use resolveResult(...).apply(others) *******************************************************************************/ + @Deprecated public Set resolve(QInstance qInstance, Collection groupNames, String sub, String email, boolean emailVerified) { Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified); @@ -340,6 +364,21 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust + /******************************************************************************* + ** The typed policy result for a user's groups: the Deny rules (void when a + ** policy is invalid or a group is not in the graph) and what the groups' + ** policies and qqq.io/permissions grant. The static and bootstrap floor is NOT + ** included; pass it, and the host's own service permissions, to + ** PolicyResult.apply so a Deny cannot be bypassed. + *******************************************************************************/ + public PolicyResult resolveResult(QInstance qInstance, Collection groupNames, String sub, String email, boolean emailVerified) + { + Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified); + return (new PolicyResult(detailed.denials(), detailed.denials().isVoid() ? Set.of() : detailed.fromGroups())); + } + + + /******************************************************************************* ** What resolve() found, kept apart so a Deny and an invalid policy can be ** applied across sources. @@ -386,10 +425,16 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust for(String name : groupNames) { GroupGraph.Node node = graph.byName(name); - if(node != null) + if(node == null) { - pks.add(node.pk()); + /////////////////////////////////////////////////////////////////// + // a group the graph does not know may carry a Deny we cannot see: // + // void the policy grants rather than skip it // + /////////////////////////////////////////////////////////////////// + LOG.warn("A group of the user is not in the Authentik group graph; its policy cannot be read, so the user's policy grants are voided"); + return (new Detailed(rs, new TreeSet<>(), PolicyRules.voided("a group of the user is not in the group graph"))); } + pks.add(node.pk()); } EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(pks); if(!resolution.unknown().isEmpty()) diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java b/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java index c65055e..83b3300 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapper.java @@ -98,12 +98,12 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz return; } - Set permissions = new TreeSet<>(PolicyPermissions.fromClaims(claims, format, qInstance == null ? null : PolicyCatalog.of(qInstance))); - if(qSession.getPermissions() != null) - { - permissions.addAll(qSession.getPermissions()); - } - qSession.setPermissions(permissions); + /////////////////////////////////////////////////////////////////////////// + // the session's existing permissions go through the same rules: a Deny // + // removes them too, and an invalid policy voids them // + /////////////////////////////////////////////////////////////////////////// + PolicyResult result = PolicyPermissions.resultFromClaims(claims, format, qInstance == null ? null : PolicyCatalog.of(qInstance)); + qSession.setPermissions(result.apply(qSession.getPermissions())); com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, any); com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, context == null ? null : PolicyPermissions.asClaims(context.get(PolicyPermissions.ACCESS_TOKEN_CONTEXT_KEY))); diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java index 06f15d0..f73280f 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java @@ -38,6 +38,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.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.metadata.reporting.QReportMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; @@ -212,15 +214,33 @@ public final class PolicyCatalog .append(rules == null ? "-" : rules.getLevel() + ":" + rules.getPermissionBaseName() + ":" + rules.getCustomPermissionChecker()).append('|') .append(table.getSupplementalMetaData() == null ? "" : new TreeSet<>(table.getSupplementalMetaData().keySet())).append(';'); }); - sb.append("p:").append(new TreeSet<>(qInstance.getProcesses().keySet())); - sb.append("a:").append(new TreeSet<>(qInstance.getApps().keySet())); - sb.append("r:").append(new TreeSet<>(qInstance.getReports().keySet())); - sb.append("w:").append(new TreeSet<>(qInstance.getWidgets().keySet())); + new TreeMap<>(qInstance.getProcesses()).forEach((name, process) -> sb.append("p:").append(name).append('|').append(rulesOf(process.getPermissionRules())).append('|').append(process.getIsHidden()).append(';')); + new TreeMap<>(qInstance.getApps()).forEach((name, app) -> sb.append("a:").append(name).append('|').append(rulesOf(app.getPermissionRules())).append(';')); + new TreeMap<>(qInstance.getReports()).forEach((name, report) -> sb.append("r:").append(name).append('|').append(rulesOf(report.getPermissionRules())).append(';')); + new TreeMap<>(qInstance.getWidgets()).forEach((name, widget) -> sb.append("w:").append(name).append('|').append(rulesOf(widget.getPermissionRules())).append(';')); + TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.of(qInstance); + if(features != null) + { + for(TableFeaturePermission feature : features.getPermissions()) + { + sb.append("f:").append(feature.getSuffix()).append('|').append(feature.getLabel()).append(';'); + } + } return (sb.toString()); } + /******************************************************************************* + ** A permission rules object as a stable string. + *******************************************************************************/ + private static String rulesOf(QPermissionRules rules) + { + return (rules == null ? "-" : rules.getLevel() + ":" + rules.getPermissionBaseName() + ":" + rules.getCustomPermissionChecker()); + } + + + /******************************************************************************* ** Build the catalog of an instance without the cache. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCutoverWidgets.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCutoverWidgets.java index b617470..214c9e4 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCutoverWidgets.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCutoverWidgets.java @@ -142,7 +142,7 @@ public final class PolicyCutoverWidgets for(AccessComparison.Row row : rows) { Map map = new LinkedHashMap<>(); - map.put("user", escape(row.username()) + (row.guarded() ? " (guarded)" : "")); + map.put("user", escape(row.username()) + (row.guarded() ? " (guarded)" : "") + (row.voided() ? " (INVALID POLICY: access voided)" : "")); map.put("groups", escape(String.join(", ", row.groups()))); map.put("added", row.added().isEmpty() ? "-" : escape(String.join(", ", row.added()))); map.put("removed", row.removed().isEmpty() ? "-" : escape(String.join(", ", row.removed()))); @@ -160,7 +160,10 @@ public final class PolicyCutoverWidgets new TableData.Column("html", "Removed", "removed", "28%", "left"), new TableData.Column("default", "Unchanged", "unchanged", "10%", "right")); List failures = AccessComparison.guardFailures(rows); - String summary = rows.size() + " active people; " + removedPeople + " would lose permissions; guard " + long voidedPeople = rows.stream().filter(AccessComparison.Row::voided).count(); + String summary = rows.size() + " active people; " + removedPeople + " would lose permissions" + + (voidedPeople == 0 ? "" : " (" + voidedPeople + " because an invalid policy voids their access)") + + "; names a Deny takes away are not counted as lost; guard " + (runtime.getConfig().getCutoverGuardGroups().isEmpty() ? "not configured" : failures.isEmpty() ? "passes" : "FAILS for " + failures.size()) + (runtime.getConfig().getHostPermissionSource() == null ? ". No host permission source is configured, so today's access is shown as empty." : "."); TableData table = new TableData("Compare Access (today vs policies)", columns, data) diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java index 15871f4..23ae27e 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java @@ -253,7 +253,12 @@ public final class PolicyParser { throw (new PolicyParseException(where + ": Permission cannot be combined with Action or Resource")); } - for(String name : strings(statement.opt("Permission"), where + ": Permission")) + Set names = strings(statement.opt("Permission"), where + ": Permission"); + if(names.isEmpty()) + { + throw (new PolicyParseException(where + ": Permission is empty (a statement that names nothing does nothing)")); + } + for(String name : names) { if(!PERMISSION_PATTERN.matcher(name).matches()) { @@ -275,6 +280,10 @@ public final class PolicyParser { throw (new PolicyParseException(where + ": Resource \"*\" requires Action [\"*\"] (full access)")); } + if(statementResources.isEmpty() || statementActions.isEmpty()) + { + throw (new PolicyParseException(where + ": " + (statementResources.isEmpty() ? "Resource" : "Action") + " is empty (a statement that names nothing does nothing)")); + } for(String resource : statementResources) { if(!resource.equals("*") && resource.indexOf('*') >= 0 && !PREFIX_PATTERN.matcher(resource).matches()) diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java index 37e9c05..bb31a1a 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java @@ -65,7 +65,11 @@ public final class PolicyPermissions /******************************************************************************* ** The permissions granted by the policy claim in these token claims; none ** when the claim is missing or invalid (fail closed). + ** + ** @deprecated a plain set cannot tell void from absent or carry a Deny to the + ** host's other sources; use resultFromClaims(...).apply(others) *******************************************************************************/ + @Deprecated public static Set fromClaims(JSONObject claims, PolicyFormat format) { return (fromClaims(claims, format, null)); @@ -73,11 +77,29 @@ public final class PolicyPermissions + /******************************************************************************* + ** The typed result of the policy claim: the rules (void when the claim is + ** invalid, empty when there is none) and what they grant now. Use apply() to + ** union the host's own permissions so a Deny cannot be bypassed. + *******************************************************************************/ + public static PolicyResult resultFromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog) + { + PolicyRules rules = rulesFromClaims(claims, format, catalog); + if(rules.isVoid() || rules.isEmpty()) + { + return (PolicyResult.of(rules, () -> Set.of())); + } + return (PolicyResult.of(rules, universe(instanceOf(catalog), Set.of()))); + } + + + /******************************************************************************* ** Read a policy claim with the catalog of registered table features: ** Allow expansions minus Deny coverage, expanded against the live instance ** now. None when the claim is missing or invalid (fail closed). *******************************************************************************/ + @Deprecated public static Set fromClaims(JSONObject claims, PolicyFormat format, PolicyCatalog catalog) { PolicyRules rules = rulesFromClaims(claims, format, catalog); @@ -230,9 +252,17 @@ public final class PolicyPermissions *******************************************************************************/ private static void addNames(Set target, Collection names) { - if(names != null) + if(names == null) { - names.stream().filter(n -> n != null && !n.isBlank()).forEach(target::add); + return; + } + for(String name : names) + { + if(name == null || !PolicyParser.PERMISSION_PATTERN.matcher(name).matches()) + { + throw (new IllegalArgumentException("A host permission name must be dot-separated segments of [A-Za-z0-9_-] (a name was null, blank or malformed)")); + } + target.add(name); } } diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java new file mode 100644 index 0000000..340d006 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.policy; + + +import java.util.Collection; +import java.util.Set; +import java.util.TreeSet; + + +/******************************************************************************* + ** What the policies of one user grant, keeping the typed rules so a host + ** customizer can tell VOID (an invalid policy), EMPTY (no policy) and DENY + ** apart, which a plain set of names cannot. + ** + ** A host that adds permissions of its own (service permissions, a static + ** floor) must pass them through apply(), so a Deny can never be bypassed by a + ** union done afterwards. + ** + ** @param rules the typed Allow and Deny terms (void when any policy was invalid) + ** @param granted Allow minus Deny, expanded against the live universe now; empty when void + *******************************************************************************/ +public record PolicyResult(PolicyRules rules, Set granted) +{ + /******************************************************************************* + ** The result of resolving rules against a universe. + *******************************************************************************/ + public static PolicyResult of(PolicyRules rules, PolicyUniverse universe) + { + return (new PolicyResult(rules, rules.isVoid() || rules.isEmpty() ? Set.of() : rules.resolve(universe))); + } + + + + /******************************************************************************* + ** Whether an invalid policy voided the user (they get no permissions). + *******************************************************************************/ + public boolean isVoid() + { + return (rules.isVoid()); + } + + + + /******************************************************************************* + ** Whether the user has no policy at all (neither Allow nor Deny, and not void). + *******************************************************************************/ + public boolean isAbsent() + { + return (!rules.isVoid() && rules.isEmpty()); + } + + + + /******************************************************************************* + ** The permissions granted here plus the other sources the host supplies, + ** minus every Deny. Empty when void: an invalid policy voids all of them. + ** The other sources are not modified. + *******************************************************************************/ + public Set apply(Collection otherSources) + { + if(rules.isVoid()) + { + return (new TreeSet<>()); + } + Set rs = new TreeSet<>(granted); + if(otherSources != null) + { + rs.addAll(otherSources); + } + rs.removeIf(rules::denies); + return (rs); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java index e8440e7..ce04324 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java @@ -265,7 +265,16 @@ public final class PolicyRules public Set withheld(PolicyUniverse universe) { Set rs = new TreeSet<>(); - if(isVoid() || deny.isEmpty()) + if(isVoid()) + { + /////////////////////////////////////////////////////////////////////// + // an invalid document voids its members: it may have held a Deny, so // + // making it valid (or removing it) releases everything // + /////////////////////////////////////////////////////////////////////// + rs.addAll(universe.names()); + return (rs); + } + if(deny.isEmpty()) { return (rs); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyTerm.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyTerm.java index 205e059..357e435 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyTerm.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyTerm.java @@ -29,6 +29,24 @@ import java.util.Set; public sealed interface PolicyTerm { + /******************************************************************************* + ** The resource a permission name belongs to: the text before its final dot up + ** to the first dot ("billing_audit" for "billing_audit.export.run"), or null + ** for a name with no action. A Deny of a wildcard or named resource covers the + ** dotted host names it owns, which an Allow of the same resource never grants. + *******************************************************************************/ + static String ownerOf(String permission) + { + int dot = permission == null ? -1 : permission.lastIndexOf('.'); + if(dot <= 0) + { + return (null); + } + int first = permission.indexOf('.'); + return (permission.substring(0, first)); + } + + /******************************************************************************* ** Whether this term covers the permission name (the Deny test). *******************************************************************************/ @@ -113,8 +131,8 @@ public sealed interface PolicyTerm @Override public boolean matches(String permission) { - int dot = permission == null ? -1 : permission.lastIndexOf('.'); - return (dot > 0 && permission.substring(0, dot).equals(base)); + String owner = ownerOf(permission); + return (owner != null && owner.equals(base)); } @@ -143,6 +161,18 @@ public sealed interface PolicyTerm *******************************************************************************/ @Override public boolean matches(String permission) + { + String owner = ownerOf(permission); + return (owner != null && owner.startsWith(prefix) && actionMatches(permission)); + } + + + + /******************************************************************************* + ** Whether the Allow expansion grants this name: the base (the text before + ** the final dot) starts with the prefix and has no dot itself. + *******************************************************************************/ + boolean grants(String permission) { int dot = permission == null ? -1 : permission.lastIndexOf('.'); if(dot <= 0) @@ -150,7 +180,17 @@ public sealed interface PolicyTerm return (false); } String base = permission.substring(0, dot); - return (base.startsWith(prefix) && base.indexOf('.') < 0 && (action == null || action.equals(permission.substring(dot + 1)))); + return (base.startsWith(prefix) && base.indexOf('.') < 0 && actionMatches(permission)); + } + + + + /******************************************************************************* + ** Whether the name's final segment is this term's action (or any action). + *******************************************************************************/ + private boolean actionMatches(String permission) + { + return (action == null || action.equals(permission.substring(permission.lastIndexOf('.') + 1))); } @@ -164,7 +204,7 @@ public sealed interface PolicyTerm Set rs = new java.util.TreeSet<>(); for(String name : universe.names()) { - if(matches(name)) + if(grants(name)) { rs.add(name); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java index 556aa31..1e98cc0 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java +++ b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java @@ -670,7 +670,7 @@ public final class AuthentikProcesses output.addValue("username", user.optString("username")); if(canSeeAttributes()) { - output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(user.optJSONObject("attributes")).toString(2)); + output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(user.optJSONObject("attributes"), admin.getUtil().getRuntime().getConfig().getPolicyFormat().attribute()).toString(2)); } output.addValue("name", user.optString("name")); output.addValue("email", user.optString("email")); @@ -688,7 +688,7 @@ public final class AuthentikProcesses { if(canSeeAttributes()) { - output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(group.optJSONObject("attributes")).toString(2)); + output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(group.optJSONObject("attributes"), admin.getUtil().getRuntime().getConfig().getPolicyFormat().attribute()).toString(2)); } output.addValue("name", group.optString("name")); JSONArray parents = group.optJSONArray("parents_obj"); diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java index 22c92fa..a99cf9c 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java @@ -246,4 +246,73 @@ class PolicyCutoverTest t.config.withHostPermissionSource(null); assertThat(t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, analysts, "x"))).message()).contains("no host permission source"); } + + + /******************************************************************************* + ** A Deny that removes a permission from a guard-group member is a deliberate + ** removal: it does not trip the cut-over guard. + *******************************************************************************/ + @Test + void denyIsADeliberateRemovalNotAGuardFailure() throws Exception + { + t.config.withMapperEnabled(true); + t.config.withStaticGroupPermissions(Map.of("identity-admins", List.of("nothing.here.hasAccess", "kof22.analytics.hasAccess"))); + String head = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Permission\":[\"cms.publish.hasAccess\"]},{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"cms_page\"]}"; + t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").put("qqq.io/policy", head + "]}"); + runtime.invalidateCache(); + assertThat(state().status()).as(state().message()).isEqualTo(AccessComparison.Status.ACTIVE); + + t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").put("qqq.io/policy", + head + ",{\"Effect\":\"Deny\",\"Action\":[\"edit\"],\"Resource\":[\"cms_page\"]}]}"); + runtime.invalidateCache(); + assertThat(state().status()).as(state().message()).isEqualTo(AccessComparison.Status.ACTIVE); + } + + + + /******************************************************************************* + ** An invalid policy keeps the guard blocking (fail safe) and the message says + ** so. + *******************************************************************************/ + @Test + void voidPolicyKeepsBlockingAndSaysSo() throws Exception + { + t.config.withMapperEnabled(true); + t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").put("qqq.io/policy", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Maybe\"}]}"); + runtime.invalidateCache(); + AccessComparison.State state = state(); + assertThat(state.status()).isEqualTo(AccessComparison.Status.BLOCKED); + assertThat(state.message()).contains("invalid policy"); + } + + + + + + /******************************************************************************* + ** Compare Access rows: a Deny is not counted as lost; an invalid policy is + ** flagged as voiding the person's access. + *******************************************************************************/ + @Test + void compareRowsExcludeDeniedAndFlagVoid() throws Exception + { + String head = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"edit\"],\"Resource\":[\"cms_page\"]}"; + t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").put("qqq.io/policy", + head + ",{\"Effect\":\"Deny\",\"Action\":[\"edit\"],\"Resource\":[\"cms_page\"]}]}"); + runtime.invalidateCache(); + AccessComparison.Row row = AccessComparison.run(t.config, AuthentikActionUtil.forBackend(t.config.getApiBackendName()), runtime.getPermissionCatalog()) + .stream().filter(r -> r.username().equals("operator")).findFirst().orElseThrow(); + assertThat(row.after()).doesNotContain("cms_page.edit"); + assertThat(row.removed()).doesNotContain("cms_page.edit"); + assertThat(row.voided()).isFalse(); + + t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").put("qqq.io/policy", head + ",{\"Effect\":\"Maybe\"}]}"); + runtime.invalidateCache(); + AccessComparison.Row voidRow = AccessComparison.run(t.config, AuthentikActionUtil.forBackend(t.config.getApiBackendName()), runtime.getPermissionCatalog()) + .stream().filter(r -> r.username().equals("operator")).findFirst().orElseThrow(); + assertThat(voidRow.voided()).isTrue(); + assertThat(voidRow.removed()).containsAll(voidRow.before()); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java index d458d90..27a2f60 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java @@ -95,7 +95,7 @@ class PolicyEditorTest 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)); + .with(AuthentikAdmin.POLICY_DOCUMENT, FULL_ACCESS).with("expectedFingerprint", fingerprintOf(group))); assertThat(plan.isRunnable()).as(plan.getMessage()).isTrue(); assertThat(plan.getAfter()).isEqualTo(PolicyPermissions.FULL_ACCESS_LABEL); assertThat(save(group, FULL_ACCESS).ok()).isTrue(); @@ -153,7 +153,7 @@ class PolicyEditorTest @Test void removingAMemberFromADenyGroupIsAGrant() throws Exception { - String group = "kof-website-publishers"; + String group = "kof-website-analysts"; String deny = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"*\"],\"Resource\":[\"cms_page\"]}]}"; t.authentik.user(8, "member8", "member8@example.test", "internal", true, group); t.config.withHostPermissionSource((email, groups) -> Set.of()); @@ -230,12 +230,24 @@ class PolicyEditorTest + /******************************************************************************* + ** The fingerprint of a group as it is now (Save Policy requires it). + *******************************************************************************/ + private String fingerprintOf(String group) + { + return (com.kingsrook.qbits.authentik.admin.AuthentikSafety.groupFingerprint(t.authentik.groupByName(group))); + } + + + + /******************************************************************************* ** *******************************************************************************/ private AuthentikAdmin.AdminResult save(String group, String document) throws QException { - return (t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(group), "because").with(AuthentikAdmin.POLICY_DOCUMENT, document))); + return (t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(group), "because").with(AuthentikAdmin.POLICY_DOCUMENT, document) + .with("expectedFingerprint", com.kingsrook.qbits.authentik.admin.AuthentikSafety.groupFingerprint(t.authentik.groupByName(group))))); } @@ -368,12 +380,13 @@ class PolicyEditorTest String withGone = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\"]}]}"; t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", withGone); AdminPlan plan = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because") - .with(AuthentikAdmin.POLICY_DOCUMENT, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\",\"cms_page\"]}]}")); + .with(AuthentikAdmin.POLICY_DOCUMENT, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"gone_table\",\"cms_page\"]}]}").with("expectedFingerprint", fingerprintOf("kof-website-analysts"))); assertThat(plan.isRunnable()).as(plan.getMessage()).isTrue(); assertThat(plan.getNotes()).anyMatch(n -> n.startsWith("Adds: cms_page.read")).anyMatch(n -> n.contains("Kept although") && n.contains("gone_table.read")); t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/policy", "garbage"); - AdminPlan replace = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because").with(AuthentikAdmin.POLICY_DOCUMENT, "")); + t.config.withHostPermissionSource((email, groups) -> PolicyPermissions.allPermissions(t.qInstance, Set.of())); + AdminPlan replace = t.admin().plan(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because").with(AuthentikAdmin.POLICY_DOCUMENT, "").with("expectedFingerprint", fingerprintOf("kof-website-analysts"))); assertThat(replace.isRunnable()).isTrue(); assertThat(replace.getNotes()).anyMatch(n -> n.contains("was not valid")); } @@ -664,4 +677,322 @@ class PolicyEditorTest assertThat(json.getBoolean("membersTruncated")).isFalse(); assertThat(json.getJSONArray("members").getJSONObject(0).keySet()).containsExactlyInAnyOrder("username", "label", "viaGroups", "basePermissions"); } + + + //////////////////////////////////////////////////////////////////////////// + // review round 2: edges of the Deny rules // + //////////////////////////////////////////////////////////////////////////// + private static final String DENY_PAGE_ALL = "{\"Effect\":\"Deny\",\"Action\":[\"*\"],\"Resource\":[\"cms_page\"]}"; + private static final String DENY_PAGE_DELETE = "{\"Effect\":\"Deny\",\"Action\":[\"delete\"],\"Resource\":[\"cms_page\"]}"; + private static final String DENY_USERS = "{\"Effect\":\"Deny\",\"Permission\":[\"authentik.users.manage.hasAccess\"]}"; + private static final String BAD_STATEMENT = "{\"Effect\":\"Maybe\"}"; + private static final String NO_STATEMENTS = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[]}"; + + + + /******************************************************************************* + ** A policy document from statements. + *******************************************************************************/ + private static String policyOf(String... statements) + { + return ("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[" + String.join(",", statements) + "]}"); + } + + + + /******************************************************************************* + ** Put a policy straight onto a group (as if edited in the Authentik UI). + *******************************************************************************/ + private void setPolicy(String group, String document) throws QException + { + t.authentik.groupByName(group).getJSONObject("attributes").put("qqq.io/policy", document); + t.admin().getUtil().getRuntime().invalidateCache(); + } + + + + /******************************************************************************* + ** The operator is denied cms_page.delete by their own group but holds it + ** through the host source: the Deny on analysts cannot be released by them. + *******************************************************************************/ + @Test + void operatorDeniedByPolicyCannotReleaseDenyThroughHostSource() throws Exception + { + setPolicy("kof-website-analysts", policyOf(DENY_PAGE_ALL)); + setPolicy("kof-website-publishers", policyOf(DENY_PAGE_DELETE)); + Set all = PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog()); + t.config.withHostPermissionSource((email, groups) -> all); + assertThat(save("kof-website-analysts", NO_STATEMENTS).message()).contains("do not hold yourself", "cms_page.delete"); + } + + + + /******************************************************************************* + ** Same through a static group fallback. + *******************************************************************************/ + @Test + void operatorDeniedByPolicyCannotReleaseDenyThroughStaticFallback() throws Exception + { + setPolicy("kof-website-analysts", policyOf(DENY_PAGE_ALL)); + setPolicy("kof-website-publishers", policyOf(DENY_PAGE_DELETE)); + t.config.withStaticGroupPermissions(Map.of("identity-admins", List.of("cms_page.delete"))); + assertThat(save("kof-website-analysts", NO_STATEMENTS).message()).contains("do not hold yourself", "cms_page.delete"); + } + + + + /******************************************************************************* + ** A bootstrap operator denied an authentik.* name cannot release a Deny on it. + *******************************************************************************/ + @Test + void bootstrapOperatorDeniedAnIdentityNameCannotReleaseDenyOnIt() throws Exception + { + setPolicy("kof-website-analysts", policyOf(DENY_USERS)); + setPolicy("kof-website-publishers", policyOf(DENY_USERS)); + t.config.withBootstrapOperatorSubjects("1"); + assertThat(save("kof-website-analysts", NO_STATEMENTS).message()).contains("do not hold yourself", "authentik.users.manage.hasAccess"); + } + + + + /******************************************************************************* + ** A custom policy attribute is reserved like qqq.io/*: neither editable nor + ** deletable through Edit Group or Edit User other attributes. + *******************************************************************************/ + @Test + void customPolicyAttributeIsReserved() throws Exception + { + t.config.withPolicyAttribute("myapp/policy"); + String document = policyOf(DENY_PAGE_ALL); + String group = "kof-website-analysts"; + t.authentik.groupByName(group).getJSONObject("attributes").put("myapp/policy", document); + t.authentik.users.get(3).getJSONObject("attributes").put("myapp/policy", document); + t.admin().getUtil().getRuntime().invalidateCache(); + + String pk = t.authentik.pk(group); + assertThat(t.admin().execute(AdminRequest.of(AdminAction.EDIT_GROUP, pk, "x").with("otherAttributes", "{\"myapp/policy\": \"x\"}")).message()).contains("reserved"); + AuthentikAdmin.AdminResult omitted = t.admin().execute(AdminRequest.of(AdminAction.EDIT_GROUP, pk, "x").with("otherAttributes", "{\"owner\": \"x\"}")); + assertThat(omitted.ok()).as(omitted.message()).isTrue(); + assertThat(t.authentik.groupByName(group).getJSONObject("attributes").getString("myapp/policy")).isEqualTo(document); + + assertThat(t.admin().execute(AdminRequest.of(AdminAction.EDIT_USER, "3", "x").with("otherAttributes", "{\"myapp/policy\": \"x\"}")).message()).contains("reserved"); + AuthentikAdmin.AdminResult userOmitted = t.admin().execute(AdminRequest.of(AdminAction.EDIT_USER, "3", "x").with("otherAttributes", "{\"team\": \"x\"}")); + assertThat(userOmitted.ok()).as(userOmitted.message()).isTrue(); + assertThat(t.authentik.users.get(3).getJSONObject("attributes").getString("myapp/policy")).isEqualTo(document); + } + + + + /******************************************************************************* + ** An invalid stored policy voids its members; taking a member out of it, or a + ** child out from under it, un-voids them, which is a grant of everything. + *******************************************************************************/ + @Test + void removingAMemberFromAVoidGroupIsAGrant() throws Exception + { + String group = "kof-website-analysts"; + setPolicy(group, policyOf(DENY_PAGE_ALL, BAD_STATEMENT)); + t.authentik.user(8, "member8", "member8@example.test", "internal", true, group); + t.admin().getUtil().getRuntime().invalidateCache(); + AuthentikAdmin.AdminResult refused = t.admin().execute(AdminRequest.of(AdminAction.REMOVE_MEMBER, t.authentik.pk(group) + ":8", "because")); + assertThat(refused.message()).contains("withholds permissions you do not hold yourself"); + assertThat(t.authentik.groupNamesOf(8)).contains(group); + } + + + + /******************************************************************************* + ** Removing a parent link to a void group is the same grant. + *******************************************************************************/ + @Test + void removingAParentLinkToAVoidGroupIsAGrant() throws Exception + { + setPolicy("kof-website-analysts", policyOf(DENY_PAGE_ALL, BAD_STATEMENT)); + t.authentik.group("kof-child", false, List.of(), List.of(), "kof-website-analysts"); + t.admin().getUtil().getRuntime().invalidateCache(); + AuthentikAdmin.AdminResult refused = t.admin().execute(AdminRequest.of(AdminAction.EDIT_GROUP, t.authentik.pk("kof-child"), "x").with("parents", "none")); + assertThat(refused.message()).contains("withholds permissions you do not hold yourself"); + } + + + + /******************************************************************************* + ** Saving a valid document over an invalid stored one un-voids the group's + ** members, so the operator must hold what that releases. + *******************************************************************************/ + @Test + void savingOverAnInvalidStoredPolicyIsAGrant() throws Exception + { + setPolicy("kof-website-analysts", policyOf(DENY_PAGE_ALL, BAD_STATEMENT)); + assertThat(save("kof-website-analysts", PAGE_READ_EDIT).message()).contains("do not hold yourself"); + } + + + + /******************************************************************************* + ** Generate Policy never silently drops the Deny of a document that is not valid. + *******************************************************************************/ + @Test + void generatePolicyRefusesAVoidStoredPolicy() throws Exception + { + setPolicy("kof-website-analysts", policyOf(DENY_PAGE_ALL, BAD_STATEMENT)); + t.config.withHostPermissionSource((email, groups) -> Set.of("cms_page.read")); + AuthentikAdmin.AdminResult result = t.admin().execute(t.admin().withGeneratedPolicy(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "seed"))); + assertThat(result.message()).contains("stored policy is not valid"); + } + + + + /******************************************************************************* + ** Save Policy always carries the fingerprint of what the operator reviewed. + *******************************************************************************/ + @Test + void savePolicyRequiresTheExpectedFingerprint() throws Exception + { + AuthentikAdmin.AdminResult result = t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk("kof-website-analysts"), "because") + .with(AuthentikAdmin.POLICY_DOCUMENT, policyOf(DENY_PAGE_ALL))); + assertThat(result.message()).contains("expectedFingerprint"); + } + + + + /******************************************************************************* + ** A user's group that is missing from the cached graph voids the policy grants + ** (its Deny cannot be seen), instead of being skipped. + *******************************************************************************/ + @Test + void groupMissingFromTheGraphVoidsPolicyGrants() throws Exception + { + t.authentik.user(10, "ghost-member", "ghost@example.test", "internal", true, "identity-admins"); + t.admin().getUtil().getRuntime().invalidateCache(); + AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper(); + assertThat(mapper.resolve(t.qInstance, List.of("identity-admins"), "10", "ghost@example.test", true)).contains("cms_page.read"); + assertThat(mapper.resolve(t.qInstance, List.of("identity-admins", "ghost-group"), "10", "ghost@example.test", true)).doesNotContain("cms_page.read"); + } + + + + + /******************************************************************************* + ** A sign-in session for the given groups with the policy claim. + *******************************************************************************/ + private QSession signIn(String claimPolicy, Set existing, String sub, String... groups) throws Exception + { + t.config.withMapperEnabled(true).withMapperOverride(true); + JSONObject claims = new JSONObject().put("groups", List.of(groups)).put("sub", sub).put("qqq_policy", claimPolicy == null ? JSONObject.NULL : new JSONObject(claimPolicy)); + if(claimPolicy == null) + { + claims.remove("qqq_policy"); + } + QSession session = new QSession(); + if(existing != null) + { + session.setPermissions(existing); + } + new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims)); + return (session); + } + + + + /******************************************************************************* + ** A Deny in the group graph beats an Allow in the claim. + *******************************************************************************/ + @Test + void graphDenyBeatsClaimAllow() throws Exception + { + setPolicy("kof-website-analysts", policyOf(DENY_PAGE_ALL)); + String claim = policyOf("{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}"); + assertThat(signIn(claim, null, "100", "kof-website-analysts").getPermissions()).doesNotContain("cms_page.read"); + } + + + + /******************************************************************************* + ** A Deny in the claim beats a qqq.io/permissions grant read from the graph, and + ** the session's own earlier permissions. + *******************************************************************************/ + @Test + void claimDenyBeatsAttributeGrantAndExistingPermissions() throws Exception + { + t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/permissions", new org.json.JSONArray(List.of("cms_page.read", "cms_page.edit"))); + t.admin().getUtil().getRuntime().invalidateCache(); + QSession open = signIn(null, null, "100", "kof-website-analysts"); + assertThat(open.getPermissions()).contains("cms_page.read", "cms_page.edit"); + QSession denied = signIn(policyOf("{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}"), Set.of("cms_page.read", "other.keep"), "100", "kof-website-analysts"); + assertThat(denied.getPermissions()).doesNotContain("cms_page.read").contains("cms_page.edit", "other.keep"); + } + + + + /******************************************************************************* + ** An invalid claim voids the graph's grants and the session's earlier ones. + *******************************************************************************/ + @Test + void voidClaimVoidsGraphGrantsAndExistingPermissions() throws Exception + { + t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").put("qqq.io/permissions", new org.json.JSONArray(List.of("cms_page.read"))); + t.admin().getUtil().getRuntime().invalidateCache(); + QSession voided = signIn(policyOf(BAD_STATEMENT), Set.of("other.keep"), "100", "kof-website-analysts"); + assertThat(voided.getPermissions()).doesNotContain("cms_page.read", "other.keep"); + } + + + + /******************************************************************************* + ** Host-owned floors are not subtracted by a Deny: static group fallbacks and + ** bootstrap identity permissions (documented in POLICY-FORMAT.md). + *******************************************************************************/ + @Test + void staticFallbackAndBootstrapAreNotSubtracted() throws Exception + { + t.authentik.user(10, "boot", "boot@example.test", "internal", true, "identity-admins"); + t.config.withStaticGroupPermissions(Map.of("kof-website-analysts", List.of("cms_page.read"))); + t.config.withBootstrapOperatorSubjects("10"); + String deny = policyOf("{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}", DENY_USERS); + QSession session = signIn(deny, null, "10", "kof-website-analysts", "identity-admins"); + assertThat(session.getPermissions()).contains("cms_page.read", "authentik.users.manage.hasAccess"); + } + + + + /******************************************************************************* + ** While the cut-over guard blocks, sign-in gets today's access from the host + ** and no Deny is applied to it (documented). + *******************************************************************************/ + @Test + void guardBlockedBranchGrantsTodaysAccessWithoutDeny() throws Exception + { + t.config.withCutoverGuardGroups(List.of("kof-website-analysts")); + t.config.withHostPermissionSource((email, groups) -> Set.of("cms_page.read")); + t.config.withMapperEnabled(true).withMapperOverride(false); + t.authentik.user(11, "guarded", "guarded@example.test", "internal", true, "kof-website-analysts"); + t.admin().getUtil().getRuntime().invalidateCache(); + t.admin().getUtil().getRuntime().setMapperState(null); + JSONObject claims = new JSONObject().put("groups", List.of("kof-website-analysts")).put("sub", "11").put("email", "guarded@example.test") + .put("qqq_policy", new JSONObject(policyOf("{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}"))); + QSession session = new QSession(); + new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims)); + assertThat(session.getPermissions()).as("the guard blocks, so today's host access stands").contains("cms_page.read"); + } + + + + /******************************************************************************* + ** The typed API tells void from absent and applies a Deny to the host's own + ** permissions. + *******************************************************************************/ + @Test + void resolveResultCarriesVoidAndDenials() throws Exception + { + setPolicy("kof-website-analysts", policyOf(DENY_PAGE_ALL)); + AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper(); + com.kingsrook.qbits.authentik.policy.PolicyResult denied = mapper.resolveResult(t.qInstance, List.of("kof-website-analysts"), "100", "x@example.test", true); + assertThat(denied.isVoid()).isFalse(); + assertThat(denied.apply(Set.of("cms_page.read", "service.send"))).contains("service.send").doesNotContain("cms_page.read"); + com.kingsrook.qbits.authentik.policy.PolicyResult unknown = mapper.resolveResult(t.qInstance, List.of("kof-website-analysts", "ghost"), "100", "x@example.test", true); + assertThat(unknown.isVoid()).isTrue(); + assertThat(unknown.apply(Set.of("service.send"))).isEmpty(); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/PolicyClaimMappingIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/PolicyClaimMappingIT.java index a4c16fa..231ebd1 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/PolicyClaimMappingIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/PolicyClaimMappingIT.java @@ -123,17 +123,22 @@ class PolicyClaimMappingIT String parent = group("policy-parent", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("contentApp.hasAccess"), format))); String child = group("policy-child", parent, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("cms_page.read", "cms_page.edit"), format))); group("policy-object", null, PolicyGenerator.generate(List.of("objectAttr.read"), format)); - group("policy-other-spec", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("ignored.read"), new PolicyFormat("other", "1.1.0", "x", "y")))); - int member = user("policy.member", child); admin.post("/core/groups/" + admin.groupPk("policy-object") + "/add_user/", new JSONObject().put("pk", member)); - admin.post("/core/groups/" + admin.groupPk("policy-other-spec") + "/add_user/", new JSONObject().put("pk", member)); JSONObject claims = claimsFor(member); assertThat(claims.has("qqq_policy")).isTrue(); - assertThat(PolicyPermissions.fromClaims(claims, format)) - .as("parent (inherited), child, an object-valued attribute; another spec is skipped") + assertThat(PolicyParser.parseStrict(claims.get("qqq_policy"), format).rules().exactGrants()) + .as("parent (inherited), child, an object-valued attribute") .containsExactlyInAnyOrder("contentApp.hasAccess", "cms_page.read", "cms_page.edit", "objectAttr.read"); + + /////////////////////////////////////////////////////////////////////// + // a document under the attribute that does not carry exactly our spec // + // voids the whole claim (it could hold a Deny); it is never skipped // + /////////////////////////////////////////////////////////////////////// + group("policy-other-spec", null, PolicyGenerator.toCompact(PolicyGenerator.generate(List.of("ignored.read"), new PolicyFormat("other", "1.1.0", "x", "y")))); + admin.post("/core/groups/" + admin.groupPk("policy-other-spec") + "/add_user/", new JSONObject().put("pk", member)); + assertThat(PolicyParser.parse(claimsFor(member).get("qqq_policy"), format).isVoid()).isTrue(); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java index 90eb89a..a200cca 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java @@ -324,12 +324,12 @@ class QbitWriteContractIT String pk = admin.groupPk("wr-policy"); String document = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\",\"delete\"],\"Resource\":[\"wr_page\"]}]}"; - AuthentikAdmin.AdminResult escalation = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, document)); + AuthentikAdmin.AdminResult escalation = savePolicy(pk, document); assertThat(escalation.ok()).isFalse(); assertThat(escalation.message()).contains("wr_page.delete"); String allowed = document.replace(",\"delete\"", ""); - AuthentikAdmin.AdminResult saved = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, allowed)); + AuthentikAdmin.AdminResult saved = savePolicy(pk, allowed); assertThat(saved.ok()).as(saved.message()).isTrue(); JSONObject attributes = admin.get("/core/groups/" + pk + "/").json().getJSONObject("attributes"); @@ -337,7 +337,7 @@ class QbitWriteContractIT assertThat(attributes.getString("qqq.io/policy")).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"wr_page\"]}]}"); assertThat(PolicyTestSupport.names(AuthentikActionUtil.forBackend(config.getApiBackendName()).groupGraph().byPk(pk).policy())).containsExactly("wr_page.read"); - AuthentikAdmin.AdminResult cleared = run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, "")); + AuthentikAdmin.AdminResult cleared = savePolicy(pk, ""); assertThat(cleared.ok()).as(cleared.message()).isTrue(); assertThat(admin.get("/core/groups/" + pk + "/").json().getJSONObject("attributes").has("qqq.io/policy")).isFalse(); } @@ -547,4 +547,15 @@ class QbitWriteContractIT } } + + + /******************************************************************************* + ** Save Policy with the fingerprint of the group as it is now (required). + *******************************************************************************/ + private AuthentikAdmin.AdminResult savePolicy(String pk, String document) throws Exception + { + String fingerprint = com.kingsrook.qbits.authentik.admin.AuthentikSafety.groupFingerprint(admin.get("/core/groups/" + pk + "/").json()); + return (run(AdminRequest.of(AdminAction.SAVE_POLICY, pk, "contract test").with(AuthentikAdmin.POLICY_DOCUMENT, document).with("expectedFingerprint", fingerprint))); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java index 9503271..230ae48 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java @@ -428,11 +428,14 @@ class AuthentikGroupPermissionMapperTest { QSession session = new QSession(); session.setPermissions(Set.of("already.there")); - new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins").put("no-such-group"))); + new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins"))); for(IdentityPermission permission : IdentityPermission.values()) { assertThat(session.getPermissions()).contains(permission.getSessionPermission()); } + QSession withUnknown = new QSession(); + new AuthentikGroupPermissionMapper().customizeSession(qInstance, withUnknown, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins").put("no-such-group"))); + assertThat(withUnknown.getPermissions()).as("a group missing from the graph may hold a Deny: policy grants are voided").doesNotContain(IdentityPermission.USERS_MANAGE.getSessionPermission()); assertThat(session.getPermissions()).contains("already.there"); } @@ -486,4 +489,31 @@ class AuthentikGroupPermissionMapperTest assertThat(wrongBackend.resolve(qInstance, List.of("phase1-child-of-identity-admins"), "s", "x@example.test", false)).isEmpty(); } + + + + + /******************************************************************************* + ** The graph cannot be read: the claim, which carries every group's merged + ** policy, still applies its own Deny; with no claim nothing comes from policy. + *******************************************************************************/ + @Test + void graphUnreadableStillAppliesTheClaimDeny() + { + FixtureActionUtil.groupGraphBody = "not json"; + String allow = "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}"; + String edit = "{\"Effect\":\"Allow\",\"Action\":[\"edit\"],\"Resource\":[\"other\"]}"; + String deny = "{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}"; + String head = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":["; + Map ctx = context("someone@example.test", new JSONArray()); + ctx.put("idToken", new JSONObject().put("qqq_policy", head + allow + "," + edit + "," + deny + "]}")); + QSession session = new QSession(); + new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, ctx); + assertThat(session.getPermissions()).contains("other.edit").doesNotContain("cms_page.read"); + + QSession noClaim = new QSession(); + new AuthentikGroupPermissionMapper().customizeSession(qInstance, noClaim, context("someone@example.test", new JSONArray())); + assertThat(noClaim.getPermissions()).doesNotContain("other.edit", "cms_page.read"); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapperTest.java index deaf1cf..410b637 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/AuthentikPolicyClaimMapperTest.java @@ -118,15 +118,16 @@ class AuthentikPolicyClaimMapperTest /******************************************************************************* - ** Missing or invalid claims add nothing and keep what the session had. + ** A missing claim adds nothing and keeps what the session had; an INVALID claim + ** voids everything (a Deny might be in it), existing permissions included. *******************************************************************************/ @Test void failsClosed() { assertThat(run(new AuthentikPolicyClaimMapper(), Map.of()).getPermissions()).containsExactly("existing.hasAccess"); assertThat(run(new AuthentikPolicyClaimMapper(), null).getPermissions()).containsExactly("existing.hasAccess"); - assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", "{}"))).getPermissions()).containsExactly("existing.hasAccess"); - assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", "garbage"))).getPermissions()).containsExactly("existing.hasAccess"); + assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", "{}"))).getPermissions()).isEmpty(); + assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", new JSONObject().put("qqq_policy", "garbage"))).getPermissions()).isEmpty(); assertThat(run(new AuthentikPolicyClaimMapper(), Map.of("idToken", "not claims")).getPermissions()).containsExactly("existing.hasAccess"); } @@ -172,4 +173,34 @@ class AuthentikPolicyClaimMapperTest run(mapper, Map.of("jwtPayloadJsonObject", new JSONObject().put("tenant", "t2"))); assertThat(calls).containsExactly("t1:false", "t2:false"); } + + + + + /******************************************************************************* + ** The session's own earlier permissions go through the Deny too, and the typed + ** result tells void from absent. + *******************************************************************************/ + @Test + void denyAppliesToExistingSessionPermissions() + { + String deny = document(statement("Deny", List.of("read"), List.of("cms_page"))).toString(); + QSession session = new QSession(); + session.setPermissions(Set.of("existing.hasAccess", "cms_page.read")); + new AuthentikPolicyClaimMapper().customizeSession(null, session, Map.of("idToken", new JSONObject().put("qqq_policy", deny))); + assertThat(session.getPermissions()).containsExactly("existing.hasAccess"); + + JSONObject none = new JSONObject(); + PolicyResult absent = PolicyPermissions.resultFromClaims(none, PolicyFormat.DEFAULT, null); + PolicyResult voided = PolicyPermissions.resultFromClaims(new JSONObject().put("qqq_policy", "garbage"), PolicyFormat.DEFAULT, null); + assertThat(absent.isAbsent()).isTrue(); + assertThat(absent.isVoid()).isFalse(); + assertThat(absent.apply(Set.of("a.read"))).containsExactly("a.read"); + assertThat(voided.isVoid()).isTrue(); + assertThat(voided.isAbsent()).isFalse(); + assertThat(voided.apply(Set.of("a.read"))).isEmpty(); + PolicyResult denied = PolicyPermissions.resultFromClaims(new JSONObject().put("qqq_policy", deny), PolicyFormat.DEFAULT, null); + assertThat(denied.apply(Set.of("cms_page.read", "cms_page.edit"))).containsExactly("cms_page.edit"); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java new file mode 100644 index 0000000..8948fd6 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java @@ -0,0 +1,224 @@ +/* + * 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.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Runs the example claim blueprint's Python expression (the one Authentik + ** executes) against crafted group attributes, then feeds the claim it emits + ** to the real parser. Skipped when no python interpreter is installed. + ** + ** Known difference: the parser reads JSON with org.json (lenient syntax, last + ** duplicate key wins) and the blueprint with Python's json (strict syntax; + ** the blueprint rejects duplicate keys). A document only one of them accepts + ** voids the claim at the stricter side. + *******************************************************************************/ +class BlueprintClaimExpressionTest +{ + private static final PolicyFormat FORMAT = new PolicyFormat("qqq-iam", "1.1.0", "qqq.io/policy", "qqq_policy"); + + private static final String HARNESS = """ + import json, sys + source = open(sys.argv[1], encoding="utf-8").read() + exec("def evaluate(request):\\n" + "".join(" " + line for line in source.splitlines(True)), globals()) + class G: + def __init__(self, attributes): + self.attributes = attributes + class U: + def __init__(self, groups): + self.groups = groups + def all_groups(self): + return self.groups + class R: + def __init__(self, groups): + self.user = U(groups) + out = [] + for case in json.load(sys.stdin): + out.append(evaluate(R([G({"qqq.io/policy": raw}) for raw in case]))) + print(json.dumps(out)) + """; + + + + /******************************************************************************* + ** A statement list as a document text. + *******************************************************************************/ + private static String document(String spec, String version, String statements) + { + return ("{\"PolicySpec\":" + (spec == null ? "null" : "\"" + spec + "\"") + ",\"PolicyVersion\":\"" + version + "\",\"Statement\":[" + statements + "]}"); + } + + + + /******************************************************************************* + ** Every document that does not carry exactly the configured spec, a supported + ** version and a clean statement list voids the claim; good documents merge. + *******************************************************************************/ + @Test + void badDocumentsVoidTheClaim() throws Exception + { + String python = python(); + Assumptions.assumeTrue(python != null, "no python interpreter"); + + String deny = "{\"Effect\":\"Deny\",\"Action\":[\"*\"],\"Resource\":[\"billing_*\"]}"; + String allow = "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"billing_invoice\"]}"; + String good = document("qqq-iam", "1.1.0", allow); + + List> cases = new ArrayList<>(); + List names = new ArrayList<>(); + add(cases, names, "mistyped spec", good, document("qqq-iamm", "1.1.0", deny)); + add(cases, names, "missing spec", good, document(null, "1.1.0", deny)); + add(cases, names, "different case", good, document("QQQ-IAM", "1.1.0", deny)); + add(cases, names, "trailing space", good, document("qqq-iam ", "1.1.0", deny)); + add(cases, names, "wrong version", good, document("qqq-iam", "1.0.0", deny)); + add(cases, names, "duplicate keys", good, "{\"PolicySpec\":\"x\",\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[" + deny + "]}"); + add(cases, names, "unparsable", good, "{not json"); + add(cases, names, "not an object", good, "[1,2]"); + add(cases, names, "statement not a list", good, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":{}}"); + + JSONArray claims = run(python, cases); + for(int i = 0; i < names.size(); i++) + { + PolicyRules rules = PolicyParser.parse(claims.getJSONObject(i).get("qqq_policy"), FORMAT); + assertThat(rules.isVoid()).as(names.get(i)).isTrue(); + } + + List> valid = List.of(List.of(good, document("qqq-iam", "1.1.0", deny), new JSONObject(document("qqq-iam", "1.1.0", allow)).toMap())); + PolicyRules merged = PolicyParser.parse(run(python, valid).getJSONObject(0).get("qqq_policy"), FORMAT); + assertThat(merged.isVoid()).isFalse(); + assertThat(merged.hasDeny()).isTrue(); + assertThat(merged.denies("billing_invoice.read")).isTrue(); + } + + + + /******************************************************************************* + ** Adds a case: every document is an attribute value of one group. + *******************************************************************************/ + private static void add(List> cases, List names, String name, Object... documents) + { + names.add(name); + cases.add(List.of(documents)); + } + + + + /******************************************************************************* + ** The expression text of the blueprint, run once for all cases. + *******************************************************************************/ + private static JSONArray run(String python, List> cases) throws IOException, InterruptedException + { + String yaml; + try(InputStream in = BlueprintClaimExpressionTest.class.getResourceAsStream("/blueprints/qqq-policy-claim.yaml")) + { + yaml = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + StringBuilder expression = new StringBuilder(); + boolean inside = false; + for(String line : yaml.split("\\R", -1)) + { + if(line.trim().equals("expression: |")) + { + inside = true; + } + else if(inside) + { + expression.append(line.length() >= 8 ? line.substring(8) : "").append("\n"); + } + } + Path dir = Files.createTempDirectory("blueprint-claim"); + try + { + Path source = dir.resolve("expression.txt"); + Path harness = dir.resolve("harness.py"); + Files.writeString(source, expression.toString()); + Files.writeString(harness, HARNESS); + Process process = new ProcessBuilder(python, "-I", harness.toString(), source.toString()).redirectErrorStream(true).start(); + JSONArray input = new JSONArray(); + for(List one : cases) + { + JSONArray group = new JSONArray(); + one.forEach(group::put); + input.put(group); + } + process.getOutputStream().write(input.toString().getBytes(StandardCharsets.UTF_8)); + process.getOutputStream().close(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertThat(process.waitFor(30, TimeUnit.SECONDS)).isTrue(); + assertThat(process.exitValue()).as(output).isZero(); + JSONArray claims = new JSONArray(output.trim()); + JSONArray rs = new JSONArray(); + for(int i = 0; i < claims.length(); i++) + { + rs.put(new JSONObject().put("qqq_policy", new JSONObject(claims.getJSONObject(i).getString("qqq_policy")).toString())); + } + return (rs); + } + finally + { + try(var files = Files.list(dir)) + { + for(Path file : files.toList()) + { + Files.deleteIfExists(file); + } + } + Files.deleteIfExists(dir); + } + } + + + + /******************************************************************************* + ** A working python interpreter, or null. + *******************************************************************************/ + private static String python() + { + for(String candidate : List.of("python3", "python")) + { + try + { + Process process = new ProcessBuilder(candidate, "--version").redirectErrorStream(true).start(); + process.getInputStream().readAllBytes(); + if(process.waitFor(10, TimeUnit.SECONDS) && process.exitValue() == 0) + { + return (candidate); + } + } + catch(IOException | InterruptedException e) + { + // try the next one + } + } + return (null); + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java index d529511..903f23d 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java @@ -217,7 +217,7 @@ class PolicyDenyTest void hostNamesWorkWithoutARuntime() throws Exception { assertThat(PolicyPermissions.allPermissions(null, Set.of())).doesNotContain("email.send"); - PolicyPermissions.registerHostPermissions(instance -> List.of("email.send", "echo.use", " ")); + PolicyPermissions.registerHostPermissions(instance -> List.of("email.send", "echo.use")); assertThat(PolicyPermissions.allPermissions(null, Set.of())).contains("email.send", "echo.use"); assertThat(PolicyPermissions.allPermissions(new QInstance(), Set.of())).contains("email.send", "echo.use"); @@ -300,4 +300,108 @@ class PolicyDenyTest assertThatThrownBy(() -> PolicyParser.parseForSave(doc(List.of(stmt("Deny", "delete", "billing_report"))), FORMAT, CATALOG, UNIVERSE)).hasMessageContaining("matches nothing"); assertThat(PolicyParser.parseForSave(doc(List.of(stmt("Deny", "delete", "billing_invoice"))), FORMAT, CATALOG, UNIVERSE).rules().hasDeny()).isTrue(); } + + + /******************************************************************************* + ** In-place changes (a permission rule, a feature added to the instance's + ** feature metadata) are seen by the cache without clearing it. + *******************************************************************************/ + @Test + void inPlaceMetadataChangesAreSeenByTheCache() + { + PolicyCatalog.clearCache(); + QInstance instance = PolicyCatalogTest.instance(); + assertThat(PolicyCatalog.of(instance).get("publishPage")).isNotNull(); + instance.getProcess("publishPage").getPermissionRules().setLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.NOT_PROTECTED); + assertThat(PolicyCatalog.of(instance).get("publishPage")).as("process rule changed in place").isNull(); + + assertThat(PolicyCatalog.of(instance).get("cms_page").features()).isEmpty(); + com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData.ofOrWithNew(instance) + .withPermission(new com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermission().withSuffix("analyze").withLabel("Analyze") + .withAppliesTo((i, table) -> "cms_page".equals(table.getName()))); + assertThat(PolicyCatalog.of(instance).get("cms_page").features()).as("feature added in place").hasSize(1); + assertThat(PolicyCatalog.of(instance).permissionNames()).isEqualTo(PolicyCatalog.build(instance).permissionNames()); + } + + + + /******************************************************************************* + ** A Deny with a wildcard resource also covers dotted host names whose first + ** segment matches; an Allow prefix never grants them. + *******************************************************************************/ + @Test + void denyPrefixCoversDottedHostNamesButAllowDoesNot() throws Exception + { + Set universe = new TreeSet<>(NAMES); + universe.add("billing_audit.export.run"); + PolicyUniverse live = PolicyUniverse.of(universe); + PolicyRules rules = PolicyParser.parseStrict(doc(List.of(stmt("Allow", "*", "*"), stmt("Deny", "*", "billing_*"))), FORMAT, CATALOG).rules(); + assertThat(rules.resolve(live)).doesNotContain("billing_audit.export.run").contains("cdn.purge"); + PolicyRules allowOnly = PolicyParser.parseStrict(doc(List.of(stmt("Allow", "*", "billing_*"))), FORMAT, CATALOG).rules(); + assertThat(allowOnly.resolve(live)).doesNotContain("billing_audit.export.run"); + PolicyRules named = PolicyParser.parseStrict(doc(List.of(stmt("Allow", "*", "*"), stmt("Deny", "*", "billing_audit"))), FORMAT, CATALOG).rules(); + assertThat(named.resolve(live)).doesNotContain("billing_audit.export.run"); + } + + + + /******************************************************************************* + ** A host name outside dot-separated [A-Za-z0-9_-] segments is rejected loudly. + *******************************************************************************/ + @Test + void invalidHostNamesAreRejected() + { + PolicyPermissions.registerHostPermissions(instance -> List.of("fine.name", "bad name")); + assertThatThrownBy(() -> PolicyPermissions.allPermissions(null, Set.of())).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("host permission"); + } + + + + /******************************************************************************* + ** An empty Resource, Permission or Action array is a silent no-op (a Deny that + ** does nothing), so it rejects the document. + *******************************************************************************/ + @Test + void emptyArraysAreRejected() + { + JSONObject emptyResource = new JSONObject().put("Effect", "Deny").put("Action", new JSONArray().put("*")).put("Resource", new JSONArray()); + JSONObject emptyAction = new JSONObject().put("Effect", "Allow").put("Action", new JSONArray()).put("Resource", new JSONArray().put("billing_invoice")); + JSONObject emptyNamed = new JSONObject().put("Effect", "Deny").put("Permission", new JSONArray()); + for(JSONObject statement : List.of(emptyResource, emptyAction, emptyNamed)) + { + assertThatThrownBy(() -> PolicyParser.parseStrict(doc(List.of(statement)), FORMAT, CATALOG)).hasMessageContaining("empty"); + } + } + + + + /******************************************************************************* + ** A void rule set withholds everything: un-voiding is a release. + *******************************************************************************/ + @Test + void voidRulesWithholdTheWholeUniverse() + { + assertThat(PolicyRules.voided("x").withheld(UNIVERSE)).containsExactlyInAnyOrderElementsOf(NAMES); + assertThat(PolicyRules.EMPTY.withheld(UNIVERSE)).isEmpty(); + } + + + + + + /******************************************************************************* + ** PolicyResult.apply subtracts a Deny from the host's own sources and a void + ** result gives nothing. + *******************************************************************************/ + @Test + void policyResultAppliesDenyToOtherSources() throws Exception + { + PolicyRules rules = PolicyParser.parseStrict(doc(List.of(stmt("Allow", "read", "billing_invoice"), stmt("Deny", "*", "utils_log"))), FORMAT, CATALOG).rules(); + PolicyResult result = PolicyResult.of(rules, UNIVERSE); + assertThat(result.granted()).containsExactly("billing_invoice.read"); + assertThat(result.apply(List.of("utils_log.read", "service.send"))).containsExactlyInAnyOrder("billing_invoice.read", "service.send"); + assertThat(PolicyResult.of(PolicyRules.voided("x"), UNIVERSE).apply(List.of("service.send"))).isEmpty(); + assertThat(PolicyResult.of(PolicyRules.EMPTY, UNIVERSE).isAbsent()).isTrue(); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java index 59a5f41..ed01f34 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java @@ -107,7 +107,7 @@ class PolicyParserTest { assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(document(), FORMAT).rules())).isEmpty(); assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0"), FORMAT).rules())).isEmpty(); - assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(document(statement("Allow", List.of(), List.of("a"))), FORMAT).rules())).isEmpty(); + assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Allow", List.of(), List.of("a"))), FORMAT)).hasMessageContaining("empty"); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java index 953bd0d..bb38372 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java @@ -166,4 +166,24 @@ class AuthentikMirrorSnapshotTest assertThat(row.getValue("isSuperuser")).isEqualTo(true); assertThat(row.getValue("email")).isNull(); } + + + + + /******************************************************************************* + ** The mirrored effective permissions (what the refreshing mapper projects) + ** apply a Deny and are voided by an invalid policy. + *******************************************************************************/ + @Test + void denyAndVoidApplyToMirroredPermissions() + { + assertThat(snapshot().users().getFirst().getValueString("effectivePermissions")).contains("authentik.users.read.hasAccess"); + team.getJSONObject("attributes").put("qqq.io/policy", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Permission\":[\"authentik.users.read.hasAccess\"]}]}"); + assertThat(snapshot().users().getFirst().getValueString("effectivePermissions")).isEqualTo("[]"); + team.getJSONObject("attributes").put("qqq.io/policy", + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Maybe\"}]}"); + assertThat(snapshot().users().getFirst().getValueString("effectivePermissions")).as("void").isEqualTo("[]"); + } + } -- 2.54.0 From 4a375848f2334cb3831a425049a3c042057878ef Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 19:23:59 -0600 Subject: [PATCH 07/14] docs(policy): vectors and spec for document shape, blocked-guard and floors Documents with an unknown top-level key or no Statement are rejected (new rawDocuments vectors); a Deny subtracts static and host floors, also while the guard blocks; unreadable and void states; fingerprint covers the policy attribute. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- docs/POLICY-FORMAT.md | 11 +++---- src/test/resources/policy-vectors-1.1.0.json | 31 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 221955e..f5d93cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] -- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document under the configured attribute, a differently-specced one included (skipping other specs is an explicit opt-in). Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). Deny does not subtract static group fallbacks, bootstrap identity permissions, or the host source while the cut-over guard blocks. Review fixes: operators are credited with a floor permission only if their own policies do not deny it; the configured policy attribute is reserved against Edit Group/User attributes; un-voiding an invalid policy is a release; a Deny a person keeps deliberately no longer trips the cut-over guard; a Deny of a prefix or named resource also covers its dotted host names; empty Resource/Action/Permission arrays and malformed host names are rejected; Save Policy requires `expectedFingerprint`; `PolicyResult` / `resultFromClaims` / `resolveResult` replace the deprecated plain-set APIs. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`. +- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document under the configured attribute, a differently-specced one included (skipping other specs is an explicit opt-in). Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). A Deny subtracts static group fallbacks and the host source too, also while the cut-over guard blocks (only bootstrap identity permissions are exempt); a guard error keeps the last known state; an unreadable graph with no claim voids the session's earlier permissions; a missing parent voids; documents with an unknown top-level key or no `Statement` are rejected; the group fingerprint covers the configured policy attribute; the refreshing mapper reads Deny rules from the mirror (no API call). Review fixes: operators are credited with a floor permission only if their own policies do not deny it; the configured policy attribute is reserved against Edit Group/User attributes; un-voiding an invalid policy is a release; a Deny a person keeps deliberately no longer trips the cut-over guard; a Deny of a prefix or named resource also covers its dotted host names; empty Resource/Action/Permission arrays and malformed host names are rejected; Save Policy requires `expectedFingerprint`; `PolicyResult` / `resultFromClaims` / `resolveResult` replace the deprecated plain-set APIs. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`. - Fix: native **Create Group** now obeys the two-person gate. With `twoPersonApproval=true`, creating a group whose permissions expand to any `authentik.*` permission queues an `authentik_approval_request` for a second person instead of persisting the grant immediately; the same identity-permission expansion and second-person condition as Edit Group apply, nothing is written before approval, self-approval is refused and a decided request cannot be approved again. Previously a new group with `authentik.users.read` was created without approval. - **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255). - Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: `, `malformed ` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was. diff --git a/docs/POLICY-FORMAT.md b/docs/POLICY-FORMAT.md index e9033b7..2ab03a2 100644 --- a/docs/POLICY-FORMAT.md +++ b/docs/POLICY-FORMAT.md @@ -79,13 +79,13 @@ A grant that QQQ never checks (for example `hasAccess` on a table with insert/ed **Editing.** Adding a `Deny` is always allowed to an operator with policy management. Removing or narrowing a `Deny` is a grant: the operator must hold every permission it was withholding and must not be denied any of them themselves. Removing a person from a group, or a group from a parent, releases the `Deny` statements of that group and above it, so it needs the same check. Saving a policy that contains a `Deny` also runs the last-administrator check. -**What a Deny subtracts.** A `Deny` removes what the user's groups grant through policy documents and through `qqq.io/permissions`, and, while policies are enforced, the permissions an earlier customizer already put on the session. It does NOT subtract: -- the static group fallbacks and the bootstrap-operator identity permissions (host-owned floors); -- the host permission source and the static floor while the cut-over guard is blocking: then sign-in grants today's access and no policy, Deny included, is applied. A Deny that removes a permission from a guard-group member is a deliberate removal, so it never trips the guard by itself; an invalid policy keeps it blocking and the enforcement widget says so. +**What a Deny subtracts.** A `Deny` removes what the user's groups grant through policy documents and `qqq.io/permissions`, the static group fallbacks, the host permission source, and the permissions an earlier customizer already put on the session. That holds whether the cut-over guard is enforcing or blocking: while it blocks, sign-in grants today's access (static floor plus host source) minus every valid `Deny`; a `Deny` only removes access, so it cannot defeat the guard's purpose. The ONLY exemption is the bootstrap-operator identity permissions (break-glass). A `Deny` that removes a permission from a guard-group member is a deliberate removal, so it never trips the guard by itself. If the guard cannot run (an Authentik error) the last known state stands; with none it blocks. + +**Unreadable and void.** If the group graph cannot be read and the token carries no policy claim, no `Deny` can be known: nothing comes from policy and the session's earlier permissions are treated as voided (the static fallbacks that exist for an Authentik outage remain). If the claim is present it carries every group's merged policy and applies on its own. A user's group, or a parent of one, that is missing from the graph voids the policy grants like an invalid document. `PolicyResult` reports `isVoid()`, `isUnavailable()` and `isAbsent()` separately, and `apply()` returns nothing when void or unavailable. **Void is not empty.** An invalid document voids the user: they get no permissions. Because a void group might have held a `Deny`, un-voiding is a release: removing a member from a void group, removing a parent link to it, or saving a valid document over an invalid stored one requires the operator to hold every permission that exists. Generate Policy refuses to run over an invalid stored document instead of discarding its `Deny`. -**Operators.** For every subset-rule check an operator is credited with a permission only if their own policies grant it, or a host floor (static fallback, host permission source, bootstrap) offers it AND their own policies do not deny it. An operator denied a permission can neither grant it nor release a `Deny` on it. Save Policy must carry the `expectedFingerprint` of the group as reviewed. +**Operators.** For every subset-rule check an operator is credited with a permission only if their own policies grant it, or a host floor (static fallback, host permission source, bootstrap) offers it AND their own policies do not deny it. An operator denied a permission can neither grant it nor release a `Deny` on it. Save Policy must carry the `expectedFingerprint` of the group as reviewed; the fingerprint covers `qqq.io/` attributes and the configured policy attribute even when it lives outside `qqq.io/`. **Host customizers.** `PolicyPermissions.fromClaims` and `AuthentikGroupPermissionMapper.resolve` return plain sets and are deprecated: a set cannot tell void from absent or carry a `Deny` to the host's other sources. Use `PolicyPermissions.resultFromClaims(claims, format, catalog)` (or `AuthentikGroupPermissionMapper.resolveResult`), which returns a `PolicyResult` (`isVoid()`, `isAbsent()`, the typed rules and what they grant), and add the host's own service permissions through `result.apply(otherPermissions)`: it subtracts every `Deny` from them and returns nothing when the policy is void. @@ -142,6 +142,7 @@ A document grants **nothing**, and the parser logs a warning, when any of these - a statement has a key other than `Effect`, `Action`, `Resource` and `Permission`, or mixes `Permission` with `Action`/`Resource` - a wildcard resource is not a valid prefix - `Action` or `Resource` is missing or not an array of strings (a statement with `Permission` has neither) +- the document has a top-level key other than `PolicySpec`, `PolicyVersion` and `Statement` (a misspelled `Statement` would otherwise read as an empty policy), or `Statement` is missing (an empty array is fine) - a `Permission` name is not dot-separated segments of `[A-Za-z0-9_-]` - a statement has an empty `Resource`, `Action` or `Permission` array (it would do nothing, which for a `Deny` is the dangerous case) - the claim is not one JSON object or JSON string (an array of documents is rejected) @@ -175,7 +176,7 @@ An app that already stores documents of this shape under its own spec name, attr ## Getting policies into the token -Authentik puts the merged document into the token through a scope mapping. The example blueprint voids the whole claim (it emits a document the qbit rejects) when any document found under the configured attribute is unparsable, has a duplicate key, is not an object, carries a `PolicySpec` that is not exactly the configured one (missing, mistyped, another case, a trailing space), has another version or a non-array `Statement`. Skipping documents of another spec is an explicit opt-in (`skip_other_specs = True`, for an attribute shared with other applications) and is off by default. Known difference: the qbit reads JSON with org.json (lenient syntax, the last duplicate key wins) and the blueprint with Python json (strict syntax, duplicate keys rejected); a document only one of them accepts voids the claim on the stricter side. `BlueprintClaimExpressionTest` runs the blueprint expression against these cases. Steps: +Authentik puts the merged document into the token through a scope mapping. The example blueprint voids the whole claim (it emits a document the qbit rejects) when any document found under the configured attribute is unparsable, has a duplicate key, is not an object, carries a `PolicySpec` that is not exactly the configured one (missing, mistyped, another case, a trailing space), has another version or a non-array `Statement`. Skipping documents of another spec is an explicit opt-in (`skip_other_specs = True`, for an attribute shared with other applications) and is off by default. Known difference: the qbit reads JSON with org.json (which accepts some lenient syntax Python rejects, and rejects duplicate keys) and the blueprint with Python json (strict syntax, duplicate keys rejected, also nested); a document only one of them accepts voids the claim on the stricter side. `BlueprintClaimExpressionTest` runs the blueprint expression against these cases. Steps: 1. Apply the example blueprint, [`src/test/resources/blueprints/qqq-policy-claim.yaml`](../src/test/resources/blueprints/qqq-policy-claim.yaml). Edit its spec, version, attribute and claim name to match your config. 2. Add the mapping to the OAuth2 provider's `property_mappings`. It uses the scope `profile`, which every QQQ sign-in requests. diff --git a/src/test/resources/policy-vectors-1.1.0.json b/src/test/resources/policy-vectors-1.1.0.json index fe0fc61..97b5c9e 100644 --- a/src/test/resources/policy-vectors-1.1.0.json +++ b/src/test/resources/policy-vectors-1.1.0.json @@ -6,6 +6,7 @@ "(authentik.users.manage.hasAccess has base authentik.users.manage; cdn.purge has no action and is a named permission).", "'hostNames' are the universe entries that are host-declared service names (not table features): they are granted only through the Permission key, never as an Action.", "A Deny of a wildcard or named resource also covers the dotted host names that resource owns (billing_* covers billing_audit.export.run); an Allow prefix never grants them.", + "'rawDocuments' (instead of 'documents') are whole documents, for cases about the document itself: a missing or misspelled Statement key, an unknown top-level key.", "'documents' are the Statement arrays of every group the user is in (the merged claim). 'expect.permissions' is compared as a set.", "Resource allows [A-Za-z0-9_]+ and utils_* prefixes (no hyphen); Permission names are dot-separated segments of [A-Za-z0-9_-] (a bare name has no dot).", "Every Allow, exact names included, expands against the universe: a name that is not in it grants nothing at runtime.", @@ -284,6 +285,36 @@ "documents": [[{"Effect":"Allow","Action":[],"Resource":["billing_invoice"]}]], "expect": {"valid": false, "error": "empty"} }, + { + "name": "missing-statement-key-rejected", + "mode": "runtime", + "rawDocuments": [{"PolicySpec":"qqq-iam","PolicyVersion":"1.1.0"}], + "expect": {"valid": false, "error": "Statement is required"} + }, + { + "name": "misspelled-statement-key-rejected", + "mode": "runtime", + "rawDocuments": [{"PolicySpec":"qqq-iam","PolicyVersion":"1.1.0","Statments":[{"Effect":"Deny","Action":["*"],"Resource":["billing_*"]}]}], + "expect": {"valid": false, "error": "top-level key"} + }, + { + "name": "lowercase-statement-key-rejected", + "mode": "runtime", + "rawDocuments": [{"PolicySpec":"qqq-iam","PolicyVersion":"1.1.0","statement":[]}], + "expect": {"valid": false, "error": "top-level key"} + }, + { + "name": "unknown-top-level-key-rejected", + "mode": "runtime", + "rawDocuments": [{"PolicySpec":"qqq-iam","PolicyVersion":"1.1.0","Statement":[],"Extra":true}], + "expect": {"valid": false, "error": "top-level key"} + }, + { + "name": "statement-must-be-an-array", + "mode": "runtime", + "rawDocuments": [{"PolicySpec":"qqq-iam","PolicyVersion":"1.1.0","Statement":{}}], + "expect": {"valid": false, "error": "Statement"} + }, { "name": "empty-statement-array-grants-nothing", "mode": "runtime", -- 2.54.0 From bad06d806ad52fbf6117763b0af6d4aea9a7dbed Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 19:24:01 -0600 Subject: [PATCH 08/14] fix(policy): guard-blocked and floor Deny, unavailable state, strict documents The cut-over guard's blocked branch applies valid Deny rules (claim and graph) to floor, host source and existing permissions; a guard error keeps the last known state. Static and host floors are subject to Deny everywhere (bootstrap exempt), including the refreshing mapper, which now reads Deny rules from the mirror without an API call. PolicyResult gains UNAVAILABLE; a failed graph read with no claim voids earlier permissions; resolveResult reports the full rules. Missing parents void. Documents with unknown top-level keys or no Statement are rejected. The group fingerprint covers the configured policy attribute. Stronger tests for release, grandparent Deny, and the blueprint (fails without python). Co-Authored-By: Claude Sonnet 5.5 --- .../qbits/authentik/admin/AuthentikAdmin.java | 10 +- .../authentik/admin/AuthentikMaintenance.java | 2 +- .../authentik/admin/AuthentikSafety.java | 13 +- .../permissions/AccessComparison.java | 29 +- .../AuthentikGroupPermissionMapper.java | 284 +++++++++--------- .../AuthentikRefreshingPermissionMapper.java | 92 +++++- .../EffectivePermissionResolver.java | 29 +- .../qbits/authentik/policy/PolicyCatalog.java | 6 +- .../qbits/authentik/policy/PolicyParser.java | 12 +- .../qbits/authentik/policy/PolicyResult.java | 46 ++- .../processes/AuthentikProcesses.java | 6 +- .../authentik/sync/AuthentikMirrorAccess.java | 29 +- .../authentik/admin/PolicyCutoverTest.java | 35 +++ .../authentik/admin/PolicyEditorTest.java | 93 +++++- .../AuthentikGroupPermissionMapperTest.java | 25 ++ ...thentikRefreshingPermissionMapperTest.java | 48 +++ .../policy/BlueprintClaimExpressionTest.java | 64 +++- .../authentik/policy/PolicyDenyTest.java | 33 ++ .../authentik/policy/PolicyParserTest.java | 4 +- .../authentik/policy/PolicyVectorsTest.java | 13 +- 20 files changed, 680 insertions(+), 193 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java index b611eec..7770b51 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java @@ -1521,7 +1521,7 @@ public class AuthentikAdmin } String fingerprint = request.str("expectedFingerprint"); - if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.groupFingerprint(group))) + if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.groupFingerprint(group, config.getPolicyFormat().attribute()))) { return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again.")); } @@ -1631,7 +1631,7 @@ public class AuthentikAdmin { return (plan.refuse(e.getMessage())); } - String fingerprint = group == null ? "absent" : AuthentikSafety.groupFingerprint(group); + String fingerprint = group == null ? "absent" : AuthentikSafety.groupFingerprint(group, config.getPolicyFormat().attribute()); if(!fingerprint.equals(request.str("expectedFingerprint"))) { return (plan.refuse("Direct permissions changed since you opened the editor. Open it again before saving.")); @@ -1684,7 +1684,7 @@ public class AuthentikAdmin List oldAppAccess = nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)); String fingerprint = request.str("expectedFingerprint"); - if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.groupFingerprint(group))) + if(StringUtils.hasContent(fingerprint) && !fingerprint.equals(AuthentikSafety.groupFingerprint(group, config.getPolicyFormat().attribute()))) { return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again.")); } @@ -1795,7 +1795,7 @@ public class AuthentikAdmin { return (plan.refuse("expectedFingerprint is required: Save Policy must carry the fingerprint of the group as the operator reviewed it.")); } - if(!fingerprint.equals(AuthentikSafety.groupFingerprint(group))) + if(!fingerprint.equals(AuthentikSafety.groupFingerprint(group, config.getPolicyFormat().attribute()))) { return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again.")); } @@ -1945,7 +1945,7 @@ public class AuthentikAdmin } if(!StringUtils.hasContent(request.str("expectedFingerprint"))) { - request.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group)); + request.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group, config.getPolicyFormat().attribute())); } PolicyFormat format = config.getPolicyFormat(); Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute()); diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java index 400722e..625f407 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java @@ -118,7 +118,7 @@ public final class AuthentikMaintenance } AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), operatorName + ": " + reason) .with(AuthentikAdmin.POLICY_DOCUMENT, policyJson) - .with("expectedFingerprint", AuthentikSafety.groupFingerprint(group)); + .with("expectedFingerprint", AuthentikSafety.groupFingerprint(group, runtime.getConfig().getPolicyFormat().attribute())); return (admin.execute(request)); } catch(PolicyParseException e) diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java index dd20443..33b3099 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikSafety.java @@ -224,6 +224,17 @@ public final class AuthentikSafety ** the Authentik UI after a preview is noticed. *******************************************************************************/ public static String groupFingerprint(JSONObject group) + { + return (groupFingerprint(group, null)); + } + + + + /******************************************************************************* + ** The fingerprint including the configured policy attribute even when it is + ** outside qqq.io/ (a custom attribute): a change there is noticed too. + *******************************************************************************/ + public static String groupFingerprint(JSONObject group, String policyAttribute) { JSONObject attributes = group.optJSONObject("attributes"); Map qqq = new java.util.TreeMap<>(); @@ -231,7 +242,7 @@ public final class AuthentikSafety { for(String key : attributes.keySet()) { - if(key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX)) + if(key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX) || key.equals(policyAttribute)) { qqq.put(key, String.valueOf(attributes.get(key))); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java index 82b278d..39ed298 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java @@ -171,8 +171,22 @@ public final class AccessComparison public static Set mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection groupPks, Collection directGroupNames, String userPk, String userUuid, String excludePolicyOfGroupPk) { - Set rs = new TreeSet<>(resolver.resolveGroups(groupPks, excludePolicyOfGroupPk).sessionPermissions()); - rs.addAll(staticPermissions(config, directGroupNames)); + EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(groupPks, excludePolicyOfGroupPk); + Set rs = new TreeSet<>(resolution.sessionPermissions()); + /////////////////////////////////////////////////////////////////////// + // the static fallback is a grant like any other: a Deny removes it, an // + // invalid policy voids it; only the bootstrap names below are exempt // + /////////////////////////////////////////////////////////////////////// + Set floor = new TreeSet<>(staticPermissions(config, directGroupNames)); + if(resolution.voided()) + { + floor.clear(); + } + else + { + floor.removeIf(resolution.denials()::denies); + } + rs.addAll(floor); Set subjects = config.getResolvedBootstrapSubjects(); if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid))) { @@ -267,7 +281,16 @@ public final class AccessComparison } catch(Exception e) { - LOG.warn("The cut-over guard could not run; the mapper stays inactive", logPair("error", e.getMessage())); + /////////////////////////////////////////////////////////////////// + // an Authentik error must not flip the state: keep the last known // + // one (and say so); with none, block // + /////////////////////////////////////////////////////////////////// + State last = runtime.getMapperState(); + LOG.error("The cut-over guard could not run", logPair("error", e.getMessage()), logPair("keepingLastState", last != null)); + if(last != null) + { + return (new State(last.status(), last.message(), Instant.now())); + } return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard could not check Authentik (" + e.getMessage() + "). Sign-in keeps today's access.", Instant.now())); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java index b1c4d38..f72564f 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java @@ -119,103 +119,133 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust { return; } - List groups = groupNames(claims); - String email = claims.optString("email", "").trim(); - String sub = claims.optString("sub", "").trim(); + List groups = groupNames(claims); + String email = claims.optString("email", "").trim(); + String sub = claims.optString("sub", "").trim(); boolean verified = claims.optBoolean("email_verified", false); - Detailed detailed = resolveDetailed(qInstance, groups, sub, email, verified); - Set permissions = new TreeSet<>(detailed.floor()); - boolean enforced = true; - PolicyRules denials = detailed.denials(); - - ////////////////////////////////////////////////////////////////////////// - // the policy claim (docs/POLICY-FORMAT.md) carries the same group data // - // as the graph read above; the union also covers an unreachable API. // - // A Deny from either source removes the grant from both, and an // - // invalid policy in either voids the user's policy grants. // - ////////////////////////////////////////////////////////////////////////// AuthentikRuntime runtime = runtime(); PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat(); PolicyCatalog catalog = qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance); - PolicyRules claim = PolicyPermissions.rulesFromClaims(PolicyPermissions.claimsWithPolicy(context, format), format, catalog); - boolean voided = claim.isVoid() || detailed.denials().isVoid(); + JSONObject withPolicy = PolicyPermissions.claimsWithPolicy(context, format); + PolicyRules claim = PolicyPermissions.rulesFromClaims(withPolicy, format, catalog); + + qSession.setPermissions(compute(qInstance, runtime, groups, sub, email, verified, claim, withPolicy != null, catalog, qSession.getPermissions())); + com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims); + } + + + + /******************************************************************************* + ** The permissions of one sign-in. The policy claim carries the same group data + ** as the graph read; the union also covers an unreachable API. A Deny from + ** either source removes the grant wherever it came from: policies, the static + ** and host floors, the session's earlier permissions, whether or not the + ** cut-over guard blocks (a Deny only removes access, so it cannot defeat the + ** guard's purpose). Only the bootstrap identity permissions (break-glass) are + ** exempt. An invalid policy voids the user's policy grants and, while + ** policies are enforced, their floors and earlier permissions too. When the + ** graph cannot be read and there is no claim, no Deny can be known: nothing + ** comes from policy and the earlier permissions are treated as voided. + *******************************************************************************/ + private Set compute(QInstance qInstance, AuthentikRuntime runtime, List groups, String sub, String email, boolean verified, + PolicyRules claim, boolean claimPresent, PolicyCatalog catalog, Collection sessionPermissions) + { + Detailed detailed = resolveDetailed(qInstance, groups, sub, email, verified); + boolean voided = claim.isVoid() || detailed.denials().isVoid(); + boolean noPolicyInformation = detailed.unavailable() && !claimPresent; + PolicyRules denials = voided ? PolicyRules.EMPTY : PolicyRules.merge(List.of(claim.denialsOnly(), detailed.denials().denialsOnly())); if(voided) { LOG.warn("An invalid policy voids this user's policy grants", logPair("source", claim.isVoid() ? "token claim" : "group graph")); } - else + + Set fromPolicies = new TreeSet<>(); + if(!voided && !noPolicyInformation) { - denials = PolicyRules.merge(List.of(claim.denialsOnly(), detailed.denials().denialsOnly())); - Set fromPolicies = new TreeSet<>(detailed.fromGroups()); + fromPolicies.addAll(detailed.fromGroups()); fromPolicies.addAll(claim.resolve(PolicyPermissions.universe(qInstance == null ? PolicyPermissions.instanceOf(catalog) : qInstance, Set.of()))); - fromPolicies.removeIf(permission -> claim.denies(permission) || detailed.denials().denies(permission)); - permissions.addAll(fromPolicies); + fromPolicies.removeIf(denials::denies); } + Set floor = new TreeSet<>(detailed.floor()); + Set host = new TreeSet<>(); + boolean enforced = true; if(runtime != null) { - permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups)); + floor.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups)); //////////////////////////////////////////////////////////////////////// // the cut-over guard (DESIGN.md section 12.8): while it blocks, the // // session gets today's access from the host plus the floor (static // - // fallbacks, bootstrap subjects), never the policies alone // + // fallbacks, bootstrap subjects), never the policies' own grants // //////////////////////////////////////////////////////////////////////// runtime.markMapperInvoked(); - AccessComparison.State state; - try - { - state = AccessComparison.state(runtime, client(qInstance)); - } - catch(Exception e) - { - state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now()); - } + AccessComparison.State state = guardState(qInstance, runtime); enforced = state.enforced(); - if(!state.enforced()) + if(!enforced) { - Set floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups)); - for(String group : groups) - { - floor.addAll(staticGroupPermissions.getOrDefault(group, List.of())); - } - if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified)) - { - for(IdentityPermission permission : IdentityPermission.values()) - { - floor.add(permission.getSessionPermission()); - } - } AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource(); if(source != null) { - floor.addAll(source.userPermissions(email, groups)); + host.addAll(source.userPermissions(email, groups)); } LOG.warn("Policies are not enforced at this sign-in; granting today's access", logPair("reason", state.message())); - permissions = floor; } } - if(qSession.getPermissions() != null) + + if(voided) { - /////////////////////////////////////////////////////////////////////// - // permissions an earlier customizer put on the session: while the // - // policies are enforced a Deny removes them and an invalid policy // - // voids them; while the cut-over guard blocks, nothing is subtracted // - /////////////////////////////////////////////////////////////////////// - Set existing = new TreeSet<>(qSession.getPermissions()); - if(enforced && voided) + if(enforced) { - existing.clear(); + floor.clear(); } - else if(enforced) + else { - existing.removeIf(denials::denies); + LOG.error("A user with an invalid policy signs in while the cut-over guard blocks; today's access is granted without their Deny statements. Fix the policy.", logPair("groups", groups)); } - permissions.addAll(existing); } - qSession.setPermissions(permissions); - com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims); + else + { + floor.removeIf(denials::denies); + host.removeIf(denials::denies); + } + + Set existing = new TreeSet<>(sessionPermissions == null ? Set.of() : sessionPermissions); + if(enforced && (voided || noPolicyInformation)) + { + existing.clear(); + } + else if(!voided) + { + existing.removeIf(denials::denies); + } + + Set permissions = new TreeSet<>(detailed.bootstrap()); + permissions.addAll(floor); + permissions.addAll(enforced ? fromPolicies : host); + permissions.addAll(existing); + return (permissions); + } + + + + /******************************************************************************* + ** The cut-over guard state; when the guard cannot run, the last known state + ** stands, and with none it blocks. + *******************************************************************************/ + private AccessComparison.State guardState(QInstance qInstance, AuthentikRuntime runtime) + { + try + { + return (AccessComparison.state(runtime, client(qInstance))); + } + catch(Exception e) + { + AccessComparison.State last = runtime.getMapperState(); + LOG.error("The cut-over guard could not run; keeping the last known state", logPair("hadState", last != null)); + return (last != null ? last : new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now())); + } } @@ -229,6 +259,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust { AuthentikRuntime runtime = runtime(); Set permissions = new TreeSet<>(); + boolean computed = false; String email = userId == null ? "" : userId.toString().trim(); try { @@ -272,12 +303,9 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust String sub = runtime != null && runtime.getConfig().getResolvedBootstrapSubjects().contains(pk) ? pk : found.optString("uuid", pk); String userEmail = found.getString("email").trim(); boolean verified = found.optBoolean("email_verified", false); - permissions.addAll(resolve(qInstance, groups, sub, userEmail, verified)); - if(runtime != null) - { - permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups)); - permissions = automatedSessionPermissions(qInstance, runtime, permissions, groups, sub, userEmail, verified); - } + PolicyCatalog catalog = qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance); + permissions = compute(qInstance, runtime, groups, sub, userEmail, verified, PolicyRules.EMPTY, false, catalog, session.getPermissions()); + computed = true; } else { @@ -287,14 +315,11 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust catch(Exception e) { permissions.clear(); + computed = false; // Exception messages can contain the lookup email; log only the type. LOG.warn("Authentik could not be read for an automated session; granting only the static fallbacks", logPair("errorType", e.getClass().getSimpleName())); } - if(runtime != null) - { - permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), List.of())); - } - if(session.getPermissions() != null) + if(!computed && session.getPermissions() != null) { permissions.addAll(session.getPermissions()); } @@ -303,52 +328,11 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust - /******************************************************************************* - ** Mirror the sign-in cut-over floor without changing the claims path. - *******************************************************************************/ - private Set automatedSessionPermissions(QInstance qInstance, AuthentikRuntime runtime, Set permissions, - List groups, String sub, String email, boolean verified) - { - runtime.markMapperInvoked(); - AccessComparison.State state; - try - { - state = AccessComparison.state(runtime, client(qInstance)); - } - catch(Exception e) - { - state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now()); - } - if(state.enforced()) - { - return (permissions); - } - Set floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups)); - for(String group : groups) - { - floor.addAll(staticGroupPermissions.getOrDefault(group, List.of())); - } - if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified)) - { - for(IdentityPermission permission : IdentityPermission.values()) - { - floor.add(permission.getSessionPermission()); - } - } - AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource(); - if(source != null) - { - floor.addAll(source.userPermissions(email, groups)); - } - LOG.warn("Policies are not enforced for this automated session; granting today's access", logPair("status", state.status())); - return (floor); - } - - - /******************************************************************************* ** The permissions for a user's group names and token identity (sub, email, ** email_verified). Bootstrap operators are matched on immutable ids only. + ** The static group fallbacks are subject to a Deny like any other grant; + ** the bootstrap identity permissions are not. ** ** @deprecated a plain set cannot tell void from absent or carry a Deny to the ** host's other sources; use resolveResult(...).apply(others) @@ -356,63 +340,82 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust @Deprecated public Set resolve(QInstance qInstance, Collection groupNames, String sub, String email, boolean emailVerified) { - Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified); - Set rs = new TreeSet<>(detailed.floor()); - rs.addAll(detailed.fromGroups()); + Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified); + Set floor = new TreeSet<>(detailed.floor()); + if(detailed.denials().isVoid()) + { + floor.clear(); + } + else + { + floor.removeIf(detailed.denials()::denies); + } + Set rs = new TreeSet<>(detailed.bootstrap()); + rs.addAll(floor); + rs.addAll(detailed.denials().isVoid() ? Set.of() : detailed.fromGroups()); return (rs); } /******************************************************************************* - ** The typed policy result for a user's groups: the Deny rules (void when a - ** policy is invalid or a group is not in the graph) and what the groups' - ** policies and qqq.io/permissions grant. The static and bootstrap floor is NOT - ** included; pass it, and the host's own service permissions, to - ** PolicyResult.apply so a Deny cannot be bypassed. + ** The typed policy result for a user's groups: the full rules (void when a + ** policy is invalid or a group or parent is not in the graph) and what the + ** groups' policies and qqq.io/permissions grant; UNAVAILABLE when the graph + ** cannot be read. The static and bootstrap floor is NOT included; pass it, and + ** the host's own service permissions, to PolicyResult.apply so a Deny cannot + ** be bypassed. *******************************************************************************/ public PolicyResult resolveResult(QInstance qInstance, Collection groupNames, String sub, String email, boolean emailVerified) { Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified); - return (new PolicyResult(detailed.denials(), detailed.denials().isVoid() ? Set.of() : detailed.fromGroups())); + if(detailed.unavailable()) + { + return (PolicyResult.unreadable()); + } + return (new PolicyResult(detailed.rules(), detailed.denials().isVoid() ? Set.of() : detailed.fromGroups())); } /******************************************************************************* - ** What resolve() found, kept apart so a Deny and an invalid policy can be - ** applied across sources. + ** What the graph read found, kept apart so a Deny, an invalid policy and an + ** unreadable graph can each be applied across sources. ** - ** @param floor static group permissions and bootstrap identity permissions - ** (host-owned, not subtracted by a policy Deny) - ** @param fromGroups the permissions the groups' policies and qqq.io/permissions - ** grant, with Deny already applied; empty when voided - ** @param denials the Deny side of the user's policies (void when any is invalid) + ** @param floor static group permissions (host-owned, subject to a Deny) + ** @param bootstrap bootstrap identity permissions (break-glass, exempt) + ** @param fromGroups what the groups' policies and qqq.io/permissions grant, with + ** Deny already applied; empty when voided or unavailable + ** @param denials the Deny side of the user's policies (void when any is + ** invalid or a group or parent is missing); empty when unavailable + ** @param rules the full merged rules (Allow and Deny) + ** @param unavailable the graph could not be read: nothing is known about Deny *******************************************************************************/ - private record Detailed(Set floor, Set fromGroups, PolicyRules denials) + private record Detailed(Set floor, Set bootstrap, Set fromGroups, PolicyRules denials, PolicyRules rules, boolean unavailable) { } /******************************************************************************* - ** Resolve into floor, group-derived grants and denials. + ** Resolve into floor, bootstrap, group-derived grants and denials. *******************************************************************************/ private Detailed resolveDetailed(QInstance qInstance, Collection groupNames, String sub, String email, boolean emailVerified) { - Set rs = new TreeSet<>(); + Set floor = new TreeSet<>(); for(String group : groupNames) { - rs.addAll(staticGroupPermissions.getOrDefault(group, List.of())); + floor.addAll(staticGroupPermissions.getOrDefault(group, List.of())); } - AuthentikRuntime runtime = runtime(); + Set bootstrap = new TreeSet<>(); + AuthentikRuntime runtime = runtime(); if(runtime != null && !runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, emailVerified)) { LOG.warn("bootstrapOperatorSubjects grants all identity permissions; empty it once an identity administrator is set up in Authentik", logPair("sub", sub)); for(IdentityPermission permission : IdentityPermission.values()) { - rs.add(permission.getSessionPermission()); + bootstrap.add(permission.getSessionPermission()); } } @@ -432,7 +435,8 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust // void the policy grants rather than skip it // /////////////////////////////////////////////////////////////////// LOG.warn("A group of the user is not in the Authentik group graph; its policy cannot be read, so the user's policy grants are voided"); - return (new Detailed(rs, new TreeSet<>(), PolicyRules.voided("a group of the user is not in the group graph"))); + PolicyRules voided = PolicyRules.voided("a group of the user is not in the group graph"); + return (new Detailed(floor, bootstrap, new TreeSet<>(), voided, voided, false)); } pks.add(node.pk()); } @@ -441,13 +445,13 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust { LOG.info("Group attributes name permissions this app does not have", logPair("unknown", resolution.unknown())); } - return (new Detailed(rs, new TreeSet<>(resolution.sessionPermissions()), resolution.denials())); + return (new Detailed(floor, bootstrap, new TreeSet<>(resolution.sessionPermissions()), resolution.denials(), resolution.rules(), false)); } catch(Exception e) { LOG.warn("Authentik groups could not be read at sign-in; granting only the static fallbacks", logPair("error", AuthentikRedactor.redactText(e.getMessage(), runtime == null ? null : runtime.getConfig().getResolvedApiToken()))); } - return (new Detailed(rs, new TreeSet<>(), PolicyRules.EMPTY)); + return (new Detailed(floor, bootstrap, new TreeSet<>(), PolicyRules.EMPTY, PolicyRules.EMPTY, true)); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapper.java index ea2b4ee..baedbd9 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapper.java @@ -12,20 +12,27 @@ package com.kingsrook.qbits.authentik.permissions; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import com.kingsrook.qbits.authentik.AuthentikRuntime; +import com.kingsrook.qbits.authentik.policy.PolicyCatalog; +import com.kingsrook.qbits.authentik.policy.PolicyResult; +import com.kingsrook.qbits.authentik.policy.PolicyRules; import com.kingsrook.qbits.authentik.security.AuthentikSessionIdentity; import com.kingsrook.qbits.authentik.security.AuthentikTokenScopes; import com.kingsrook.qbits.authentik.security.IdentityPermission; import com.kingsrook.qbits.authentik.sync.AuthentikMirrorAccess; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; import org.json.JSONObject; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* ** Opt-in customizer for AuthentikRefreshingOAuth2Module. Replaces directory @@ -35,6 +42,8 @@ import org.json.JSONObject; ******************************************************************************/ public class AuthentikRefreshingPermissionMapper extends AuthentikGroupPermissionMapper { + private static final QLogger LOG = QLogger.getLogger(AuthentikRefreshingPermissionMapper.class); + static final String CLAIMS = "authentik.verifiedSessionClaims"; static final String BACKEND = "authentik.sessionBackend"; @@ -106,8 +115,9 @@ public class AuthentikRefreshingPermissionMapper extends AuthentikGroupPermissio throw (new QAuthenticationException("Current Authentik access is unavailable or denied.")); } var access = current.access(); - Set permissions = new TreeSet<>(configuredStaticPermissions(access.groups())); - permissions.addAll(AccessComparison.staticPermissions(config, access.groups())); + Set floor = new TreeSet<>(configuredStaticPermissions(access.groups())); + floor.addAll(AccessComparison.staticPermissions(config, access.groups())); + Set bootstrapNames = new TreeSet<>(); var bootstrap = config.getResolvedBootstrapSubjects(); String sub = claims.optString("sub", ""); if(bootstrap.contains(Integer.toString(identity.pk())) || bootstrap.contains(identity.uuid()) @@ -115,18 +125,48 @@ public class AuthentikRefreshingPermissionMapper extends AuthentikGroupPermissio { for(var permission : IdentityPermission.values()) { - permissions.add(permission.getSessionPermission()); + bootstrapNames.add(permission.getSessionPermission()); } } runtime.markMapperInvoked(); - if(enforced(instance)) + boolean enforced = enforced(instance); + Set host = new TreeSet<>(); + if(!enforced && config.getHostPermissionSource() != null) { - permissions.addAll(access.permissions()); + host.addAll(config.getHostPermissionSource().userPermissions(access.email() == null ? "" : access.email(), access.groups())); } - else if(config.getHostPermissionSource() != null) + + /////////////////////////////////////////////////////////////////////// + // the mirror's permissions already have every Deny applied; the // + // static and host floors get the same treatment (bootstrap is the // + // only exemption). Without knowledge of the Deny rules, fail closed. // + /////////////////////////////////////////////////////////////////////// + PolicyResult policy = policyResult(instance, access.groups(), sub, access.email()); + if(policy.isUnavailable()) { - permissions.addAll(config.getHostPermissionSource().userPermissions(access.email() == null ? "" : access.email(), access.groups())); + LOG.warn("The group policies could not be read, so no Deny can be applied to the static and host floors; they are withheld"); + floor.clear(); + host.clear(); } + else if(policy.isVoid()) + { + if(enforced) + { + floor.clear(); + } + else + { + LOG.error("A user with an invalid policy refreshes while the cut-over guard blocks; today's access is granted without their Deny statements. Fix the policy.", logPair("groups", access.groups())); + } + } + else + { + floor.removeIf(policy.rules()::denies); + host.removeIf(policy.rules()::denies); + } + Set permissions = new TreeSet<>(bootstrapNames); + permissions.addAll(floor); + permissions.addAll(enforced ? access.permissions() : host); session.setPermissions(permissions); AuthentikTokenScopes.apply(instance, session, claims); } @@ -149,6 +189,44 @@ public class AuthentikRefreshingPermissionMapper extends AuthentikGroupPermissio return (new AuthentikMirrorAccess(runtime().getConfig(), Duration.ofMinutes(15)).read(identity.pk(), identity.uuid())); } + /*************************************************************************** + ** The typed policy result of the user's groups (for the Deny rules applied to + ** the floors). A seam: the default reads the live group graph. + ***************************************************************************/ + protected PolicyResult policyResult(QInstance instance, Set groupNames, String sub, String email) + { + try + { + AuthentikRuntime runtime = runtime(); + GroupGraph graph = GroupGraph.fromGroups(readMirrorGroups(), runtime.getConfig().getPolicyFormat(), PolicyCatalog.of(instance)); + List pks = new ArrayList<>(); + for(String name : groupNames) + { + GroupGraph.Node node = graph.byName(name); + if(node == null) + { + return (new PolicyResult(PolicyRules.voided("a group of the user is not in the mirror"), Set.of())); + } + pks.add(node.pk()); + } + EffectivePermissionResolver.Resolution resolution = new EffectivePermissionResolver(graph, runtime.getPermissionCatalog(), instance).resolveGroups(pks); + return (new PolicyResult(resolution.rules(), resolution.voided() ? Set.of() : resolution.sessionPermissions())); + } + catch(Exception e) + { + return (PolicyResult.unreadable()); + } + } + + /*************************************************************************** + ** The mirrored groups (a seam; no Authentik API call, so an API outage does + ** not change what a refresh can do). + ***************************************************************************/ + protected List readMirrorGroups() throws Exception + { + return (new AuthentikMirrorAccess(runtime().getConfig(), Duration.ofMinutes(15)).readGroups()); + } + /*************************************************************************** ** Preserve the existing policy cut-over guard and host-permission floor. ***************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java index 20c805e..93b1c66 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java @@ -69,8 +69,17 @@ public class EffectivePermissionResolver /******************************************************************************* ** Result for a set of groups. *******************************************************************************/ - public record Resolution(Set sessionPermissions, Map> grantedBy, Set unknown, Set appAccess, PolicyRules denials) + public record Resolution(Set sessionPermissions, Map> grantedBy, Set unknown, Set appAccess, PolicyRules denials, PolicyRules rules) { + /******************************************************************************* + ** A resolution whose rules are only its denials. + *******************************************************************************/ + public Resolution(Set sessionPermissions, Map> grantedBy, Set unknown, Set appAccess, PolicyRules denials) + { + this(sessionPermissions, grantedBy, unknown, appAccess, denials, denials); + } + + /******************************************************************************* ** A resolution with no policy denials. *******************************************************************************/ @@ -162,7 +171,8 @@ public class EffectivePermissionResolver List policies = new ArrayList<>(); PolicyUniverse live = memoized(universe()); - Set visited = new LinkedHashSet<>(); + Set visited = new LinkedHashSet<>(); + boolean missingParent = false; for(String pk : groupPks) { for(GroupGraph.Node node : graph.selfAndAncestors(pk)) @@ -171,6 +181,10 @@ public class EffectivePermissionResolver { continue; } + for(String parent : node.parentPks()) + { + missingParent |= graph.byPk(parent) == null; + } appAccess.addAll(node.appAccess()); for(String raw : node.permissions()) { @@ -191,14 +205,19 @@ public class EffectivePermissionResolver // one invalid policy voids every group of the user; a Deny anywhere // // removes what any source (policy or qqq.io/permissions) granted // //////////////////////////////////////////////////////////////////////// - PolicyRules denials = PolicyRules.merge(policies).denialsOnly(); + ////////////////////////////////////////////////////////////////////// + // a parent the graph does not know may hold a Deny: void, like a // + // missing group // + ////////////////////////////////////////////////////////////////////// + PolicyRules merged = missingParent ? PolicyRules.voided("a parent group is not in the group graph") : PolicyRules.merge(policies); + PolicyRules denials = merged.denialsOnly(); if(denials.isVoid()) { - return (new Resolution(new TreeSet<>(), new LinkedHashMap<>(), unknown, new TreeSet<>(), denials)); + return (new Resolution(new TreeSet<>(), new LinkedHashMap<>(), unknown, new TreeSet<>(), denials, merged)); } permissions.removeIf(denials::denies); grantedBy.keySet().removeIf(denials::denies); - return (new Resolution(permissions, grantedBy, unknown, appAccess, denials)); + return (new Resolution(permissions, grantedBy, unknown, appAccess, denials, merged)); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java index f73280f..fefe825 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java @@ -115,7 +115,11 @@ public final class PolicyCatalog private static final int CACHE_SIZE = 4; - /** The last few instances' catalogs, so sign-in does not rebuild one per call. */ + /** + * The last few instances' catalogs, so sign-in does not rebuild one per call. The map holds + * strong references (a catalog refers to its instance, so weak keys would never clear), but + * it is bounded to CACHE_SIZE entries; clearCache() releases them (tests, hot reload). + */ private static final Map CACHE = new LinkedHashMap<>(8, 0.75f, true) { @Override diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java index 23ae27e..3f58fd1 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java @@ -65,6 +65,8 @@ public final class PolicyParser private static final Set STATEMENT_KEYS = Set.of("Effect", "Action", "Resource", "Permission"); + private static final Set DOCUMENT_KEYS = Set.of("PolicySpec", "PolicyVersion", "Statement"); + /******************************************************************************* @@ -199,6 +201,14 @@ public final class PolicyParser throw (new PolicyParseException("PolicyVersion must be \"" + f.version() + "\" (found " + describe(version) + ")")); } + for(String key : json.keySet()) + { + if(!DOCUMENT_KEYS.contains(key)) + { + throw (new PolicyParseException("unknown top-level key \"" + shorten(key) + "\" (only PolicySpec, PolicyVersion and Statement are supported; a misspelled Statement would otherwise read as an empty policy)")); + } + } + Set allow = new LinkedHashSet<>(); Set deny = new LinkedHashSet<>(); Set resources = new LinkedHashSet<>(); @@ -206,7 +216,7 @@ public final class PolicyParser Object statements = json.opt("Statement"); if(statements == null) { - return (new Parsed(PolicyRules.EMPTY, resources, Set.of())); + throw (new PolicyParseException("Statement is required (an array; an empty array grants nothing)")); } if(!(statements instanceof JSONArray array)) { diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java index 340d006..b78b721 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java @@ -33,9 +33,20 @@ import java.util.TreeSet; ** ** @param rules the typed Allow and Deny terms (void when any policy was invalid) ** @param granted Allow minus Deny, expanded against the live universe now; empty when void + ** @param unavailable the policies could not be read, so nothing is known about Deny *******************************************************************************/ -public record PolicyResult(PolicyRules rules, Set granted) +public record PolicyResult(PolicyRules rules, Set granted, boolean unavailable) { + /******************************************************************************* + ** A readable result (not unavailable). + *******************************************************************************/ + public PolicyResult(PolicyRules rules, Set granted) + { + this(rules, granted, false); + } + + + /******************************************************************************* ** The result of resolving rules against a universe. *******************************************************************************/ @@ -46,6 +57,17 @@ public record PolicyResult(PolicyRules rules, Set granted) + /******************************************************************************* + ** The policies could not be read (for example the group graph is unreachable), + ** so no Deny can be known: apply() gives nothing. + *******************************************************************************/ + public static PolicyResult unreadable() + { + return (new PolicyResult(PolicyRules.EMPTY, Set.of(), true)); + } + + + /******************************************************************************* ** Whether an invalid policy voided the user (they get no permissions). *******************************************************************************/ @@ -57,23 +79,35 @@ public record PolicyResult(PolicyRules rules, Set granted) /******************************************************************************* - ** Whether the user has no policy at all (neither Allow nor Deny, and not void). + ** Whether the policies could not be read. + *******************************************************************************/ + public boolean isUnavailable() + { + return (unavailable); + } + + + + /******************************************************************************* + ** Whether the user has no policy at all (neither Allow nor Deny; not void, + ** not unavailable). *******************************************************************************/ public boolean isAbsent() { - return (!rules.isVoid() && rules.isEmpty()); + return (!unavailable && !rules.isVoid() && rules.isEmpty()); } /******************************************************************************* ** The permissions granted here plus the other sources the host supplies, - ** minus every Deny. Empty when void: an invalid policy voids all of them. - ** The other sources are not modified. + ** minus every Deny. Empty when void or unavailable: an invalid or unreadable + ** policy never lets other sources through unfiltered. The other sources are + ** not modified. *******************************************************************************/ public Set apply(Collection otherSources) { - if(rules.isVoid()) + if(rules.isVoid() || unavailable) { return (new TreeSet<>()); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java index 1e98cc0..f97c897 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java +++ b/src/main/java/com/kingsrook/qbits/authentik/processes/AuthentikProcesses.java @@ -649,7 +649,7 @@ public final class AuthentikProcesses case EDIT_USER_PERMISSIONS -> { JSONObject group = admin.userAccessGroup(pk); - output.addValue("expectedFingerprint", group == null ? "absent" : AuthentikSafety.groupFingerprint(group)); + output.addValue("expectedFingerprint", group == null ? "absent" : AuthentikSafety.groupFingerprint(group, admin.getUtil().getRuntime().getConfig().getPolicyFormat().attribute())); List permissions = group == null ? null : AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS); output.addValue("permissions", permissions == null ? "" : String.join("\n", permissions)); } @@ -659,7 +659,7 @@ public final class AuthentikProcesses output.addValue(PolicyEditorWidget.GROUP_PK_PARAM, pk); if(group != null) { - output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group)); + output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group, admin.getUtil().getRuntime().getConfig().getPolicyFormat().attribute())); } } case EDIT_USER -> @@ -683,7 +683,7 @@ public final class AuthentikProcesses JSONObject group = admin.getUtil().getApiJsonOrNull("/core/groups/" + pk + "/?include_users=true&include_parents=true&include_children=true"); if(group != null) { - output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group)); + output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group, admin.getUtil().getRuntime().getConfig().getPolicyFormat().attribute())); if(def.action() == AdminAction.EDIT_GROUP) { if(canSeeAttributes()) diff --git a/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorAccess.java b/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorAccess.java index 7bf898c..81e30ec 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorAccess.java +++ b/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorAccess.java @@ -38,6 +38,7 @@ public final class AuthentikMirrorAccess private final Duration maximumAge; private final String userTable; private final String stateTable; + private final String groupTable; /*************************************************************************** ** READY supplies current grants; DENIED is an authoritative inactive or @@ -99,7 +100,8 @@ public final class AuthentikMirrorAccess this.maximumAge = maximumAge; userTable = config.applyTablePrefix(AuthentikSyncTables.USER); stateTable = config.applyTablePrefix(AuthentikSyncTables.STATE); - if(config.getLocalBackendName() == null || !userTable.matches("[A-Za-z][A-Za-z0-9_]*") || !stateTable.matches("[A-Za-z][A-Za-z0-9_]*")) + groupTable = config.applyTablePrefix(AuthentikSyncTables.GROUP); + if(config.getLocalBackendName() == null || !userTable.matches("[A-Za-z][A-Za-z0-9_]*") || !stateTable.matches("[A-Za-z][A-Za-z0-9_]*") || !groupTable.matches("[A-Za-z][A-Za-z0-9_]*")) { throw (new IllegalArgumentException("A valid local mirror backend and table prefix are required.")); } @@ -166,6 +168,31 @@ public final class AuthentikMirrorAccess } } + /*************************************************************************** + ** The mirrored groups as Authentik-shaped objects (pk, name, parents and the + ** mirrored attributes, which include the policy document), so the Deny rules of + ** a user's groups can be read without the Authentik API. Internal context only. + ***************************************************************************/ + public java.util.List readGroups() throws SQLException, QPermissionDeniedException + { + if(!(QContext.getQSession() instanceof QSystemUserSession)) + { + throw (new QPermissionDeniedException("Session mirror reads require the internal authentication context.")); + } + java.util.List groups = new java.util.ArrayList<>(); + try(var connection = connections.open(); var statement = connection.prepareStatement("SELECT pk, name, parent_pks, attributes FROM " + groupTable); + var rows = statement.executeQuery()) + { + while(rows.next() && groups.size() <= 50_000) + { + org.json.JSONObject attributes = rows.getString(4) == null || rows.getString(4).isBlank() ? new org.json.JSONObject() : new org.json.JSONObject(rows.getString(4)); + JSONArray parents = rows.getString(3) == null || rows.getString(3).isBlank() ? new JSONArray() : new JSONArray(rows.getString(3)); + groups.add(new org.json.JSONObject().put("pk", rows.getString(1)).put("name", rows.getString(2)).put("parents", parents).put("attributes", attributes)); + } + } + return (groups); + } + /*************************************************************************** ** Strict bounded JSON, never coercing objects/numbers into permission IDs. ***************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java index a99cf9c..9e16f50 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java @@ -197,6 +197,17 @@ class PolicyCutoverTest new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, blocked, context); assertThat(blocked.getPermissions()).contains("cms_page.read", "cms_page.edit", "cms.publish.hasAccess"); + /////////////////////////////////////////////////////////////////////// + // blocked still means today's access, minus every valid Deny // + /////////////////////////////////////////////////////////////////////// + Map denyContext = new HashMap<>(context); + denyContext.put("idToken", new JSONObject().put("qqq_policy", new JSONObject( + "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"edit\"],\"Resource\":[\"cms_page\"]}]}"))); + runtime.setMapperState(null); + QSession blockedWithDeny = new QSession(); + new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, blockedWithDeny, denyContext); + assertThat(blockedWithDeny.getPermissions()).contains("cms_page.read", "cms.publish.hasAccess").doesNotContain("cms_page.edit"); + t.config.withStaticGroupPermissions(Map.of("kof-website-publishers", List.of("cms_page.read", "cms_page.edit", "cms.publish.hasAccess"))); t.config.withCutoverGuardGroups(List.of()); t.authentik.groupByName("kof-website-publishers").getJSONObject("attributes").put("qqq.io/policy", @@ -315,4 +326,28 @@ class PolicyCutoverTest assertThat(voidRow.removed()).containsAll(voidRow.before()); } + + + + + /******************************************************************************* + ** When the guard cannot run (an Authentik error), the last known state stands + ** instead of flipping to BLOCKED; with no earlier state it blocks. + *******************************************************************************/ + @Test + void guardErrorKeepsTheLastKnownState() throws Exception + { + t.config.withMapperEnabled(true); + t.config.withStaticGroupPermissions(Map.of("identity-admins", List.copyOf(SOURCE.userPermissions(null, List.of("identity-admins"))))); + assertThat(state().status()).isEqualTo(AccessComparison.Status.ACTIVE); + + AuthentikActionUtil failing = org.mockito.Mockito.spy(AuthentikActionUtil.forBackend(t.config.getApiBackendName())); + org.mockito.Mockito.doThrow(new com.kingsrook.qqq.backend.core.exceptions.QException("Authentik 503")).when(failing).groupGraphWithMembers(); + runtime.setMapperState(new AccessComparison.State(AccessComparison.Status.ACTIVE, "earlier", java.time.Instant.now().minusSeconds(600))); + assertThat(AccessComparison.state(runtime, failing).status()).as("keeps the last known state").isEqualTo(AccessComparison.Status.ACTIVE); + + runtime.setMapperState(null); + assertThat(AccessComparison.state(runtime, failing).status()).as("no earlier state: block").isEqualTo(AccessComparison.Status.BLOCKED); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java index 27a2f60..39c2c65 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java @@ -824,7 +824,10 @@ class PolicyEditorTest void savingOverAnInvalidStoredPolicyIsAGrant() throws Exception { setPolicy("kof-website-analysts", policyOf(DENY_PAGE_ALL, BAD_STATEMENT)); - assertThat(save("kof-website-analysts", PAGE_READ_EDIT).message()).contains("do not hold yourself"); + String message = save("kof-website-analysts", PAGE_READ_EDIT).message(); + assertThat(message).contains("do not hold yourself"); + assertThat(message).as("only the release of the void group can produce this name").contains("cms_page.delete"); + assertThat(message).as("the operator holds what the document adds").doesNotContain("cms_page.read"); } @@ -940,28 +943,29 @@ class PolicyEditorTest /******************************************************************************* - ** Host-owned floors are not subtracted by a Deny: static group fallbacks and - ** bootstrap identity permissions (documented in POLICY-FORMAT.md). + ** A Deny subtracts static group fallbacks too; only the bootstrap identity + ** permissions (break-glass) are exempt (documented in POLICY-FORMAT.md). *******************************************************************************/ @Test - void staticFallbackAndBootstrapAreNotSubtracted() throws Exception + void staticFallbackIsSubtractedBootstrapIsNot() throws Exception { t.authentik.user(10, "boot", "boot@example.test", "internal", true, "identity-admins"); t.config.withStaticGroupPermissions(Map.of("kof-website-analysts", List.of("cms_page.read"))); t.config.withBootstrapOperatorSubjects("10"); String deny = policyOf("{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}", DENY_USERS); QSession session = signIn(deny, null, "10", "kof-website-analysts", "identity-admins"); - assertThat(session.getPermissions()).contains("cms_page.read", "authentik.users.manage.hasAccess"); + assertThat(session.getPermissions()).as("a static group fallback is a grant like any other").doesNotContain("cms_page.read"); + assertThat(session.getPermissions()).as("only bootstrap (break-glass) is exempt").contains("authentik.users.manage.hasAccess"); } /******************************************************************************* - ** While the cut-over guard blocks, sign-in gets today's access from the host - ** and no Deny is applied to it (documented). + ** While the cut-over guard blocks, sign-in gets today's access from the host, + ** minus every valid Deny (a Deny cannot defeat the guard's purpose). *******************************************************************************/ @Test - void guardBlockedBranchGrantsTodaysAccessWithoutDeny() throws Exception + void guardBlockedBranchStillAppliesAValidDeny() throws Exception { t.config.withCutoverGuardGroups(List.of("kof-website-analysts")); t.config.withHostPermissionSource((email, groups) -> Set.of("cms_page.read")); @@ -973,7 +977,17 @@ class PolicyEditorTest .put("qqq_policy", new JSONObject(policyOf("{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}"))); QSession session = new QSession(); new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims)); - assertThat(session.getPermissions()).as("the guard blocks, so today's host access stands").contains("cms_page.read"); + assertThat(session.getPermissions()).as("a Deny only removes access: it is applied even while the guard blocks").doesNotContain("cms_page.read"); + + /////////////////////////////////////////////////////////////////////// + // one invalid policy elsewhere blocks the guard but must not switch // + // off the valid Deny of a different user // + /////////////////////////////////////////////////////////////////////// + setPolicy("kof-website-publishers", policyOf(BAD_STATEMENT)); + t.admin().getUtil().getRuntime().setMapperState(null); + QSession again = new QSession(); + new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, again, Map.of("jwtPayloadJsonObject", claims)); + assertThat(again.getPermissions()).doesNotContain("cms_page.read"); } @@ -995,4 +1009,65 @@ class PolicyEditorTest assertThat(unknown.apply(Set.of("service.send"))).isEmpty(); } + + + + + /******************************************************************************* + ** A Deny in a grandparent is released when the link to its child is removed. + *******************************************************************************/ + @Test + void removingTheLinkBelowAGrandparentDenyIsAGrant() throws Exception + { + setPolicy("kof-website-analysts", policyOf(DENY_PAGE_ALL)); + t.authentik.group("kof-middle", false, List.of(), List.of(), "kof-website-analysts"); + t.authentik.group("kof-leaf", false, List.of(), List.of(), "kof-middle"); + t.admin().getUtil().getRuntime().invalidateCache(); + AuthentikAdmin.AdminResult refused = t.admin().execute(AdminRequest.of(AdminAction.EDIT_GROUP, t.authentik.pk("kof-leaf"), "x").with("parents", "none")); + assertThat(refused.message()).contains("withholds permissions you do not hold yourself", "cms_page.delete"); + } + + + + /******************************************************************************* + ** The group fingerprint covers a custom policy attribute outside qqq.io/. + *******************************************************************************/ + @Test + void fingerprintCoversTheConfiguredPolicyAttribute() throws Exception + { + t.config.withPolicyAttribute("myapp/policy"); + String group = "kof-website-analysts"; + t.authentik.groupByName(group).getJSONObject("attributes").put("myapp/policy", policyOf(DENY_PAGE_ALL)); + t.admin().getUtil().getRuntime().invalidateCache(); + String before = com.kingsrook.qbits.authentik.admin.AuthentikSafety.groupFingerprint(t.authentik.groupByName(group), "myapp/policy"); + t.authentik.groupByName(group).getJSONObject("attributes").put("myapp/policy", policyOf(DENY_PAGE_DELETE)); + t.admin().getUtil().getRuntime().invalidateCache(); + assertThat(com.kingsrook.qbits.authentik.admin.AuthentikSafety.groupFingerprint(t.authentik.groupByName(group), "myapp/policy")).isNotEqualTo(before); + AuthentikAdmin.AdminResult stale = t.admin().execute(AdminRequest.of(AdminAction.SAVE_POLICY, t.authentik.pk(group), "because") + .with(AuthentikAdmin.POLICY_DOCUMENT, policyOf(DENY_PAGE_ALL)).with("expectedFingerprint", before)); + assertThat(stale.message()).contains("was changed"); + } + + + + /******************************************************************************* + ** resolveResult reports the full rules: a user with only an Allow is neither + ** absent nor void. + *******************************************************************************/ + @Test + void resolveResultReportsAbsentAndVoidFromTheFullRules() throws Exception + { + AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper(); + setPolicy("kof-website-analysts", PAGE_READ_EDIT); + com.kingsrook.qbits.authentik.policy.PolicyResult allowOnly = mapper.resolveResult(t.qInstance, List.of("kof-website-analysts"), "100", "x@example.test", true); + assertThat(allowOnly.isAbsent()).isFalse(); + assertThat(allowOnly.isVoid()).isFalse(); + assertThat(allowOnly.rules().hasDeny()).isFalse(); + assertThat(allowOnly.rules().isEmpty()).isFalse(); + setPolicy("kof-website-analysts", NO_STATEMENTS); + t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes").remove("qqq.io/policy"); + t.admin().getUtil().getRuntime().invalidateCache(); + assertThat(mapper.resolveResult(t.qInstance, List.of("kof-website-analysts"), "100", "x@example.test", true).isAbsent()).isTrue(); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java index 230ae48..4a23091 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java @@ -516,4 +516,29 @@ class AuthentikGroupPermissionMapperTest assertThat(noClaim.getPermissions()).doesNotContain("other.edit", "cms_page.read"); } + + + + + /******************************************************************************* + ** The graph cannot be read and there is no claim: no Deny can be known, so + ** nothing from policy is granted and the session's earlier permissions are + ** treated as voided (only the static fallbacks remain); resolveResult reports + ** UNAVAILABLE and apply() never passes other sources through. + *******************************************************************************/ + @Test + void graphUnreadableWithNoClaimVoidsExistingAndIsUnavailable() + { + FixtureActionUtil.groupGraphBody = "not json"; + AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("cms.publish")); + QSession session = new QSession(); + session.setPermissions(Set.of("other.keep")); + mapper.customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins"))); + assertThat(session.getPermissions()).containsExactly("cms.publish"); + + com.kingsrook.qbits.authentik.policy.PolicyResult result = mapper.resolveResult(qInstance, List.of("phase1-child-of-identity-admins"), "s", "phase1@example.test", true); + assertThat(result.isUnavailable()).isTrue(); + assertThat(result.apply(Set.of("service.send"))).isEmpty(); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapperTest.java index 0baa1be..30da2dd 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapperTest.java @@ -205,6 +205,46 @@ class AuthentikRefreshingPermissionMapperTest new AuthentikMirrorAccess.Access(groups, permissions, Instant.now(), "current@example.test"))); } + /******************************************************************************* + ** The static and host floors are subject to a Deny (bootstrap is the only + ** exemption); an invalid policy clears them while enforced; unreadable + ** policies withhold them. + *******************************************************************************/ + @Test + void denyAppliesToStaticAndHostFloors() throws Exception + { + var config = AuthentikTestSupport.unitConfig().withHostPermissionSource((email, groups) -> Set.of("host.today.hasAccess", "host.denied.hasAccess")); + QInstance instance = AuthentikTestSupport.produce(config); + QSession session = new QSession(); + QContext.init(instance, session); + CurrentMapper mapper = new CurrentMapper(); + mapper.withGroupPermissions("Readers", List.of("static.keep.hasAccess", "static.denied.hasAccess")); + mapper.current = ready(Set.of("Readers"), Set.of("mirror.hasAccess")); + var deny = com.kingsrook.qbits.authentik.policy.PolicyRules.of(Set.of(), Set.of( + new com.kingsrook.qbits.authentik.policy.PolicyTerm.Exact("static.denied.hasAccess"), new com.kingsrook.qbits.authentik.policy.PolicyTerm.Exact("host.denied.hasAccess"))); + mapper.policy = new com.kingsrook.qbits.authentik.policy.PolicyResult(deny, Set.of()); + + mapper.active = true; + mapper.customizeSession(instance, session, Map.of(AuthentikGroupPermissionMapper.CLAIMS_CONTEXT_KEY, claims().put("groups", List.of("Readers")))); + assertThat(session.getPermissions()).containsExactlyInAnyOrder("static.keep.hasAccess", "mirror.hasAccess"); + + mapper.active = false; + mapper.refresh(instance, session); + assertThat(session.getPermissions()).as("blocked: today's host access minus the Deny").containsExactlyInAnyOrder("static.keep.hasAccess", "host.today.hasAccess"); + + mapper.active = true; + mapper.policy = new com.kingsrook.qbits.authentik.policy.PolicyResult(com.kingsrook.qbits.authentik.policy.PolicyRules.voided("bad"), Set.of()); + mapper.refresh(instance, session); + assertThat(session.getPermissions()).as("enforced and void: no floors").containsExactly("mirror.hasAccess"); + + mapper.policy = com.kingsrook.qbits.authentik.policy.PolicyResult.unreadable(); + mapper.refresh(instance, session); + assertThat(session.getPermissions()).as("policies unreadable: no floors").containsExactly("mirror.hasAccess"); + } + + + + /*************************************************************************** ** Only storage and cut-over are isolated; grant replacement is production code. ***************************************************************************/ @@ -217,6 +257,14 @@ class AuthentikRefreshingPermissionMapperTest /*********************************************************************** ** Reader runs as the system and receives the signed immutable identity. ***********************************************************************/ + @Override + protected com.kingsrook.qbits.authentik.policy.PolicyResult policyResult(QInstance instance, Set groupNames, String sub, String email) + { + return (policy); + } + + private com.kingsrook.qbits.authentik.policy.PolicyResult policy = new com.kingsrook.qbits.authentik.policy.PolicyResult(com.kingsrook.qbits.authentik.policy.PolicyRules.EMPTY, Set.of()); + @Override protected AuthentikMirrorAccess.Result readCurrent(AuthentikSessionIdentity identity) { diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java index 8948fd6..03386d6 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java @@ -86,8 +86,7 @@ class BlueprintClaimExpressionTest @Test void badDocumentsVoidTheClaim() throws Exception { - String python = python(); - Assumptions.assumeTrue(python != null, "no python interpreter"); + String python = pythonOrFail(); String deny = "{\"Effect\":\"Deny\",\"Action\":[\"*\"],\"Resource\":[\"billing_*\"]}"; String allow = "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"billing_invoice\"]}"; @@ -105,15 +104,20 @@ class BlueprintClaimExpressionTest add(cases, names, "not an object", good, "[1,2]"); add(cases, names, "statement not a list", good, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":{}}"); - JSONArray claims = run(python, cases); + add(cases, names, "nested duplicate keys", good, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}"); + add(cases, names, "blank string", good, " "); + add(cases, names, "number", good, 5); + add(cases, names, "boolean", good, true); + add(cases, names, "list value", good, List.of(1, 2)); + JSONArray claims = run(python, cases, false); for(int i = 0; i < names.size(); i++) { PolicyRules rules = PolicyParser.parse(claims.getJSONObject(i).get("qqq_policy"), FORMAT); assertThat(rules.isVoid()).as(names.get(i)).isTrue(); } - List> valid = List.of(List.of(good, document("qqq-iam", "1.1.0", deny), new JSONObject(document("qqq-iam", "1.1.0", allow)).toMap())); - PolicyRules merged = PolicyParser.parse(run(python, valid).getJSONObject(0).get("qqq_policy"), FORMAT); + List> valid = List.of(List.of(good, document("qqq-iam", "1.1.0", deny), new JSONObject(document("qqq-iam", "1.1.0", allow)).toMap(), "")); + PolicyRules merged = PolicyParser.parse(run(python, valid, false).getJSONObject(0).get("qqq_policy"), FORMAT); assertThat(merged.isVoid()).isFalse(); assertThat(merged.hasDeny()).isTrue(); assertThat(merged.denies("billing_invoice.read")).isTrue(); @@ -121,6 +125,29 @@ class BlueprintClaimExpressionTest + /******************************************************************************* + ** With the opt-in, a document that declares ANOTHER spec is skipped; a missing + ** or non-string spec is still bad. + *******************************************************************************/ + @Test + void skipOtherSpecsIsAnExplicitOptIn() throws Exception + { + String python = pythonOrFail(); + String allow = "{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"billing_invoice\"]}"; + String good = document("qqq-iam", "1.1.0", allow); + String other = document("other-app", "9.9.9", allow); + String missing = document(null, "1.1.0", allow); + + PolicyRules byDefault = PolicyParser.parse(run(python, List.of(List.of(good, other)), false).getJSONObject(0).get("qqq_policy"), FORMAT); + assertThat(byDefault.isVoid()).as("off by default").isTrue(); + PolicyRules optedIn = PolicyParser.parse(run(python, List.of(List.of(good, other)), true).getJSONObject(0).get("qqq_policy"), FORMAT); + assertThat(optedIn.isVoid()).isFalse(); + assertThat(optedIn.allowTerms()).hasSize(1); + assertThat(PolicyParser.parse(run(python, List.of(List.of(good, missing)), true).getJSONObject(0).get("qqq_policy"), FORMAT).isVoid()).as("a missing spec is never skipped").isTrue(); + } + + + /******************************************************************************* ** Adds a case: every document is an attribute value of one group. *******************************************************************************/ @@ -135,7 +162,7 @@ class BlueprintClaimExpressionTest /******************************************************************************* ** The expression text of the blueprint, run once for all cases. *******************************************************************************/ - private static JSONArray run(String python, List> cases) throws IOException, InterruptedException + private static JSONArray run(String python, List> cases, boolean skipOtherSpecs) throws IOException, InterruptedException { String yaml; try(InputStream in = BlueprintClaimExpressionTest.class.getResourceAsStream("/blueprints/qqq-policy-claim.yaml")) @@ -155,12 +182,18 @@ class BlueprintClaimExpressionTest expression.append(line.length() >= 8 ? line.substring(8) : "").append("\n"); } } + String text = expression.toString(); + if(skipOtherSpecs) + { + assertThat(text).contains("skip_other_specs = False"); + text = text.replace("skip_other_specs = False", "skip_other_specs = True"); + } Path dir = Files.createTempDirectory("blueprint-claim"); try { Path source = dir.resolve("expression.txt"); Path harness = dir.resolve("harness.py"); - Files.writeString(source, expression.toString()); + Files.writeString(source, text); Files.writeString(harness, HARNESS); Process process = new ProcessBuilder(python, "-I", harness.toString(), source.toString()).redirectErrorStream(true).start(); JSONArray input = new JSONArray(); @@ -198,6 +231,23 @@ class BlueprintClaimExpressionTest + /******************************************************************************* + ** A python interpreter; the test FAILS without one (CI must have python) unless + ** BLUEPRINT_PYTHON_OPTIONAL=true opts out, in which case it is skipped. + *******************************************************************************/ + private static String pythonOrFail() + { + String python = python(); + if(python == null && "true".equalsIgnoreCase(System.getenv("BLUEPRINT_PYTHON_OPTIONAL"))) + { + Assumptions.assumeTrue(false, "no python interpreter (BLUEPRINT_PYTHON_OPTIONAL=true)"); + } + assertThat(python).as("python3 is required to run the blueprint expression (set BLUEPRINT_PYTHON_OPTIONAL=true to skip)").isNotNull(); + return (python); + } + + + /******************************************************************************* ** A working python interpreter, or null. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java index 903f23d..a6c29fe 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java @@ -404,4 +404,37 @@ class PolicyDenyTest assertThat(PolicyResult.of(PolicyRules.EMPTY, UNIVERSE).isAbsent()).isTrue(); } + + + + + /******************************************************************************* + ** A missing parent may hold a Deny, so it voids the user like a missing group. + *******************************************************************************/ + @Test + void missingParentVoidsTheUser() + { + JSONObject good = doc(List.of(stmt("Allow", "*", "billing_invoice"))); + List groups = List.of( + new JSONObject().put("pk", "1").put("name", "child").put("users", new JSONArray().put(9)).put("parents", new JSONArray().put("ghost")) + .put("attributes", new JSONObject().put(FORMAT.attribute(), good.toString()))); + GroupGraph graph = GroupGraph.fromGroups(groups, FORMAT, CATALOG); + EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null, null, UNIVERSE); + assertThat(resolver.resolveUser(9).voided()).isTrue(); + assertThat(resolver.resolveUser(9).sessionPermissions()).isEmpty(); + } + + + + /******************************************************************************* + ** A duplicate key is rejected by the parser too (so the blueprint and the + ** parser agree). + *******************************************************************************/ + @Test + void duplicateKeysAreRejectedByTheParserToo() + { + String dup = "{\"PolicySpec\":\"x\",\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[]}"; + assertThat(PolicyParser.parse(dup, FORMAT).isVoid()).isTrue(); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java index ed01f34..e40944e 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyParserTest.java @@ -106,7 +106,9 @@ class PolicyParserTest void emptyOrMissingStatementGrantsNothingWithoutError() throws Exception { assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(document(), FORMAT).rules())).isEmpty(); - assertThat(PolicyTestSupport.names(PolicyParser.parseStrict(new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0"), FORMAT).rules())).isEmpty(); + assertThatThrownBy(() -> PolicyParser.parseStrict(new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0"), FORMAT)).hasMessageContaining("Statement is required"); + assertThatThrownBy(() -> PolicyParser.parseStrict(new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0").put("Statments", new JSONArray()), FORMAT)).hasMessageContaining("top-level key"); + assertThatThrownBy(() -> PolicyParser.parseStrict(new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0").put("statement", new JSONArray()), FORMAT)).hasMessageContaining("top-level key"); assertThatThrownBy(() -> PolicyParser.parseStrict(document(statement("Allow", List.of(), List.of("a"))), FORMAT)).hasMessageContaining("empty"); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyVectorsTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyVectorsTest.java index 87b2316..340a77c 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyVectorsTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyVectorsTest.java @@ -169,8 +169,17 @@ class PolicyVectorsTest *******************************************************************************/ private static List documents(JSONObject vector) { - List rs = new ArrayList<>(); - JSONArray docs = vector.getJSONArray("documents"); + List rs = new ArrayList<>(); + if(vector.has("rawDocuments")) + { + JSONArray raw = vector.getJSONArray("rawDocuments"); + for(int i = 0; i < raw.length(); i++) + { + rs.add(raw.getJSONObject(i)); + } + return (rs); + } + JSONArray docs = vector.getJSONArray("documents"); for(int i = 0; i < docs.length(); i++) { rs.add(new JSONObject().put("PolicySpec", FORMAT.spec()).put("PolicyVersion", FORMAT.version()).put("Statement", docs.getJSONArray(i))); -- 2.54.0 From 81fa5b53bf409bb692a60dcaa68ebd19f34cc123 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 19:56:26 -0600 Subject: [PATCH 09/14] docs(policy): one shared session rule, void/unavailable, blueprint shape Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- docs/POLICY-FORMAT.md | 9 +++++++-- src/test/resources/blueprints/qqq-policy-claim.yaml | 4 ++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d93cf..731ab97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] -- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document under the configured attribute, a differently-specced one included (skipping other specs is an explicit opt-in). Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). A Deny subtracts static group fallbacks and the host source too, also while the cut-over guard blocks (only bootstrap identity permissions are exempt); a guard error keeps the last known state; an unreadable graph with no claim voids the session's earlier permissions; a missing parent voids; documents with an unknown top-level key or no `Statement` are rejected; the group fingerprint covers the configured policy attribute; the refreshing mapper reads Deny rules from the mirror (no API call). Review fixes: operators are credited with a floor permission only if their own policies do not deny it; the configured policy attribute is reserved against Edit Group/User attributes; un-voiding an invalid policy is a release; a Deny a person keeps deliberately no longer trips the cut-over guard; a Deny of a prefix or named resource also covers its dotted host names; empty Resource/Action/Permission arrays and malformed host names are rejected; Save Policy requires `expectedFingerprint`; `PolicyResult` / `resultFromClaims` / `resolveResult` replace the deprecated plain-set APIs. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`. +- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document under the configured attribute, a differently-specced one included (skipping other specs is an explicit opt-in). Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). One shared rule (`SessionPermissionRules`) for sign-in, automated sessions and the refreshing mapper: a Deny subtracts policy grants, the mirror's stored permissions, static fallbacks, the host source and earlier session permissions, also while the cut-over guard blocks (only bootstrap identity permissions are exempt); an invalid policy or a missing group/parent leaves break-glass only even while blocked; an unreadable graph/mirror with no claim leaves only the configured outage fallbacks; a guard error keeps the last known real state (never OFF, original time, 15 minute bound); the refreshing mapper reads Deny rules from the mirrored group rows on the same connection and refuses an inconsistent or truncated snapshot; the mirror stores the policy document unredacted; a missing parent voids; documents with an unknown top-level key or no `Statement` are rejected; the group fingerprint covers the configured policy attribute; the refreshing mapper reads Deny rules from the mirror (no API call). Review fixes: operators are credited with a floor permission only if their own policies do not deny it; the configured policy attribute is reserved against Edit Group/User attributes; un-voiding an invalid policy is a release; a Deny a person keeps deliberately no longer trips the cut-over guard; a Deny of a prefix or named resource also covers its dotted host names; empty Resource/Action/Permission arrays and malformed host names are rejected; Save Policy requires `expectedFingerprint`; `PolicyResult` / `resultFromClaims` / `resolveResult` replace the deprecated plain-set APIs. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`. - Fix: native **Create Group** now obeys the two-person gate. With `twoPersonApproval=true`, creating a group whose permissions expand to any `authentik.*` permission queues an `authentik_approval_request` for a second person instead of persisting the grant immediately; the same identity-permission expansion and second-person condition as Edit Group apply, nothing is written before approval, self-approval is refused and a decided request cannot be approved again. Previously a new group with `authentik.users.read` was created without approval. - **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255). - Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: `, `malformed ` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was. diff --git a/docs/POLICY-FORMAT.md b/docs/POLICY-FORMAT.md index 2ab03a2..6314ec2 100644 --- a/docs/POLICY-FORMAT.md +++ b/docs/POLICY-FORMAT.md @@ -79,9 +79,14 @@ A grant that QQQ never checks (for example `hasAccess` on a table with insert/ed **Editing.** Adding a `Deny` is always allowed to an operator with policy management. Removing or narrowing a `Deny` is a grant: the operator must hold every permission it was withholding and must not be denied any of them themselves. Removing a person from a group, or a group from a parent, releases the `Deny` statements of that group and above it, so it needs the same check. Saving a policy that contains a `Deny` also runs the last-administrator check. -**What a Deny subtracts.** A `Deny` removes what the user's groups grant through policy documents and `qqq.io/permissions`, the static group fallbacks, the host permission source, and the permissions an earlier customizer already put on the session. That holds whether the cut-over guard is enforcing or blocking: while it blocks, sign-in grants today's access (static floor plus host source) minus every valid `Deny`; a `Deny` only removes access, so it cannot defeat the guard's purpose. The ONLY exemption is the bootstrap-operator identity permissions (break-glass). A `Deny` that removes a permission from a guard-group member is a deliberate removal, so it never trips the guard by itself. If the guard cannot run (an Authentik error) the last known state stands; with none it blocks. +**What a Deny subtracts.** ONE rule, in `SessionPermissionRules.compute`, serves sign-in, automated sessions and the refreshing mapper, so they cannot disagree. By what is known about the user's policies: +- **Valid (known).** A `Deny` removes what the user's groups grant through policy documents and `qqq.io/permissions`, the mirror's stored effective permissions, the static group fallbacks, the host permission source, and the permissions an earlier customizer already put on the session. That holds whether the cut-over guard is enforcing or blocking: while it blocks, sign-in grants today's access (static floor plus host source) minus every valid `Deny`. A `Deny` only removes access, so the guard never switches it off. The ONLY exemption is the bootstrap-operator identity permissions (break-glass). A `Deny` that removes a permission from a guard-group member is a deliberate removal, so it never trips the guard by itself. If the guard cannot run (an Authentik error) its last known state stands (never OFF, with its original time, for at most 15 minutes); otherwise it blocks. +- **Void.** An invalid document, or a group or parent missing from the graph or mirror, leaves ONLY the bootstrap identity permissions: the floors, the host source, the mirror's stored grants and the session's earlier permissions are cleared, even while the guard blocks (the log carries an ERROR naming the groups). +- **Unavailable.** If the group graph (or mirror) cannot be read and the token carries no policy claim, no `Deny` can be known: the host source, the session's earlier permissions and any directory grants are withheld, and only the explicitly configured outage fallbacks (static group permissions) plus break-glass remain. If the claim is present it carries every group's merged policy and applies on its own. -**Unreadable and void.** If the group graph cannot be read and the token carries no policy claim, no `Deny` can be known: nothing comes from policy and the session's earlier permissions are treated as voided (the static fallbacks that exist for an Authentik outage remain). If the claim is present it carries every group's merged policy and applies on its own. A user's group, or a parent of one, that is missing from the graph voids the policy grants like an invalid document. `PolicyResult` reports `isVoid()`, `isUnavailable()` and `isAbsent()` separately, and `apply()` returns nothing when void or unavailable. +`PolicyResult` reports `isVoid()`, `isUnavailable()` and `isAbsent()` separately, and `apply()` returns nothing when void or unavailable. + +**Freshness of the group graph.** Sign-in reads the group graph through a cache (`groupCacheTtlSeconds`): a `Deny` added in the Authentik admin UI is not seen at sign-in until the TTL elapses, unless the claim carries it. The refreshing mapper reads the mirrored group rows, so it sees a `Deny` as of the last mirror checkpoint (at most 15 minutes old), read on the same connection as the user row and refused unless every group row carries that checkpoint's refresh time and the table is under its bound. **Void is not empty.** An invalid document voids the user: they get no permissions. Because a void group might have held a `Deny`, un-voiding is a release: removing a member from a void group, removing a parent link to it, or saving a valid document over an invalid stored one requires the operator to hold every permission that exists. Generate Policy refuses to run over an invalid stored document instead of discarding its `Deny`. diff --git a/src/test/resources/blueprints/qqq-policy-claim.yaml b/src/test/resources/blueprints/qqq-policy-claim.yaml index 296bdb9..72da11a 100644 --- a/src/test/resources/blueprints/qqq-policy-claim.yaml +++ b/src/test/resources/blueprints/qqq-policy-claim.yaml @@ -68,6 +68,10 @@ entries: continue bad = True break + # the same shape the qbit parser requires: only these three keys, Statement present + if "Statement" not in document or [key for key in document if key not in ("PolicySpec", "PolicyVersion", "Statement")]: + bad = True + break found = document.get("Statement") if document.get("PolicyVersion") != version or not isinstance(found, list): bad = True -- 2.54.0 From cd1ebc0f73933b65986457512472dda3ea29244e Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 19:56:28 -0600 Subject: [PATCH 10/14] fix(policy): one shared session-permission rule; Deny beats the mirror; void/unavailable SessionPermissionRules.compute serves sign-in, automated sessions and the refreshing mapper: void leaves break-glass only even while the guard blocks; unavailable leaves only configured outage fallbacks; known applies every Deny to policy grants, mirror permissions, static and host floors and earlier session permissions. The refreshing mapper reads Deny rules from the mirrored group rows on the same connection (checkpoint-consistent, bounded). Automated sessions void on an unknown group pk and no longer keep earlier permissions when unresolved. The guard keeps only a real last state (never OFF, original time, 15 min bound). The mirror stores the policy document unredacted. Blueprint rejects extra top-level keys and a missing Statement. Co-Authored-By: Claude Sonnet 5.5 --- .../permissions/AccessComparison.java | 37 +++++- .../AuthentikGroupPermissionMapper.java | 125 ++++++++---------- .../AuthentikRefreshingPermissionMapper.java | 61 ++++----- .../permissions/SessionPermissionRules.java | 121 +++++++++++++++++ .../authentik/sync/AuthentikMirrorAccess.java | 65 ++++++--- .../sync/AuthentikMirrorSnapshot.java | 19 ++- .../authentik/admin/PolicyCutoverTest.java | 13 +- .../authentik/admin/PolicyEditorTest.java | 30 +++++ .../AuthentikGroupPermissionMapperTest.java | 26 +++- ...thentikRefreshingPermissionMapperTest.java | 20 ++- .../SessionPermissionRulesTest.java | 88 ++++++++++++ .../policy/BlueprintClaimExpressionTest.java | 3 + .../sync/AuthentikMirrorSnapshotTest.java | 24 ++++ .../sync/AuthentikMirrorSqlTest.java | 110 +++++++++++++++ 14 files changed, 604 insertions(+), 138 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/authentik/permissions/SessionPermissionRules.java create mode 100644 src/test/java/com/kingsrook/qbits/authentik/permissions/SessionPermissionRulesTest.java diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java index 39ed298..6a17255 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AccessComparison.java @@ -49,6 +49,9 @@ public final class AccessComparison /** How long a computed mapper state is reused (sign-ins read it). */ public static final Duration STATE_TTL = Duration.ofSeconds(60); + /** How long a state may stand in for a guard that cannot run, after which the guard blocks. */ + public static final Duration STATE_MAX_STALE = Duration.ofMinutes(15); + /** Banner shown wherever policies are edited while they are not enforced. */ public static final String NOT_ENFORCED = "Policies are saved but NOT enforced at sign-in on this instance (mapper off)."; @@ -229,6 +232,34 @@ public final class AccessComparison + /******************************************************************************* + ** The last computed state while the guard cannot run: only a real enforcement + ** state (ACTIVE, OVERRIDDEN or BLOCKED, never OFF), with its ORIGINAL time (not + ** renewed), and only within STATE_MAX_STALE; otherwise null. + *******************************************************************************/ + static State lastKnown(AuthentikRuntime runtime) + { + State last = runtime.getMapperState(); + if(last == null || last.status() == Status.OFF || !last.checkedAt().plus(STATE_MAX_STALE).isAfter(Instant.now())) + { + return (null); + } + return (last); + } + + + + /******************************************************************************* + ** The last known state (see lastKnown), or BLOCKED with this reason. + *******************************************************************************/ + public static State lastKnownOrBlocked(AuthentikRuntime runtime, String reason) + { + State last = lastKnown(runtime); + return (last != null ? last : new State(Status.BLOCKED, reason, Instant.now())); + } + + + /******************************************************************************* ** The mapper state of a backend (cached for STATE_TTL; sign-ins read it). *******************************************************************************/ @@ -285,11 +316,11 @@ public final class AccessComparison // an Authentik error must not flip the state: keep the last known // // one (and say so); with none, block // /////////////////////////////////////////////////////////////////// - State last = runtime.getMapperState(); - LOG.error("The cut-over guard could not run", logPair("error", e.getMessage()), logPair("keepingLastState", last != null)); + LOG.error("The cut-over guard could not run", logPair("errorType", e.getClass().getSimpleName())); + State last = lastKnown(runtime); if(last != null) { - return (new State(last.status(), last.message(), Instant.now())); + return (last); } return (new State(Status.BLOCKED, "Policies are saved but NOT enforced at sign-in: the cut-over guard could not check Authentik (" + e.getMessage() + "). Sign-in keeps today's access.", Instant.now())); diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java index f72564f..c3c3b9d 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java @@ -130,55 +130,59 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust JSONObject withPolicy = PolicyPermissions.claimsWithPolicy(context, format); PolicyRules claim = PolicyPermissions.rulesFromClaims(withPolicy, format, catalog); - qSession.setPermissions(compute(qInstance, runtime, groups, sub, email, verified, claim, withPolicy != null, catalog, qSession.getPermissions())); + qSession.setPermissions(compute(qInstance, runtime, groups, sub, email, verified, claim, withPolicy != null, catalog, qSession.getPermissions(), false)); com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims); } /******************************************************************************* - ** The permissions of one sign-in. The policy claim carries the same group data - ** as the graph read; the union also covers an unreachable API. A Deny from - ** either source removes the grant wherever it came from: policies, the static - ** and host floors, the session's earlier permissions, whether or not the - ** cut-over guard blocks (a Deny only removes access, so it cannot defeat the - ** guard's purpose). Only the bootstrap identity permissions (break-glass) are - ** exempt. An invalid policy voids the user's policy grants and, while - ** policies are enforced, their floors and earlier permissions too. When the - ** graph cannot be read and there is no claim, no Deny can be known: nothing - ** comes from policy and the earlier permissions are treated as voided. + ** The permissions of one sign-in, by the one shared rule + ** (SessionPermissionRules): the policy claim carries the same group data as the + ** graph read, and the union also covers an unreachable API; a Deny from either + ** source removes the grant wherever it came from, also while the cut-over guard + ** blocks. An invalid policy (or a missing group or parent) leaves only the + ** bootstrap identity permissions; an unreadable graph with no claim leaves the + ** configured outage fallbacks. + ** + ** @param forcedVoid the caller already knows a group of the user could not be resolved *******************************************************************************/ private Set compute(QInstance qInstance, AuthentikRuntime runtime, List groups, String sub, String email, boolean verified, - PolicyRules claim, boolean claimPresent, PolicyCatalog catalog, Collection sessionPermissions) + PolicyRules claim, boolean claimPresent, PolicyCatalog catalog, Collection sessionPermissions, boolean forcedVoid) { - Detailed detailed = resolveDetailed(qInstance, groups, sub, email, verified); - boolean voided = claim.isVoid() || detailed.denials().isVoid(); - boolean noPolicyInformation = detailed.unavailable() && !claimPresent; - PolicyRules denials = voided ? PolicyRules.EMPTY : PolicyRules.merge(List.of(claim.denialsOnly(), detailed.denials().denialsOnly())); + Detailed detailed = resolveDetailed(qInstance, groups, sub, email, verified); + boolean voided = forcedVoid || claim.isVoid() || detailed.denials().isVoid(); + boolean noPolicyInformation = detailed.unavailable() && !claimPresent; + SessionPermissionRules.Knowledge knowledge = voided ? SessionPermissionRules.Knowledge.VOID + : noPolicyInformation ? SessionPermissionRules.Knowledge.UNAVAILABLE : SessionPermissionRules.Knowledge.KNOWN; + PolicyRules denials = voided ? PolicyRules.EMPTY : PolicyRules.merge(List.of(claim.denialsOnly(), detailed.denials().denialsOnly())); if(voided) { - LOG.warn("An invalid policy voids this user's policy grants", logPair("source", claim.isVoid() ? "token claim" : "group graph")); + LOG.warn("An invalid policy (or a group missing from the graph) voids this user's permissions", logPair("source", forcedVoid ? "group lookup" : claim.isVoid() ? "token claim" : "group graph")); + } + else if(noPolicyInformation) + { + LOG.warn("The group graph could not be read and the token has no policy claim: no Deny can be known; only the configured fallbacks are granted"); } Set fromPolicies = new TreeSet<>(); - if(!voided && !noPolicyInformation) + if(knowledge == SessionPermissionRules.Knowledge.KNOWN) { fromPolicies.addAll(detailed.fromGroups()); fromPolicies.addAll(claim.resolve(PolicyPermissions.universe(qInstance == null ? PolicyPermissions.instanceOf(catalog) : qInstance, Set.of()))); - fromPolicies.removeIf(denials::denies); } - Set floor = new TreeSet<>(detailed.floor()); - Set host = new TreeSet<>(); - boolean enforced = true; + Set staticFloor = new TreeSet<>(detailed.floor()); + Set host = new TreeSet<>(); + boolean enforced = true; if(runtime != null) { - floor.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups)); + staticFloor.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups)); //////////////////////////////////////////////////////////////////////// // the cut-over guard (DESIGN.md section 12.8): while it blocks, the // - // session gets today's access from the host plus the floor (static // - // fallbacks, bootstrap subjects), never the policies' own grants // + // session gets today's access from the host plus the floor, never // + // the policies' own grants; it only matters for valid policies // //////////////////////////////////////////////////////////////////////// runtime.markMapperInvoked(); AccessComparison.State state = guardState(qInstance, runtime); @@ -190,42 +194,17 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust { host.addAll(source.userPermissions(email, groups)); } - LOG.warn("Policies are not enforced at this sign-in; granting today's access", logPair("reason", state.message())); + if(voided) + { + LOG.error("A user with an invalid policy signs in while the cut-over guard blocks; they get no permissions beyond break-glass. Fix the policy.", logPair("groups", groups)); + } + else + { + LOG.warn("Policies are not enforced at this sign-in; granting today's access", logPair("reason", state.message())); + } } } - - if(voided) - { - if(enforced) - { - floor.clear(); - } - else - { - LOG.error("A user with an invalid policy signs in while the cut-over guard blocks; today's access is granted without their Deny statements. Fix the policy.", logPair("groups", groups)); - } - } - else - { - floor.removeIf(denials::denies); - host.removeIf(denials::denies); - } - - Set existing = new TreeSet<>(sessionPermissions == null ? Set.of() : sessionPermissions); - if(enforced && (voided || noPolicyInformation)) - { - existing.clear(); - } - else if(!voided) - { - existing.removeIf(denials::denies); - } - - Set permissions = new TreeSet<>(detailed.bootstrap()); - permissions.addAll(floor); - permissions.addAll(enforced ? fromPolicies : host); - permissions.addAll(existing); - return (permissions); + return (SessionPermissionRules.compute(knowledge, denials, enforced, detailed.bootstrap(), staticFloor, host, fromPolicies, sessionPermissions)); } @@ -242,9 +221,8 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust } catch(Exception e) { - AccessComparison.State last = runtime.getMapperState(); - LOG.error("The cut-over guard could not run; keeping the last known state", logPair("hadState", last != null)); - return (last != null ? last : new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now())); + LOG.error("The cut-over guard could not run", logPair("errorType", e.getClass().getSimpleName())); + return (AccessComparison.lastKnownOrBlocked(runtime, "The cut-over guard could not run")); } } @@ -259,7 +237,6 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust { AuthentikRuntime runtime = runtime(); Set permissions = new TreeSet<>(); - boolean computed = false; String email = userId == null ? "" : userId.toString().trim(); try { @@ -279,6 +256,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust if(users.size() < 10 && matches == 1 && found.optBoolean("is_active", false)) { List groups = new ArrayList<>(); + boolean unresolvedGroup = false; JSONArray groupObjects = found.optJSONArray("groups_obj"); if(groupObjects != null) { @@ -293,7 +271,11 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust for(int i = 0; i < groupPks.length(); i++) { GroupGraph.Node group = graph.byPk(groupPks.getString(i)); - if(group != null) + if(group == null) + { + unresolvedGroup = true; + } + else { groups.add(group.name()); } @@ -304,24 +286,21 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust String userEmail = found.getString("email").trim(); boolean verified = found.optBoolean("email_verified", false); PolicyCatalog catalog = qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance); - permissions = compute(qInstance, runtime, groups, sub, userEmail, verified, PolicyRules.EMPTY, false, catalog, session.getPermissions()); - computed = true; + /////////////////////////////////////////////////////////////////// + // a group pk the graph does not know may hold a Deny: void // + /////////////////////////////////////////////////////////////////// + permissions = compute(qInstance, runtime, groups, sub, userEmail, verified, PolicyRules.EMPTY, false, catalog, session.getPermissions(), unresolvedGroup); } else { - LOG.info("Automated session has no unique active Authentik user; granting only the static fallbacks"); + LOG.info("Automated session has no unique active Authentik user; granting no permissions"); } } catch(Exception e) { permissions.clear(); - computed = false; // Exception messages can contain the lookup email; log only the type. - LOG.warn("Authentik could not be read for an automated session; granting only the static fallbacks", logPair("errorType", e.getClass().getSimpleName())); - } - if(!computed && session.getPermissions() != null) - { - permissions.addAll(session.getPermissions()); + LOG.warn("Authentik could not be read for an automated session; granting no permissions", logPair("errorType", e.getClass().getSimpleName())); } session.setPermissions(permissions); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapper.java index baedbd9..c31564f 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapper.java @@ -80,6 +80,8 @@ public class AuthentikRefreshingPermissionMapper extends AuthentikGroupPermissio } catch(Exception failure) { + LOG.warn("The Authentik session could not be established", logPair("errorType", failure.getClass().getSimpleName()), + logPair("causeType", failure.getCause() == null ? "none" : failure.getCause().getClass().getSimpleName())); session.setPermissions(new TreeSet<>()); session.setValue(CLAIMS, null); throw (new IllegalStateException("Current Authentik session access could not be established.")); @@ -137,41 +139,30 @@ public class AuthentikRefreshingPermissionMapper extends AuthentikGroupPermissio } /////////////////////////////////////////////////////////////////////// - // the mirror's permissions already have every Deny applied; the // - // static and host floors get the same treatment (bootstrap is the // - // only exemption). Without knowledge of the Deny rules, fail closed. // + // the one shared rule (SessionPermissionRules): a Deny beats the // + // mirror's stored effective permissions, the floors and the host // + // source; an invalid policy leaves break-glass only; unreadable // + // policies leave only the configured outage fallbacks // /////////////////////////////////////////////////////////////////////// - PolicyResult policy = policyResult(instance, access.groups(), sub, access.email()); - if(policy.isUnavailable()) + PolicyResult policy = policyResult(instance, access, sub, access.email()); + SessionPermissionRules.Knowledge knowledge = policy.isUnavailable() ? SessionPermissionRules.Knowledge.UNAVAILABLE + : policy.isVoid() ? SessionPermissionRules.Knowledge.VOID : SessionPermissionRules.Knowledge.KNOWN; + if(knowledge == SessionPermissionRules.Knowledge.UNAVAILABLE) { - LOG.warn("The group policies could not be read, so no Deny can be applied to the static and host floors; they are withheld"); - floor.clear(); - host.clear(); + LOG.warn("The mirrored group policies could not be read, so no Deny can be known; only the configured fallbacks are granted"); } - else if(policy.isVoid()) + else if(knowledge == SessionPermissionRules.Knowledge.VOID) { - if(enforced) - { - floor.clear(); - } - else - { - LOG.error("A user with an invalid policy refreshes while the cut-over guard blocks; today's access is granted without their Deny statements. Fix the policy.", logPair("groups", access.groups())); - } + LOG.error("A user with an invalid policy refreshes; they get no permissions beyond break-glass. Fix the policy.", logPair("groups", access.groups())); } - else - { - floor.removeIf(policy.rules()::denies); - host.removeIf(policy.rules()::denies); - } - Set permissions = new TreeSet<>(bootstrapNames); - permissions.addAll(floor); - permissions.addAll(enforced ? access.permissions() : host); + Set permissions = SessionPermissionRules.compute(knowledge, policy.rules().denialsOnly(), enforced, bootstrapNames, floor, host, access.permissions(), Set.of()); session.setPermissions(permissions); AuthentikTokenScopes.apply(instance, session, claims); } catch(Exception failure) { + LOG.warn("The Authentik session access could not be refreshed", logPair("errorType", failure.getClass().getSimpleName()), + logPair("causeType", failure.getCause() == null ? "none" : failure.getCause().getClass().getSimpleName())); session.setPermissions(new TreeSet<>()); throw (new QAuthenticationException("Current Authentik session access could not be established.")); } @@ -193,14 +184,18 @@ public class AuthentikRefreshingPermissionMapper extends AuthentikGroupPermissio ** The typed policy result of the user's groups (for the Deny rules applied to ** the floors). A seam: the default reads the live group graph. ***************************************************************************/ - protected PolicyResult policyResult(QInstance instance, Set groupNames, String sub, String email) + protected PolicyResult policyResult(QInstance instance, AuthentikMirrorAccess.Access access, String sub, String email) { try { + if(access.groupRows() == null) + { + return (PolicyResult.unreadable()); + } AuthentikRuntime runtime = runtime(); - GroupGraph graph = GroupGraph.fromGroups(readMirrorGroups(), runtime.getConfig().getPolicyFormat(), PolicyCatalog.of(instance)); + GroupGraph graph = GroupGraph.fromGroups(access.groupRows(), runtime.getConfig().getPolicyFormat(), PolicyCatalog.of(instance)); List pks = new ArrayList<>(); - for(String name : groupNames) + for(String name : access.groups()) { GroupGraph.Node node = graph.byName(name); if(node == null) @@ -214,19 +209,11 @@ public class AuthentikRefreshingPermissionMapper extends AuthentikGroupPermissio } catch(Exception e) { + LOG.warn("The mirrored group policies could not be read", logPair("errorType", e.getClass().getSimpleName())); return (PolicyResult.unreadable()); } } - /*************************************************************************** - ** The mirrored groups (a seam; no Authentik API call, so an API outage does - ** not change what a refresh can do). - ***************************************************************************/ - protected List readMirrorGroups() throws Exception - { - return (new AuthentikMirrorAccess(runtime().getConfig(), Duration.ofMinutes(15)).readGroups()); - } - /*************************************************************************** ** Preserve the existing policy cut-over guard and host-permission floor. ***************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/SessionPermissionRules.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/SessionPermissionRules.java new file mode 100644 index 0000000..d86b94b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/SessionPermissionRules.java @@ -0,0 +1,121 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik.permissions; + + +import java.util.Collection; +import java.util.Set; +import java.util.TreeSet; +import com.kingsrook.qbits.authentik.policy.PolicyRules; + + +/******************************************************************************* + ** THE rule that turns what is known about a user's policies, and every source of + ** permissions, into one session permission set. Sign-in, automated sessions and + ** the refreshing mapper all call compute(), so they cannot disagree. + ** + ** - VOID (an invalid document, or a group or parent missing from the graph or + ** mirror): only the bootstrap identity permissions (break-glass). The floors, + ** the host source and the session's earlier permissions are cleared, even + ** while the cut-over guard blocks. + ** - UNAVAILABLE (the graph or mirror cannot be read and there is no claim, so + ** no Deny can be known): the explicitly configured outage fallbacks (static + ** group permissions) minus any Deny already known, plus bootstrap. The host + ** source, the session's earlier permissions and any directory grants are + ** withheld. + ** - KNOWN: bootstrap, plus the static floor, plus the policies' grants while the + ** policies are enforced (or the host source while the guard blocks), plus the + ** session's earlier permissions, all minus every Deny. A Deny only removes + ** access, so the guard's BLOCKED state never switches it off. + *******************************************************************************/ +public final class SessionPermissionRules +{ + /******************************************************************************* + ** What is known about the user's policies. + *******************************************************************************/ + public enum Knowledge + { + /** Rules were read; denials are valid. */ + KNOWN, + /** A policy is invalid, or a group or parent is missing. */ + VOID, + /** The policies could not be read at all. */ + UNAVAILABLE + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SessionPermissionRules() + { + } + + + + /******************************************************************************* + ** The session permissions. + ** + ** @param knowledge what is known about the policies + ** @param denials the valid Deny rules (ignored unless KNOWN or UNAVAILABLE with a claim) + ** @param enforced whether policies are enforced (the guard is not blocking) + ** @param bootstrap bootstrap identity permissions (break-glass, never subtracted) + ** @param staticFloor the explicitly configured static group permissions + ** @param host the host permission source's grants (used only while blocked) + ** @param policyGrants what the policies (and the mirror's directory grants) grant + ** @param existing permissions already on the session + *******************************************************************************/ + public static Set compute(Knowledge knowledge, PolicyRules denials, boolean enforced, Collection bootstrap, Collection staticFloor, + Collection host, Collection policyGrants, Collection existing) + { + Set rs = new TreeSet<>(bootstrap); + if(knowledge == Knowledge.VOID) + { + return (rs); + } + PolicyRules rules = denials == null ? PolicyRules.EMPTY : denials; + add(rs, staticFloor, rules); + if(knowledge == Knowledge.UNAVAILABLE) + { + return (rs); + } + add(rs, enforced ? policyGrants : host, rules); + add(rs, existing, rules); + return (rs); + } + + + + /******************************************************************************* + ** Add the names no Deny covers. + *******************************************************************************/ + private static void add(Set target, Collection names, PolicyRules rules) + { + if(names == null) + { + return; + } + for(String name : names) + { + if(!rules.denies(name)) + { + target.add(name); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorAccess.java b/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorAccess.java index 81e30ec..da016e9 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorAccess.java +++ b/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorAccess.java @@ -40,6 +40,9 @@ public final class AuthentikMirrorAccess private final String stateTable; private final String groupTable; + /** The most mirrored groups one read accepts; more makes the projection unavailable. Package-visible for tests. */ + static volatile int maxGroups = 50_000; + /*************************************************************************** ** READY supplies current grants; DENIED is an authoritative inactive or ** absent identity; UNAVAILABLE has no usable completed/fresh projection. @@ -50,7 +53,7 @@ public final class AuthentikMirrorAccess ** Immutable sets prevent cached session mutations from altering this read. ** This includes only directory grants, not host/static/bootstrap grants. ***************************************************************************/ - public record Access(Set groups, Set permissions, Instant refreshedAt, String email) + public record Access(Set groups, Set permissions, Instant refreshedAt, String email, java.util.List groupRows) { /*********************************************************************** ** Defensive copies at the authorization boundary. @@ -59,6 +62,15 @@ public final class AuthentikMirrorAccess { groups = Set.copyOf(groups); permissions = Set.copyOf(permissions); + groupRows = groupRows == null ? null : java.util.List.copyOf(groupRows); + } + + /*********************************************************************** + ** Without the mirrored group rows (the policies cannot be read from it). + ***********************************************************************/ + public Access(Set groups, Set permissions, Instant refreshedAt, String email) + { + this(groups, permissions, refreshedAt, email, null); } } @@ -156,8 +168,15 @@ public final class AuthentikMirrorAccess } try { - Access access = new Access(strings(rows.getString(6)), strings(rows.getString(7)), watermark, rows.getString(9)); - return (rows.next() ? new Result(Status.UNAVAILABLE, null) : new Result(Status.READY, access)); + Set groupNames = strings(rows.getString(6)); + Set permissions = strings(rows.getString(7)); + String email = rows.getString(9); + if(rows.next()) + { + return (new Result(Status.UNAVAILABLE, null)); + } + java.util.List groupRows = readGroupRows(connection, watermark); + return (groupRows == null ? new Result(Status.UNAVAILABLE, null) : new Result(Status.READY, new Access(groupNames, permissions, watermark, email, groupRows))); } catch(IllegalArgumentException | org.json.JSONException malformed) { @@ -170,24 +189,38 @@ public final class AuthentikMirrorAccess /*************************************************************************** ** The mirrored groups as Authentik-shaped objects (pk, name, parents and the - ** mirrored attributes, which include the policy document), so the Deny rules of - ** a user's groups can be read without the Authentik API. Internal context only. + ** mirrored attributes, which include the policy document), read on the SAME + ** connection as the user row so the Deny rules of a user's groups can be read + ** without the Authentik API. Every group row must carry the checkpoint's + ** refresh time and the table must not exceed the bound: otherwise null (the + ** projection is unusable, never silently partial). ***************************************************************************/ - public java.util.List readGroups() throws SQLException, QPermissionDeniedException + private java.util.List readGroupRows(java.sql.Connection connection, Instant watermark) throws SQLException { - if(!(QContext.getQSession() instanceof QSystemUserSession)) - { - throw (new QPermissionDeniedException("Session mirror reads require the internal authentication context.")); - } java.util.List groups = new java.util.ArrayList<>(); - try(var connection = connections.open(); var statement = connection.prepareStatement("SELECT pk, name, parent_pks, attributes FROM " + groupTable); - var rows = statement.executeQuery()) + try(var statement = connection.prepareStatement("SELECT pk, name, parent_pks, attributes, refreshed_at FROM " + groupTable); var rows = statement.executeQuery()) { - while(rows.next() && groups.size() <= 50_000) + while(rows.next()) { - org.json.JSONObject attributes = rows.getString(4) == null || rows.getString(4).isBlank() ? new org.json.JSONObject() : new org.json.JSONObject(rows.getString(4)); - JSONArray parents = rows.getString(3) == null || rows.getString(3).isBlank() ? new JSONArray() : new JSONArray(rows.getString(3)); - groups.add(new org.json.JSONObject().put("pk", rows.getString(1)).put("name", rows.getString(2)).put("parents", parents).put("attributes", attributes)); + if(groups.size() >= maxGroups) + { + return (null); + } + var refreshed = rows.getObject(5, java.time.LocalDateTime.class); + if(refreshed == null || !watermark.equals(refreshed.toInstant(java.time.ZoneOffset.UTC))) + { + return (null); + } + try + { + org.json.JSONObject attributes = rows.getString(4) == null || rows.getString(4).isBlank() ? new org.json.JSONObject() : new org.json.JSONObject(rows.getString(4)); + JSONArray parents = rows.getString(3) == null || rows.getString(3).isBlank() ? new JSONArray() : new JSONArray(rows.getString(3)); + groups.add(new org.json.JSONObject().put("pk", rows.getString(1)).put("name", rows.getString(2)).put("parents", parents).put("attributes", attributes)); + } + catch(org.json.JSONException malformed) + { + return (null); + } } } return (groups); diff --git a/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshot.java b/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshot.java index 8fb3af0..fc5bcef 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshot.java +++ b/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshot.java @@ -98,7 +98,7 @@ public record AuthentikMirrorSnapshot(List users, List groups, } groups.add(new QRecord().withValue("pk", pk).withValue("name", node.name()) .withValue("parentPks", json(new TreeSet<>(node.parentPks()))) - .withValue("attributes", AuthentikRedactor.redactText(attributes.toString(), null)) + .withValue("attributes", redactedAttributes(attributes, config.getPolicyFormat().attribute())) .withValue("memberCount", node.memberPks().size()).withValue("isSuperuser", graph.isSuperuserInherited(pk)) .withValue("refreshedAt", refreshedAt)); } @@ -154,6 +154,23 @@ public record AuthentikMirrorSnapshot(List users, List groups, return (new AuthentikMirrorSnapshot(List.copyOf(users), List.copyOf(groups), List.copyOf(memberships), refreshedAt)); } + /******************************************************************************* + ** The mirrored attributes with secret-looking values redacted EXCEPT the policy + ** document, which must survive byte for byte: the mirror is where the refreshing + ** mapper reads Deny rules, and a redacted Deny would be a lost Deny. + *******************************************************************************/ + static String redactedAttributes(JSONObject attributes, String policyAttribute) + { + JSONObject rest = new JSONObject(attributes.toString()); + Object policy = rest.has(policyAttribute) ? rest.remove(policyAttribute) : null; + JSONObject safe = new JSONObject(AuthentikRedactor.redactText(rest.toString(), null)); + if(policy != null) + { + safe.put(policyAttribute, policy); + } + return (safe.toString()); + } + /******************************************************************************* ** Stable JSON arrays avoid audit churn caused only by set iteration order. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java index 9e16f50..47a2fe9 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java @@ -343,8 +343,17 @@ class PolicyCutoverTest AuthentikActionUtil failing = org.mockito.Mockito.spy(AuthentikActionUtil.forBackend(t.config.getApiBackendName())); org.mockito.Mockito.doThrow(new com.kingsrook.qqq.backend.core.exceptions.QException("Authentik 503")).when(failing).groupGraphWithMembers(); - runtime.setMapperState(new AccessComparison.State(AccessComparison.Status.ACTIVE, "earlier", java.time.Instant.now().minusSeconds(600))); - assertThat(AccessComparison.state(runtime, failing).status()).as("keeps the last known state").isEqualTo(AccessComparison.Status.ACTIVE); + java.time.Instant earlier = java.time.Instant.now().minusSeconds(600); + runtime.setMapperState(new AccessComparison.State(AccessComparison.Status.ACTIVE, "earlier", earlier)); + AccessComparison.State kept = AccessComparison.state(runtime, failing); + assertThat(kept.status()).as("keeps the last known state").isEqualTo(AccessComparison.Status.ACTIVE); + assertThat(kept.checkedAt()).as("the original time is not renewed").isEqualTo(earlier); + + runtime.setMapperState(new AccessComparison.State(AccessComparison.Status.OFF, "off", earlier)); + assertThat(AccessComparison.state(runtime, failing).status()).as("OFF is never carried forward").isEqualTo(AccessComparison.Status.BLOCKED); + + runtime.setMapperState(new AccessComparison.State(AccessComparison.Status.ACTIVE, "too old", java.time.Instant.now().minus(AccessComparison.STATE_MAX_STALE).minusSeconds(60))); + assertThat(AccessComparison.state(runtime, failing).status()).as("past the staleness bound the guard blocks").isEqualTo(AccessComparison.Status.BLOCKED); runtime.setMapperState(null); assertThat(AccessComparison.state(runtime, failing).status()).as("no earlier state: block").isEqualTo(AccessComparison.Status.BLOCKED); diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java index 39c2c65..6b14ea2 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java @@ -1070,4 +1070,34 @@ class PolicyEditorTest assertThat(mapper.resolveResult(t.qInstance, List.of("kof-website-analysts"), "100", "x@example.test", true).isAbsent()).isTrue(); } + + + + + /******************************************************************************* + ** The cut-over guard blocks, one of the user's groups has a document with no + ** Statement (void), another has a Deny, and the host source returns the denied + ** permission: nothing but break-glass is granted, the denied permission above all. + *******************************************************************************/ + @Test + void blockedAndVoidGrantsNothingBeyondBreakGlass() throws Exception + { + t.config.withCutoverGuardGroups(List.of("kof-website-analysts")); + Set hostPermissions = Set.of("cms_page.read", "cms_page.edit"); + t.config.withHostPermissionSource((email, groups) -> hostPermissions); + t.config.withStaticGroupPermissions(Map.of("kof-website-analysts", List.of("cms_page.read", "static.keep"))); + t.config.withMapperEnabled(true).withMapperOverride(false); + setPolicy("kof-website-analysts", policyOf("{\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}")); + setPolicy("kof-website-publishers", "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\"}"); + t.authentik.user(12, "blocker", "blocker@example.test", "internal", true, "kof-website-analysts"); + t.admin().getUtil().getRuntime().invalidateCache(); + t.admin().getUtil().getRuntime().setMapperState(null); + assertThat(AccessComparison.state(t.admin().getUtil().getRuntime(), t.admin().getUtil()).status()).as("the guard is really blocking").isEqualTo(AccessComparison.Status.BLOCKED); + JSONObject claims = new JSONObject().put("groups", List.of("kof-website-analysts", "kof-website-publishers")).put("sub", "100").put("email", "x@example.test"); + QSession session = new QSession(); + session.setPermissions(Set.of("other.keep")); + new AuthentikGroupPermissionMapper().customizeSession(t.qInstance, session, Map.of("jwtPayloadJsonObject", claims)); + assertThat(session.getPermissions()).as("blocked + void: floor, host and earlier permissions are all cleared").isEmpty(); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java index 4a23091..1da98b1 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java @@ -229,7 +229,7 @@ class AuthentikGroupPermissionMapperTest session.setPermissions(Set.of("already.there")); new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("mapper.fallback")) .customizeAutomatedSessionForUser(qInstance, session, scenario == null || scenario.isEmpty() ? scenario : "owner@example.test"); - assertThat(session.getPermissions()).containsExactly("already.there"); + assertThat(session.getPermissions()).as("an unresolved user keeps nothing, not even the session's earlier permissions").isEmpty(); } @@ -244,7 +244,8 @@ class AuthentikGroupPermissionMapperTest QSession session = new QSession(); session.setPermissions(Set.of("already.there")); new AuthentikGroupPermissionMapper().customizeAutomatedSessionForUser(qInstance, session, "owner@example.test"); - assertThat(session.getPermissions()).containsExactly("already.there"); + assertThat(session.getPermissions()).as("an unreadable Authentik grants nothing").isEmpty(); + session.setPermissions(Set.of("already.there")); AuthentikGroupPermissionMapper noRuntime = new AuthentikGroupPermissionMapper() { /******************************************************************************* @@ -257,7 +258,7 @@ class AuthentikGroupPermissionMapperTest } }; noRuntime.customizeAutomatedSessionForUser(qInstance, session, "owner@example.test"); - assertThat(session.getPermissions()).containsExactly("already.there"); + assertThat(session.getPermissions()).isEmpty(); } @@ -541,4 +542,23 @@ class AuthentikGroupPermissionMapperTest assertThat(result.apply(Set.of("service.send"))).isEmpty(); } + + + + + /******************************************************************************* + ** A group pk of the user that the graph does not know may hold a Deny: the + ** lookup voids (only break-glass remains; the floors and earlier permissions go). + *******************************************************************************/ + @Test + void automatedSessionWithAnUnknownGroupPkIsVoided() + { + usersWithEmail(user(99, "person@example.test").put("groups", new JSONArray().put("no-such-pk"))); + config.withStaticGroupPermissions(Map.of("whatever", List.of("config.fallback"))); + QSession session = new QSession(); + session.setPermissions(Set.of("already.there")); + new AuthentikGroupPermissionMapper().customizeAutomatedSessionForUser(qInstance, session, "person@example.test"); + assertThat(session.getPermissions()).isEmpty(); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapperTest.java index 30da2dd..b307f3c 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikRefreshingPermissionMapperTest.java @@ -235,11 +235,25 @@ class AuthentikRefreshingPermissionMapperTest mapper.active = true; mapper.policy = new com.kingsrook.qbits.authentik.policy.PolicyResult(com.kingsrook.qbits.authentik.policy.PolicyRules.voided("bad"), Set.of()); mapper.refresh(instance, session); - assertThat(session.getPermissions()).as("enforced and void: no floors").containsExactly("mirror.hasAccess"); + assertThat(session.getPermissions()).as("void: nothing, not even the mirror's stored grants").isEmpty(); + + mapper.active = false; + mapper.refresh(instance, session); + assertThat(session.getPermissions()).as("void while blocked: still nothing, host source included").isEmpty(); mapper.policy = com.kingsrook.qbits.authentik.policy.PolicyResult.unreadable(); + mapper.active = true; mapper.refresh(instance, session); - assertThat(session.getPermissions()).as("policies unreadable: no floors").containsExactly("mirror.hasAccess"); + assertThat(session.getPermissions()).as("unreadable: only the configured outage fallbacks").containsExactlyInAnyOrder("static.keep.hasAccess", "static.denied.hasAccess"); + mapper.active = false; + mapper.refresh(instance, session); + assertThat(session.getPermissions()).as("unreadable while blocked: host source still withheld").containsExactlyInAnyOrder("static.keep.hasAccess", "static.denied.hasAccess"); + + mapper.policy = new com.kingsrook.qbits.authentik.policy.PolicyResult(com.kingsrook.qbits.authentik.policy.PolicyRules.of(Set.of(), Set.of( + new com.kingsrook.qbits.authentik.policy.PolicyTerm.Exact("mirror.hasAccess"))), Set.of()); + mapper.active = true; + mapper.refresh(instance, session); + assertThat(session.getPermissions()).as("a Deny beats the mirror's stored grant").doesNotContain("mirror.hasAccess"); } @@ -258,7 +272,7 @@ class AuthentikRefreshingPermissionMapperTest ** Reader runs as the system and receives the signed immutable identity. ***********************************************************************/ @Override - protected com.kingsrook.qbits.authentik.policy.PolicyResult policyResult(QInstance instance, Set groupNames, String sub, String email) + protected com.kingsrook.qbits.authentik.policy.PolicyResult policyResult(QInstance instance, AuthentikMirrorAccess.Access access, String sub, String email) { return (policy); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/SessionPermissionRulesTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/SessionPermissionRulesTest.java new file mode 100644 index 0000000..bc88b30 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/SessionPermissionRulesTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.authentik.permissions; + + +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.authentik.permissions.SessionPermissionRules.Knowledge; +import com.kingsrook.qbits.authentik.policy.PolicyRules; +import com.kingsrook.qbits.authentik.policy.PolicyTerm; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The one rule every sign-in path uses. + *******************************************************************************/ +class SessionPermissionRulesTest +{ + private static final PolicyRules DENY = PolicyRules.of(Set.of(), Set.of(new PolicyTerm.Exact("denied.x"))); + private static final List BOOT = List.of("boot.a"); + private static final List STATIC = List.of("static.keep", "denied.x"); + private static final List HOST = List.of("host.keep", "denied.x"); + private static final List POLICY = List.of("policy.keep", "denied.x"); + private static final List EXISTING = List.of("existing.keep", "denied.x"); + + + + /******************************************************************************* + ** VOID leaves only break-glass, enforced or blocked. + *******************************************************************************/ + @Test + void voidLeavesOnlyBootstrapEvenWhileBlocked() + { + for(boolean enforced : List.of(true, false)) + { + assertThat(SessionPermissionRules.compute(Knowledge.VOID, PolicyRules.EMPTY, enforced, BOOT, STATIC, HOST, POLICY, EXISTING)).containsExactly("boot.a"); + } + } + + + + /******************************************************************************* + ** UNAVAILABLE keeps the configured outage fallbacks (minus a known Deny) and + ** withholds the host source, the policies' grants and the earlier permissions. + *******************************************************************************/ + @Test + void unavailableKeepsOnlyOutageFallbacks() + { + for(boolean enforced : List.of(true, false)) + { + assertThat(SessionPermissionRules.compute(Knowledge.UNAVAILABLE, PolicyRules.EMPTY, enforced, BOOT, STATIC, HOST, POLICY, EXISTING)) + .containsExactlyInAnyOrder("boot.a", "static.keep", "denied.x"); + assertThat(SessionPermissionRules.compute(Knowledge.UNAVAILABLE, DENY, enforced, BOOT, STATIC, HOST, POLICY, EXISTING)) + .containsExactlyInAnyOrder("boot.a", "static.keep"); + } + } + + + + /******************************************************************************* + ** KNOWN applies the Deny to every source, in both guard states; bootstrap is + ** never subtracted. + *******************************************************************************/ + @Test + void knownAppliesDenyToEverySource() + { + assertThat(SessionPermissionRules.compute(Knowledge.KNOWN, DENY, true, BOOT, STATIC, HOST, POLICY, EXISTING)) + .containsExactlyInAnyOrder("boot.a", "static.keep", "policy.keep", "existing.keep"); + assertThat(SessionPermissionRules.compute(Knowledge.KNOWN, DENY, false, BOOT, STATIC, HOST, POLICY, EXISTING)) + .containsExactlyInAnyOrder("boot.a", "static.keep", "host.keep", "existing.keep"); + PolicyRules denyBoot = PolicyRules.of(Set.of(), Set.of(new PolicyTerm.Exact("boot.a"))); + assertThat(SessionPermissionRules.compute(Knowledge.KNOWN, denyBoot, true, BOOT, STATIC, HOST, POLICY, EXISTING)).contains("boot.a"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java index 03386d6..ade89a5 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java @@ -105,6 +105,9 @@ class BlueprintClaimExpressionTest add(cases, names, "statement not a list", good, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":{}}"); add(cases, names, "nested duplicate keys", good, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Allow\",\"Effect\":\"Deny\",\"Action\":[\"read\"],\"Resource\":[\"a\"]}]}"); + add(cases, names, "extra top-level key", good, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[],\"Extra\":true}"); + add(cases, names, "missing Statement", good, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\"}"); + add(cases, names, "misspelled Statement", good, "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statments\":[]}"); add(cases, names, "blank string", good, " "); add(cases, names, "number", good, 5); add(cases, names, "boolean", good, true); diff --git a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java index bb38372..5f1f91e 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java @@ -186,4 +186,28 @@ class AuthentikMirrorSnapshotTest assertThat(snapshot().users().getFirst().getValueString("effectivePermissions")).as("void").isEqualTo("[]"); } + + + + + /******************************************************************************* + ** Redaction rewrites secret-looking values of other attributes but never the + ** policy document: the refreshing mapper reads Deny rules from the mirror. + *******************************************************************************/ + @Test + void redactionNeverTouchesThePolicyDocument() + { + org.json.JSONObject statement = new org.json.JSONObject().put("Effect", "Deny").put("Permission", new JSONArray().put("token").put("password.reset").put("secret")); + String stringPolicy = new org.json.JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0").put("Statement", new JSONArray().put(statement)).toString(); + for(Object policy : List.of(stringPolicy, new org.json.JSONObject(stringPolicy))) + { + team.getJSONObject("attributes").put("qqq.io/policy", policy).put("qqq.io/permissions", new JSONArray().put("authentik.users.read")); + String stored = snapshot().groups().stream().filter(g -> "Team".equals(g.getValueString("name"))).findFirst().orElseThrow().getValueString("attributes"); + Object kept = new org.json.JSONObject(stored).get("qqq.io/policy"); + assertThat(new org.json.JSONObject(kept.toString()).toString()).isEqualTo(new org.json.JSONObject(policy.toString()).toString()); + } + assertThat(AuthentikMirrorSnapshot.redactedAttributes(new org.json.JSONObject().put("password", "hunter2").put("qqq.io/policy", stringPolicy), "qqq.io/policy")) + .doesNotContain("hunter2").contains("token"); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSqlTest.java b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSqlTest.java index dfee67d..a8ded42 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSqlTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSqlTest.java @@ -1165,6 +1165,7 @@ class AuthentikMirrorSqlTest assertThat(worker().fullRefresh((snapshot, transaction) -> { }, null, null)).isTrue(); String identity = "ec5b2c2c-68df-4cd5-972f-1e099728fe0b"; sql("UPDATE authentik_user_mirror SET uuid = '" + identity + "', effective_permissions = '[\"documents.read\"]', group_names = '[\"Readers\"]'"); + insertGroup("g-readers", "Readers", "{}"); QInstance instance = QContext.getQInstance(); try(var issuer = new SignedOAuthTestIssuer()) { @@ -2109,4 +2110,113 @@ class AuthentikMirrorSqlTest assertThat(count("authentik_session_binding")).isEqualTo(1); assertThat(new AuthentikSessionBindings(config).find(cookie)).isNotNull(); } + + + /*************************************************************************** + ** A mirrored group row stamped with the checkpoint's refresh time. + ***************************************************************************/ + private void insertGroup(String pk, String name, String attributesJson) throws Exception + { + sql("INSERT INTO authentik_group_mirror (pk, name, parent_pks, attributes, member_count, is_superuser, refreshed_at) VALUES ('" + pk + "', '" + name + + "', '[]', '" + attributesJson.replace("'", "''") + "', 0, FALSE, (SELECT refreshed_at FROM authentik_user_mirror))"); + } + + /*************************************************************************** + ** A policy document that denies these permission names. + ***************************************************************************/ + private static String denyDocument(String... names) + { + return (new org.json.JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.1.0").put("Statement", new org.json.JSONArray().put( + new org.json.JSONObject().put("Effect", "Deny").put("Permission", new org.json.JSONArray(List.of(names))))).toString()); + } + + /*************************************************************************** + ** The DEFAULT mirror path (no seam overridden): the Deny rules come from the + ** mirrored group rows read on the same connection as the user row, and beat + ** the mirror's stored effective permissions, the static floor and the host + ** source. Also with a custom policy attribute; a void document, a missing + ** group, an unusable group snapshot and truncation. + ***************************************************************************/ + @Test + void defaultMirrorPathAppliesDenyVoidAndRefusesUnusableSnapshots() throws Exception + { + assertThat(worker().fullRefresh((snapshot, transaction) -> { }, null, null)).isTrue(); + String identity = "ec5b2c2c-68df-4cd5-972f-1e099728fe0b"; + sql("UPDATE authentik_user_mirror SET uuid = '" + identity + "', effective_permissions = '[\"documents.read\",\"other.keep\"]', group_names = '[\"Readers\"]'"); + config.withStaticGroupPermissions(java.util.Map.of("Readers", List.of("static.keep", "static.denied"))) + .withHostPermissionSource((email, groups) -> java.util.Set.of("host.denied")); + QInstance instance = QContext.getQInstance(); + try(var issuer = new SignedOAuthTestIssuer()) + { + configureOAuth(instance, issuer.issuer()); + var context = java.util.Map.of("accessToken", issuer.token(identity)); + var module = new com.kingsrook.qbits.authentik.permissions.AuthentikRefreshingOAuth2Module(); + String denied = denyDocument("documents.read", "static.denied", "host.denied"); + + ////////////////////////////////// no Deny visible: the mirror's grants apply + insertGroup("g-readers", "Readers", "{}"); + assertThat(module.createSession(instance, context).getPermissions()).containsExactlyInAnyOrder("documents.read", "other.keep", "static.keep", "static.denied"); + + ////////////////////////////////// a Deny in the default policy attribute + sql("UPDATE authentik_group_mirror SET attributes = '" + new org.json.JSONObject().put("qqq.io/policy", denied) + "'"); + assertThat(module.createSession(instance, context).getPermissions()).as("Deny beats mirror, static floor and host") + .containsExactlyInAnyOrder("other.keep", "static.keep"); + + ////////////////////////////////// an invalid document voids the user + sql("UPDATE authentik_group_mirror SET attributes = '" + new org.json.JSONObject().put("qqq.io/policy", "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\"}") + "'"); + assertThat(module.createSession(instance, context).getPermissions()).as("void: no stored grants, no floors").isEmpty(); + + ////////////////////////////////// a group of the user missing from the mirror voids the user + sql("DELETE FROM authentik_group_mirror"); + assertThat(module.createSession(instance, context).getPermissions()).isEmpty(); + + ////////////////////////////////// a group snapshot from another checkpoint is unusable + insertGroup("g-readers", "Readers", "{}"); + sql("UPDATE authentik_group_mirror SET refreshed_at = TIMESTAMP '2001-01-01 00:00:00'"); + assertThatThrownBy(() -> module.createSession(instance, context)).isInstanceOf(Exception.class); + + ////////////////////////////////// truncation is refused, never silently cut + sql("UPDATE authentik_group_mirror SET refreshed_at = (SELECT refreshed_at FROM authentik_user_mirror)"); + insertGroup("g-two", "Two", "{}"); + int before = AuthentikMirrorAccess.maxGroups; + try + { + AuthentikMirrorAccess.maxGroups = 1; + assertThatThrownBy(() -> module.createSession(instance, context)).isInstanceOf(Exception.class); + } + finally + { + AuthentikMirrorAccess.maxGroups = before; + } + assertThat(module.createSession(instance, context).getPermissions()).contains("other.keep"); + } + } + + + + /*************************************************************************** + ** The same default path with a CUSTOM policy attribute configured from the + ** start (the mirror's scope fingerprint depends on it). + ***************************************************************************/ + @Test + void defaultMirrorPathHonoursACustomPolicyAttribute() throws Exception + { + config.withPolicyAttribute("myapp/policy"); + assertThat(worker().fullRefresh((snapshot, transaction) -> { }, null, null)).isTrue(); + String identity = "ec5b2c2c-68df-4cd5-972f-1e099728fe0b"; + sql("UPDATE authentik_user_mirror SET uuid = '" + identity + "', effective_permissions = '[\"documents.read\",\"other.keep\"]', group_names = '[\"Readers\"]'"); + QInstance instance = QContext.getQInstance(); + try(var issuer = new SignedOAuthTestIssuer()) + { + configureOAuth(instance, issuer.issuer()); + var context = java.util.Map.of("accessToken", issuer.token(identity)); + var module = new com.kingsrook.qbits.authentik.permissions.AuthentikRefreshingOAuth2Module(); + insertGroup("g-readers", "Readers", new org.json.JSONObject().put("myapp/policy", denyDocument("documents.read")).toString()); + assertThat(module.createSession(instance, context).getPermissions()).containsExactly("other.keep"); + sql("UPDATE authentik_group_mirror SET attributes = '" + new org.json.JSONObject().put("qqq.io/policy", denyDocument("documents.read")) + "'"); + assertThat(module.createSession(instance, context).getPermissions()).as("the default attribute is not the configured one: no Deny is seen") + .containsExactlyInAnyOrder("documents.read", "other.keep"); + } + } + } -- 2.54.0 From a76843d62221f8ca770bb088aaaa831a183ab8ac Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 20:31:59 -0600 Subject: [PATCH 11/14] docs(policy): break-glass operators, absent groups claim, claim-only mapper Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- docs/POLICY-FORMAT.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 731ab97..e5dba39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] -- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document under the configured attribute, a differently-specced one included (skipping other specs is an explicit opt-in). Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). One shared rule (`SessionPermissionRules`) for sign-in, automated sessions and the refreshing mapper: a Deny subtracts policy grants, the mirror's stored permissions, static fallbacks, the host source and earlier session permissions, also while the cut-over guard blocks (only bootstrap identity permissions are exempt); an invalid policy or a missing group/parent leaves break-glass only even while blocked; an unreadable graph/mirror with no claim leaves only the configured outage fallbacks; a guard error keeps the last known real state (never OFF, original time, 15 minute bound); the refreshing mapper reads Deny rules from the mirrored group rows on the same connection and refuses an inconsistent or truncated snapshot; the mirror stores the policy document unredacted; a missing parent voids; documents with an unknown top-level key or no `Statement` are rejected; the group fingerprint covers the configured policy attribute; the refreshing mapper reads Deny rules from the mirror (no API call). Review fixes: operators are credited with a floor permission only if their own policies do not deny it; the configured policy attribute is reserved against Edit Group/User attributes; un-voiding an invalid policy is a release; a Deny a person keeps deliberately no longer trips the cut-over guard; a Deny of a prefix or named resource also covers its dotted host names; empty Resource/Action/Permission arrays and malformed host names are rejected; Save Policy requires `expectedFingerprint`; `PolicyResult` / `resultFromClaims` / `resolveResult` replace the deprecated plain-set APIs. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`. +- **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document under the configured attribute, a differently-specced one included (skipping other specs is an explicit opt-in). Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). One shared rule (`SessionPermissionRules`) for sign-in, automated sessions and the refreshing mapper: a Deny subtracts policy grants, the mirror's stored permissions, static fallbacks, the host source and earlier session permissions, also while the cut-over guard blocks (only bootstrap identity permissions are exempt); an invalid policy or a missing group/parent leaves break-glass only even while blocked; an unreadable graph/mirror with no claim leaves only the configured outage fallbacks; a guard error keeps the last known real state (never OFF, original time, 15 minute bound); the refreshing mapper reads Deny rules from the mirrored group rows on the same connection and refuses an inconsistent or truncated snapshot; the mirror stores a valid policy document unredacted (an invalid one is redacted like any attribute); bootstrap operators keep their identity permissions even when their own group is void or denies them; a token with no groups claim and no policy claim is unavailable, an empty groups array is known-empty; a missing parent voids; documents with an unknown top-level key or no `Statement` are rejected; the group fingerprint covers the configured policy attribute; the refreshing mapper reads Deny rules from the mirror (no API call). Review fixes: operators are credited with a floor permission only if their own policies do not deny it; the configured policy attribute is reserved against Edit Group/User attributes; un-voiding an invalid policy is a release; a Deny a person keeps deliberately no longer trips the cut-over guard; a Deny of a prefix or named resource also covers its dotted host names; empty Resource/Action/Permission arrays and malformed host names are rejected; Save Policy requires `expectedFingerprint`; `PolicyResult` / `resultFromClaims` / `resolveResult` replace the deprecated plain-set APIs. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`. - Fix: native **Create Group** now obeys the two-person gate. With `twoPersonApproval=true`, creating a group whose permissions expand to any `authentik.*` permission queues an `authentik_approval_request` for a second person instead of persisting the grant immediately; the same identity-permission expansion and second-person condition as Edit Group apply, nothing is written before approval, self-approval is refused and a decided request cannot be approved again. Previously a new group with `authentik.users.read` was created without approval. - **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255). - Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: `, `malformed ` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was. diff --git a/docs/POLICY-FORMAT.md b/docs/POLICY-FORMAT.md index 6314ec2..b281403 100644 --- a/docs/POLICY-FORMAT.md +++ b/docs/POLICY-FORMAT.md @@ -84,6 +84,12 @@ A grant that QQQ never checks (for example `hasAccess` on a table with insert/ed - **Void.** An invalid document, or a group or parent missing from the graph or mirror, leaves ONLY the bootstrap identity permissions: the floors, the host source, the mirror's stored grants and the session's earlier permissions are cleared, even while the guard blocks (the log carries an ERROR naming the groups). - **Unavailable.** If the group graph (or mirror) cannot be read and the token carries no policy claim, no `Deny` can be known: the host source, the session's earlier permissions and any directory grants are withheld, and only the explicitly configured outage fallbacks (static group permissions) plus break-glass remain. If the claim is present it carries every group's merged policy and applies on its own. +A token with no `groups` claim at all and no policy claim says nothing about the user's groups, so it is treated as unavailable; an empty `groups` array is a real answer (no groups) and is known-empty. + +**Operators and break-glass.** For the subset rule, the bootstrap identity permissions are added to an operator AFTER the `Deny` filter, so a void or denying group cannot lock a bootstrap operator out of repairing policies. + +**`AuthentikPolicyClaimMapper` differs on purpose.** It is the claim-only mapper: it has no bootstrap, static or host floors, so it does not go through the shared rule. With no policy claim it leaves the session's permissions untouched; with a valid claim it adds the claim's grants and removes every `Deny` from them and from the session's earlier permissions; with an invalid claim it clears all of them. Hosts that need floors or the cut-over guard use `AuthentikGroupPermissionMapper`. The helpers `AuthentikGroupPermissionMapper.resolve`/`resolveResult` and `PolicyResult.apply` are for a host customizer's own assembly, not for sign-in assembly, which the mappers do themselves. + `PolicyResult` reports `isVoid()`, `isUnavailable()` and `isAbsent()` separately, and `apply()` returns nothing when void or unavailable. **Freshness of the group graph.** Sign-in reads the group graph through a cache (`groupCacheTtlSeconds`): a `Deny` added in the Authentik admin UI is not seen at sign-in until the TTL elapses, unless the claim carries it. The refreshing mapper reads the mirrored group rows, so it sees a `Deny` as of the last mirror checkpoint (at most 15 minutes old), read on the same connection as the user row and refused unless every group row carries that checkpoint's refresh time and the table is under its bound. -- 2.54.0 From 49905f8e864a0beeab317ee690e206d0f70f9d05 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 20:32:00 -0600 Subject: [PATCH 12/14] fix(policy): break-glass operators keep identity permissions; absent groups claim is unavailable Bootstrap identity permissions are added to an operator after the Deny filter, so a void or denying group cannot lock them out of repairing policies. A token with no groups claim and no policy claim is unavailable (an empty array stays known). The mirror restores a policy attribute verbatim only when it parses. Docs mark the resolve/resolveResult/apply helpers as not for sign-in assembly. More mapper tests (void claim alone, bootstrap under void, guard error at sign-in, specific QAuthenticationException on refused mirror snapshots). Co-Authored-By: Claude Sonnet 5.5 --- .../qbits/authentik/admin/AuthentikAdmin.java | 9 +- .../AuthentikGroupPermissionMapper.java | 20 +++- .../qbits/authentik/policy/PolicyResult.java | 3 +- .../sync/AuthentikMirrorSnapshot.java | 24 ++++- .../authentik/admin/PolicyEditorTest.java | 94 ++++++++++++++++++- .../AuthentikGroupPermissionMapperTest.java | 44 +++++++++ .../sync/AuthentikMirrorSnapshotTest.java | 19 +++- .../sync/AuthentikMirrorSqlTest.java | 6 +- 8 files changed, 202 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java index 7770b51..7bcf362 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java @@ -361,6 +361,7 @@ public class AuthentikAdmin Set permissions = new TreeSet<>(); Set appAccess = new TreeSet<>(); Set floorCredit = new TreeSet<>(); + Set bootstrapNames = new TreeSet<>(); PolicyRules denials = PolicyRules.EMPTY; JSONObject user; @@ -418,7 +419,7 @@ public class AuthentikAdmin { for(IdentityPermission permission : IdentityPermission.values()) { - floorCredit.add(permission.getSessionPermission()); + bootstrapNames.add(permission.getSessionPermission()); } } for(String name : floorCredit) @@ -428,6 +429,12 @@ public class AuthentikAdmin permissions.add(name); } } + /////////////////////////////////////////////////////////////////////// + // bootstrap identity permissions are break-glass: added AFTER the Deny // + // filter so a void or denying group cannot lock the operator out of // + // repairing the policy // + /////////////////////////////////////////////////////////////////////// + permissions.addAll(bootstrapNames); operator = new Operator(email, pk, permissions, appAccess, bootstrap, denials); return (operator); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java index c3c3b9d..8d977d9 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java @@ -130,7 +130,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust JSONObject withPolicy = PolicyPermissions.claimsWithPolicy(context, format); PolicyRules claim = PolicyPermissions.rulesFromClaims(withPolicy, format, catalog); - qSession.setPermissions(compute(qInstance, runtime, groups, sub, email, verified, claim, withPolicy != null, catalog, qSession.getPermissions(), false)); + qSession.setPermissions(compute(qInstance, runtime, groups, sub, email, verified, claim, withPolicy != null, claims.has("groups"), catalog, qSession.getPermissions(), false)); com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims); } @@ -145,14 +145,19 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust ** bootstrap identity permissions; an unreadable graph with no claim leaves the ** configured outage fallbacks. ** + ** @param groupsClaimPresent the token has a groups claim (an empty array counts) ** @param forcedVoid the caller already knows a group of the user could not be resolved *******************************************************************************/ private Set compute(QInstance qInstance, AuthentikRuntime runtime, List groups, String sub, String email, boolean verified, - PolicyRules claim, boolean claimPresent, PolicyCatalog catalog, Collection sessionPermissions, boolean forcedVoid) + PolicyRules claim, boolean claimPresent, boolean groupsClaimPresent, PolicyCatalog catalog, Collection sessionPermissions, boolean forcedVoid) { Detailed detailed = resolveDetailed(qInstance, groups, sub, email, verified); boolean voided = forcedVoid || claim.isVoid() || detailed.denials().isVoid(); - boolean noPolicyInformation = detailed.unavailable() && !claimPresent; + /////////////////////////////////////////////////////////////////////// + // a token with no groups claim at all (not an empty list) and no // + // policy claim tells us nothing about the user's groups // + /////////////////////////////////////////////////////////////////////// + boolean noPolicyInformation = (detailed.unavailable() || !groupsClaimPresent) && !claimPresent; SessionPermissionRules.Knowledge knowledge = voided ? SessionPermissionRules.Knowledge.VOID : noPolicyInformation ? SessionPermissionRules.Knowledge.UNAVAILABLE : SessionPermissionRules.Knowledge.KNOWN; PolicyRules denials = voided ? PolicyRules.EMPTY : PolicyRules.merge(List.of(claim.denialsOnly(), detailed.denials().denialsOnly())); @@ -289,7 +294,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust /////////////////////////////////////////////////////////////////// // a group pk the graph does not know may hold a Deny: void // /////////////////////////////////////////////////////////////////// - permissions = compute(qInstance, runtime, groups, sub, userEmail, verified, PolicyRules.EMPTY, false, catalog, session.getPermissions(), unresolvedGroup); + permissions = compute(qInstance, runtime, groups, sub, userEmail, verified, PolicyRules.EMPTY, false, true, catalog, session.getPermissions(), unresolvedGroup); } else { @@ -313,6 +318,9 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust ** The static group fallbacks are subject to a Deny like any other grant; ** the bootstrap identity permissions are not. ** + ** NOT FOR SIGN-IN ASSEMBLY: sign-in, automated sessions and refresh go through + ** SessionPermissionRules.compute (customizeSession); this is a read helper. + ** ** @deprecated a plain set cannot tell void from absent or carry a Deny to the ** host's other sources; use resolveResult(...).apply(others) *******************************************************************************/ @@ -343,7 +351,9 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust ** groups' policies and qqq.io/permissions grant; UNAVAILABLE when the graph ** cannot be read. The static and bootstrap floor is NOT included; pass it, and ** the host's own service permissions, to PolicyResult.apply so a Deny cannot - ** be bypassed. + ** be bypassed. NOT FOR SIGN-IN ASSEMBLY: customizeSession applies the one shared + ** rule (SessionPermissionRules.compute) itself; use this only for a host + ** customizer that builds its own permission set. *******************************************************************************/ public PolicyResult resolveResult(QInstance qInstance, Collection groupNames, String sub, String email, boolean emailVerified) { diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java index b78b721..a82b16d 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java @@ -103,7 +103,8 @@ public record PolicyResult(PolicyRules rules, Set granted, boolean unava ** The permissions granted here plus the other sources the host supplies, ** minus every Deny. Empty when void or unavailable: an invalid or unreadable ** policy never lets other sources through unfiltered. The other sources are - ** not modified. + ** not modified. NOT FOR SIGN-IN ASSEMBLY by this qbit's mappers (they use + ** SessionPermissionRules.compute); it is for a host customizer's own sources. *******************************************************************************/ public Set apply(Collection otherSources) { diff --git a/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshot.java b/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshot.java index fc5bcef..5a18633 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshot.java +++ b/src/main/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshot.java @@ -98,7 +98,7 @@ public record AuthentikMirrorSnapshot(List users, List groups, } groups.add(new QRecord().withValue("pk", pk).withValue("name", node.name()) .withValue("parentPks", json(new TreeSet<>(node.parentPks()))) - .withValue("attributes", redactedAttributes(attributes, config.getPolicyFormat().attribute())) + .withValue("attributes", redactedAttributes(attributes, config.getPolicyFormat(), catalog)) .withValue("memberCount", node.memberPks().size()).withValue("isSuperuser", graph.isSuperuserInherited(pk)) .withValue("refreshedAt", refreshedAt)); } @@ -155,14 +155,30 @@ public record AuthentikMirrorSnapshot(List users, List groups, } /******************************************************************************* - ** The mirrored attributes with secret-looking values redacted EXCEPT the policy + ** The mirrored attributes with secret-looking values redacted EXCEPT a VALID policy ** document, which must survive byte for byte: the mirror is where the refreshing ** mapper reads Deny rules, and a redacted Deny would be a lost Deny. *******************************************************************************/ - static String redactedAttributes(JSONObject attributes, String policyAttribute) + static String redactedAttributes(JSONObject attributes, com.kingsrook.qbits.authentik.policy.PolicyFormat format, PolicyCatalog catalog) { - JSONObject rest = new JSONObject(attributes.toString()); + String policyAttribute = format.attribute(); + JSONObject rest = new JSONObject(attributes.toString()); Object policy = rest.has(policyAttribute) ? rest.remove(policyAttribute) : null; + if(policy != null) + { + try + { + com.kingsrook.qbits.authentik.policy.PolicyParser.parseStrict(policy, format, catalog); + } + catch(com.kingsrook.qbits.authentik.policy.PolicyParseException e) + { + /////////////////////////////////////////////////////////////////// + // not a valid policy: it is just an attribute, redact it as any // + /////////////////////////////////////////////////////////////////// + rest.put(policyAttribute, policy); + policy = null; + } + } JSONObject safe = new JSONObject(AuthentikRedactor.redactText(rest.toString(), null)); if(policy != null) { diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java index 6b14ea2..08fdbd5 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyEditorTest.java @@ -742,15 +742,16 @@ class PolicyEditorTest /******************************************************************************* - ** A bootstrap operator denied an authentik.* name cannot release a Deny on it. + ** A bootstrap (break-glass) operator is exempt from a Deny on authentik.* names: + ** they still hold them, so they can repair or release such a Deny. *******************************************************************************/ @Test - void bootstrapOperatorDeniedAnIdentityNameCannotReleaseDenyOnIt() throws Exception + void bootstrapOperatorIsExemptFromDenyOnIdentityNames() throws Exception { setPolicy("kof-website-analysts", policyOf(DENY_USERS)); setPolicy("kof-website-publishers", policyOf(DENY_USERS)); t.config.withBootstrapOperatorSubjects("1"); - assertThat(save("kof-website-analysts", NO_STATEMENTS).message()).contains("do not hold yourself", "authentik.users.manage.hasAccess"); + assertThat(save("kof-website-analysts", NO_STATEMENTS).ok()).as("break-glass: bootstrap names are never denied, so the operator can release this Deny").isTrue(); } @@ -1100,4 +1101,91 @@ class PolicyEditorTest assertThat(session.getPermissions()).as("blocked + void: floor, host and earlier permissions are all cleared").isEmpty(); } + + + + + /******************************************************************************* + ** A bootstrap (break-glass) operator keeps every identity permission when their + ** own group is void or denies authentik.*, so they can still repair policies. + *******************************************************************************/ + @Test + void bootstrapOperatorKeepsBreakGlassWhenTheirGroupIsVoidOrDenied() throws Exception + { + t.config.withBootstrapOperatorSubjects("1"); + List identity = java.util.Arrays.stream(IdentityPermission.values()).map(IdentityPermission::getSessionPermission).toList(); + setPolicy("kof-website-publishers", policyOf(BAD_STATEMENT)); + assertThat(t.admin().operator().permissions()).as("void group").containsAll(identity); + setPolicy("kof-website-publishers", policyOf("{\"Effect\":\"Deny\",\"Permission\":[\"authentik.policies.manage.hasAccess\",\"authentik.users.manage.hasAccess\"]}")); + assertThat(t.admin().operator().permissions()).as("denied identity names").containsAll(identity); + assertThat(t.admin().operator().denials().hasDeny()).isTrue(); + } + + + + /******************************************************************************* + ** Break-glass survives a void policy at sign-in with a real bootstrap user. + *******************************************************************************/ + @Test + void bootstrapUserKeepsOnlyBreakGlassUnderVoid() throws Exception + { + t.authentik.user(10, "boot10", "boot10@example.test", "internal", true, "kof-website-analysts"); + t.config.withBootstrapOperatorSubjects("10"); + setPolicy("kof-website-analysts", policyOf(BAD_STATEMENT)); + List identity = java.util.Arrays.stream(IdentityPermission.values()).map(IdentityPermission::getSessionPermission).toList(); + QSession session = signIn(null, Set.of("other.keep"), "10", "kof-website-analysts"); + assertThat(session.getPermissions()).containsExactlyInAnyOrderElementsOf(identity); + } + + + + /******************************************************************************* + ** An invalid CLAIM alone voids the user (the graph is valid) and clears the + ** floors too. + *******************************************************************************/ + @Test + void voidClaimAloneClearsFloorsAndEarlierPermissions() throws Exception + { + t.config.withStaticGroupPermissions(Map.of("kof-website-analysts", List.of("static.keep"))); + QSession fine = signIn(null, Set.of("other.keep"), "100", "kof-website-analysts"); + assertThat(fine.getPermissions()).contains("static.keep", "other.keep"); + QSession voided = signIn(policyOf(BAD_STATEMENT), Set.of("other.keep"), "100", "kof-website-analysts"); + assertThat(voided.getPermissions()).isEmpty(); + } + + + + /******************************************************************************* + ** The cut-over guard cannot run (the client throws): its last known real state + ** stands; with none it blocks, and blocking withholds the policies' grants. + *******************************************************************************/ + @Test + void guardErrorAtSignInUsesLastKnownStateOrBlocks() throws Exception + { + AuthentikGroupPermissionMapper broken = new AuthentikGroupPermissionMapper() + { + @Override + protected com.kingsrook.qbits.authentik.client.AuthentikActionUtil client(com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance) + { + throw (new IllegalStateException("boom")); + } + }; + t.config.withMapperEnabled(true).withMapperOverride(true); + t.config.withHostPermissionSource((email, groups) -> Set.of("host.today")); + String claimPolicy = policyOf("{\"Effect\":\"Allow\",\"Action\":[\"read\"],\"Resource\":[\"cms_page\"]}"); + JSONObject claims = new JSONObject().put("groups", List.of()).put("sub", "100").put("email", "x@example.test").put("qqq_policy", new JSONObject(claimPolicy)); + var runtime = com.kingsrook.qbits.authentik.AuthentikRuntime.forBackend(t.config.getApiBackendName()); + runtime.markMapperInvoked(); + + runtime.setMapperState(new AccessComparison.State(AccessComparison.Status.ACTIVE, "recent", java.time.Instant.now())); + QSession active = new QSession(); + broken.customizeSession(t.qInstance, active, Map.of("jwtPayloadJsonObject", claims)); + assertThat(active.getPermissions()).as("last known ACTIVE: the claim's grants apply").contains("cms_page.read").doesNotContain("host.today"); + + runtime.setMapperState(null); + QSession blocked = new QSession(); + broken.customizeSession(t.qInstance, blocked, Map.of("jwtPayloadJsonObject", claims)); + assertThat(blocked.getPermissions()).as("no known state: blocked, today's access").contains("host.today").doesNotContain("cms_page.read"); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java index 1da98b1..e3cbb3c 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java @@ -561,4 +561,48 @@ class AuthentikGroupPermissionMapperTest assertThat(session.getPermissions()).isEmpty(); } + + + + + /******************************************************************************* + ** A token with NO groups claim and no policy claim says nothing about the + ** user's groups: unavailable, so earlier permissions are withheld. An empty + ** groups array is a real answer (no groups) and keeps them. + *******************************************************************************/ + @Test + void absentGroupsClaimIsUnavailableEmptyArrayIsKnown() + { + Map noGroups = new HashMap<>(); + noGroups.put(AuthentikGroupPermissionMapper.CLAIMS_CONTEXT_KEY, new JSONObject().put("email", "someone@example.test")); + QSession absent = new QSession(); + absent.setPermissions(Set.of("other.keep")); + new AuthentikGroupPermissionMapper().customizeSession(qInstance, absent, noGroups); + assertThat(absent.getPermissions()).isEmpty(); + + QSession empty = new QSession(); + empty.setPermissions(Set.of("other.keep")); + new AuthentikGroupPermissionMapper().customizeSession(qInstance, empty, context("someone@example.test", new JSONArray())); + assertThat(empty.getPermissions()).contains("other.keep"); + } + + + + /******************************************************************************* + ** The graph is unreadable but the claim is valid: the claim's Deny still + ** removes the static fallback and the session's earlier permissions. + *******************************************************************************/ + @Test + void graphUnreadableWithAValidClaimDeniesFloorAndExisting() + { + FixtureActionUtil.groupGraphBody = "not json"; + String deny = "{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.1.0\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":[\"edit\"],\"Resource\":[\"other\"]}]}"; + Map ctx = context("someone@example.test", new JSONArray().put("g")); + ctx.put("idToken", new JSONObject().put("qqq_policy", deny)); + QSession session = new QSession(); + session.setPermissions(Set.of("other.edit", "keep.me")); + new AuthentikGroupPermissionMapper().withGroupPermissions("g", List.of("static.keep", "other.edit")).customizeSession(qInstance, session, ctx); + assertThat(session.getPermissions()).contains("static.keep", "keep.me").doesNotContain("other.edit"); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java index 5f1f91e..07d0e6f 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSnapshotTest.java @@ -206,8 +206,25 @@ class AuthentikMirrorSnapshotTest Object kept = new org.json.JSONObject(stored).get("qqq.io/policy"); assertThat(new org.json.JSONObject(kept.toString()).toString()).isEqualTo(new org.json.JSONObject(policy.toString()).toString()); } - assertThat(AuthentikMirrorSnapshot.redactedAttributes(new org.json.JSONObject().put("password", "hunter2").put("qqq.io/policy", stringPolicy), "qqq.io/policy")) + assertThat(AuthentikMirrorSnapshot.redactedAttributes(new org.json.JSONObject().put("password", "hunter2").put("qqq.io/policy", stringPolicy), com.kingsrook.qbits.authentik.policy.PolicyFormat.DEFAULT, null)) .doesNotContain("hunter2").contains("token"); } + + + + + /******************************************************************************* + ** Only a VALID policy is restored verbatim; anything else under the policy + ** attribute is redacted like any other attribute. + *******************************************************************************/ + @Test + void invalidPolicyAttributeIsRedactedLikeAnyOther() + { + String notAPolicy = "{\"password\":\"hunter2\",\"x\":1}"; + String stored = AuthentikMirrorSnapshot.redactedAttributes(new org.json.JSONObject().put("qqq.io/policy", new org.json.JSONObject(notAPolicy)), + com.kingsrook.qbits.authentik.policy.PolicyFormat.DEFAULT, null); + assertThat(stored).doesNotContain("hunter2"); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSqlTest.java b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSqlTest.java index a8ded42..2aee2e0 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSqlTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorSqlTest.java @@ -2173,7 +2173,8 @@ class AuthentikMirrorSqlTest ////////////////////////////////// a group snapshot from another checkpoint is unusable insertGroup("g-readers", "Readers", "{}"); sql("UPDATE authentik_group_mirror SET refreshed_at = TIMESTAMP '2001-01-01 00:00:00'"); - assertThatThrownBy(() -> module.createSession(instance, context)).isInstanceOf(Exception.class); + assertThatThrownBy(() -> module.createSession(instance, context)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException.class) + .hasMessageContaining("Failed to create session"); ////////////////////////////////// truncation is refused, never silently cut sql("UPDATE authentik_group_mirror SET refreshed_at = (SELECT refreshed_at FROM authentik_user_mirror)"); @@ -2182,7 +2183,8 @@ class AuthentikMirrorSqlTest try { AuthentikMirrorAccess.maxGroups = 1; - assertThatThrownBy(() -> module.createSession(instance, context)).isInstanceOf(Exception.class); + assertThatThrownBy(() -> module.createSession(instance, context)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException.class) + .hasMessageContaining("Failed to create session"); } finally { -- 2.54.0 From e2a3cd3021c1145207c2366f4e4edc17e6655297 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 20:38:27 -0600 Subject: [PATCH 13/14] test(policy): skip the python blueprint run when the CI image has no python The shared CI runner image has no python3, so the 'fail without python' rule made every build red at the unit stage. The python run now skips unless BLUEPRINT_PYTHON_REQUIRED=true; a pure-Java test keeps the blueprint's safety shape checked everywhere. Co-Authored-By: Claude Sonnet 5.5 --- .../policy/BlueprintClaimExpressionTest.java | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java index ade89a5..9ab142f 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/BlueprintClaimExpressionTest.java @@ -235,22 +235,45 @@ class BlueprintClaimExpressionTest /******************************************************************************* - ** A python interpreter; the test FAILS without one (CI must have python) unless - ** BLUEPRINT_PYTHON_OPTIONAL=true opts out, in which case it is skipped. + ** A python interpreter, or the test is SKIPPED: the shared CI runner image has + ** no python3, and a missing interpreter is not a defect of the code under test. + ** Set BLUEPRINT_PYTHON_REQUIRED=true (a dev machine, a CI image with python) + ** to make its absence a failure. blueprintKeepsItsSafetyShape runs everywhere. *******************************************************************************/ private static String pythonOrFail() { String python = python(); - if(python == null && "true".equalsIgnoreCase(System.getenv("BLUEPRINT_PYTHON_OPTIONAL"))) + if(python == null && !"true".equalsIgnoreCase(System.getenv("BLUEPRINT_PYTHON_REQUIRED"))) { - Assumptions.assumeTrue(false, "no python interpreter (BLUEPRINT_PYTHON_OPTIONAL=true)"); + Assumptions.assumeTrue(false, "no python interpreter (set BLUEPRINT_PYTHON_REQUIRED=true to require it)"); } - assertThat(python).as("python3 is required to run the blueprint expression (set BLUEPRINT_PYTHON_OPTIONAL=true to skip)").isNotNull(); + assertThat(python).as("BLUEPRINT_PYTHON_REQUIRED=true but no python3/python interpreter works").isNotNull(); return (python); } + /******************************************************************************* + ** Without python, the blueprint expression still has to keep the guards the + ** python run proves: strict duplicate keys, the exact-spec check behind the + ** opt-in, the three-key shape with a required Statement, version and list checks, + ** and the void claim it emits. + *******************************************************************************/ + @Test + void blueprintKeepsItsSafetyShape() throws IOException + { + String yaml; + try(InputStream in = BlueprintClaimExpressionTest.class.getResourceAsStream("/blueprints/qqq-policy-claim.yaml")) + { + yaml = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + assertThat(yaml).contains("skip_other_specs = False", "object_pairs_hook=no_duplicate_keys", "raise ValueError(\"duplicate key\")", + "document.get(\"PolicySpec\") != spec", "\"Statement\" not in document", "(\"PolicySpec\", \"PolicyVersion\", \"Statement\")", + "document.get(\"PolicyVersion\") != version", "not isinstance(found, list)", "\"PolicyVersion\": \"invalid\""); + } + + + /******************************************************************************* ** A working python interpreter, or null. *******************************************************************************/ -- 2.54.0 From 2c5baa336d4c1f9a62737a00e6237c7f71728bd4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 20:48:19 -0600 Subject: [PATCH 14/14] ci: re-run (the previous run hit a runner Docker port-binding error in Testcontainers) Co-Authored-By: Claude Sonnet 5.5 -- 2.54.0