Compare commits

..
27 Commits
Author SHA1 Message Date
CI dd8cebd666 Bump version to 0.1.0-RC.11 [skip ci] 2026-11-09 03:43:12 +00:00
james.maes 7e579cc1cd Merge pull request 'Merge develop into release/0.1 (policy format 1.1.0, RC.10)' (#70) from merge/develop-into-release-0-1-rc10 into release/0.1
maven / build (push) Successful in 12m0s
build / maven (push) Successful in 12m0s
2026-11-09 03:31:23 +00:00
james.maesandClaude Sonnet 5.5 a058403857 ci: re-run (previous run hit a runner Docker port-binding error in Testcontainers; unit tests passed)
maven / build (push) Successful in 11m18s
build / maven (push) Successful in 11m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:05:06 -06:00
james.maes ce9cf33fd4 Merge develop into release/0.1 (policy format 1.1.0, RC.10)
maven / build (push) Failing after 1m56s
build / maven (push) Failing after 1m56s
2026-11-08 21:01:35 -06:00
CI c089ea5749 Bump version to 0.1.0-RC.10 [skip ci] 2026-11-07 18:56:58 +00:00
james.maes 178bf01857 Merge pull request 'Merge develop into release/0.1 (Gitea-aligned RC)' (#68) from align/develop-into-release-0.1 into release/0.1
maven / build (push) Successful in 13m20s
build / maven (push) Successful in 13m19s
2026-11-07 18:22:02 +00:00
James Maes 5e114114c5 Merge develop into release/0.1 (Gitea-aligned RC)
maven / build (push) Canceled after 0s
build / maven (push) Canceled after 0s
2026-11-07 12:21:39 -06:00
CI ed139520df Bump version to 0.1.0-RC.9 [skip ci] 2026-11-07 13:59:39 +00:00
James Maes afedbd1f29 Merge remote-tracking branch 'origin/develop' into release/0.1
maven / build (push) Successful in 13m31s
build / maven (push) Successful in 13m31s
2026-11-07 07:46:09 -06:00
james.maes 3ee07e26ba Merge pull request 'release: promote develop to release/0.1 (0.1.0-RC.9)' (#64) from develop into release/0.1
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 10s
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Waiting to run
maven / build (push) Failing after 18m56s
build / maven (push) Failing after 1h5m18s
2026-11-06 15:35:39 +00:00
Gitea Actions 19cec0f147 Merge pull request 'Merge develop into release/0.1 for the next 0.1.0 RC' (#60) from develop into release/0.1 2026-11-05 11:43:05 +00:00
CircleCI 21d246faf8 Bump version to 0.1.0-RC.8 [skip ci] 2026-10-16 19:35:13 +00:00
james.maesandClaude Opus 5.5 d7ce084112 Merge develop into release/0.1 for 0.1.0-RC.8 (#37 broker probe, #38 maintenance service principal, #39 mapper drift message; QQQ RC.19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:06:52 -05:00
CircleCI f98dd97786 Bump version to 0.1.0-RC.7 [skip ci] 2026-10-15 03:52:08 +00:00
james.maesandClaude Opus 5.5 e0e6ce601c Merge develop into release/0.1 for 0.1.0-RC.7 (#34 help bundle, #31 health, #35 bundle version)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:28:11 -05:00
CircleCI 0f9169b9ee Bump version to 0.1.0-RC.6 [skip ci] 2026-10-14 22:59:06 +00:00
james.maesandClaude Opus 5.5 7559321967 Merge develop into release/0.1 for 0.1.0-RC.6 (#33 help and guardrail)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:36:44 -05:00
CircleCI b22844590f Bump version to 0.1.0-RC.5 [skip ci] 2026-10-14 22:22:41 +00:00
james.maesandClaude Opus 5.5 09451baeb6 Merge develop into release/0.1 for 0.1.0-RC.5 (#32 full-access policy and maintenance API)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:14:19 -05:00
james.maesandClaude Opus 5.5 9787d2d184 Merge develop into release/0.1 for 0.1.0-RC.5 (member preview payload, effective-features startup log)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:22:07 -05:00
CircleCI 04bc8c1055 Bump version to 0.1.0-RC.4 [skip ci] 2026-10-14 13:35:55 +00:00
james.maesandClaude Opus 5.5 839c76f19e Merge develop into release/0.1 for 0.1.0-RC.4 (#23 #24 #26 #27)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:16:40 -05:00
CircleCI ffc12a8041 Bump version to 0.1.0-RC.3 [skip ci] 2026-10-13 20:02:45 +00:00
james.maesandClaude Opus 5.5 6e307e24c5 Merge develop into release/0.1 for 0.1.0-RC.3
Policy cut-over tooling (#18): enforcement status, Compare Access, the
guard, and Generate Policy From Current Access.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:56:58 -05:00
CircleCI b30a30c53f Bump version to 0.1.0-RC.2 [skip ci] 2026-10-13 19:29:47 +00:00
james.maesandClaude Opus 5.5 ea9342368a Merge develop into release/0.1 for 0.1.0-RC.2
Spec (#13) and phases #14-#16: policy model, parser and generator,
runtime mapper, policy editor, user/group CRUD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:21:15 -05:00
CircleCI ede7cd9bdb Bump version to 0.1.0-RC.1 [skip ci] 2026-10-12 23:45:14 +00:00
15 changed files with 77 additions and 958 deletions

No files matched your search

-2
View File
@@ -6,8 +6,6 @@ All notable changes to this project are documented here. Format: [Keep a Changel
- **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").
+2 -2
View File
@@ -98,7 +98,7 @@ A token with no `groups` claim at all and no policy claim says nothing about the
**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.
**Host customizers.** `PolicyPermissions.fromClaims` and `AuthentikGroupPermissionMapper.resolve` return plain sets and are deprecated: a set cannot tell void from absent or carry a `Deny` to the host's other sources. Use `PolicyPermissions.resultFromClaims(claims, format, catalog)` (or `AuthentikGroupPermissionMapper.resolveResult`), which returns a `PolicyResult` (`isVoid()`, `isAbsent()`, the typed rules and what they grant), and add the host's own service permissions through `result.apply(otherPermissions)`: it subtracts every `Deny` from them and returns nothing when the policy is void.
The golden vectors in `src/test/resources/policy-vectors-1.1.0.json` define these rules over a small fixed permission universe. Every implementation of the grammar must pass them: this qbit's parser, the Authentik claim validator in the identity CD repository, and Forge's catalog tests.
@@ -197,7 +197,7 @@ Authentik puts the merged document into the token through a scope mapping. The e
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
The expanded catalog 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 expanded catalog and permission universe are cached per `QInstance` (the last few instances, keyed by a digest of table permission rules, supplemental metadata and object names); `PolicyCatalog.clearCache()` forgets them. Host-declared names are never cached. Host names must be dot-separated segments of `[A-Za-z0-9_-]`; a function that returns anything else fails the expansion loudly. The cache key also covers the permission rules of processes, apps, reports and widgets, hidden flags and the registered table-feature list; a feature's applies-to predicate is opaque, so a host that changes only that in place calls `clearCache()`.
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog accept standard actions and full access; `PolicyPermissions.fromClaims` uses the current `QContext` instance for full-access expansion when no catalog is supplied. Raw `PolicyParser` results are symbolic and must be resolved before entering a session. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the available-permission API from the pinned RC.6 core; registered `TableFeaturePermissionsMetaData` features appear in that API.
+1 -1
View File
@@ -21,7 +21,7 @@
<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>
<revision>0.1.0-RC.11</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -65,13 +65,7 @@ public final class AuthentikHealthChecks
public static void register(QInstance instance, AuthentikQBitConfig config)
{
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
/////////////////////////////////////////////////////////////////////////////////////////////////
// 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)
for(String name : List.of("api", "api_token", "mapper", "token_broker"))
{
registry.withCheck(new HealthCheckMetaData().withName("authentik." + name).withLabel("Authentik " + name)
.withComponent("authentik").withCategory(HealthCategory.IDENTITY).withKind(HealthCheckKind.DEPENDENCY)
@@ -169,7 +169,7 @@ public class EffectivePermissionResolver
Set<String> unknown = new TreeSet<>();
Set<String> appAccess = new TreeSet<>();
List<PolicyRules> policies = new ArrayList<>();
PolicyUniverse live = PolicyUniverse.once(universe());
PolicyUniverse live = memoized(universe());
Set<String> visited = new LinkedHashSet<>();
boolean missingParent = false;
@@ -255,6 +255,24 @@ public class EffectivePermissionResolver
/*******************************************************************************
** Evaluate a universe at most once.
*******************************************************************************/
private static PolicyUniverse memoized(PolicyUniverse source)
{
List<Set<String>> cache = new ArrayList<>();
return (() ->
{
if(cache.isEmpty())
{
cache.add(source.names());
}
return (cache.get(0));
});
}
/*******************************************************************************
** Every permission full access grants now (the live universe).
*******************************************************************************/
@@ -19,7 +19,6 @@ 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;
@@ -27,7 +26,6 @@ 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;
@@ -136,12 +134,6 @@ public final class PolicyCatalog
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;
/*******************************************************************************
@@ -153,70 +145,6 @@ public final class PolicyCatalog
/*******************************************************************************
** 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.
*******************************************************************************/
@@ -233,48 +161,25 @@ 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);
}
/*******************************************************************************
** 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.
** Build the catalog of an instance.
*******************************************************************************/
public static PolicyCatalog of(QInstance qInstance)
{
Cached cached;
String fingerprint = fingerprint(qInstance);
synchronized(CACHE)
{
cached = CACHE.get(qInstance);
}
if(cached != null)
{
FingerprintMatcher matcher = new FingerprintMatcher(cached.fingerprint());
describe(qInstance, matcher);
if(matcher.matched())
Cached cached = CACHE.get(qInstance);
if(cached != null && cached.fingerprint().equals(fingerprint))
{
return (cached.catalog());
}
}
String fingerprint = fingerprint(qInstance);
PolicyCatalog built = build(qInstance);
PolicyCatalog built = build(qInstance);
synchronized(CACHE)
{
CACHE.put(qInstance, new Cached(fingerprint, built));
@@ -305,111 +210,37 @@ public final class PolicyCatalog
*******************************************************************************/
static String fingerprint(QInstance qInstance)
{
StringBuilder sb = new StringBuilder(16384);
describe(qInstance, sb::append);
StringBuilder sb = new StringBuilder();
new TreeMap<>(qInstance.getTables()).forEach((name, table) ->
{
QPermissionRules rules = table.getPermissionRules();
sb.append("t:").append(name).append('|')
.append(rules == null ? "-" : rules.getLevel() + ":" + rules.getPermissionBaseName() + ":" + rules.getCustomPermissionChecker()).append('|')
.append(table.getSupplementalMetaData() == null ? "" : new TreeSet<>(table.getSupplementalMetaData().keySet())).append(';');
});
new TreeMap<>(qInstance.getProcesses()).forEach((name, process) -> sb.append("p:").append(name).append('|').append(rulesOf(process.getPermissionRules())).append('|').append(process.getIsHidden()).append(';'));
new TreeMap<>(qInstance.getApps()).forEach((name, app) -> sb.append("a:").append(name).append('|').append(rulesOf(app.getPermissionRules())).append(';'));
new TreeMap<>(qInstance.getReports()).forEach((name, report) -> sb.append("r:").append(name).append('|').append(rulesOf(report.getPermissionRules())).append(';'));
new TreeMap<>(qInstance.getWidgets()).forEach((name, widget) -> sb.append("w:").append(name).append('|').append(rulesOf(widget.getPermissionRules())).append(';'));
TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.of(qInstance);
if(features != null)
{
for(TableFeaturePermission feature : features.getPermissions())
{
sb.append("f:").append(feature.getSuffix()).append('|').append(feature.getLabel()).append(';');
}
}
return (sb.toString());
}
/*******************************************************************************
** 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.
** A permission rules object as a stable string.
*******************************************************************************/
private static void describe(QInstance qInstance, FingerprintSink sink)
private static String rulesOf(QPermissionRules rules)
{
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()));
return (rules == null ? "-" : rules.getLevel() + ":" + rules.getPermissionBaseName() + ":" + rules.getCustomPermissionChecker());
}
@@ -784,7 +615,16 @@ public final class PolicyCatalog
*******************************************************************************/
public Set<String> permissionNames()
{
return (new TreeSet<>(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);
}
@@ -795,10 +635,11 @@ public final class PolicyCatalog
*******************************************************************************/
public Set<String> unknownPermissions(Set<String> permissions)
{
Set<String> rs = new TreeSet<>();
Set<String> known = permissionNames();
Set<String> rs = new TreeSet<>();
for(String permission : permissions)
{
if(!permissionNames.contains(permission))
if(!known.contains(permission))
{
rs.add(permission);
}
@@ -830,18 +671,6 @@ public final class PolicyCatalog
/*******************************************************************************
** 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).
@@ -177,11 +177,7 @@ public final class PolicyParser
{
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)));
return (parseStrict(document, format, catalog, universe));
}
@@ -140,10 +140,8 @@ public final class PolicyPermissions
/*******************************************************************************
** 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.
** The live universe of an instance (allPermissions, evaluated when a
** symbolic term needs it, never cached).
*******************************************************************************/
public static PolicyUniverse universe(QInstance qInstance, Collection<String> additional)
{
@@ -160,24 +158,21 @@ public final class PolicyPermissions
public static Set<String> allPermissions(QInstance qInstance, Collection<String> additional)
{
Set<String> permissions = new TreeSet<>();
if(additional != null)
{
permissions.addAll(additional);
}
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());
PolicyCatalog catalog = PolicyCatalog.of(qInstance);
permissions.addAll(catalog.availableNames());
permissions.addAll(catalog.permissionNames());
permissions.addAll(hostPermissions(qInstance));
}
else
{
permissions.addAll(registeredHostPermissions(null));
}
if(additional != null)
{
permissions.addAll(additional);
}
for(IdentityPermission permission : IdentityPermission.values())
{
permissions.add(permission.getSessionPermission());
@@ -105,10 +105,6 @@ public record PolicyResult(PolicyRules rules, Set<String> granted, boolean unava
** 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)
{
@@ -124,20 +120,4 @@ public record PolicyResult(PolicyRules rules, Set<String> granted, boolean unava
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));
}
}
@@ -235,17 +235,9 @@ public final class PolicyRules
{
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));
rs.addAll(term.expand(universe));
}
return (rs);
}
@@ -286,9 +278,8 @@ public final class PolicyRules
{
return (rs);
}
PolicyUniverse once = PolicyUniverse.once(universe);
Set<String> candidates = new TreeSet<>(once.names());
candidates.addAll(allowed(once));
Set<String> candidates = new TreeSet<>(universe.names());
candidates.addAll(allowed(universe));
for(String name : candidates)
{
if(denies(name))
@@ -17,9 +17,7 @@ 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;
@@ -50,43 +48,4 @@ public interface PolicyUniverse
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);
}
});
}
}
@@ -117,33 +117,6 @@ 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.
*******************************************************************************/
@@ -19,24 +19,17 @@ 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;
@@ -179,84 +172,4 @@ 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 //
/////////////////////////////////
}
}
}
@@ -406,37 +406,6 @@ class PolicyDenyTest
/*******************************************************************************
** 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();
}
/*******************************************************************************
@@ -1,496 +0,0 @@
/*
* 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]);
}
}