Compare commits
31
Commits
No files matched your search
@@ -3,5 +3,8 @@ on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@main
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
# Starts a full Authentik stack per build: use the large runners.
|
||||
runner: container-build-large
|
||||
@@ -4,7 +4,10 @@ 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 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.
|
||||
- Fix (performance): one policy resolution builds the permission universe once, whatever the policy holds. `PolicyRules.allowed`, `resolve` and `withheld` read the universe they are given once per call (they read it once per Allow term), so `PolicyPermissions.resultFromClaims`, `fromClaims`, both claims mappers and the editor's save-time checks no longer pay one `PolicyCatalog` fingerprint, one walk of every resource and one call of every host-name function for each named permission. A host customizer runs on every request of a session, so a role of nine named permissions paid nine universes per request: on a 1,400-object instance one resolution went from 12 ms and 7.9 MB to 0.3 ms and 0.12 MB, and it no longer grows with the number of names. `PolicyCatalog.of` checks that its cached catalog is current without building the fingerprint text, and the catalog keeps its permission names. Grants are unchanged (the golden vectors pass as they were); the universe and the catalog still follow in-place instance changes on the next call; nothing is cached across resolutions. New: `PolicyUniverse.once(universe)`.
|
||||
- `PolicyResult.replacement()`: the complete permission set of a session whose only source is the policies (Allow minus Deny, nothing carried over, nothing when void or unreadable). `apply(otherSources)` is unchanged and remains a union; a host that calls `apply(session.getPermissions())` keeps what the session already held, so a host whose permissions come only from the token calls `session.setPermissions(result.replacement())`.
|
||||
- **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: <permission>`, `malformed <attribute>` 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.
|
||||
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
|
||||
|
||||
@@ -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.
|
||||
|
||||
+67
-15
@@ -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,15 +28,16 @@ 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, 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 |
|
||||
|---|---|---|
|
||||
@@ -56,7 +57,50 @@ 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`. 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`.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.** 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.
|
||||
|
||||
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.
|
||||
|
||||
**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; 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. `apply` is a union: `apply(session.getPermissions())` keeps whatever the session already held that no `Deny` covers. A host whose sessions take their permissions from the policies alone (QQQ runs the customizer again on every request of a session, and the session must end with exactly what the current token grants) calls `session.setPermissions(result.replacement())` instead: the grant alone, nothing carried over, nothing when the policy is void or unreadable.
|
||||
|
||||
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 +115,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 +149,22 @@ 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)
|
||||
- `Action` or `Resource` is missing or not an array of strings
|
||||
- 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 (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)
|
||||
- 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,13 +181,13 @@ 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.
|
||||
|
||||
## 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 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.
|
||||
@@ -147,6 +197,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 is cached per `QInstance` (the last few instances, keyed by a digest of table permission rules, supplemental metadata and object names); `PolicyCatalog.clearCache()` forgets them. The cache lives in each JVM and is derived only from that JVM's own instance, so nodes of a cluster need no invalidation between them. Every `PolicyCatalog.of` call compares the instance with the digest, so an in-place metadata change is seen by the next call. The permission universe is never cached: it is built from the catalog's names, the host-declared names and the identity names at each resolution, once per resolution however many terms the policy has (`PolicyRules` reads the universe it is given once per call; `PolicyUniverse.once` does the same for a caller's own loop). No decision is cached: the same claim is resolved again on every request. Host-declared names are never cached, and a host-name function runs once per resolution, so it should be cheap. 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
@@ -38,7 +39,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.1.0</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -164,7 +165,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.37</version>
|
||||
<version>1.0.0-RC.38</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
@@ -283,7 +284,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.21</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
@@ -296,18 +297,6 @@
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>sonatype-snapshots</id>
|
||||
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -134,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<String> permissions, Set<String> appAccess, boolean bootstrap)
|
||||
public record Operator(String email, Integer pk, Set<String> permissions, Set<String> appAccess, boolean bootstrap, PolicyRules denials)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -352,6 +360,9 @@ public class AuthentikAdmin
|
||||
Integer pk = null;
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
Set<String> appAccess = new TreeSet<>();
|
||||
Set<String> floorCredit = new TreeSet<>();
|
||||
Set<String> bootstrapNames = new TreeSet<>();
|
||||
PolicyRules denials = PolicyRules.EMPTY;
|
||||
|
||||
JSONObject user;
|
||||
if(deferredInvitation != null)
|
||||
@@ -381,20 +392,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<String> 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)
|
||||
@@ -406,10 +419,23 @@ public class AuthentikAdmin
|
||||
{
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
permissions.add(permission.getSessionPermission());
|
||||
bootstrapNames.add(permission.getSessionPermission());
|
||||
}
|
||||
}
|
||||
operator = new Operator(email, pk, permissions, appAccess, bootstrap);
|
||||
for(String name : floorCredit)
|
||||
{
|
||||
if(!denials.denies(name))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1361,11 +1387,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<String> withheld = new TreeSet<>();
|
||||
for(GroupGraph.Node held : graph().selfAndAncestors(node.pk()))
|
||||
{
|
||||
withheld.addAll(held.policy().withheld(universe));
|
||||
}
|
||||
Set<String> 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.
|
||||
*******************************************************************************/
|
||||
@@ -1472,7 +1528,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."));
|
||||
}
|
||||
@@ -1489,6 +1545,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<Integer> before = adminsNow();
|
||||
@@ -1547,7 +1612,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."));
|
||||
@@ -1573,7 +1638,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."));
|
||||
@@ -1626,7 +1691,7 @@ public class AuthentikAdmin
|
||||
List<String> 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."));
|
||||
}
|
||||
@@ -1686,66 +1751,88 @@ public class AuthentikAdmin
|
||||
Set<String> extrasNew = new TreeSet<>(AuthentikSafety.parseList(request.str(EXTRA_PERMISSIONS)));
|
||||
extrasNew.removeAll(oldNamed);
|
||||
|
||||
Set<String> 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<String> 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<String> 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)
|
||||
{
|
||||
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, config.getPolicyFormat().attribute())))
|
||||
{
|
||||
return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again."));
|
||||
}
|
||||
|
||||
Set<String> added = new TreeSet<>(newPermissions);
|
||||
added.removeAll(oldPermissions);
|
||||
Set<String> removed = new TreeSet<>(oldPermissions);
|
||||
removed.removeAll(newPermissions);
|
||||
Set<String> effectiveNew = newRules.resolve(universe);
|
||||
Set<String> effectiveOld = oldRules.resolve(universe);
|
||||
Set<String> added = new TreeSet<>(effectiveNew);
|
||||
added.removeAll(effectiveOld);
|
||||
Set<String> removed = new TreeSet<>(effectiveOld);
|
||||
removed.removeAll(effectiveNew);
|
||||
Set<String> released = new TreeSet<>(oldRules.withheld(universe));
|
||||
released.removeAll(newRules.withheld(universe));
|
||||
|
||||
Set<String> unknownNew = runtime.getPolicyCatalog().unknownPermissions(added);
|
||||
Set<String> exactAdded = exactAllowed(newRules);
|
||||
exactAdded.removeAll(exactAllowed(oldRules));
|
||||
exactAdded.removeAll(newNamed);
|
||||
Set<String> 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<String> needed = new TreeSet<>(added);
|
||||
if(fullAccess)
|
||||
{
|
||||
needed = resolver().expand(PolicyPermissions.FULL_ACCESS);
|
||||
}
|
||||
else if(oldPermissions.contains(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
needed.removeAll(resolver().expand(PolicyPermissions.FULL_ACCESS));
|
||||
}
|
||||
Set<String> needed = new TreeSet<>(fullAccess ? effectiveNew : added);
|
||||
needed.addAll(released);
|
||||
for(String extra : extrasNew)
|
||||
{
|
||||
needed.addAll(resolver().expand(extra));
|
||||
@@ -1761,20 +1848,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 +1874,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<String> 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<String> keptUnknown = runtime.getPolicyCatalog().unknownPermissions(newPermissions);
|
||||
Set<String> 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 +1912,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<String> exactAllowed(PolicyRules rules)
|
||||
{
|
||||
Set<String> 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
|
||||
@@ -1832,15 +1945,23 @@ 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, config.getPolicyFormat().attribute()));
|
||||
}
|
||||
PolicyFormat format = config.getPolicyFormat();
|
||||
Set<String> policy = new TreeSet<>();
|
||||
Object stored = group.optJSONObject("attributes") == null ? null : group.getJSONObject("attributes").opt(format.attribute());
|
||||
policy.addAll(PolicyParser.parse(stored, format, runtime.getPolicyCatalog()));
|
||||
PolicyRules existing = PolicyParser.parse(stored, format, runtime.getPolicyCatalog());
|
||||
if(existing.isVoid())
|
||||
{
|
||||
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<PolicyTerm> policy = new LinkedHashSet<>(existing.allowTerms());
|
||||
|
||||
Set<String> expressible = runtime.getPolicyCatalog().permissionNames();
|
||||
Set<String> extras = new TreeSet<>();
|
||||
@@ -1849,7 +1970,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 +1983,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)
|
||||
{
|
||||
@@ -1968,7 +2089,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.");
|
||||
}
|
||||
@@ -1992,14 +2113,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<String> changed = new TreeSet<>();
|
||||
for(String key : submitted.keySet())
|
||||
{
|
||||
@@ -2024,14 +2145,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));
|
||||
}
|
||||
@@ -2040,7 +2161,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));
|
||||
}
|
||||
@@ -2053,14 +2174,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));
|
||||
}
|
||||
@@ -2072,11 +2193,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -2105,13 +2228,19 @@ public class AuthentikAdmin
|
||||
/*******************************************************************************
|
||||
** "n permission(s): a, b, ..." for the audit before/after.
|
||||
*******************************************************************************/
|
||||
private static String policySummary(Set<String> 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<String> allow = rules.allowLabels();
|
||||
return ((allow.isEmpty() ? "no grants" : allow.size() + " permission(s): " + abbreviate(allow)) + deny);
|
||||
}
|
||||
|
||||
|
||||
@@ -2119,7 +2248,7 @@ public class AuthentikAdmin
|
||||
/*******************************************************************************
|
||||
** The first 12 names, then "and n more".
|
||||
*******************************************************************************/
|
||||
private static String abbreviate(Set<String> names)
|
||||
private static String abbreviate(Collection<String> names)
|
||||
{
|
||||
List<String> list = new ArrayList<>(names);
|
||||
if(list.size() <= 12)
|
||||
@@ -2542,7 +2671,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.");
|
||||
@@ -2617,7 +2746,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"))
|
||||
{
|
||||
@@ -2726,13 +2855,13 @@ public class AuthentikAdmin
|
||||
{
|
||||
AdminRequest request = plan.getRequest();
|
||||
PolicyFormat format = config.getPolicyFormat();
|
||||
Set<String> 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 +2883,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 +2895,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.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -141,7 +141,7 @@ public final class AuthentikSafety
|
||||
{
|
||||
List<Integer> members = new ArrayList<>(node.memberPks());
|
||||
members.remove(Integer.valueOf(userPk));
|
||||
return (new GroupGraph.Node(node.pk(), node.name(), node.ownSuperuser(), node.parentPks(), node.permissions(), node.appAccess(), members, node.malformed(), 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<String> permissions, List<String> 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<String> 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -214,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<String, Object> qqq = new java.util.TreeMap<>();
|
||||
@@ -221,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)));
|
||||
}
|
||||
|
||||
@@ -65,7 +65,13 @@ public final class AuthentikHealthChecks
|
||||
public static void register(QInstance instance, AuthentikQBitConfig config)
|
||||
{
|
||||
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
for(String name : List.of("api", "api_token", "mapper", "token_broker"))
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the token broker is only used when tokens are on; with tokens off a registered check //
|
||||
// could only ever report UNKNOWN "not configured", and core cannot remove a registered check. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
List<String> names = Boolean.TRUE.equals(config.getTokensEnabled())
|
||||
? List.of("api", "api_token", "mapper", "token_broker") : List.of("api", "api_token", "mapper");
|
||||
for(String name : names)
|
||||
{
|
||||
registry.withCheck(new HealthCheckMetaData().withName("authentik." + name).withLabel("Authentik " + name)
|
||||
.withComponent("authentik").withCategory(HealthCategory.IDENTITY).withKind(HealthCheckKind.DEPENDENCY)
|
||||
|
||||
@@ -49,16 +49,21 @@ 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).";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String> groups, Set<String> before, Set<String> after,
|
||||
Set<String> added, Set<String> removed, boolean guarded)
|
||||
Set<String> added, Set<String> removed, boolean guarded, boolean voided)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -136,8 +141,13 @@ public final class AccessComparison
|
||||
added.removeAll(before);
|
||||
Set<String> 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);
|
||||
}
|
||||
@@ -164,8 +174,22 @@ public final class AccessComparison
|
||||
public static Set<String> mapperPermissions(AuthentikQBitConfig config, EffectivePermissionResolver resolver, Collection<String> groupPks, Collection<String> directGroupNames,
|
||||
String userPk, String userUuid, String excludePolicyOfGroupPk)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>(resolver.resolveGroups(groupPks, excludePolicyOfGroupPk).sessionPermissions());
|
||||
rs.addAll(staticPermissions(config, directGroupNames));
|
||||
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(groupPks, excludePolicyOfGroupPk);
|
||||
Set<String> 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<String> floor = new TreeSet<>(staticPermissions(config, directGroupNames));
|
||||
if(resolution.voided())
|
||||
{
|
||||
floor.clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
floor.removeIf(resolution.denials()::denies);
|
||||
}
|
||||
rs.addAll(floor);
|
||||
Set<String> subjects = config.getResolvedBootstrapSubjects();
|
||||
if(subjects.contains(userPk) || (userUuid != null && !userUuid.isEmpty() && subjects.contains(userUuid)))
|
||||
{
|
||||
@@ -208,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).
|
||||
*******************************************************************************/
|
||||
@@ -252,13 +304,24 @@ public final class AccessComparison
|
||||
+ String.join(", ", config.getCutoverGuardGroups()) + " loses a permission).", Instant.now()));
|
||||
}
|
||||
List<String> 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)
|
||||
{
|
||||
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 //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
LOG.error("The cut-over guard could not run", logPair("errorType", e.getClass().getSimpleName()));
|
||||
State last = lastKnown(runtime);
|
||||
if(last != null)
|
||||
{
|
||||
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()));
|
||||
}
|
||||
|
||||
+185
-103
@@ -33,6 +33,8 @@ 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;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
@@ -117,70 +119,116 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<String> groups = groupNames(claims);
|
||||
String email = claims.optString("email", "").trim();
|
||||
String sub = claims.optString("sub", "").trim();
|
||||
List<String> groups = groupNames(claims);
|
||||
String email = claims.optString("email", "").trim();
|
||||
String sub = claims.optString("sub", "").trim();
|
||||
boolean verified = claims.optBoolean("email_verified", false);
|
||||
|
||||
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, sub, email, verified));
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// the policy claim (docs/POLICY-FORMAT.md) carries the same group data //
|
||||
// as the graph read above; the union also covers an unreachable API //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
AuthentikRuntime runtime = runtime();
|
||||
PolicyFormat format = runtime == null ? PolicyFormat.DEFAULT : runtime.getConfig().getPolicyFormat();
|
||||
permissions.addAll(PolicyPermissions.fromClaims(PolicyPermissions.claimsWithPolicy(context, format), format,
|
||||
qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance)));
|
||||
PolicyCatalog catalog = qInstance == null ? (runtime == null ? null : runtime.getPolicyCatalog()) : PolicyCatalog.of(qInstance);
|
||||
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, claims.has("groups"), catalog, qSession.getPermissions(), false));
|
||||
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 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<String> compute(QInstance qInstance, AuthentikRuntime runtime, List<String> groups, String sub, String email, boolean verified,
|
||||
PolicyRules claim, boolean claimPresent, boolean groupsClaimPresent, PolicyCatalog catalog, Collection<String> sessionPermissions, boolean forcedVoid)
|
||||
{
|
||||
Detailed detailed = resolveDetailed(qInstance, groups, sub, email, verified);
|
||||
boolean voided = forcedVoid || claim.isVoid() || detailed.denials().isVoid();
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// 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()));
|
||||
if(voided)
|
||||
{
|
||||
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<String> fromPolicies = new TreeSet<>();
|
||||
if(knowledge == SessionPermissionRules.Knowledge.KNOWN)
|
||||
{
|
||||
fromPolicies.addAll(detailed.fromGroups());
|
||||
fromPolicies.addAll(claim.resolve(PolicyPermissions.universe(qInstance == null ? PolicyPermissions.instanceOf(catalog) : qInstance, Set.of())));
|
||||
}
|
||||
|
||||
Set<String> staticFloor = new TreeSet<>(detailed.floor());
|
||||
Set<String> host = new TreeSet<>();
|
||||
boolean enforced = true;
|
||||
if(runtime != null)
|
||||
{
|
||||
permissions.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 alone //
|
||||
// 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;
|
||||
try
|
||||
AccessComparison.State state = guardState(qInstance, runtime);
|
||||
enforced = state.enforced();
|
||||
if(!enforced)
|
||||
{
|
||||
state = AccessComparison.state(runtime, client(qInstance));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now());
|
||||
}
|
||||
if(!state.enforced())
|
||||
{
|
||||
Set<String> 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));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
LOG.warn("Policies are not enforced at this sign-in; granting today's access", logPair("reason", state.message()));
|
||||
permissions = floor;
|
||||
}
|
||||
}
|
||||
if(qSession.getPermissions() != null)
|
||||
return (SessionPermissionRules.compute(knowledge, denials, enforced, detailed.bootstrap(), staticFloor, host, fromPolicies, sessionPermissions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
{
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
return (AccessComparison.state(runtime, client(qInstance)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
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"));
|
||||
}
|
||||
qSession.setPermissions(permissions);
|
||||
com.kingsrook.qbits.authentik.security.AuthentikTokenScopes.apply(qInstance, qSession, claims);
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +261,7 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
if(users.size() < 10 && matches == 1 && found.optBoolean("is_active", false))
|
||||
{
|
||||
List<String> groups = new ArrayList<>();
|
||||
boolean unresolvedGroup = false;
|
||||
JSONArray groupObjects = found.optJSONArray("groups_obj");
|
||||
if(groupObjects != null)
|
||||
{
|
||||
@@ -227,7 +276,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());
|
||||
}
|
||||
@@ -237,31 +290,22 @@ 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);
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// a group pk the graph does not know may hold a Deny: void //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
permissions = compute(qInstance, runtime, groups, sub, userEmail, verified, PolicyRules.EMPTY, false, true, 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();
|
||||
// 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -269,67 +313,98 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mirror the sign-in cut-over floor without changing the claims path.
|
||||
** 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.
|
||||
**
|
||||
** 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)
|
||||
*******************************************************************************/
|
||||
private Set<String> automatedSessionPermissions(QInstance qInstance, AuthentikRuntime runtime, Set<String> permissions,
|
||||
List<String> groups, String sub, String email, boolean verified)
|
||||
@Deprecated
|
||||
public Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
|
||||
{
|
||||
runtime.markMapperInvoked();
|
||||
AccessComparison.State state;
|
||||
try
|
||||
Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified);
|
||||
Set<String> floor = new TreeSet<>(detailed.floor());
|
||||
if(detailed.denials().isVoid())
|
||||
{
|
||||
state = AccessComparison.state(runtime, client(qInstance));
|
||||
floor.clear();
|
||||
}
|
||||
catch(Exception e)
|
||||
else
|
||||
{
|
||||
state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now());
|
||||
floor.removeIf(detailed.denials()::denies);
|
||||
}
|
||||
if(state.enforced())
|
||||
{
|
||||
return (permissions);
|
||||
}
|
||||
Set<String> 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);
|
||||
Set<String> rs = new TreeSet<>(detailed.bootstrap());
|
||||
rs.addAll(floor);
|
||||
rs.addAll(detailed.denials().isVoid() ? Set.of() : detailed.fromGroups());
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permissions for a user's group names and token identity (sub, email,
|
||||
** email_verified). Bootstrap operators are matched on immutable ids only.
|
||||
** 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. 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 Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
|
||||
public PolicyResult resolveResult(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
Detailed detailed = resolveDetailed(qInstance, groupNames, sub, email, emailVerified);
|
||||
if(detailed.unavailable())
|
||||
{
|
||||
return (PolicyResult.unreadable());
|
||||
}
|
||||
return (new PolicyResult(detailed.rules(), detailed.denials().isVoid() ? Set.of() : detailed.fromGroups()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 (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<String> floor, Set<String> bootstrap, Set<String> fromGroups, PolicyRules denials, PolicyRules rules, boolean unavailable)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve into floor, bootstrap, group-derived grants and denials.
|
||||
*******************************************************************************/
|
||||
private Detailed resolveDetailed(QInstance qInstance, Collection<String> groupNames, String sub, String email, boolean emailVerified)
|
||||
{
|
||||
Set<String> 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<String> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,23 +417,30 @@ 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");
|
||||
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());
|
||||
}
|
||||
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(pks);
|
||||
if(!resolution.unknown().isEmpty())
|
||||
{
|
||||
LOG.info("Group attributes name permissions this app does not have", logPair("unknown", resolution.unknown()));
|
||||
}
|
||||
rs.addAll(resolution.sessionPermissions());
|
||||
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 (rs);
|
||||
return (new Detailed(floor, bootstrap, new TreeSet<>(), PolicyRules.EMPTY, PolicyRules.EMPTY, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+72
-7
@@ -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";
|
||||
|
||||
@@ -71,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."));
|
||||
@@ -106,8 +117,9 @@ public class AuthentikRefreshingPermissionMapper extends AuthentikGroupPermissio
|
||||
throw (new QAuthenticationException("Current Authentik access is unavailable or denied."));
|
||||
}
|
||||
var access = current.access();
|
||||
Set<String> permissions = new TreeSet<>(configuredStaticPermissions(access.groups()));
|
||||
permissions.addAll(AccessComparison.staticPermissions(config, access.groups()));
|
||||
Set<String> floor = new TreeSet<>(configuredStaticPermissions(access.groups()));
|
||||
floor.addAll(AccessComparison.staticPermissions(config, access.groups()));
|
||||
Set<String> bootstrapNames = new TreeSet<>();
|
||||
var bootstrap = config.getResolvedBootstrapSubjects();
|
||||
String sub = claims.optString("sub", "");
|
||||
if(bootstrap.contains(Integer.toString(identity.pk())) || bootstrap.contains(identity.uuid())
|
||||
@@ -115,23 +127,42 @@ 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<String> 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 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, sub, access.email());
|
||||
SessionPermissionRules.Knowledge knowledge = policy.isUnavailable() ? SessionPermissionRules.Knowledge.UNAVAILABLE
|
||||
: policy.isVoid() ? SessionPermissionRules.Knowledge.VOID : SessionPermissionRules.Knowledge.KNOWN;
|
||||
if(knowledge == SessionPermissionRules.Knowledge.UNAVAILABLE)
|
||||
{
|
||||
permissions.addAll(config.getHostPermissionSource().userPermissions(access.email() == null ? "" : access.email(), access.groups()));
|
||||
LOG.warn("The mirrored group policies could not be read, so no Deny can be known; only the configured fallbacks are granted");
|
||||
}
|
||||
else if(knowledge == SessionPermissionRules.Knowledge.VOID)
|
||||
{
|
||||
LOG.error("A user with an invalid policy refreshes; they get no permissions beyond break-glass. Fix the policy.", logPair("groups", access.groups()));
|
||||
}
|
||||
Set<String> 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."));
|
||||
}
|
||||
@@ -149,6 +180,40 @@ 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, AuthentikMirrorAccess.Access access, String sub, String email)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(access.groupRows() == null)
|
||||
{
|
||||
return (PolicyResult.unreadable());
|
||||
}
|
||||
AuthentikRuntime runtime = runtime();
|
||||
GroupGraph graph = GroupGraph.fromGroups(access.groupRows(), runtime.getConfig().getPolicyFormat(), PolicyCatalog.of(instance));
|
||||
List<String> pks = new ArrayList<>();
|
||||
for(String name : access.groups())
|
||||
{
|
||||
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)
|
||||
{
|
||||
LOG.warn("The mirrored group policies could not be read", logPair("errorType", e.getClass().getSimpleName()));
|
||||
return (PolicyResult.unreadable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve the existing policy cut-over guard and host-permission floor.
|
||||
***************************************************************************/
|
||||
|
||||
+128
-22
@@ -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,42 @@ public class EffectivePermissionResolver
|
||||
private final GroupGraph graph;
|
||||
private final Set<String> catalog;
|
||||
private final QInstance qInstance;
|
||||
private final PolicyUniverse universe;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result for a set of groups.
|
||||
*******************************************************************************/
|
||||
public record Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess)
|
||||
public record Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess, PolicyRules denials, PolicyRules rules)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A resolution whose rules are only its denials.
|
||||
*******************************************************************************/
|
||||
public Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess, PolicyRules denials)
|
||||
{
|
||||
this(sessionPermissions, grantedBy, unknown, appAccess, denials, denials);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A resolution with no policy denials.
|
||||
*******************************************************************************/
|
||||
public Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> 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 +122,18 @@ public class EffectivePermissionResolver
|
||||
*******************************************************************************/
|
||||
public EffectivePermissionResolver(GroupGraph graph, Collection<String> 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<String> catalog, QInstance qInstance, PolicyUniverse universe)
|
||||
{
|
||||
this.universe = universe;
|
||||
this.graph = graph;
|
||||
this.qInstance = qInstance;
|
||||
this.catalog = catalog == null ? null : new TreeSet<>(catalog);
|
||||
@@ -118,8 +168,11 @@ public class EffectivePermissionResolver
|
||||
Map<String, Set<String>> grantedBy = new LinkedHashMap<>();
|
||||
Set<String> unknown = new TreeSet<>();
|
||||
Set<String> appAccess = new TreeSet<>();
|
||||
List<PolicyRules> policies = new ArrayList<>();
|
||||
PolicyUniverse live = PolicyUniverse.once(universe());
|
||||
|
||||
Set<String> visited = new LinkedHashSet<>();
|
||||
Set<String> visited = new LinkedHashSet<>();
|
||||
boolean missingParent = false;
|
||||
for(String pk : groupPks)
|
||||
{
|
||||
for(GroupGraph.Node node : graph.selfAndAncestors(pk))
|
||||
@@ -128,28 +181,86 @@ public class EffectivePermissionResolver
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(String parent : node.parentPks())
|
||||
{
|
||||
missingParent |= graph.byPk(parent) == null;
|
||||
}
|
||||
appAccess.addAll(node.appAccess());
|
||||
List<String> 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<String> 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 //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// 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, merged));
|
||||
}
|
||||
permissions.removeIf(denials::denies);
|
||||
grantedBy.keySet().removeIf(denials::denies);
|
||||
return (new Resolution(permissions, grantedBy, unknown, appAccess, denials, merged));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record what a node granted; a name that expanded to nothing is unknown.
|
||||
*******************************************************************************/
|
||||
private static void grant(Set<String> expanded, String raw, GroupGraph.Node node, Set<String> permissions, Map<String, Set<String>> grantedBy, Set<String> 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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every permission full access grants now (the live universe).
|
||||
*******************************************************************************/
|
||||
public Set<String> fullAccessPermissions()
|
||||
{
|
||||
return (new TreeSet<>(universe().names()));
|
||||
}
|
||||
|
||||
|
||||
@@ -176,11 +287,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())
|
||||
|
||||
@@ -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<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed,
|
||||
List<String> policyPermissions)
|
||||
PolicyRules policy)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A group without a policy document.
|
||||
*******************************************************************************/
|
||||
public Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
|
||||
{
|
||||
this(pk, name, ownSuperuser, parentPks, permissions, appAccess, memberPks, malformed, List.of());
|
||||
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<JSONObject> groups, PolicyFormat format)
|
||||
{
|
||||
@@ -106,7 +110,7 @@ public class GroupGraph
|
||||
graph.userAccessOwners.put(group.getString("pk"), attributes.optString(USER_ACCESS_ATTRIBUTE));
|
||||
}
|
||||
List<String> malformed = malformed(attributes);
|
||||
List<String> 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<String> policyPermissions(JSONObject attributes, PolicyFormat format, List<String> malformed, PolicyCatalog catalog)
|
||||
private static PolicyRules policyRules(JSONObject attributes, PolicyFormat format, List<String> malformed, PolicyCatalog catalog)
|
||||
{
|
||||
Object document = attributes == null ? null : attributes.opt(format.attribute());
|
||||
if(document == null || JSONObject.NULL.equals(document) || "".equals(document))
|
||||
{
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> compute(Knowledge knowledge, PolicyRules denials, boolean enforced, Collection<String> bootstrap, Collection<String> staticFloor,
|
||||
Collection<String> host, Collection<String> policyGrants, Collection<String> existing)
|
||||
{
|
||||
Set<String> 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<String> target, Collection<String> names, PolicyRules rules)
|
||||
{
|
||||
if(names == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(String name : names)
|
||||
{
|
||||
if(!rules.denies(name))
|
||||
{
|
||||
target.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,12 +98,12 @@ public class AuthentikPolicyClaimMapper implements QAuthenticationModuleCustomiz
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> 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)));
|
||||
@@ -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) //
|
||||
////////////////////////////////////////////////////////
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -26,6 +27,7 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.SortedSet;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
@@ -38,6 +40,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;
|
||||
@@ -111,17 +115,114 @@ 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 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<QInstance, Cached> CACHE = new LinkedHashMap<>(8, 0.75f, true)
|
||||
{
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<QInstance, Cached> eldest)
|
||||
{
|
||||
return (size() > CACHE_SIZE);
|
||||
}
|
||||
};
|
||||
|
||||
private final List<Group> groups;
|
||||
private final Map<String, Resource> byName;
|
||||
private final QInstance qInstance;
|
||||
private final Set<String> availableNames;
|
||||
|
||||
/** permissionNames(), computed once: a catalog never changes after it is built. */
|
||||
private final SortedSet<String> permissionNames;
|
||||
|
||||
/** availableNames and permissionNames together, sorted: what the catalog gives the permission universe. */
|
||||
private final SortedSet<String> universeNames;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A cached catalog with the metadata fingerprint it was built from.
|
||||
*******************************************************************************/
|
||||
private record Cached(String fingerprint, PolicyCatalog catalog)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Receives the pieces of an instance's fingerprint, in the order describe()
|
||||
** produces them.
|
||||
*******************************************************************************/
|
||||
private interface FingerprintSink
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The next piece.
|
||||
*******************************************************************************/
|
||||
void add(String piece);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compares the pieces of an instance's fingerprint with a fingerprint text as
|
||||
** they are produced, so checking that a cached catalog is still current builds
|
||||
** no text: the same comparison as building the fingerprint and calling equals.
|
||||
*******************************************************************************/
|
||||
private static final class FingerprintMatcher implements FingerprintSink
|
||||
{
|
||||
private final String expected;
|
||||
|
||||
private int position;
|
||||
private boolean same = true;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
private FingerprintMatcher(String expected)
|
||||
{
|
||||
this.expected = expected;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void add(String piece)
|
||||
{
|
||||
if(same)
|
||||
{
|
||||
same = expected.regionMatches(position, piece, 0, piece.length());
|
||||
position += piece.length();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether every piece matched and nothing of the expected text is left over.
|
||||
*******************************************************************************/
|
||||
private boolean matched()
|
||||
{
|
||||
return (same && position == expected.length());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
private PolicyCatalog(List<Group> groups, QInstance qInstance)
|
||||
private PolicyCatalog(List<Group> groups, QInstance qInstance, Set<String> availableNames)
|
||||
{
|
||||
this.availableNames = Set.copyOf(availableNames);
|
||||
this.qInstance = qInstance;
|
||||
this.groups = groups;
|
||||
this.byName = new LinkedHashMap<>();
|
||||
@@ -132,14 +233,191 @@ public final class PolicyCatalog
|
||||
byName.putIfAbsent(resource.name(), resource);
|
||||
}
|
||||
}
|
||||
|
||||
SortedSet<String> grantable = new TreeSet<>();
|
||||
for(Resource resource : byName.values())
|
||||
{
|
||||
for(PolicyAction action : resource.actions())
|
||||
{
|
||||
grantable.add(action.permission(resource.name()));
|
||||
}
|
||||
resource.features().forEach(feature -> grantable.add(resource.name() + "." + feature.name()));
|
||||
}
|
||||
this.permissionNames = Collections.unmodifiableSortedSet(grantable);
|
||||
|
||||
SortedSet<String> all = new TreeSet<>(grantable);
|
||||
all.addAll(this.availableNames);
|
||||
this.universeNames = Collections.unmodifiableSortedSet(all);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the catalog of an instance.
|
||||
** The catalog of an instance: the cached one while the metadata it was built
|
||||
** from is unchanged (checked on every call, see describe), else a new one.
|
||||
*******************************************************************************/
|
||||
public static PolicyCatalog of(QInstance qInstance)
|
||||
{
|
||||
Cached cached;
|
||||
synchronized(CACHE)
|
||||
{
|
||||
cached = CACHE.get(qInstance);
|
||||
}
|
||||
if(cached != null)
|
||||
{
|
||||
FingerprintMatcher matcher = new FingerprintMatcher(cached.fingerprint());
|
||||
describe(qInstance, matcher);
|
||||
if(matcher.matched())
|
||||
{
|
||||
return (cached.catalog());
|
||||
}
|
||||
}
|
||||
|
||||
String fingerprint = fingerprint(qInstance);
|
||||
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(16384);
|
||||
describe(qInstance, sb::append);
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The pieces of the fingerprint, in the instance's own (insertion) order:
|
||||
** every table, process, app, report and widget with its permission rules, the
|
||||
** tables' supplemental metadata keys, the processes' hidden flags and the
|
||||
** registered table features. The same instance content always gives the same
|
||||
** pieces; any change to it gives different ones. This runs on every
|
||||
** PolicyCatalog.of call (every sign-in and session resume), so it creates no
|
||||
** sorted copy and no text of its own.
|
||||
*******************************************************************************/
|
||||
private static void describe(QInstance qInstance, FingerprintSink sink)
|
||||
{
|
||||
for(Map.Entry<String, QTableMetaData> entry : qInstance.getTables().entrySet())
|
||||
{
|
||||
QTableMetaData table = entry.getValue();
|
||||
sink.add("t:");
|
||||
sink.add(String.valueOf(entry.getKey()));
|
||||
sink.add("|");
|
||||
describeRules(table.getPermissionRules(), sink);
|
||||
sink.add("|");
|
||||
if(table.getSupplementalMetaData() != null)
|
||||
{
|
||||
sink.add("[");
|
||||
for(String key : table.getSupplementalMetaData().keySet())
|
||||
{
|
||||
sink.add(String.valueOf(key));
|
||||
sink.add(",");
|
||||
}
|
||||
sink.add("]");
|
||||
}
|
||||
sink.add(";");
|
||||
}
|
||||
for(Map.Entry<String, QProcessMetaData> entry : qInstance.getProcesses().entrySet())
|
||||
{
|
||||
sink.add("p:");
|
||||
sink.add(String.valueOf(entry.getKey()));
|
||||
sink.add("|");
|
||||
describeRules(entry.getValue().getPermissionRules(), sink);
|
||||
sink.add("|");
|
||||
sink.add(String.valueOf(entry.getValue().getIsHidden()));
|
||||
sink.add(";");
|
||||
}
|
||||
for(Map.Entry<String, QAppMetaData> entry : qInstance.getApps().entrySet())
|
||||
{
|
||||
describeObject("a:", entry.getKey(), entry.getValue().getPermissionRules(), sink);
|
||||
}
|
||||
for(Map.Entry<String, QReportMetaData> entry : qInstance.getReports().entrySet())
|
||||
{
|
||||
describeObject("r:", entry.getKey(), entry.getValue().getPermissionRules(), sink);
|
||||
}
|
||||
for(Map.Entry<String, QWidgetMetaDataInterface> entry : qInstance.getWidgets().entrySet())
|
||||
{
|
||||
describeObject("w:", entry.getKey(), entry.getValue().getPermissionRules(), sink);
|
||||
}
|
||||
TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.of(qInstance);
|
||||
if(features != null)
|
||||
{
|
||||
for(TableFeaturePermission feature : features.getPermissions())
|
||||
{
|
||||
sink.add("f:");
|
||||
sink.add(String.valueOf(feature.getSuffix()));
|
||||
sink.add("|");
|
||||
sink.add(String.valueOf(feature.getLabel()));
|
||||
sink.add(";");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One app, report or widget: its kind, name and permission rules.
|
||||
*******************************************************************************/
|
||||
private static void describeObject(String kind, String name, QPermissionRules rules, FingerprintSink sink)
|
||||
{
|
||||
sink.add(kind);
|
||||
sink.add(String.valueOf(name));
|
||||
sink.add("|");
|
||||
describeRules(rules, sink);
|
||||
sink.add(";");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A permission rules object: its level, base name and custom checker.
|
||||
*******************************************************************************/
|
||||
private static void describeRules(QPermissionRules rules, FingerprintSink sink)
|
||||
{
|
||||
if(rules == null)
|
||||
{
|
||||
sink.add("-");
|
||||
return;
|
||||
}
|
||||
sink.add(String.valueOf(rules.getLevel()));
|
||||
sink.add(":");
|
||||
sink.add(String.valueOf(rules.getPermissionBaseName()));
|
||||
sink.add(":");
|
||||
sink.add(String.valueOf(rules.getCustomPermissionChecker()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the catalog of an instance without the cache.
|
||||
*******************************************************************************/
|
||||
public static PolicyCatalog build(QInstance qInstance)
|
||||
{
|
||||
Collection<AvailablePermission> availablePermissions = PermissionsHelper.getAllAvailablePermissions(qInstance);
|
||||
Map<String, String> appOfObject = appsOfObjects(qInstance);
|
||||
@@ -258,7 +536,61 @@ public final class PolicyCatalog
|
||||
}
|
||||
groups.add(new Group(entry.getKey(), groupLabel, resources));
|
||||
}
|
||||
return (new PolicyCatalog(groups, qInstance));
|
||||
Set<String> 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<String> 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<String> names, Collection<String> hostNames)
|
||||
{
|
||||
Map<String, ResourceDraft> 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<Resource> 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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -452,16 +784,7 @@ public final class PolicyCatalog
|
||||
*******************************************************************************/
|
||||
public Set<String> permissionNames()
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(Resource resource : byName.values())
|
||||
{
|
||||
for(PolicyAction action : resource.actions())
|
||||
{
|
||||
rs.add(action.permission(resource.name()));
|
||||
}
|
||||
resource.features().forEach(feature -> rs.add(resource.name() + "." + feature.name()));
|
||||
}
|
||||
return (rs);
|
||||
return (new TreeSet<>(permissionNames));
|
||||
}
|
||||
|
||||
|
||||
@@ -472,11 +795,10 @@ public final class PolicyCatalog
|
||||
*******************************************************************************/
|
||||
public Set<String> unknownPermissions(Set<String> permissions)
|
||||
{
|
||||
Set<String> known = permissionNames();
|
||||
Set<String> rs = new TreeSet<>();
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(String permission : permissions)
|
||||
{
|
||||
if(!known.contains(permission) && !PolicyPermissions.FULL_ACCESS.equals(permission))
|
||||
if(!permissionNames.contains(permission))
|
||||
{
|
||||
rs.add(permission);
|
||||
}
|
||||
@@ -497,6 +819,41 @@ public final class PolicyCatalog
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The names core reports as available permissions (the catalog source), which
|
||||
** the universe starts from.
|
||||
*******************************************************************************/
|
||||
public Set<String> availableNames()
|
||||
{
|
||||
return (availableNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** availableNames() and permissionNames() together, sorted and read-only: the
|
||||
** catalog's part of the permission universe (PolicyPermissions.allPermissions),
|
||||
** kept so a universe is a copy of this set and not a walk over every resource.
|
||||
*******************************************************************************/
|
||||
SortedSet<String> universeNames()
|
||||
{
|
||||
return (universeNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -142,7 +142,7 @@ public final class PolicyCutoverWidgets
|
||||
for(AccessComparison.Row row : rows)
|
||||
{
|
||||
Map<String, Object> 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<AccessComparison.Row> 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)
|
||||
|
||||
@@ -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<String> 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<String> denied = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Operator-facing labels of the Deny terms of the stored document.
|
||||
*******************************************************************************/
|
||||
public List<String> getDenied()
|
||||
{
|
||||
return (denied);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for denied.
|
||||
*******************************************************************************/
|
||||
public Data withDenied(List<String> denied)
|
||||
{
|
||||
this.denied = denied;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<String> permissions, PolicyFormat format) throws PolicyParseException
|
||||
{
|
||||
@@ -67,81 +64,158 @@ public final class PolicyGenerator
|
||||
*******************************************************************************/
|
||||
public static JSONObject generate(Collection<String> permissions, PolicyFormat format, PolicyCatalog catalog) throws PolicyParseException
|
||||
{
|
||||
PolicyFormat f = format == null ? PolicyFormat.DEFAULT : format;
|
||||
if(permissions.contains(PolicyPermissions.FULL_ACCESS))
|
||||
{
|
||||
JSONObject document = new JSONObject().put("PolicySpec", f.spec()).put("PolicyVersion", f.version())
|
||||
.put("Statement", new JSONArray().put(new JSONObject().put("Effect", PolicyParser.ALLOW)
|
||||
.put("Action", new JSONArray().put("*")).put("Resource", new JSONArray().put("*"))));
|
||||
if(!PolicyParser.parseStrict(toCompact(document), f, catalog).permissions().equals(Set.of(PolicyPermissions.FULL_ACCESS)))
|
||||
{
|
||||
throw (new PolicyParseException("The full-access document does not round-trip"));
|
||||
}
|
||||
return (document);
|
||||
}
|
||||
|
||||
Map<String, Set<String>> byAction = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> byFeature = new TreeMap<>();
|
||||
for(PolicyAction action : PolicyAction.values())
|
||||
{
|
||||
byAction.put(action.getJsonName(), new TreeSet<>());
|
||||
}
|
||||
|
||||
Set<String> expected = new TreeSet<>();
|
||||
for(String permission : permissions)
|
||||
{
|
||||
int dot = permission == null ? -1 : permission.lastIndexOf('.');
|
||||
if(dot <= 0 || dot == permission.length() - 1)
|
||||
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<String, Set<String>> 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<String, Set<String>> 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<String> 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<String> missing = new TreeSet<>(expected);
|
||||
missing.removeAll(parsed);
|
||||
Set<String> extra = new TreeSet<>(parsed);
|
||||
extra.removeAll(expected);
|
||||
throw (new PolicyParseException("The generated document does not round-trip. Missing: " + missing + "; unexpected: " + extra));
|
||||
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<PolicyTerm> terms, PolicyCatalog catalog)
|
||||
{
|
||||
boolean isDeny = PolicyParser.DENY.equals(effect);
|
||||
Set<String> all = new TreeSet<>();
|
||||
Map<String, Set<String>> byAction = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> byFeature = new TreeMap<>();
|
||||
Set<String> 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<String, Set<String>> 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<String> bucket(Map<String, Set<String>> byAction, Map<String, Set<String>> byFeature, String action)
|
||||
{
|
||||
Map<String, Set<String>> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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,32 @@ 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<String> STATEMENT_KEYS = Set.of("Effect", "Action", "Resource", "Permission");
|
||||
|
||||
private static final Set<String> DOCUMENT_KEYS = Set.of("PolicySpec", "PolicyVersion", "Statement");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String> permissions, Set<String> resources)
|
||||
public record Parsed(PolicyRules rules, Set<String> resources, Set<String> named)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -72,13 +92,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<String> parse(Object document, PolicyFormat format)
|
||||
public static PolicyRules parse(Object document, PolicyFormat format)
|
||||
{
|
||||
return (parse(document, format, null));
|
||||
}
|
||||
@@ -87,27 +107,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<String> 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<PolicyRules> 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 +160,39 @@ 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"));
|
||||
}
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// one reading of the universe for the whole document, not one for //
|
||||
// each named permission, prefix and Deny it checks //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
return (parseStrict(document, format, catalog, PolicyUniverse.once(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 +205,22 @@ public final class PolicyParser
|
||||
throw (new PolicyParseException("PolicyVersion must be \"" + f.version() + "\" (found " + describe(version) + ")"));
|
||||
}
|
||||
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
Set<String> resources = new LinkedHashSet<>();
|
||||
Object statements = json.opt("Statement");
|
||||
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<PolicyTerm> allow = new LinkedHashSet<>();
|
||||
Set<PolicyTerm> deny = new LinkedHashSet<>();
|
||||
Set<String> resources = new LinkedHashSet<>();
|
||||
Set<String> named = new LinkedHashSet<>();
|
||||
Object statements = json.opt("Statement");
|
||||
if(statements == null)
|
||||
{
|
||||
return (new Parsed(permissions, resources));
|
||||
throw (new PolicyParseException("Statement is required (an array; an empty array grants nothing)"));
|
||||
}
|
||||
if(!(statements instanceof JSONArray array))
|
||||
{
|
||||
@@ -156,59 +233,220 @@ 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<String> statementResources = strings(statement.opt("Resource"), "Statement " + (i + 1) + ": Resource");
|
||||
Set<String> statementActions = strings(statement.opt("Action"), "Statement " + (i + 1) + ": Action");
|
||||
if(statementResources.contains("*") && !statementActions.equals(Set.of("*")))
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One statement into the Allow or Deny terms.
|
||||
*******************************************************************************/
|
||||
private static void parseStatement(String where, JSONObject statement, PolicyCatalog catalog, PolicyUniverse universe,
|
||||
Set<PolicyTerm> allow, Set<PolicyTerm> deny, Set<String> resources, Set<String> 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<String> 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<PolicyTerm> 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"));
|
||||
}
|
||||
Set<String> 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())
|
||||
{
|
||||
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<String> statementResources = strings(statement.opt("Resource"), where + ": Resource");
|
||||
Set<String> statementActions = strings(statement.opt("Action"), where + ": Action");
|
||||
if(statementResources.contains("*") && !statementActions.equals(Set.of("*")))
|
||||
{
|
||||
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())
|
||||
{
|
||||
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<String> 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<String> concreteActions(Set<String> statementActions)
|
||||
{
|
||||
Set<String> 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<String> statementActions, Set<String> 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<String> 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<String> 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 +507,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 +528,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());
|
||||
}
|
||||
|
||||
@@ -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 = "<full-access>";
|
||||
|
||||
private static final List<Function<QInstance, Collection<String>>> 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)";
|
||||
|
||||
@@ -61,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<String> fromClaims(JSONObject claims, PolicyFormat format)
|
||||
{
|
||||
return (fromClaims(claims, format, null));
|
||||
@@ -70,18 +78,76 @@ public final class PolicyPermissions
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a policy claim with the catalog of registered table features.
|
||||
** 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<String> 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<String> parsed = PolicyParser.parse(claims.opt(f.claim()), f, catalog);
|
||||
return (parsed.contains(FULL_ACCESS)
|
||||
? allPermissions(catalog == null ? QContext.getQInstance() : catalog.getQInstance(), Set.of()) : parsed);
|
||||
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, built again on EVERY
|
||||
** names() call and never cached, so instance changes are always visible.
|
||||
** PolicyRules reads it once per resolution (PolicyUniverse.once); a caller
|
||||
** that reads names() itself more than once should wrap it the same way.
|
||||
*******************************************************************************/
|
||||
public static PolicyUniverse universe(QInstance qInstance, Collection<String> additional)
|
||||
{
|
||||
return (() -> allPermissions(qInstance, additional));
|
||||
}
|
||||
|
||||
|
||||
@@ -94,16 +160,24 @@ public final class PolicyPermissions
|
||||
public static Set<String> allPermissions(QInstance qInstance, Collection<String> additional)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
if(qInstance != null)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// the catalog's names first: they are already sorted, so the set //
|
||||
// is built from them in one pass (the catalog is checked against //
|
||||
// the instance on every call; its names are not walked again) //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
permissions.addAll(PolicyCatalog.of(qInstance).universeNames());
|
||||
permissions.addAll(hostPermissions(qInstance));
|
||||
}
|
||||
else
|
||||
{
|
||||
permissions.addAll(registeredHostPermissions(null));
|
||||
}
|
||||
if(additional != null)
|
||||
{
|
||||
permissions.addAll(additional);
|
||||
}
|
||||
if(qInstance != null)
|
||||
{
|
||||
PermissionsHelper.getAllAvailablePermissions(qInstance).forEach(permission -> permissions.add(permission.getName()));
|
||||
permissions.addAll(PolicyCatalog.of(qInstance).permissionNames());
|
||||
permissions.addAll(hostPermissions(qInstance));
|
||||
}
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
permissions.add(permission.getSessionPermission());
|
||||
@@ -120,17 +194,13 @@ public final class PolicyPermissions
|
||||
*******************************************************************************/
|
||||
static Set<String> hostPermissions(QInstance qInstance)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
Set<String> 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<String> 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 +208,71 @@ public final class PolicyPermissions
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The names of every registered host-name function (no runtime needed).
|
||||
*******************************************************************************/
|
||||
private static Set<String> registeredHostPermissions(QInstance qInstance)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(Function<QInstance, Collection<String>> 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<QInstance, Collection<String>> 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<String> target, Collection<String> names)
|
||||
{
|
||||
if(names == null)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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
|
||||
** @param unavailable the policies could not be read, so nothing is known about Deny
|
||||
*******************************************************************************/
|
||||
public record PolicyResult(PolicyRules rules, Set<String> granted, boolean unavailable)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A readable result (not unavailable).
|
||||
*******************************************************************************/
|
||||
public PolicyResult(PolicyRules rules, Set<String> granted)
|
||||
{
|
||||
this(rules, granted, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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).
|
||||
*******************************************************************************/
|
||||
public boolean isVoid()
|
||||
{
|
||||
return (rules.isVoid());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 (!unavailable && !rules.isVoid() && rules.isEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 FOR SIGN-IN ASSEMBLY by this qbit's mappers (they use
|
||||
** SessionPermissionRules.compute); it is for a host customizer's own sources.
|
||||
**
|
||||
** This is a UNION: apply(session.getPermissions()) keeps everything the session
|
||||
** already held that no Deny covers. A host whose sessions take their permissions
|
||||
** from the policies alone must call replacement() instead.
|
||||
*******************************************************************************/
|
||||
public Set<String> apply(Collection<String> otherSources)
|
||||
{
|
||||
if(rules.isVoid() || unavailable)
|
||||
{
|
||||
return (new TreeSet<>());
|
||||
}
|
||||
Set<String> rs = new TreeSet<>(granted);
|
||||
if(otherSources != null)
|
||||
{
|
||||
rs.addAll(otherSources);
|
||||
}
|
||||
rs.removeIf(rules::denies);
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The complete permission set of a session whose ONLY source is these
|
||||
** policies: exactly what they grant (Allow minus Deny) and nothing else, so
|
||||
** nothing the session held before survives. Empty when void or unavailable.
|
||||
** For a host that replaces the session's permissions at every authentication
|
||||
** ("permissions come only from the token"): session.setPermissions(replacement()).
|
||||
** Unlike apply(), it takes no other source, so it cannot union by accident.
|
||||
** The set is new on every call; the caller may keep or change it.
|
||||
*******************************************************************************/
|
||||
public Set<String> replacement()
|
||||
{
|
||||
return (apply(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* 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<PolicyTerm> allow;
|
||||
private final Set<PolicyTerm> deny;
|
||||
private final String voidReason;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor. Full access makes every other Allow redundant.
|
||||
*******************************************************************************/
|
||||
private PolicyRules(Set<PolicyTerm> allow, Set<PolicyTerm> 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<PolicyTerm> allow, Collection<PolicyTerm> 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<String> names)
|
||||
{
|
||||
Set<PolicyTerm> 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<PolicyRules> all)
|
||||
{
|
||||
Set<PolicyTerm> allow = new LinkedHashSet<>();
|
||||
Set<PolicyTerm> 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<PolicyTerm> allowTerms()
|
||||
{
|
||||
return (allow);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Deny terms.
|
||||
*******************************************************************************/
|
||||
public Set<PolicyTerm> 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<String> allowed(PolicyUniverse universe)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
if(isVoid())
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// every term expands against ONE reading of the universe: a live //
|
||||
// universe builds its names on each names() call, so reading it per //
|
||||
// term made the cost grow with the number of terms (one catalog check //
|
||||
// and one full name set for each named permission of a role) //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
PolicyUniverse once = PolicyUniverse.once(universe);
|
||||
for(PolicyTerm term : allow)
|
||||
{
|
||||
rs.addAll(term.expand(once));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What these rules grant: Allow expansions minus Deny coverage. Empty when
|
||||
** void.
|
||||
*******************************************************************************/
|
||||
public Set<String> resolve(PolicyUniverse universe)
|
||||
{
|
||||
Set<String> 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<String> withheld(PolicyUniverse universe)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
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);
|
||||
}
|
||||
PolicyUniverse once = PolicyUniverse.once(universe);
|
||||
Set<String> candidates = new TreeSet<>(once.names());
|
||||
candidates.addAll(allowed(once));
|
||||
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<String> exactGrants()
|
||||
{
|
||||
Set<String> 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<String> denyLabels()
|
||||
{
|
||||
return (labels(deny));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Operator-facing labels of the Allow terms, sorted.
|
||||
*******************************************************************************/
|
||||
public List<String> allowLabels()
|
||||
{
|
||||
return (labels(allow));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sorted labels of terms.
|
||||
*******************************************************************************/
|
||||
private static List<String> labels(Set<PolicyTerm> terms)
|
||||
{
|
||||
List<String> 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() + "]");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* 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
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** 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).
|
||||
*******************************************************************************/
|
||||
boolean matches(String permission);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission names this term grants (the Allow expansion).
|
||||
*******************************************************************************/
|
||||
Set<String> 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<String> 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<String> 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)
|
||||
{
|
||||
String owner = ownerOf(permission);
|
||||
return (owner != null && owner.equals(base));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Set<String> 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String base = permission.substring(0, dot);
|
||||
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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Set<String> expand(PolicyUniverse universe)
|
||||
{
|
||||
Set<String> rs = new java.util.TreeSet<>();
|
||||
for(String name : universe.names())
|
||||
{
|
||||
if(grants(name))
|
||||
{
|
||||
rs.add(name);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.SortedSet;
|
||||
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<String> names();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fixed universe.
|
||||
*******************************************************************************/
|
||||
static PolicyUniverse of(Collection<String> names)
|
||||
{
|
||||
Set<String> copy = new TreeSet<>(names);
|
||||
return (() -> copy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A universe that asks the given one for its names at most once, and then
|
||||
** answers with that same read-only set. For ONE resolution: a live universe
|
||||
** (PolicyPermissions.universe) builds its names again on every names() call,
|
||||
** and one resolution must cost one universe however many terms it expands.
|
||||
** Nothing is read until the first names() call. Never keep the result after
|
||||
** the resolution, or instance changes would be hidden. Not thread-safe: a
|
||||
** resolution runs on one thread.
|
||||
*******************************************************************************/
|
||||
static PolicyUniverse once(PolicyUniverse universe)
|
||||
{
|
||||
return (new PolicyUniverse()
|
||||
{
|
||||
private Set<String> names;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Set<String> names()
|
||||
{
|
||||
if(names == null)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// a sorted set stays a sorted set, so a result built from the //
|
||||
// whole universe (full access) is built in one pass //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
Set<String> read = universe.names();
|
||||
names = read instanceof SortedSet<String> sorted ? Collections.unmodifiableSortedSet(sorted) : Collections.unmodifiableSet(read);
|
||||
}
|
||||
return (names);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<String> 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 ->
|
||||
@@ -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"));
|
||||
@@ -683,12 +683,12 @@ 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())
|
||||
{
|
||||
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");
|
||||
|
||||
@@ -38,6 +38,10 @@ public final class AuthentikMirrorAccess
|
||||
private final Duration maximumAge;
|
||||
private final String userTable;
|
||||
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
|
||||
@@ -49,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<String> groups, Set<String> permissions, Instant refreshedAt, String email)
|
||||
public record Access(Set<String> groups, Set<String> permissions, Instant refreshedAt, String email, java.util.List<org.json.JSONObject> groupRows)
|
||||
{
|
||||
/***********************************************************************
|
||||
** Defensive copies at the authorization boundary.
|
||||
@@ -58,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<String> groups, Set<String> permissions, Instant refreshedAt, String email)
|
||||
{
|
||||
this(groups, permissions, refreshedAt, email, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +112,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."));
|
||||
}
|
||||
@@ -154,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<String> groupNames = strings(rows.getString(6));
|
||||
Set<String> permissions = strings(rows.getString(7));
|
||||
String email = rows.getString(9);
|
||||
if(rows.next())
|
||||
{
|
||||
return (new Result(Status.UNAVAILABLE, null));
|
||||
}
|
||||
java.util.List<org.json.JSONObject> 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)
|
||||
{
|
||||
@@ -166,6 +187,45 @@ public final class AuthentikMirrorAccess
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** The mirrored groups as Authentik-shaped objects (pk, name, parents and the
|
||||
** 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).
|
||||
***************************************************************************/
|
||||
private java.util.List<org.json.JSONObject> readGroupRows(java.sql.Connection connection, Instant watermark) throws SQLException
|
||||
{
|
||||
java.util.List<org.json.JSONObject> groups = new java.util.ArrayList<>();
|
||||
try(var statement = connection.prepareStatement("SELECT pk, name, parent_pks, attributes, refreshed_at FROM " + groupTable); var rows = statement.executeQuery())
|
||||
{
|
||||
while(rows.next())
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Strict bounded JSON, never coercing objects/numbers into permission IDs.
|
||||
***************************************************************************/
|
||||
|
||||
@@ -98,7 +98,7 @@ public record AuthentikMirrorSnapshot(List<QRecord> users, List<QRecord> 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(), catalog))
|
||||
.withValue("memberCount", node.memberPks().size()).withValue("isSuperuser", graph.isSuperuserInherited(pk))
|
||||
.withValue("refreshedAt", refreshedAt));
|
||||
}
|
||||
@@ -154,6 +154,39 @@ public record AuthentikMirrorSnapshot(List<QRecord> users, List<QRecord> groups,
|
||||
return (new AuthentikMirrorSnapshot(List.copyOf(users), List.copyOf(groups), List.copyOf(memberships), refreshedAt));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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, com.kingsrook.qbits.authentik.policy.PolicyFormat format, PolicyCatalog catalog)
|
||||
{
|
||||
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)
|
||||
{
|
||||
safe.put(policyAttribute, policy);
|
||||
}
|
||||
return (safe.toString());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable JSON arrays avoid audit churn caused only by set iteration order.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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;
|
||||
@@ -196,10 +197,21 @@ 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<String, Object> 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",
|
||||
"{\"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 +249,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();
|
||||
@@ -245,4 +257,106 @@ 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());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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();
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -94,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();
|
||||
@@ -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<String> 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-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());
|
||||
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<String> 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.
|
||||
@@ -174,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)))));
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +288,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 +318,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<QRecord> rows = policyRows();
|
||||
assertThat(rows).hasSize(1);
|
||||
@@ -263,7 +331,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 +353,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,15 +377,16 @@ 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\"]}]}").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"));
|
||||
}
|
||||
@@ -394,7 +463,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");
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
@@ -608,4 +677,515 @@ 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<String> 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 (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 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).ok()).as("break-glass: bootstrap names are never denied, so the operator can release this Deny").isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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));
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String> 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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A Deny subtracts static group fallbacks too; only the bootstrap identity
|
||||
** permissions (break-glass) are exempt (documented in POLICY-FORMAT.md).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
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()).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,
|
||||
** minus every valid Deny (a Deny cannot defeat the guard's purpose).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void guardBlockedBranchStillAppliesAValidDeny() 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("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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String> 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String> 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<String> 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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -62,7 +62,17 @@ public final class AuthentikTestStack
|
||||
private static final String SECRET_KEY = "contract-test-secret-key-0123456789abcdefghijklmnop";
|
||||
public static final String SA_TOKEN_ID = "qqq-contract-test";
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Budget for anything Authentik's worker delivers asynchronously (emails, //
|
||||
// notifications, events, blueprint applies). The waits return as soon as //
|
||||
// the condition holds; the budget only matters on a busy shared CI //
|
||||
// runner, where the worker queue can lag by minutes. //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
public static final Duration ASYNC_TIMEOUT = Duration.ofMinutes(3);
|
||||
public static final Duration APPLY_TIMEOUT = Duration.ofMinutes(10);
|
||||
|
||||
private static AuthentikTestStack instance;
|
||||
private static volatile AuthentikTestStack starting;
|
||||
private static RuntimeException startupFailure;
|
||||
|
||||
private final Network network = Network.newNetwork();
|
||||
@@ -119,7 +129,20 @@ public final class AuthentikTestStack
|
||||
.withNetworkAliases("postgresql")
|
||||
.withDatabaseName("authentik")
|
||||
.withUsername("authentik")
|
||||
.withPassword("authentik-test");
|
||||
.withPassword("authentik-test")
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// jit=off: Authentik validates every policy binding in a blueprint by //
|
||||
// loading PolicyBindingModel.select_subclasses(), a ~20-way LEFT JOIN //
|
||||
// whose planner estimate (cost ~1e8 on a fresh database) is far above //
|
||||
// the JIT thresholds. Postgres then spends seconds JIT-compiling a //
|
||||
// query that runs in milliseconds: 7.6 s instead of 77 ms at 0.5 CPU. //
|
||||
// Nested metaapplyblueprint entries multiply it (kof22-qqq-tokens -> //
|
||||
// bootstrap -> oauth2-apps validates and applies oauth2-apps 8 times), //
|
||||
// so on a busy CI runner one apply outran the worker's 10-minute task //
|
||||
// limit, was killed and retried, and the stack never became ready. //
|
||||
// fsync=off is the Testcontainers default this command replaces. //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
.withCommand("postgres", "-c", "fsync=off", "-c", "jit=off");
|
||||
|
||||
mailpit = new GenericContainer<>(DockerImageName.parse(mailpitImage))
|
||||
.withNetwork(network)
|
||||
@@ -202,6 +225,7 @@ public final class AuthentikTestStack
|
||||
*******************************************************************************/
|
||||
private void start()
|
||||
{
|
||||
starting = this;
|
||||
postgres.start();
|
||||
mailpit.start();
|
||||
server.start();
|
||||
@@ -255,35 +279,47 @@ public final class AuthentikTestStack
|
||||
TestApi admin = admin();
|
||||
try
|
||||
{
|
||||
long[] nextRetry = { System.currentTimeMillis() + 60_000 };
|
||||
waitFor(Duration.ofMinutes(8), "all three kof22 blueprints applied", () ->
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// On first boot the worker discovers ~40 built-in blueprints and ours //
|
||||
// and applies them concurrently. Ours depend on objects the built-in //
|
||||
// ones create (managed OAuth2 scope mappings, default flows), so on a //
|
||||
// slow runner ours can validate first and end in "error" -- and //
|
||||
// Authentik never re-applies a failed blueprint whose file is //
|
||||
// unchanged. Concurrent applies can also deadlock in Postgres. So: let //
|
||||
// the worker drain ALL blueprint work first, then re-apply ours one at //
|
||||
// a time in dependency order, never enqueueing while an apply is still //
|
||||
// pending (piling up applies only deepens the backlog and the lock //
|
||||
// contention). With Postgres JIT off this takes ~1.5 minutes even at //
|
||||
// 0.5 CPU; the 15-minute budget is headroom for a saturated runner. //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
waitFor(Duration.ofMinutes(15), "all three kof22 blueprints applied", () ->
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// built-in blueprints may race our custom dependencies on first //
|
||||
// boot, leaving one of ours in "error" or never applied //
|
||||
// ("unknown"); re-apply it (throttled). //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
if(System.currentTimeMillis() >= nextRetry[0])
|
||||
try
|
||||
{
|
||||
nextRetry[0] = System.currentTimeMillis() + 30_000;
|
||||
if(blueprintWorkPending(admin))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
for(String name : List.of(INFRA_BLUEPRINT, BOOTSTRAP_BLUEPRINT, TOKEN_BLUEPRINT))
|
||||
{
|
||||
try
|
||||
JSONObject current = blueprint(name);
|
||||
if(current == null)
|
||||
{
|
||||
JSONObject current = blueprint(name);
|
||||
if(current != null && List.of("error", "unknown").contains(current.optString("status")))
|
||||
{
|
||||
admin.post("/managed/blueprints/" + current.getString("pk") + "/apply/", new JSONObject());
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
catch(Exception e)
|
||||
if(!"successful".equals(current.optString("status")))
|
||||
{
|
||||
// Bootstrap authentication and built-in dependencies settle after HTTP readiness.
|
||||
admin.post("/managed/blueprints/" + current.getString("pk") + "/apply/", new JSONObject());
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
return (admin.first("/core/users/?username=" + SERVICE_ACCOUNT) != null);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// Bootstrap authentication and built-in dependencies settle after HTTP readiness.
|
||||
return (false);
|
||||
}
|
||||
return (isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT) && isSuccessful(admin, TOKEN_BLUEPRINT)
|
||||
&& admin.first("/core/users/?username=" + SERVICE_ACCOUNT) != null);
|
||||
});
|
||||
}
|
||||
catch(IllegalStateException e)
|
||||
@@ -514,7 +550,7 @@ public final class AuthentikTestStack
|
||||
JSONObject before = blueprint(name);
|
||||
String lastApplied = before.optString("last_applied", "");
|
||||
admin().post("/managed/blueprints/" + before.getString("pk") + "/apply/", new JSONObject());
|
||||
waitFor(Duration.ofMinutes(3), "blueprint " + name + " re-applied", () ->
|
||||
waitFor(APPLY_TIMEOUT, "blueprint " + name + " re-applied", () ->
|
||||
{
|
||||
JSONObject now = blueprint(name);
|
||||
return (!now.optString("last_applied", "").equals(lastApplied) && "successful".equals(now.optString("status")));
|
||||
@@ -523,6 +559,26 @@ public final class AuthentikTestStack
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the worker still has blueprint discovery or apply tasks that are
|
||||
** queued or in progress (unknown counts as pending).
|
||||
*******************************************************************************/
|
||||
private static boolean blueprintWorkPending(TestApi admin)
|
||||
{
|
||||
for(String actor : List.of("authentik.blueprints.v1.tasks.blueprints_discovery", "authentik.blueprints.v1.tasks.apply_blueprint"))
|
||||
{
|
||||
TestApi.Response response = admin.get("/tasks/tasks/?page_size=1&actor_name=" + actor
|
||||
+ "&aggregated_status=queued&aggregated_status=consumed&aggregated_status=preprocess&aggregated_status=running&aggregated_status=postprocess");
|
||||
if(response.status() != 200 || response.json().getJSONObject("pagination").getInt("count") > 0)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a content-based blueprint instance and wait for it to apply.
|
||||
*******************************************************************************/
|
||||
@@ -579,7 +635,49 @@ public final class AuthentikTestStack
|
||||
break;
|
||||
}
|
||||
}
|
||||
AuthentikTestStack stack = starting;
|
||||
if(stack != null)
|
||||
{
|
||||
stack.printTaskDiagnostics();
|
||||
}
|
||||
throw (new IllegalStateException("Timed out waiting for " + what));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Print the Authentik worker queue (most waits depend on worker tasks:
|
||||
** blueprint applies, emails, notifications), so a timeout says whether
|
||||
** the worker was busy, stuck or failing, and on what.
|
||||
*******************************************************************************/
|
||||
private void printTaskDiagnostics()
|
||||
{
|
||||
try
|
||||
{
|
||||
TestApi admin = admin();
|
||||
System.err.println("authentik tasks status: " + admin.get("/tasks/tasks/status/").body());
|
||||
for(String state : List.of("running", "consumed", "queued", "warning", "error"))
|
||||
{
|
||||
TestApi.Response tasks = admin.get("/tasks/tasks/?page_size=20&aggregated_status=" + state);
|
||||
for(Object o : tasks.results())
|
||||
{
|
||||
JSONObject task = (JSONObject) o;
|
||||
StringBuilder logs = new StringBuilder();
|
||||
for(Object l : task.optJSONArray("logs") == null ? new org.json.JSONArray() : task.getJSONArray("logs"))
|
||||
{
|
||||
JSONObject log = (JSONObject) l;
|
||||
logs.append(" | ").append(log.optString("log_level")).append(": ").append(log.optString("event"))
|
||||
.append(" ").append(String.valueOf(log.optJSONObject("attributes")).substring(0, Math.min(400, String.valueOf(log.optJSONObject("attributes")).length())));
|
||||
}
|
||||
System.err.println("authentik task [" + state + "] " + task.optString("actor_name") + " uid=" + task.optString("uid") + " mtime=" + task.optString("mtime")
|
||||
+ " eta=" + task.optString("eta") + " retries=" + task.optInt("retries") + logs);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
System.err.println("authentik task diagnostics unavailable: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -82,6 +82,34 @@ class BlueprintReapplyIT
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
String identityAdmins = admin.groupPk("identity-admins");
|
||||
String kof22Admins = admin.groupPk("kof22-admins");
|
||||
String roleUuid = admin.first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
|
||||
try
|
||||
{
|
||||
assertEditsSurviveReapply(member, identityAdmins, kof22Admins, roleUuid);
|
||||
}
|
||||
finally
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// put the seed state back as the bootstrap created it, even if //
|
||||
// an assertion or re-apply failed, so later contract tests on //
|
||||
// the shared stack do not inherit this test's edits //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
admin.post("/core/groups/" + identityAdmins + "/remove_user/", new JSONObject().put("pk", member));
|
||||
admin.post("/core/groups/" + kof22Admins + "/remove_user/", new JSONObject().put("pk", member));
|
||||
admin.patch("/core/groups/" + kof22Admins + "/", new JSONObject().put("attributes", new JSONObject()));
|
||||
admin.patch("/core/groups/" + identityAdmins + "/", new JSONObject().put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.*")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod"))));
|
||||
admin.post("/rbac/permissions/assigned_by_roles/" + roleUuid + "/assign/", new JSONObject().put("permissions", new JSONArray().put("authentik_core.view_user")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Body of (c); the caller restores the seed state.
|
||||
*******************************************************************************/
|
||||
private void assertEditsSurviveReapply(int member, String identityAdmins, String kof22Admins, String roleUuid)
|
||||
{
|
||||
assertThat(admin.post("/core/groups/" + identityAdmins + "/add_user/", new JSONObject().put("pk", member)).status()).isEqualTo(204);
|
||||
assertThat(admin.post("/core/groups/" + kof22Admins + "/add_user/", new JSONObject().put("pk", member)).status()).isEqualTo(204);
|
||||
assertThat(admin.patch("/core/groups/" + kof22Admins + "/", new JSONObject().put("attributes",
|
||||
@@ -89,7 +117,6 @@ class BlueprintReapplyIT
|
||||
assertThat(admin.patch("/core/groups/" + identityAdmins + "/", new JSONObject().put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.*").put("cms.publish")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod")))).status()).isEqualTo(200);
|
||||
|
||||
String roleUuid = admin.first("/rbac/roles/?name=" + SERVICE_ACCOUNT).getString("pk");
|
||||
try
|
||||
{
|
||||
assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + roleUuid + "/unassign/", new JSONObject().put("permissions", new JSONArray().put("authentik_core.view_user"))).status()).isEqualTo(204);
|
||||
@@ -113,15 +140,6 @@ class BlueprintReapplyIT
|
||||
JSONObject serviceAccount = admin.first("/core/users/?username=" + SERVICE_ACCOUNT + "&include_roles=true");
|
||||
assertThat(BootstrapBlueprintIT.names(serviceAccount.getJSONArray("roles_obj"))).containsExactly(SERVICE_ACCOUNT);
|
||||
assertThat(admin.first("/core/users/?username=" + BREAK_GLASS).getBoolean("is_superuser")).isTrue();
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// put the seed groups back as the bootstrap created them //
|
||||
///////////////////////////////////////////////////////////
|
||||
admin.post("/core/groups/" + identityAdmins + "/remove_user/", new JSONObject().put("pk", member));
|
||||
admin.post("/core/groups/" + kof22Admins + "/remove_user/", new JSONObject().put("pk", member));
|
||||
admin.patch("/core/groups/" + kof22Admins + "/", new JSONObject().put("attributes", new JSONObject()));
|
||||
admin.patch("/core/groups/" + identityAdmins + "/", new JSONObject().put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.*")).put("qqq.io/app-access", new JSONArray().put("kof22-website-prod"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -297,7 +297,7 @@ class InvitationEnrollmentContractIT
|
||||
var result = AuthentikAdmin.forBackend(config.getApiBackendName()).execute(AdminRequest.of(AdminAction.INVITE_USER, "", "Native onboarding contract")
|
||||
.with("email", owner + "@example.test").with("name", "Invitation Contract").with("groupIds", group));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "the invitation email", () ->
|
||||
AuthentikTestStack.waitFor(AuthentikTestStack.ASYNC_TIMEOUT, "the invitation email", () ->
|
||||
{
|
||||
try(var client = HttpClient.newHttpClient())
|
||||
{
|
||||
|
||||
@@ -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,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.0.0", "x", "y"))));
|
||||
|
||||
int member = user("policy.member", child);
|
||||
admin.post("/core/groups/" + admin.groupPk("policy-object") + "/add_user/", new JSONObject().put("pk", member));
|
||||
admin.post("/core/groups/" + admin.groupPk("policy-other-spec") + "/add_user/", new JSONObject().put("pk", member));
|
||||
|
||||
JSONObject claims = claimsFor(member);
|
||||
assertThat(claims.has("qqq_policy")).isTrue();
|
||||
assertThat(PolicyPermissions.fromClaims(claims, format))
|
||||
.as("parent (inherited), child, an object-valued attribute; another spec is skipped")
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +150,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();
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -34,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;
|
||||
@@ -74,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<String> ROLE_PERMISSIONS = List.of(
|
||||
@@ -228,9 +228,9 @@ class QbitWriteContractIT
|
||||
assertThat(user.getJSONObject("attributes").getString("qqq.io/created-by")).isEqualTo(OPERATOR_EMAIL);
|
||||
assertThat(user.getJSONArray("groups_obj").getJSONObject(0).getString("name")).isEqualTo("wr-readers");
|
||||
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "the recovery email in Mailpit", () -> mailpitHas("wr.created@example.test"));
|
||||
AuthentikTestStack.waitFor(AuthentikTestStack.ASYNC_TIMEOUT, "the recovery email in Mailpit", () -> mailpitHas("wr.created@example.test"));
|
||||
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(30), "the audit event naming the operator", () ->
|
||||
AuthentikTestStack.waitFor(AuthentikTestStack.ASYNC_TIMEOUT, "the audit event naming the operator", () ->
|
||||
{
|
||||
for(Object event : admin.get("/events/events/?search=" + TestApi.encode(OPERATOR_EMAIL) + "&ordering=-created").results())
|
||||
{
|
||||
@@ -322,22 +322,22 @@ 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));
|
||||
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");
|
||||
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, ""));
|
||||
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();
|
||||
}
|
||||
@@ -437,7 +437,7 @@ class QbitWriteContractIT
|
||||
AuthentikAdmin.AdminResult reset = run(AdminRequest.of(AdminAction.SEND_RECOVERY_EMAIL, String.valueOf(pk), "contract test"));
|
||||
assertThat(reset.ok()).as(reset.message()).isTrue();
|
||||
assertThat(reset.message()).doesNotContain("http");
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "the reset email in Mailpit", () -> mailpitHas("wr.target@example.test"));
|
||||
AuthentikTestStack.waitFor(AuthentikTestStack.ASYNC_TIMEOUT, "the reset email in Mailpit", () -> mailpitHas("wr.target@example.test"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -424,7 +424,7 @@ class TokenContractIT
|
||||
tokens.add(ordinaryToken);
|
||||
String ordinaryEvent = setKeyEvent(ordinaryToken);
|
||||
String protectedEvent = setKeyEvent(protectedToken);
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "protected-token local notification", () ->
|
||||
AuthentikTestStack.waitFor(AuthentikTestStack.ASYNC_TIMEOUT, "protected-token local notification", () ->
|
||||
notifications(protectedOwner, protectedEvent).length() > 0);
|
||||
Instant deadline = Instant.now().plusSeconds(60);
|
||||
do
|
||||
@@ -489,7 +489,7 @@ class TokenContractIT
|
||||
users.add(servicePk);
|
||||
tokens.add("service-account-" + created.getString("username") + "-password");
|
||||
String[] eventPk = { null };
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(30), "service-account model_created event", () ->
|
||||
AuthentikTestStack.waitFor(AuthentikTestStack.ASYNC_TIMEOUT, "service-account model_created event", () ->
|
||||
{
|
||||
JSONArray events = admin.getOk("/events/events/?action=model_created&ordering=-created&page_size=50&username="
|
||||
+ TestApi.encode(AuthentikTestStack.SERVICE_ACCOUNT)).getJSONArray("results");
|
||||
@@ -504,7 +504,7 @@ class TokenContractIT
|
||||
}
|
||||
return (false);
|
||||
});
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(60), "service-account creation notification", () ->
|
||||
AuthentikTestStack.waitFor(AuthentikTestStack.ASYNC_TIMEOUT, "service-account creation notification", () ->
|
||||
notifications(member, eventPk[0]).length() > 0);
|
||||
}
|
||||
|
||||
@@ -778,7 +778,7 @@ class TokenContractIT
|
||||
String[] found = { null };
|
||||
try
|
||||
{
|
||||
AuthentikTestStack.waitFor(Duration.ofSeconds(30), "token model_updated event", () ->
|
||||
AuthentikTestStack.waitFor(AuthentikTestStack.ASYNC_TIMEOUT, "token model_updated event", () ->
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// set_key logs model_to_dict(token): identifier, intent, user (key //
|
||||
|
||||
@@ -117,6 +117,33 @@ class AuthentikHealthChecksTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With tokens off the token broker is not used, so no token_broker check is
|
||||
** registered (it could only ever show UNKNOWN "not configured"); the rest stay.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registrationWithTokensOffOmitsTokenBroker() throws QException
|
||||
{
|
||||
QInstance tokensOff = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig().withApiToken(SECRET).withTokensEnabled(false));
|
||||
var checks = HealthChecksMetaData.ofOrWithNew(tokensOff).resolveAll(tokensOff);
|
||||
assertThat(checks).extracting(c -> c.getName()).containsExactly("authentik.api", "authentik.api_token", "authentik.mapper");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With tokens explicitly on the token broker check is registered as before.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registrationWithTokensOnKeepsTokenBroker() throws QException
|
||||
{
|
||||
QInstance tokensOn = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig().withApiToken(SECRET).withTokensEnabled(true));
|
||||
var checks = HealthChecksMetaData.ofOrWithNew(tokensOn).resolveAll(tokensOn);
|
||||
assertThat(checks).extracting(c -> c.getName()).containsExactly("authentik.api", "authentik.api_token", "authentik.mapper", "authentik.token_broker");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Version responses are allowlisted before entering details.
|
||||
*******************************************************************************/
|
||||
|
||||
+169
-5
@@ -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;
|
||||
|
||||
@@ -207,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -222,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()
|
||||
{
|
||||
/*******************************************************************************
|
||||
@@ -235,7 +258,7 @@ class AuthentikGroupPermissionMapperTest
|
||||
}
|
||||
};
|
||||
noRuntime.customizeAutomatedSessionForUser(qInstance, session, "owner@example.test");
|
||||
assertThat(session.getPermissions()).containsExactly("already.there");
|
||||
assertThat(session.getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -360,7 +383,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<String, Object> ctx = context("someone@example.test", new JSONArray());
|
||||
ctx.put("idToken", new JSONObject().put("qqq_policy", policy));
|
||||
QSession session = new QSession();
|
||||
@@ -375,6 +398,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<String, Object> 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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -383,11 +429,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");
|
||||
}
|
||||
|
||||
@@ -441,4 +490,119 @@ 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<String, Object> 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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String, Object> 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<String, Object> 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");
|
||||
}
|
||||
|
||||
}
|
||||
+62
@@ -205,6 +205,60 @@ 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("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("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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only storage and cut-over are isolated; grant replacement is production code.
|
||||
***************************************************************************/
|
||||
@@ -217,6 +271,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, AuthentikMirrorAccess.Access access, 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)
|
||||
{
|
||||
|
||||
+3
-2
@@ -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");
|
||||
|
||||
+88
@@ -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<String> BOOT = List.of("boot.a");
|
||||
private static final List<String> STATIC = List.of("static.keep", "denied.x");
|
||||
private static final List<String> HOST = List.of("host.keep", "denied.x");
|
||||
private static final List<String> POLICY = List.of("policy.keep", "denied.x");
|
||||
private static final List<String> 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");
|
||||
}
|
||||
}
|
||||
+57
-4
@@ -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();
|
||||
|
||||
|
||||
@@ -96,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");
|
||||
}
|
||||
|
||||
@@ -116,7 +139,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)));
|
||||
@@ -150,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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* 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 = pythonOrFail();
|
||||
|
||||
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<List<Object>> cases = new ArrayList<>();
|
||||
List<String> 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\":{}}");
|
||||
|
||||
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);
|
||||
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<List<Object>> 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
private static void add(List<List<Object>> cases, List<String> 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<List<Object>> cases, boolean skipOtherSpecs) 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");
|
||||
}
|
||||
}
|
||||
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, text);
|
||||
Files.writeString(harness, HARNESS);
|
||||
Process process = new ProcessBuilder(python, "-I", harness.toString(), source.toString()).redirectErrorStream(true).start();
|
||||
JSONArray input = new JSONArray();
|
||||
for(List<Object> 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 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_REQUIRED")))
|
||||
{
|
||||
Assumptions.assumeTrue(false, "no python interpreter (set BLUEPRINT_PYTHON_REQUIRED=true to require it)");
|
||||
}
|
||||
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.
|
||||
*******************************************************************************/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -19,17 +19,24 @@ package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import org.json.JSONArray;
|
||||
@@ -172,4 +179,84 @@ class PolicyCatalogTest
|
||||
assertThat(json.getJSONObject(1).isNull("app")).isTrue();
|
||||
assertThat(PolicyCatalog.of(new QInstance()).getGroups()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The catalog of an instance is kept and handed out again while the metadata
|
||||
** it was built from is unchanged, and EVERY in-place change to that metadata
|
||||
** gives a new catalog on the very next call (no stale catalog, no clearCache):
|
||||
** an object added or removed, a permission level, base name or custom checker,
|
||||
** a table's supplemental metadata, a process's hidden flag, a table feature.
|
||||
** PolicyCatalog.of checks this on every sign-in and session resume by comparing
|
||||
** the instance with the kept fingerprint piece by piece.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyInPlaceMetadataChangeGivesANewCatalogAndAnUnchangedInstanceKeepsItsCatalog()
|
||||
{
|
||||
QInstance instance = instance();
|
||||
PolicyCatalog first = PolicyCatalog.of(instance);
|
||||
assertThat(PolicyCatalog.of(instance)).isSameAs(first);
|
||||
assertThat(PolicyCatalog.of(instance)).isSameAs(first);
|
||||
|
||||
List<Runnable> changes = List.of(
|
||||
() -> instance.addTable(table("added_later", "Added Later", PermissionLevel.HAS_ACCESS_PERMISSION)),
|
||||
() -> instance.getTable("gate").getPermissionRules().setLevel(PermissionLevel.READ_WRITE_PERMISSIONS),
|
||||
() -> instance.getTable("gate").getPermissionRules().setPermissionBaseName("gate_base"),
|
||||
() -> instance.getTable("gate").getPermissionRules().setCustomPermissionChecker(new QCodeReference(Checker.class)),
|
||||
() -> instance.getTable("gate").setPermissionRules(null),
|
||||
() -> instance.getTable("orphan").withSupplementalMetaData(new QSupplementalTableMetaData()
|
||||
{
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("policyCatalogTest");
|
||||
}
|
||||
}),
|
||||
() -> instance.getProcess("loose").setIsHidden(true),
|
||||
() -> instance.getProcess("loose").getPermissionRules().setLevel(PermissionLevel.NOT_PROTECTED),
|
||||
() -> instance.addProcess(new QProcessMetaData().withName("addedProcess").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION))),
|
||||
() -> instance.getApp("emptyApp").getPermissionRules().setLevel(PermissionLevel.HAS_ACCESS_PERMISSION),
|
||||
() -> instance.getReport("pageReport").getPermissionRules().setPermissionBaseName("page_report"),
|
||||
() -> instance.getWidget("pageStats").getPermissionRules().setLevel(PermissionLevel.NOT_PROTECTED),
|
||||
() -> instance.getWidgets().remove("openWidget"),
|
||||
() -> instance.addWidget(new QWidgetMetaData().withName("zLast").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION))),
|
||||
() -> instance.getWidgets().remove("zLast"),
|
||||
() -> TableFeaturePermissionsMetaData.ofOrWithNew(instance).withPermission(new TableFeaturePermission().withSuffix("analyze").withLabel("Analyze")
|
||||
.withAppliesTo((qInstance, table) -> "cms_page".equals(table.getName()))),
|
||||
() -> TableFeaturePermissionsMetaData.of(instance).getPermissions().get(0).setLabel("Analyse"));
|
||||
|
||||
for(int i = 0; i < changes.size(); i++)
|
||||
{
|
||||
PolicyCatalog before = PolicyCatalog.of(instance);
|
||||
changes.get(i).run();
|
||||
PolicyCatalog after = PolicyCatalog.of(instance);
|
||||
assertThat(after).as("change %s gives a new catalog", i).isNotSameAs(before);
|
||||
assertThat(PolicyCatalog.of(instance)).as("and the new catalog is kept after change %s", i).isSameAs(after);
|
||||
assertThat(after.permissionNames()).as("the kept catalog equals a fresh build after change %s", i).isEqualTo(PolicyCatalog.build(instance).permissionNames());
|
||||
assertThat(after.availableNames()).isEqualTo(PolicyCatalog.build(instance).availableNames());
|
||||
}
|
||||
assertThat(PolicyCatalog.of(instance).get("added_later")).isNotNull();
|
||||
assertThat(PolicyCatalog.of(instance).get("cms_page").features()).extracting(PolicyCatalog.Feature::name).containsExactly("analyze");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A custom permission checker that leaves everything to the defaults (a policy
|
||||
** cannot grant an object that has one, so the catalog leaves the object out).
|
||||
*******************************************************************************/
|
||||
public static class Checker implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules)
|
||||
{
|
||||
/////////////////////////////////
|
||||
// allows; never called here //
|
||||
/////////////////////////////////
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
/*
|
||||
* 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<String> 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<String> 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<JSONObject> 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<JSONObject> 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<String> 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<JSONObject> 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<JSONObject> 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<JSONObject> 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<JSONObject> 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<String> 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();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String> 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PolicyResult.replacement is the whole permission set of a session whose only
|
||||
** source is the policies: exactly Allow minus Deny, with no way to carry over
|
||||
** what a session held before (apply(session.getPermissions()) is a union and
|
||||
** keeps it). Void and unreadable policies give nothing. A Deny is applied even
|
||||
** to a result built by hand, and the set is the caller's own each time.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void policyResultReplacementIsExactlyTheGrantAndNothingCarriedOver() 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);
|
||||
List<String> held = List.of("held_before.read", "service.send", "utils_log.read");
|
||||
|
||||
assertThat(result.apply(held)).as("apply is a union with what the host passes").containsExactlyInAnyOrder("billing_invoice.read", "held_before.read", "service.send");
|
||||
assertThat(result.replacement()).as("replacement is the grant alone").containsExactly("billing_invoice.read").isEqualTo(result.granted());
|
||||
|
||||
Set<String> mine = result.replacement();
|
||||
mine.add("added.by.caller");
|
||||
assertThat(result.replacement()).as("a new set on every call").containsExactly("billing_invoice.read");
|
||||
assertThat(result.granted()).containsExactly("billing_invoice.read");
|
||||
|
||||
assertThat(PolicyResult.of(PolicyRules.voided("x"), UNIVERSE).replacement()).isEmpty();
|
||||
assertThat(PolicyResult.unreadable().replacement()).isEmpty();
|
||||
assertThat(PolicyResult.of(PolicyRules.EMPTY, UNIVERSE).replacement()).isEmpty();
|
||||
assertThat(new PolicyResult(rules, Set.of("billing_invoice.read", "utils_log.read")).replacement()).as("a Deny also covers a hand-built result").containsExactly("billing_invoice.read");
|
||||
assertThat(new PolicyResult(PolicyRules.voided("x"), Set.of("billing_invoice.read")).replacement()).as("void gives nothing whatever it was built with").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<JSONObject> 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();
|
||||
}
|
||||
|
||||
}
|
||||
+18
-15
@@ -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<String> permissions = List.of("person.comment", "person.read", "person.analyze", "gate.analyze");
|
||||
JSONObject generated = PolicyGenerator.generate(permissions, FORMAT, catalog);
|
||||
String compact = PolicyGenerator.toCompact(generated);
|
||||
assertThat(compact).isEqualTo("{\"PolicySpec\":\"qqq-iam\",\"PolicyVersion\":\"1.0.0\",\"Statement\":["
|
||||
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<String> standard = List.of("person.read", "person.edit", "gate.hasAccess");
|
||||
assertThat(PolicyGenerator.toCompact(PolicyGenerator.generate(standard, FORMAT, catalog))).isEqualTo(PolicyGenerator.toCompact(PolicyGenerator.generate(standard, FORMAT)));
|
||||
}
|
||||
|
||||
@@ -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<String> actions : List.of(List.of("read"), List.of("*", "read"), List.<String>of()))
|
||||
{
|
||||
JSONObject invalid = PolicyParserTest.document(PolicyParserTest.statement("Allow", actions, List.of("*")));
|
||||
assertThatThrownBy(() -> PolicyParser.parseStrict(invalid, FORMAT)).hasMessageContaining("requires Action");
|
||||
assertThat(PolicyParser.parse(invalid, FORMAT)).isEmpty();
|
||||
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<String> 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<String> 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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,11 @@ 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();
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -122,29 +124,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 +160,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 +172,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 +190,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 +205,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 +217,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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/*
|
||||
* 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.lang.management.ManagementFactory;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
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.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.document;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.statement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What one policy resolution costs. QQQ runs a host's authentication
|
||||
** customizer on every request of a session, so the work of turning a policy
|
||||
** claim into permissions must not grow with the number of permissions the
|
||||
** policy names. These tests count that work: the live permission universe is
|
||||
** built ONCE per resolution, whatever the policy holds, and the memory one
|
||||
** resolution allocates stays flat. They also pin that reading the universe once
|
||||
** grants exactly what expanding each term against its own reading grants.
|
||||
**
|
||||
** The counter is a registered host-name function: the qbit calls every one of
|
||||
** them each time it builds the live universe (PolicyPermissions.allPermissions).
|
||||
*******************************************************************************/
|
||||
class PolicyResolutionCostTest
|
||||
{
|
||||
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
|
||||
|
||||
private static final int HOST_NAMES = 40;
|
||||
|
||||
private final AtomicInteger universes = new AtomicInteger();
|
||||
|
||||
private final Function<QInstance, Collection<String>> counter = qInstance ->
|
||||
{
|
||||
universes.incrementAndGet();
|
||||
List<String> names = new ArrayList<>();
|
||||
for(int i = 0; i < HOST_NAMES; i++)
|
||||
{
|
||||
names.add("svc.action" + i);
|
||||
}
|
||||
return (names);
|
||||
};
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count every build of the live universe.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void countUniverses()
|
||||
{
|
||||
PolicyPermissions.clearRegisteredHostPermissions();
|
||||
PolicyPermissions.registerHostPermissions(counter);
|
||||
universes.set(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave no host-name function behind for other tests.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void forgetCounter()
|
||||
{
|
||||
PolicyPermissions.clearRegisteredHostPermissions();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An instance about the size of a real host: 200 tables, 1,000 hidden record
|
||||
** actions, 200 widgets and 20 apps, all protected.
|
||||
*******************************************************************************/
|
||||
private static QInstance largeInstance()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
List<QAppMetaData> apps = new ArrayList<>();
|
||||
for(int a = 0; a < 20; a++)
|
||||
{
|
||||
apps.add(new QAppMetaData().withName("app" + a).withLabel("App " + a).withSortOrder(a).withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
}
|
||||
for(int t = 0; t < 200; t++)
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData().withName("svc" + (t % 20) + "_table" + t).withLabel("Table " + t).withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withPermissionRules(rules(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
|
||||
instance.addTable(table);
|
||||
apps.get(t % 20).withChild(table);
|
||||
}
|
||||
for(int p = 0; p < 1000; p++)
|
||||
{
|
||||
int t = p % 200;
|
||||
instance.addProcess(new QProcessMetaData().withName("svc" + (t % 20) + "_table" + t + "Action" + p).withLabel("Action " + p).withTableName("svc" + (t % 20) + "_table" + t)
|
||||
.withIsHidden(true).withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
}
|
||||
for(int w = 0; w < 200; w++)
|
||||
{
|
||||
instance.addWidget(new QWidgetMetaData().withName("widget" + w).withLabel("Widget " + w).withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
}
|
||||
apps.forEach(instance::addApp);
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission rules of a level.
|
||||
*******************************************************************************/
|
||||
private static QPermissionRules rules(PermissionLevel level)
|
||||
{
|
||||
return (new QPermissionRules().withLevel(level));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A role of this many named permissions (host names the counter declares), as
|
||||
** the claims of a token.
|
||||
*******************************************************************************/
|
||||
private static JSONObject namedRole(int count)
|
||||
{
|
||||
JSONArray names = new JSONArray();
|
||||
for(int i = 0; i < count; i++)
|
||||
{
|
||||
names.put("svc.action" + i);
|
||||
}
|
||||
return (claims(document(new JSONObject().put("Effect", "Allow").put("Permission", names))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Token claims carrying one policy document.
|
||||
*******************************************************************************/
|
||||
private static JSONObject claims(JSONObject policy)
|
||||
{
|
||||
return (new JSONObject().put("email", "a@example.test").put(FORMAT.claim(), policy));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A named-permission statement.
|
||||
*******************************************************************************/
|
||||
private static JSONObject named(String effect, String... names)
|
||||
{
|
||||
return (new JSONObject().put("Effect", effect).put("Permission", new JSONArray(List.of(names))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How many times the live universe was built by this work.
|
||||
*******************************************************************************/
|
||||
private int universesBuiltBy(Runnable work)
|
||||
{
|
||||
universes.set(0);
|
||||
work.run();
|
||||
return (universes.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The reported defect: a role of nine named permissions built nine universes
|
||||
** on every request. Every entry point a host customizer can call builds one,
|
||||
** for one name or thirty.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void oneUniversePerResolutionWhateverTheNumberOfNamedPermissions()
|
||||
{
|
||||
QInstance instance = largeInstance();
|
||||
for(int count : new int[] { 1, 9, 30 })
|
||||
{
|
||||
JSONObject claims = namedRole(count);
|
||||
|
||||
assertThat(universesBuiltBy(() -> assertThat(PolicyPermissions.resultFromClaims(claims, FORMAT, PolicyCatalog.of(instance)).granted()).hasSize(count)))
|
||||
.as("resultFromClaims, %s named permissions", count).isEqualTo(1);
|
||||
|
||||
assertThat(universesBuiltBy(() -> assertThat(deprecatedFromClaims(claims, instance)).hasSize(count)))
|
||||
.as("fromClaims, %s named permissions", count).isEqualTo(1);
|
||||
|
||||
assertThat(universesBuiltBy(() ->
|
||||
{
|
||||
QSession session = new QSession();
|
||||
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
|
||||
assertThat(session.getPermissions()).hasSize(count);
|
||||
})).as("AuthentikPolicyClaimMapper, %s named permissions", count).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The deprecated plain-set read, still used by hosts.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("deprecation")
|
||||
private static Set<String> deprecatedFromClaims(JSONObject claims, QInstance instance)
|
||||
{
|
||||
return (PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every kind of term together (exact resources, a prefix, named permissions,
|
||||
** a Deny) is still one universe; a void or absent claim needs none.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mixedTermsReadOneUniverseAndVoidOrAbsentClaimsReadNone()
|
||||
{
|
||||
QInstance instance = largeInstance();
|
||||
JSONObject mixed = claims(document(
|
||||
statement("Allow", List.of("read", "edit"), List.of("svc1_table1", "svc2_table2", "svc3_table3")),
|
||||
statement("Allow", List.of("read"), List.of("svc4_*")),
|
||||
named("Allow", "svc.action1", "svc.action2", "svc.action3", "not.declared"),
|
||||
statement("Deny", List.of("edit"), List.of("svc2_table2")),
|
||||
named("Deny", "svc.action3")));
|
||||
|
||||
PolicyResult[] result = new PolicyResult[1];
|
||||
assertThat(universesBuiltBy(() -> result[0] = PolicyPermissions.resultFromClaims(mixed, FORMAT, PolicyCatalog.of(instance)))).isEqualTo(1);
|
||||
assertThat(result[0].granted()).contains("svc1_table1.read", "svc1_table1.edit", "svc2_table2.read", "svc4_table4.read", "svc4_table24.read", "svc.action1", "svc.action2")
|
||||
.doesNotContain("svc2_table2.edit", "svc.action3", "not.declared", "svc4_table4.edit");
|
||||
|
||||
JSONObject voided = claims(new JSONObject().put("PolicySpec", "not-the-format"));
|
||||
assertThat(universesBuiltBy(() -> assertThat(PolicyPermissions.resultFromClaims(voided, FORMAT, PolicyCatalog.of(instance)).isVoid()).isTrue())).isZero();
|
||||
|
||||
JSONObject absent = new JSONObject().put("email", "a@example.test");
|
||||
assertThat(universesBuiltBy(() -> assertThat(PolicyPermissions.resultFromClaims(absent, FORMAT, PolicyCatalog.of(instance)).isAbsent()).isTrue())).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rule set itself reads whatever universe it is given once per call:
|
||||
** allowed, resolve and withheld, however many terms there are.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rulesReadTheUniverseOncePerCall()
|
||||
{
|
||||
Set<String> names = new TreeSet<>(List.of("a_one.read", "a_one.edit", "a_two.read", "b_one.read", "svc.x", "svc.y", "svc.z"));
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
PolicyUniverse universe = () ->
|
||||
{
|
||||
reads.incrementAndGet();
|
||||
return (new TreeSet<>(names));
|
||||
};
|
||||
PolicyRules rules = PolicyParser.parse(document(
|
||||
statement("Allow", List.of("read", "edit"), List.of("a_one", "a_two", "b_one")),
|
||||
statement("Allow", List.of("read"), List.of("a_*")),
|
||||
named("Allow", "svc.x", "svc.y", "svc.z"),
|
||||
named("Deny", "svc.z")), FORMAT);
|
||||
assertThat(rules.allowTerms()).hasSizeGreaterThan(8);
|
||||
|
||||
assertThat(rules.allowed(universe)).containsExactly("a_one.edit", "a_one.read", "a_two.read", "b_one.read", "svc.x", "svc.y", "svc.z");
|
||||
assertThat(reads.getAndSet(0)).as("allowed").isEqualTo(1);
|
||||
|
||||
assertThat(rules.resolve(universe)).containsExactly("a_one.edit", "a_one.read", "a_two.read", "b_one.read", "svc.x", "svc.y");
|
||||
assertThat(reads.getAndSet(0)).as("resolve").isEqualTo(1);
|
||||
|
||||
assertThat(rules.withheld(universe)).containsExactly("svc.z");
|
||||
assertThat(reads.getAndSet(0)).as("withheld").isEqualTo(1);
|
||||
|
||||
assertThat(PolicyRules.voided("bad").resolve(universe)).isEmpty();
|
||||
assertThat(PolicyRules.EMPTY.resolve(universe)).isEmpty();
|
||||
assertThat(reads.get()).as("void and empty rules need no universe").isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The editor's save-time checks (every named permission, prefix and Deny must
|
||||
** match something) read the universe once for the whole document.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void saveTimeChecksReadTheUniverseOnce() throws Exception
|
||||
{
|
||||
Set<String> names = new TreeSet<>(List.of("a_one.read", "a_one.edit", "a_two.read", "svc.x", "svc.y"));
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
PolicyUniverse universe = () ->
|
||||
{
|
||||
reads.incrementAndGet();
|
||||
return (names);
|
||||
};
|
||||
JSONObject valid = document(statement("Allow", List.of("read"), List.of("a_*")), named("Allow", "svc.x", "svc.y"),
|
||||
statement("Deny", List.of("edit"), List.of("a_one")), statement("Deny", List.of("*"), List.of("a_*")));
|
||||
PolicyParser.parseForSave(valid, FORMAT, PolicyCatalog.ofPermissionNames(names), universe);
|
||||
assertThat(reads.getAndSet(0)).isEqualTo(1);
|
||||
|
||||
JSONObject mistyped = document(named("Allow", "svc.x", "svc.mistyped"));
|
||||
assertThatThrownBy(() -> PolicyParser.parseForSave(mistyped, FORMAT, PolicyCatalog.ofPermissionNames(names), universe))
|
||||
.isInstanceOf(PolicyParseException.class).hasMessageContaining("svc.mistyped");
|
||||
assertThat(reads.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The one-resolution universe: nothing is read before the first use, the
|
||||
** source is read once, and the names it hands out cannot be changed (no term
|
||||
** can alter what a later term sees).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theUniverseOfOneResolutionIsReadLazilyOnceAndIsReadOnly()
|
||||
{
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
PolicyUniverse sorted = PolicyUniverse.once(() ->
|
||||
{
|
||||
reads.incrementAndGet();
|
||||
return (new TreeSet<>(List.of("b.read", "a.read")));
|
||||
});
|
||||
assertThat(reads.get()).isZero();
|
||||
assertThat(sorted.names()).containsExactly("a.read", "b.read");
|
||||
assertThat(sorted.names()).isSameAs(sorted.names());
|
||||
assertThat(reads.get()).isEqualTo(1);
|
||||
assertThatThrownBy(() -> sorted.names().add("c.read")).isInstanceOf(UnsupportedOperationException.class);
|
||||
|
||||
PolicyUniverse unsorted = PolicyUniverse.once(() -> Set.of("a.read"));
|
||||
assertThat(unsorted.names()).containsExactly("a.read");
|
||||
assertThatThrownBy(() -> unsorted.names().clear()).isInstanceOf(UnsupportedOperationException.class);
|
||||
|
||||
assertThat(PolicyUniverse.once(sorted).names()).containsExactly("a.read", "b.read");
|
||||
assertThat(reads.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reading the universe once changes nothing a policy grants: for documents of
|
||||
** every shape, on the large instance, resolve() gives exactly the union of each
|
||||
** Allow term expanded against its own fresh reading of the live universe, minus
|
||||
** the Deny terms (how resolution worked before it read the universe once).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void oneReadingGrantsExactlyWhatAReadingPerTermGrants()
|
||||
{
|
||||
QInstance instance = largeInstance();
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance);
|
||||
PolicyUniverse live = PolicyPermissions.universe(instance, Set.of());
|
||||
List<JSONObject> documents = List.of(
|
||||
document(statement("Allow", List.of("*"), List.of("*"))),
|
||||
document(statement("Allow", List.of("*"), List.of("*")), statement("Deny", List.of("*"), List.of("svc3_*")), named("Deny", "svc.action7")),
|
||||
document(named("Allow", "svc.action0", "svc.action1", "svc.action2", "svc.action3", "svc.action4", "svc.action5", "svc.action6", "svc.action7", "svc.action8")),
|
||||
document(statement("Allow", List.of("read", "insert", "edit", "delete"), List.of("svc1_table1", "svc2_table2", "noSuchTable"))),
|
||||
document(statement("Allow", List.of("read"), List.of("svc5_*", "svc6_*")), statement("Deny", List.of("read"), List.of("svc5_table5"))),
|
||||
document(statement("Allow", List.of("hasAccess"), List.of("app3", "widget17", "svc0_table0Action0")), named("Allow", "not.declared")),
|
||||
document(named("Deny", "svc.action1")),
|
||||
document());
|
||||
|
||||
for(JSONObject document : documents)
|
||||
{
|
||||
PolicyRules rules = PolicyParser.parse(document, FORMAT, catalog);
|
||||
assertThat(rules.isVoid()).isFalse();
|
||||
|
||||
Set<String> perTerm = new TreeSet<>();
|
||||
for(PolicyTerm term : rules.allowTerms())
|
||||
{
|
||||
perTerm.addAll(term.expand(live));
|
||||
}
|
||||
perTerm.removeIf(rules::denies);
|
||||
|
||||
assertThat(rules.resolve(live)).as(document.toString()).isEqualTo(perTerm);
|
||||
assertThat(PolicyPermissions.resultFromClaims(claims(document), FORMAT, catalog).granted()).as(document.toString()).isEqualTo(perTerm);
|
||||
}
|
||||
assertThat(PolicyRules.allowing(List.of("svc.action0")).resolve(live)).containsExactly("svc.action0");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The live universe itself is unchanged by keeping the catalog's names: the
|
||||
** catalog's available names, its grantable names, the host names and the
|
||||
** identity names, and a later instance change is visible on the next call.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theLiveUniverseHoldsTheSameNamesAndStillFollowsTheInstance()
|
||||
{
|
||||
QInstance instance = largeInstance();
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance);
|
||||
Set<String> expected = new TreeSet<>(catalog.availableNames());
|
||||
expected.addAll(catalog.permissionNames());
|
||||
for(int i = 0; i < HOST_NAMES; i++)
|
||||
{
|
||||
expected.add("svc.action" + i);
|
||||
}
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
expected.add(permission.getSessionPermission());
|
||||
}
|
||||
expected.add("extra.name");
|
||||
assertThat(PolicyPermissions.allPermissions(instance, List.of("extra.name", PolicyPermissions.FULL_ACCESS))).isEqualTo(expected);
|
||||
|
||||
Set<String> names = catalog.permissionNames();
|
||||
names.clear();
|
||||
assertThat(catalog.permissionNames()).as("permissionNames() is the caller's own copy").isNotEmpty();
|
||||
|
||||
instance.addProcess(new QProcessMetaData().withName("addedLater").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
assertThat(PolicyPermissions.allPermissions(instance, Set.of())).contains("addedLater.hasAccess");
|
||||
assertThat(PolicyPermissions.resultFromClaims(claims(document(statement("Allow", List.of("hasAccess"), List.of("addedLater")))), FORMAT, PolicyCatalog.of(instance)).granted())
|
||||
.containsExactly("addedLater.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An allocation budget for one resolution on the large instance: it does not
|
||||
** grow with the number of named permissions (thirty names cost about what one
|
||||
** costs), and it stays under 2 MB. Before the universe was read once, nine
|
||||
** names allocated about 8 MB and thirty about 25 MB on this instance; now
|
||||
** about 0.15 MB whatever the count. The bounds leave room for differences
|
||||
** between JVMs; they catch a return to one universe per term.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theMemoryOneResolutionAllocatesDoesNotGrowWithTheNumberOfNamedPermissions()
|
||||
{
|
||||
assumeTrue(ManagementFactory.getThreadMXBean() instanceof com.sun.management.ThreadMXBean bean && bean.isThreadAllocatedMemorySupported(), "needs per-thread allocation counts");
|
||||
QInstance instance = largeInstance();
|
||||
JSONObject one = namedRole(1);
|
||||
JSONObject thirty = namedRole(30);
|
||||
|
||||
long allocatedForOne = medianAllocatedBytes(() -> PolicyPermissions.resultFromClaims(one, FORMAT, PolicyCatalog.of(instance)));
|
||||
long allocatedForThirty = medianAllocatedBytes(() -> PolicyPermissions.resultFromClaims(thirty, FORMAT, PolicyCatalog.of(instance)));
|
||||
|
||||
assertThat(allocatedForThirty).as("thirty named permissions against one (%s bytes)", allocatedForOne).isLessThan(3 * allocatedForOne);
|
||||
assertThat(allocatedForThirty).as("bytes allocated by one resolution of thirty named permissions").isLessThan(2L * 1024 * 1024);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The median number of bytes this thread allocates for one run of the work,
|
||||
** after the work has run enough to be compiled.
|
||||
*******************************************************************************/
|
||||
private static long medianAllocatedBytes(Supplier<Object> work)
|
||||
{
|
||||
com.sun.management.ThreadMXBean bean = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
|
||||
long thread = Thread.currentThread().threadId();
|
||||
for(int i = 0; i < 200; i++)
|
||||
{
|
||||
assertThat(work.get()).isNotNull();
|
||||
}
|
||||
long[] allocated = new long[21];
|
||||
for(int i = 0; i < allocated.length; i++)
|
||||
{
|
||||
long before = bean.getThreadAllocatedBytes(thread);
|
||||
Object result = work.get();
|
||||
allocated[i] = bean.getThreadAllocatedBytes(thread) - before;
|
||||
assertThat(result).isNotNull();
|
||||
}
|
||||
Arrays.sort(allocated);
|
||||
return (allocated[allocated.length / 2]);
|
||||
}
|
||||
}
|
||||
@@ -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<String> names(PolicyRules rules)
|
||||
{
|
||||
if(rules.hasFullAccess() && !rules.hasDeny())
|
||||
{
|
||||
return (new TreeSet<>(Set.of(PolicyPermissions.FULL_ACCESS)));
|
||||
}
|
||||
return (rules.exactGrants());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* 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<DynamicTest> vectors() throws IOException
|
||||
{
|
||||
JSONObject file = load();
|
||||
Set<String> universe = strings(file.getJSONArray("universe"));
|
||||
Set<String> 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<DynamicTest> 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<PolicyRules> 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<PolicyRules> 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<JSONObject> 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<JSONObject> documents(JSONObject vector)
|
||||
{
|
||||
List<JSONObject> 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)));
|
||||
}
|
||||
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<String> strings(JSONArray array)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
rs.add(array.getString(i));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
}
|
||||
@@ -166,4 +166,65 @@ 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("[]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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), 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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,115 @@ 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(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)");
|
||||
insertGroup("g-two", "Two", "{}");
|
||||
int before = AuthentikMirrorAccess.maxGroups;
|
||||
try
|
||||
{
|
||||
AuthentikMirrorAccess.maxGroups = 1;
|
||||
assertThatThrownBy(() -> module.createSession(instance, context)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException.class)
|
||||
.hasMessageContaining("Failed to create session");
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -28,24 +28,56 @@ entries:
|
||||
import json
|
||||
|
||||
spec = "qqq-iam"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
attribute = "qqq.io/policy"
|
||||
|
||||
# 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():
|
||||
raw = (group.attributes or {}).get(attribute)
|
||||
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):
|
||||
continue
|
||||
bad = True
|
||||
break
|
||||
if not isinstance(document, dict):
|
||||
continue
|
||||
if document.get("PolicySpec") != spec or document.get("PolicyVersion") != version:
|
||||
continue
|
||||
bad = True
|
||||
break
|
||||
if document.get("PolicySpec") != spec:
|
||||
if skip_other_specs and isinstance(document.get("PolicySpec"), str):
|
||||
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 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})}
|
||||
@@ -0,0 +1,409 @@
|
||||
{
|
||||
"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).",
|
||||
"'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.",
|
||||
"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",
|
||||
"document-generation.print",
|
||||
"ping",
|
||||
"billing_audit.export.run"
|
||||
],
|
||||
"hostNames": ["authentik.users.manage.hasAccess", "cdn.purge", "document-generation.print", "ping", "billing_audit.export.run"],
|
||||
"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", "document-generation.print", "ping"
|
||||
]}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"billing_audit.export.run", "cdn.purge", "document-generation.print", "ping"
|
||||
]}
|
||||
},
|
||||
{
|
||||
"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", "billing_audit.export.run", "document-generation.print", "ping"
|
||||
]}
|
||||
},
|
||||
{
|
||||
"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": "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",
|
||||
"billing_audit.export.run", "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": "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": "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",
|
||||
"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"}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user