diff --git a/CHANGELOG.md b/CHANGELOG.md index e5dba39..5cc3b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `, `malformed ` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was. - 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"). diff --git a/docs/POLICY-FORMAT.md b/docs/POLICY-FORMAT.md index b281403..f7cbed0 100644 --- a/docs/POLICY-FORMAT.md +++ b/docs/POLICY-FORMAT.md @@ -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. diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java index 93b1c66..c1b41ac 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java @@ -169,7 +169,7 @@ public class EffectivePermissionResolver Set unknown = new TreeSet<>(); Set appAccess = new TreeSet<>(); List policies = new ArrayList<>(); - PolicyUniverse live = memoized(universe()); + PolicyUniverse live = PolicyUniverse.once(universe()); Set 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> cache = new ArrayList<>(); - return (() -> - { - if(cache.isEmpty()) - { - cache.add(source.names()); - } - return (cache.get(0)); - }); - } - - - /******************************************************************************* ** Every permission full access grants now (the live universe). *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java index fefe825..2ab5fad 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyCatalog.java @@ -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 availableNames; + /** permissionNames(), computed once: a catalog never changes after it is built. */ + private final SortedSet permissionNames; + + /** availableNames and permissionNames together, sorted: what the catalog gives the permission universe. */ + private final SortedSet 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 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 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 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 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 entry : qInstance.getApps().entrySet()) + { + describeObject("a:", entry.getKey(), entry.getValue().getPermissionRules(), sink); + } + for(Map.Entry entry : qInstance.getReports().entrySet()) + { + describeObject("r:", entry.getKey(), entry.getValue().getPermissionRules(), sink); + } + for(Map.Entry 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 permissionNames() { - Set 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 unknownPermissions(Set permissions) { - Set known = permissionNames(); - Set rs = new TreeSet<>(); + Set 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 universeNames() + { + return (universeNames); + } + + + /******************************************************************************* ** Whether at least one resource whose name starts with the prefix registers ** this feature action (a prefix resource's feature rule). diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java index 3f58fd1..87df5a8 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyParser.java @@ -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))); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java index bb31a1a..09d0b99 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyPermissions.java @@ -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 additional) { @@ -158,21 +160,24 @@ public final class PolicyPermissions public static Set allPermissions(QInstance qInstance, Collection additional) { Set 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()); diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java index a82b16d..e3ba1cd 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyResult.java @@ -105,6 +105,10 @@ public record PolicyResult(PolicyRules rules, Set 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 apply(Collection otherSources) { @@ -120,4 +124,20 @@ public record PolicyResult(PolicyRules rules, Set 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 replacement() + { + return (apply(null)); + } } diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java index ce04324..1b2789a 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyRules.java @@ -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 candidates = new TreeSet<>(universe.names()); - candidates.addAll(allowed(universe)); + PolicyUniverse once = PolicyUniverse.once(universe); + Set candidates = new TreeSet<>(once.names()); + candidates.addAll(allowed(once)); for(String name : candidates) { if(denies(name)) diff --git a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyUniverse.java b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyUniverse.java index aa26cd2..e8eb647 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyUniverse.java +++ b/src/main/java/com/kingsrook/qbits/authentik/policy/PolicyUniverse.java @@ -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 names; + + + + /******************************************************************************* + ** {@inheritDoc} + *******************************************************************************/ + @Override + public Set 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 read = universe.names(); + names = read instanceof SortedSet sorted ? Collections.unmodifiableSortedSet(sorted) : Collections.unmodifiableSet(read); + } + return (names); + } + }); + } + } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyCatalogTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyCatalogTest.java index 08b1d58..3e75d74 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyCatalogTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyCatalogTest.java @@ -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 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 // + ///////////////////////////////// + } + } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java index a6c29fe..ee564f2 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyDenyTest.java @@ -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 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 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(); + } + + + /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyResolutionCostTest.java b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyResolutionCostTest.java new file mode 100644 index 0000000..d144912 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/policy/PolicyResolutionCostTest.java @@ -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> counter = qInstance -> + { + universes.incrementAndGet(); + List 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 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 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 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 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 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 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 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 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 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]); + } +}