Compare commits

...
Author SHA1 Message Date
james.maes 7fcf061455 Merge pull request 'perf(policy): one permission universe per resolution; PolicyResult.replacement' (#74) from perf/policy-resolution-single-universe into develop
maven / build (push) Successful in 13m5s
build / maven (push) Successful in 13m5s
2026-11-11 01:08:27 +00:00
james.maesandClaude Fable 5.2 537e56d744 perf(policy): one permission universe per resolution; PolicyResult.replacement
maven / build (push) Successful in 15m31s
build / maven (push) Successful in 15m31s
A host's authentication customizer runs on every request of a session. Policy
resolution read the live permission universe once for every Allow term, and each
reading recomputed the PolicyCatalog fingerprint, walked every catalog resource
and called every host-name function. A role of nine named permissions therefore
built nine universes per request; a full-access document built one.

- PolicyRules.allowed, resolve and withheld read the universe they are given once
  per call (PolicyUniverse.once), so resultFromClaims, fromClaims, both claims
  mappers, the access editor and AuthentikAdmin cost one universe per resolution
  whatever the policy holds. PolicyParser.parseForSave reads it once per document.
- PolicyCatalog.of compares the instance with the kept fingerprint piece by piece
  instead of building the fingerprint text on every call; the same content is
  compared, so every in-place metadata change still gives a new catalog on the
  next call. The catalog keeps its permission names and its part of the universe.
- Nothing is cached across resolutions: no universe, no decision. The catalog
  cache is per JVM and derived only from that JVM's instance, as before.
- PolicyResult.replacement(): the grant alone (Allow minus Deny), for a host whose
  sessions take their permissions only from the token. apply() is unchanged and
  remains a union with what the host passes.

Measured on a 1,400-object instance (212 tables, 985 processes, 199 widgets), one
resolution: 9 named permissions 12.1 ms and 7.9 MB to 0.33 ms and 0.12 MB; 30
named 28.7 ms and 25.3 MB to 0.32 ms and 0.14 MB; full access 1.19 ms and 1.37 MB
to 0.77 ms and 0.20 MB.

Tests: PolicyResolutionCostTest counts universe builds (1 for 1, 9 and 30 names,
0 for a void or absent claim), pins that one reading grants exactly what a reading
per term grants, and holds an allocation budget; PolicyCatalogTest changes every
fingerprinted aspect in place. The golden vectors are untouched and pass.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 18:51:54 -06:00
12 changed files with 923 additions and 75 deletions

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ 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.
**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.
@@ -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 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 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.
@@ -169,7 +169,7 @@ public class EffectivePermissionResolver
Set<String> unknown = new TreeSet<>();
Set<String> appAccess = new TreeSet<>();
List<PolicyRules> policies = new ArrayList<>();
PolicyUniverse live = memoized(universe());
PolicyUniverse live = PolicyUniverse.once(universe());
Set<String> visited = new LinkedHashSet<>();
boolean missingParent = false;
@@ -255,24 +255,6 @@ 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,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;
@@ -134,6 +136,12 @@ 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;
/*******************************************************************************
@@ -145,6 +153,70 @@ 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.
*******************************************************************************/
@@ -161,25 +233,48 @@ 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)
{
String fingerprint = fingerprint(qInstance);
Cached cached;
synchronized(CACHE)
{
Cached cached = CACHE.get(qInstance);
if(cached != null && cached.fingerprint().equals(fingerprint))
cached = CACHE.get(qInstance);
}
if(cached != null)
{
FingerprintMatcher matcher = new FingerprintMatcher(cached.fingerprint());
describe(qInstance, matcher);
if(matcher.matched())
{
return (cached.catalog());
}
}
PolicyCatalog built = build(qInstance);
String fingerprint = fingerprint(qInstance);
PolicyCatalog built = build(qInstance);
synchronized(CACHE)
{
CACHE.put(qInstance, new Cached(fingerprint, built));
@@ -210,37 +305,111 @@ public final class PolicyCatalog
*******************************************************************************/
static String fingerprint(QInstance qInstance)
{
StringBuilder sb = new StringBuilder();
new TreeMap<>(qInstance.getTables()).forEach((name, table) ->
{
QPermissionRules rules = table.getPermissionRules();
sb.append("t:").append(name).append('|')
.append(rules == null ? "-" : rules.getLevel() + ":" + rules.getPermissionBaseName() + ":" + rules.getCustomPermissionChecker()).append('|')
.append(table.getSupplementalMetaData() == null ? "" : new TreeSet<>(table.getSupplementalMetaData().keySet())).append(';');
});
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(';');
}
}
StringBuilder sb = new StringBuilder(16384);
describe(qInstance, sb::append);
return (sb.toString());
}
/*******************************************************************************
** A permission rules object as a stable string.
** 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 String rulesOf(QPermissionRules rules)
private static void describe(QInstance qInstance, FingerprintSink sink)
{
return (rules == null ? "-" : rules.getLevel() + ":" + rules.getPermissionBaseName() + ":" + rules.getCustomPermissionChecker());
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()));
}
@@ -615,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));
}
@@ -635,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))
if(!permissionNames.contains(permission))
{
rs.add(permission);
}
@@ -671,6 +830,18 @@ 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,7 +177,11 @@ public final class PolicyParser
{
throw (new IllegalArgumentException("A universe is required for save-time checks"));
}
return (parseStrict(document, format, catalog, universe));
///////////////////////////////////////////////////////////////////////
// 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)));
}
@@ -140,8 +140,10 @@ public final class PolicyPermissions
/*******************************************************************************
** The live universe of an instance (allPermissions, evaluated when a
** symbolic term needs it, never cached).
** 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)
{
@@ -158,21 +160,24 @@ 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)
{
PolicyCatalog catalog = PolicyCatalog.of(qInstance);
permissions.addAll(catalog.availableNames());
permissions.addAll(catalog.permissionNames());
/////////////////////////////////////////////////////////////////////
// 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);
}
for(IdentityPermission permission : IdentityPermission.values())
{
permissions.add(permission.getSessionPermission());
@@ -105,6 +105,10 @@ 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)
{
@@ -120,4 +124,20 @@ 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,9 +235,17 @@ 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(universe));
rs.addAll(term.expand(once));
}
return (rs);
}
@@ -278,8 +286,9 @@ public final class PolicyRules
{
return (rs);
}
Set<String> candidates = new TreeSet<>(universe.names());
candidates.addAll(allowed(universe));
PolicyUniverse once = PolicyUniverse.once(universe);
Set<String> candidates = new TreeSet<>(once.names());
candidates.addAll(allowed(once));
for(String name : candidates)
{
if(denies(name))
@@ -17,7 +17,9 @@ 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;
@@ -48,4 +50,43 @@ 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);
}
});
}
}
@@ -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 //
/////////////////////////////////
}
}
}
@@ -406,6 +406,37 @@ 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();
}
/*******************************************************************************
@@ -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]);
}
}