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
21 changed files with 926 additions and 1050 deletions

No files matched your search

+2 -1
View File
@@ -4,9 +4,10 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- **Session policy** (README "Session policy"): `AuthentikSessionPolicy` states an idle timeout, an absolute maximum and the access-token lifetime in host code (`AuthentikQBitConfig.withSessionPolicy`, with `appClientId`). Numbers that cannot be enforced, missing refresh tokens or `offline_access`, and a `maxSessionLifetimeSeconds` that differs are configuration errors; `applyTo(OAuth2AuthenticationMetaData)` sets the maximum. The new `authentik.session_policy` health check (hourly, only when a policy is stated) reads the app's OAuth2 provider (`GET /providers/oauth2/?client_id=`, validity settings and grant types only) and is DEGRADED when `access_token_validity`, `refresh_token_validity` or `refresh_token_threshold` differ, DOWN when the service account cannot view the provider. Nothing is written to Authentik. The service-account role needs `view_oauth2provider` as an object permission on the app's provider only. `SessionPolicyContractIT` proves the read and the grant on Authentik 2026.8.3. No schema change; hosts that state no policy are unaffected.
- **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").
-41
View File
@@ -156,7 +156,6 @@ version, expiry-day, and finding-code details.
| `authentik.api_token` | DEPENDENCY | 21600 seconds |
| `authentik.mapper` | DEPENDENCY | 21600 seconds |
| `authentik.token_broker` | DEPENDENCY | 300 seconds |
| `authentik.session_policy` (only with a `sessionPolicy`) | DEPENDENCY | 3600 seconds |
The broker check uses a client-credentials grant and discards the returned bearer;
it reports UNKNOWN when broker credentials are not configured. Token metadata must
@@ -168,46 +167,6 @@ The Identity home links its central status to System Health. The configuration
These registrations use the core health SPI released in QQQ 4.1.0-RC.8; no
SNAPSHOT override or enforcer skip is needed.
## Session policy (idle timeout and absolute maximum)
A host states in code how long a sign-in lasts. The qbit validates the numbers, checks that the
instance's OAuth2 authentication carries them, and compares them with the Authentik provider. It
never writes them to Authentik: the provider's own configuration (a blueprint) states the matching
token validities, and the `authentik.session_policy` health check reports any difference.
```java
AuthentikSessionPolicy policy = new AuthentikSessionPolicy()
.withIdleTimeoutSeconds(900) // ends after 15 minutes without a request
.withAbsoluteMaximumSeconds(43200) // ends twelve hours after sign-in, however busy
.withAccessTokenSeconds(300); // how often an active session is renewed (default 600)
policy.applyTo(oauth2MetaData); // sets maxSessionLifetimeSeconds; needs refresh tokens and offline_access
config.withAppClientId("my-app").withSessionPolicy(policy);
```
| Policy | Enforced by | Authentik provider setting it must equal |
| --- | --- | --- |
| `accessTokenSeconds` | Authentik | `access_token_validity` |
| `idleTimeoutSeconds` | Authentik (refresh token expiry, restarted at every renewal) | `refresh_token_validity`, with `refresh_token_threshold: seconds=0` |
| `absoluteMaximumSeconds` | QQQ core (`maxSessionLifetimeSeconds`) | none |
- Renewal is QQQ core's server-side refresh token (`docs/misc/OAuth2RefreshTokens.adoc` in qqq): the
host needs the refresh-token table, an encryption key, `refreshTokensEnabled` and the
`offline_access` scope. `applyTo` and the config validation refuse an instance without the last two.
- Precision is one access token: a session ends after between `idle - accessToken` and `idle` without
a request, and at most `absoluteMaximum + accessToken` after sign-in.
- This idle timeout counts requests, not user actions, so by itself it is not an automatic logoff after user
inactivity (HIPAA 164.312(a)(2)(iii), HITRUST session time-out): the host needs QQQ core's user inactivity
timeout for that, and this refresh-token validity is then the outer bound.
- Any request counts as use. A page that polls or a device that sends heartbeats is not idle; only the
absolute maximum ends such a session.
- Numbers that cannot be enforced are configuration errors: `accessTokenSeconds` under 120, an idle
timeout under twice the access token, a maximum not longer than the idle timeout or over 90 days.
- The health check needs the service account to view the app's provider and nothing else: grant
`authentik_providers_oauth2.view_oauth2provider` as an object permission on that one provider (never
model-wide: the provider API returns client secrets). Without it Authentik answers 403 and the check is DOWN ("unverified");
a provider that differs is DEGRADED and the message names each setting in seconds.
## In-app help
The qbit ships `src/main/resources/help/qbit-authentik.yaml` and registers it with
+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.
@@ -28,7 +28,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.authentik.policy.PolicyFormat;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -72,7 +71,6 @@ public class AuthentikQBitConfig implements QBitConfig
private String appPublicUrl = null;
private String authentikPublicUrl = null;
private String appClientId = null;
private AuthentikSessionPolicy sessionPolicy = null;
private Integer tokenUnusedDays = 60;
private String serviceAccountTokenLifetime = "days=365";
private com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource subjectTokenSource = new com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource();
@@ -284,28 +282,6 @@ public class AuthentikQBitConfig implements QBitConfig
{
errors.add("throttleRequestsPerSecond must be at least 1");
}
////////////////////////////////////////////////////////////////////////////////////
// a stated session policy must be one the instance really enforces: the numbers //
// make sense, the app's provider can be found, and the OAuth2 authentication //
// carries the absolute maximum and can renew (refresh tokens, offline_access). //
////////////////////////////////////////////////////////////////////////////////////
if(sessionPolicy != null)
{
errors.addAll(sessionPolicy.problems());
if(!StringUtils.hasContent(getAppClientId()))
{
errors.add("sessionPolicy needs appClientId (the OAuth2 client id of the app's Authentik provider)");
}
if(qInstance != null && qInstance.getAuthentication() instanceof OAuth2AuthenticationMetaData oauth2)
{
errors.addAll(sessionPolicy.differencesFrom(oauth2));
}
else
{
errors.add("sessionPolicy needs the instance's OAuth2 authentication to be set before the qbit is produced");
}
}
}
@@ -569,7 +545,6 @@ public class AuthentikQBitConfig implements QBitConfig
+ ", appPublicUrl=" + appPublicUrl
+ ", authentikPublicUrl=" + authentikPublicUrl
+ ", appClientId=" + appClientId
+ ", sessionPolicy=" + sessionPolicy
+ ", tokenUnusedDays=" + tokenUnusedDays
+ ", serviceAccountTokenLifetime=" + serviceAccountTokenLifetime
+ ", features=" + features + ", readOnly=" + readOnly + ", apiToken=" + (StringUtils.hasContent(apiToken) ? "******" : "(unset)") + "}");
@@ -1922,38 +1897,6 @@ public class AuthentikQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for sessionPolicy: how long a sign-in lasts (null = not stated; the
** qbit then neither validates nor health-checks session lifetime).
*******************************************************************************/
public AuthentikSessionPolicy getSessionPolicy()
{
return (this.sessionPolicy);
}
/*******************************************************************************
** Setter for sessionPolicy.
*******************************************************************************/
public void setSessionPolicy(AuthentikSessionPolicy sessionPolicy)
{
this.sessionPolicy = sessionPolicy;
}
/*******************************************************************************
** Fluent setter for sessionPolicy.
*******************************************************************************/
public AuthentikQBitConfig withSessionPolicy(AuthentikSessionPolicy sessionPolicy)
{
this.sessionPolicy = sessionPolicy;
return (this);
}
/*******************************************************************************
** Getter for tokenUnusedDays.
*******************************************************************************/
@@ -1,346 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** How long a sign-in lasts, declared by the host application in code.
**
** Three numbers, all in seconds:
** - accessTokenSeconds: how long one provider access token lasts. An active
** session is renewed (QQQ core's server-side refresh token) each time it
** runs out, so this is also the precision of the other two.
** - idleTimeoutSeconds: a session nobody uses for this long ends. It is the
** provider's refresh-token validity, which restarts at every renewal. A
** session ends after between (idle - access token) and idle of no requests,
** never later.
** - absoluteMaximumSeconds: no renewal this long after sign-in, however busy
** the session is; it then ends with its current access token, so at most
** (maximum + access token) after sign-in.
**
** The policy is not written to Authentik. The provider states the matching
** token validities in its own configuration (a blueprint), and the
** authentik.session_policy health check compares the two (differencesFrom).
** A request counts as use whoever made it: a page that polls or a device that
** sends heartbeats is not idle.
*******************************************************************************/
public class AuthentikSessionPolicy
{
public static final int DEFAULT_ACCESS_TOKEN_SECONDS = 600;
public static final int MIN_ACCESS_TOKEN_SECONDS = 120;
public static final int MAX_ABSOLUTE_SECONDS = 90 * 86400;
private static final String OFFLINE_ACCESS = "offline_access";
private static final Map<String, BigDecimal> UNIT_SECONDS = Map.of(
"microseconds", new BigDecimal("0.000001"),
"milliseconds", new BigDecimal("0.001"),
"seconds", BigDecimal.ONE,
"minutes", BigDecimal.valueOf(60),
"hours", BigDecimal.valueOf(3600),
"days", BigDecimal.valueOf(86400),
"weeks", BigDecimal.valueOf(604800));
private Integer idleTimeoutSeconds;
private Integer absoluteMaximumSeconds;
private Integer accessTokenSeconds = DEFAULT_ACCESS_TOKEN_SECONDS;
/*******************************************************************************
** What is wrong with these numbers; empty when the policy can be enforced.
** Messages name settings and never repeat a secret (there is none here).
*******************************************************************************/
public List<String> problems()
{
List<String> problems = new ArrayList<>();
if(idleTimeoutSeconds == null || absoluteMaximumSeconds == null || accessTokenSeconds == null)
{
problems.add("sessionPolicy needs idleTimeoutSeconds, absoluteMaximumSeconds and accessTokenSeconds");
return (problems);
}
if(accessTokenSeconds < MIN_ACCESS_TOKEN_SECONDS)
{
problems.add("sessionPolicy.accessTokenSeconds must be at least " + MIN_ACCESS_TOKEN_SECONDS + " (QQQ renews a token in its last 60 seconds)");
}
if((long) idleTimeoutSeconds < 2L * accessTokenSeconds)
{
problems.add("sessionPolicy.idleTimeoutSeconds must be at least twice accessTokenSeconds (a session can end one access token early)");
}
if(absoluteMaximumSeconds <= idleTimeoutSeconds)
{
problems.add("sessionPolicy.absoluteMaximumSeconds must be longer than idleTimeoutSeconds");
}
if(absoluteMaximumSeconds > MAX_ABSOLUTE_SECONDS)
{
problems.add("sessionPolicy.absoluteMaximumSeconds must be at most " + MAX_ABSOLUTE_SECONDS + " (90 days)");
}
return (problems);
}
/*******************************************************************************
** Put the absolute maximum on the host's OAuth2 meta-data. Refuses meta-data
** that cannot keep a session alive: refresh tokens off, or no offline_access
** scope (Authentik issues a refresh token only for that scope).
*******************************************************************************/
public void applyTo(OAuth2AuthenticationMetaData metaData)
{
List<String> problems = problems();
if(metaData == null)
{
problems.add("sessionPolicy needs OAuth2 authentication meta-data");
}
else
{
metaData.setMaxSessionLifetimeSeconds(absoluteMaximumSeconds);
problems.addAll(differencesFrom(metaData));
}
if(!problems.isEmpty())
{
throw (new IllegalStateException(String.join("; ", problems)));
}
}
/*******************************************************************************
** Where the host's OAuth2 meta-data does not carry this policy.
*******************************************************************************/
public List<String> differencesFrom(OAuth2AuthenticationMetaData metaData)
{
List<String> differences = new ArrayList<>();
if(!Boolean.TRUE.equals(metaData.getRefreshTokensEnabled()))
{
differences.add("sessionPolicy needs refreshTokensEnabled on the OAuth2 authentication");
}
String scopes = metaData.getScopes();
if(scopes == null || !Arrays.asList(scopes.trim().split("\\s+")).contains(OFFLINE_ACCESS))
{
differences.add("sessionPolicy needs the offline_access scope on the OAuth2 authentication");
}
if(absoluteMaximumSeconds != null && !absoluteMaximumSeconds.equals(metaData.getMaxSessionLifetimeSeconds()))
{
differences.add("sessionPolicy.absoluteMaximumSeconds is not the OAuth2 authentication's maxSessionLifetimeSeconds (call applyTo)");
}
return (differences);
}
/*******************************************************************************
** Where an Authentik OAuth2 provider (its API object) does not match this
** policy; empty when it does. Only the validity settings and the grant types
** are read; the texts carry durations, never another field of the provider.
*******************************************************************************/
public List<String> differencesFrom(JSONObject provider)
{
List<String> differences = new ArrayList<>();
compare(differences, provider, "access_token_validity", accessTokenSeconds, "access token lifetime");
compare(differences, provider, "refresh_token_validity", idleTimeoutSeconds, "idle timeout");
compare(differences, provider, "refresh_token_threshold", 0, "renewal of the refresh token at every use");
JSONArray grantTypes = provider.optJSONArray("grant_types");
if(grantTypes != null && !grantTypes.toList().contains("refresh_token"))
{
differences.add("grant_types lacks refresh_token");
}
return (differences);
}
/*******************************************************************************
** One validity setting against the seconds the policy needs.
*******************************************************************************/
private static void compare(List<String> differences, JSONObject provider, String field, Integer wanted, String meaning)
{
Long actual = parseValiditySeconds(provider.optString(field, null));
if(actual == null)
{
differences.add(field + " is missing or unreadable (" + meaning + " needs " + wanted + " s)");
}
else if(wanted == null || actual != wanted.longValue())
{
differences.add(field + " is " + actual + " s; the " + meaning + " needs " + wanted + " s");
}
}
/*******************************************************************************
** Whole seconds of an Authentik duration ("hours=1;minutes=2;seconds=3"), read
** the way Authentik reads it (unknown units are skipped); null when it has no
** usable part, is negative or is not a whole number of seconds.
*******************************************************************************/
public static Long parseValiditySeconds(String expression)
{
if(expression == null)
{
return (null);
}
BigDecimal total = BigDecimal.ZERO;
boolean any = false;
for(String pair : expression.split(";"))
{
String[] parts = pair.split("=");
if(parts.length != 2)
{
return (null);
}
BigDecimal unit = UNIT_SECONDS.get(parts[0].trim().toLowerCase(Locale.ROOT));
if(unit == null)
{
continue;
}
try
{
total = total.add(new BigDecimal(parts[1].trim()).multiply(unit));
any = true;
}
catch(NumberFormatException e)
{
return (null);
}
}
try
{
return (any && total.signum() >= 0 ? (Long) total.longValueExact() : null);
}
catch(ArithmeticException e)
{
return (null);
}
}
/*******************************************************************************
** Getter for idleTimeoutSeconds.
*******************************************************************************/
public Integer getIdleTimeoutSeconds()
{
return (this.idleTimeoutSeconds);
}
/*******************************************************************************
** Setter for idleTimeoutSeconds.
*******************************************************************************/
public void setIdleTimeoutSeconds(Integer idleTimeoutSeconds)
{
this.idleTimeoutSeconds = idleTimeoutSeconds;
}
/*******************************************************************************
** Fluent setter for idleTimeoutSeconds.
*******************************************************************************/
public AuthentikSessionPolicy withIdleTimeoutSeconds(Integer idleTimeoutSeconds)
{
this.idleTimeoutSeconds = idleTimeoutSeconds;
return (this);
}
/*******************************************************************************
** Getter for absoluteMaximumSeconds.
*******************************************************************************/
public Integer getAbsoluteMaximumSeconds()
{
return (this.absoluteMaximumSeconds);
}
/*******************************************************************************
** Setter for absoluteMaximumSeconds.
*******************************************************************************/
public void setAbsoluteMaximumSeconds(Integer absoluteMaximumSeconds)
{
this.absoluteMaximumSeconds = absoluteMaximumSeconds;
}
/*******************************************************************************
** Fluent setter for absoluteMaximumSeconds.
*******************************************************************************/
public AuthentikSessionPolicy withAbsoluteMaximumSeconds(Integer absoluteMaximumSeconds)
{
this.absoluteMaximumSeconds = absoluteMaximumSeconds;
return (this);
}
/*******************************************************************************
** Getter for accessTokenSeconds.
*******************************************************************************/
public Integer getAccessTokenSeconds()
{
return (this.accessTokenSeconds);
}
/*******************************************************************************
** Setter for accessTokenSeconds.
*******************************************************************************/
public void setAccessTokenSeconds(Integer accessTokenSeconds)
{
this.accessTokenSeconds = accessTokenSeconds;
}
/*******************************************************************************
** Fluent setter for accessTokenSeconds.
*******************************************************************************/
public AuthentikSessionPolicy withAccessTokenSeconds(Integer accessTokenSeconds)
{
this.accessTokenSeconds = accessTokenSeconds;
return (this);
}
/*******************************************************************************
** The three numbers, for logs and config dumps (no secret is held here).
*******************************************************************************/
@Override
public String toString()
{
return ("AuthentikSessionPolicy{idleTimeoutSeconds=" + idleTimeoutSeconds + ", absoluteMaximumSeconds=" + absoluteMaximumSeconds
+ ", accessTokenSeconds=" + accessTokenSeconds + "}");
}
}
@@ -217,34 +217,6 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** The validity settings and grant types of the OAuth2 providers with this
** client id (one, when the service account may view the app's provider; none
** otherwise). Every other field of the provider, its client secret included,
** is dropped here: it is never returned to a caller, and response bodies are
** not logged. Health probes (the only caller) also bypass the response cache.
*******************************************************************************/
public List<JSONObject> getOAuth2ProviderValidity(String clientId) throws QException
{
runtime().getGuard().checkHealth();
List<JSONObject> rs = new ArrayList<>();
for(JSONObject provider : getAllPages("/providers/oauth2/?client_id=" + java.net.URLEncoder.encode(clientId, StandardCharsets.UTF_8), 2))
{
JSONObject validity = new JSONObject();
for(String field : List.of("access_token_validity", "refresh_token_validity", "refresh_token_threshold", "grant_types"))
{
if(provider.has(field) && !provider.isNull(field))
{
validity.put(field, provider.get(field));
}
}
rs.add(validity);
}
return (rs);
}
/*******************************************************************************
** Broker credentials use the same secret-safe request loop as token exchange.
*******************************************************************************/
@@ -82,7 +82,6 @@ public final class AuthentikApiSurface
rs.add(new Operation("get", "/events/events/", Set.of("username", "action", "ordering", "page", "page_size")));
rs.add(new Operation("get", "/events/events/stats/", Set.of("username", "action", "count_steps")));
rs.add(new Operation("get", "/authenticators/admin/all/", Set.of("user")));
rs.add(new Operation("get", "/providers/oauth2/", Set.of("client_id", "page", "page_size")));
return (rs);
}
@@ -25,14 +25,12 @@ import java.util.Locale;
import java.util.TreeMap;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikSessionPolicy;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
@@ -71,24 +69,13 @@ public final class AuthentikHealthChecks
// the token broker is only used when tokens are on; with tokens off a registered check //
// could only ever report UNKNOWN "not configured", and core cannot remove a registered check. //
/////////////////////////////////////////////////////////////////////////////////////////////////
List<String> names = new ArrayList<>(List.of("api", "api_token", "mapper"));
if(Boolean.TRUE.equals(config.getTokensEnabled()))
{
names.add("token_broker");
}
///////////////////////////////////////////////////////////////////////////////////
// only a host that states a session policy has something to compare the //
// provider with; hourly, because a blueprint apply can change the provider. //
///////////////////////////////////////////////////////////////////////////////////
if(config.getSessionPolicy() != null)
{
names.add("session_policy");
}
List<String> names = Boolean.TRUE.equals(config.getTokensEnabled())
? List.of("api", "api_token", "mapper", "token_broker") : List.of("api", "api_token", "mapper");
for(String name : names)
{
registry.withCheck(new HealthCheckMetaData().withName("authentik." + name).withLabel("Authentik " + name)
.withComponent("authentik").withCategory(HealthCategory.IDENTITY).withKind(HealthCheckKind.DEPENDENCY)
.withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : name.equals("session_policy") ? 3600 : 21600)
.withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : 21600)
.withTimeoutMs(10000).withCheckInstance(input ->
{
try
@@ -100,7 +87,6 @@ public final class AuthentikHealthChecks
case "api" -> outcome = api(util, config);
case "api_token" -> outcome = apiToken(util, Instant.now());
case "mapper" -> outcome = mapper(util, config);
case "session_policy" -> outcome = sessionPolicy(util, config);
default -> outcome = tokenBroker(util, config);
}
return (Instant.now().isBefore(input.getDeadline()) && !Thread.currentThread().isInterrupted()
@@ -244,52 +230,6 @@ public final class AuthentikHealthChecks
/*******************************************************************************
** The session policy the host states in code against what the app's Authentik
** provider really does. Read only: a difference is reported, never corrected
** here, because the provider is owned by its own configuration (a blueprint).
** Not being able to read the provider is DOWN: the policy is then unverified.
*******************************************************************************/
static HealthCheckOutcome sessionPolicy(AuthentikActionUtil util, AuthentikQBitConfig config) throws QException
{
AuthentikSessionPolicy policy = config.getSessionPolicy();
HealthDetails details = new HealthDetails()
.withValue("idleTimeoutSeconds", policy.getIdleTimeoutSeconds())
.withValue("absoluteMaximumSeconds", policy.getAbsoluteMaximumSeconds())
.withValue("renewEverySeconds", policy.getAccessTokenSeconds());
///////////////////////////////////////////////////////////////////////////////////
// Authentik answers 403 to an account that may view no provider at all, and an //
// empty list to one that may view others but not this one: both are unverified //
///////////////////////////////////////////////////////////////////////////////////
List<JSONObject> providers;
try
{
providers = util.getOAuth2ProviderValidity(config.getAppClientId());
}
catch(QPermissionDeniedException e)
{
providers = List.of();
}
if(providers.size() != 1)
{
return (HealthCheckOutcome.down(providers.isEmpty()
? "Session policy unverified: the service account cannot view the app's OAuth2 provider (grant view_oauth2provider on that provider)"
: "Session policy unverified: more than one OAuth2 provider has the app's client id").withDetails(details));
}
List<String> differences = policy.differencesFrom(providers.getFirst());
if(differences.isEmpty())
{
return (HealthCheckOutcome.up("Authentik provider matches the session policy").withDetails(details));
}
String summary = truncate(String.join("; ", differences), 500);
return (HealthCheckOutcome.degraded(truncate("Authentik provider differs from the session policy: " + summary, 500))
.withDetails(details.withValue("differences", summary)));
}
/*******************************************************************************
** A group name fit for a health message: printable and bounded.
*******************************************************************************/
@@ -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);
}
});
}
}
@@ -1,221 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The session policy fails closed on numbers it cannot enforce, and reports
** every way a provider or the host's OAuth2 meta-data disagrees with it.
*******************************************************************************/
class AuthentikSessionPolicyTest
{
/*******************************************************************************
** One hour idle, twelve hours at most, ten-minute access tokens.
*******************************************************************************/
private static AuthentikSessionPolicy policy()
{
return (new AuthentikSessionPolicy().withIdleTimeoutSeconds(3600).withAbsoluteMaximumSeconds(43200));
}
/*******************************************************************************
** OAuth2 meta-data that can renew a session.
*******************************************************************************/
private static OAuth2AuthenticationMetaData oauth2()
{
return (new OAuth2AuthenticationMetaData().withScopes("openid profile email offline_access").withRefreshTokensEnabled(true));
}
/*******************************************************************************
** A provider that matches policy().
*******************************************************************************/
private static JSONObject provider()
{
return (new JSONObject().put("access_token_validity", "minutes=10").put("refresh_token_validity", "hours=1")
.put("refresh_token_threshold", "seconds=0").put("grant_types", new JSONArray(List.of("authorization_code", "refresh_token"))));
}
/*******************************************************************************
** The access token lifetime has a default; the two limits must be stated.
*******************************************************************************/
@Test
void defaultsAndRequiredNumbers()
{
assertThat(policy().getAccessTokenSeconds()).isEqualTo(600);
assertThat(policy().problems()).isEmpty();
assertThat(new AuthentikSessionPolicy().problems()).singleElement().asString().contains("idleTimeoutSeconds", "absoluteMaximumSeconds");
assertThat(policy().withAccessTokenSeconds(null).problems()).hasSize(1);
assertThat(policy().toString()).contains("idleTimeoutSeconds=3600", "absoluteMaximumSeconds=43200", "accessTokenSeconds=600");
}
/*******************************************************************************
** Zero, negative, inverted and absurd numbers are all refused.
*******************************************************************************/
@Test
void nonsenseNumbersAreRefused()
{
assertThat(policy().withAccessTokenSeconds(119).problems()).anyMatch(p -> p.contains("accessTokenSeconds must be at least 120"));
assertThat(policy().withAccessTokenSeconds(0).problems()).isNotEmpty();
assertThat(policy().withIdleTimeoutSeconds(-1).problems()).isNotEmpty();
assertThat(policy().withIdleTimeoutSeconds(1199).problems()).anyMatch(p -> p.contains("at least twice accessTokenSeconds"));
assertThat(policy().withIdleTimeoutSeconds(1200).problems()).isEmpty();
assertThat(policy().withAbsoluteMaximumSeconds(3600).problems()).anyMatch(p -> p.contains("longer than idleTimeoutSeconds"));
assertThat(policy().withAbsoluteMaximumSeconds(0).problems()).isNotEmpty();
assertThat(policy().withAbsoluteMaximumSeconds(90 * 86400).problems()).isEmpty();
assertThat(policy().withAbsoluteMaximumSeconds(90 * 86400 + 1).problems()).anyMatch(p -> p.contains("90 days"));
////////////////////////////////////////////////////////////////////////
// milliseconds given where seconds are meant: twelve hours as 43.2M //
////////////////////////////////////////////////////////////////////////
assertThat(policy().withAbsoluteMaximumSeconds(43_200_000).problems()).isNotEmpty();
assertThat(policy().withIdleTimeoutSeconds(Integer.MAX_VALUE).withAccessTokenSeconds(Integer.MAX_VALUE).problems()).isNotEmpty();
}
/*******************************************************************************
** applyTo sets the absolute maximum and refuses meta-data that cannot renew.
*******************************************************************************/
@Test
void applyToOAuth2MetaData()
{
OAuth2AuthenticationMetaData metaData = oauth2();
policy().applyTo(metaData);
assertThat(metaData.getMaxSessionLifetimeSeconds()).isEqualTo(43200);
assertThat(policy().differencesFrom(metaData)).isEmpty();
assertThatThrownBy(() -> policy().applyTo(oauth2().withScopes("openid profile email"))).isInstanceOf(IllegalStateException.class).hasMessageContaining("offline_access");
assertThatThrownBy(() -> policy().applyTo(oauth2().withScopes(null))).hasMessageContaining("offline_access");
assertThatThrownBy(() -> policy().applyTo(oauth2().withScopes("openid not_offline_access"))).hasMessageContaining("offline_access");
assertThatThrownBy(() -> policy().applyTo(oauth2().withRefreshTokensEnabled(false))).hasMessageContaining("refreshTokensEnabled");
assertThatThrownBy(() -> policy().applyTo(null)).hasMessageContaining("OAuth2 authentication meta-data");
assertThatThrownBy(() -> policy().withAbsoluteMaximumSeconds(60).applyTo(oauth2())).hasMessageContaining("absoluteMaximumSeconds");
assertThat(policy().differencesFrom(oauth2().withMaxSessionLifetimeSeconds(1))).singleElement().asString().contains("maxSessionLifetimeSeconds");
}
/*******************************************************************************
** A stated policy the instance does not carry is a configuration error.
*******************************************************************************/
@Test
void configValidationFailsClosed()
{
QInstance oauth = new QInstance();
OAuth2AuthenticationMetaData metaData = oauth2();
oauth.withInstanceDefaultAuthentication(metaData);
policy().applyTo(metaData);
assertThat(errors(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy()), oauth)).isEmpty();
assertThat(errors(AuthentikTestSupport.unitConfig().withSessionPolicy(policy()), oauth)).anyMatch(e -> e.contains("appClientId"));
assertThat(errors(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy().withIdleTimeoutSeconds(1)), oauth))
.anyMatch(e -> e.contains("idleTimeoutSeconds"));
metaData.setMaxSessionLifetimeSeconds(null);
assertThat(errors(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy()), oauth)).anyMatch(e -> e.contains("maxSessionLifetimeSeconds"));
QInstance mock = new QInstance();
mock.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
assertThat(errors(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy()), mock)).anyMatch(e -> e.contains("OAuth2 authentication"));
assertThat(errors(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy()), null)).anyMatch(e -> e.contains("OAuth2 authentication"));
assertThat(errors(AuthentikTestSupport.unitConfig(), mock)).isEmpty();
assertThat(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy()).toString()).contains("sessionPolicy=AuthentikSessionPolicy{");
}
/*******************************************************************************
** Every provider setting the policy depends on is compared, in seconds.
*******************************************************************************/
@Test
void providerDifferences()
{
assertThat(policy().differencesFrom(provider())).isEmpty();
assertThat(policy().differencesFrom(provider().put("access_token_validity", "seconds=600").put("refresh_token_validity", "minutes=60"))).isEmpty();
assertThat(policy().differencesFrom(provider().put("access_token_validity", "hours=8")))
.containsExactly("access_token_validity is 28800 s; the access token lifetime needs 600 s");
assertThat(policy().differencesFrom(provider().put("refresh_token_validity", "days=30")))
.containsExactly("refresh_token_validity is 2592000 s; the idle timeout needs 3600 s");
assertThat(policy().differencesFrom(provider().put("refresh_token_threshold", "hours=1")))
.containsExactly("refresh_token_threshold is 3600 s; the renewal of the refresh token at every use needs 0 s");
assertThat(policy().differencesFrom(provider().put("grant_types", new JSONArray(List.of("authorization_code"))))).containsExactly("grant_types lacks refresh_token");
JSONObject bare = provider();
bare.remove("refresh_token_threshold");
bare.remove("grant_types");
assertThat(policy().differencesFrom(bare)).singleElement().asString().startsWith("refresh_token_threshold is missing or unreadable");
assertThat(policy().differencesFrom(new JSONObject())).hasSize(3);
assertThat(policy().differencesFrom(provider().put("access_token_validity", "S3NT1NEL"))).singleElement().asString().doesNotContain("S3NT1NEL");
}
/*******************************************************************************
** Durations are read the way Authentik reads them.
*******************************************************************************/
@Test
void parseValiditySeconds()
{
assertThat(AuthentikSessionPolicy.parseValiditySeconds("hours=1;minutes=2;seconds=3")).isEqualTo(3723L);
assertThat(AuthentikSessionPolicy.parseValiditySeconds("days=30")).isEqualTo(2592000L);
assertThat(AuthentikSessionPolicy.parseValiditySeconds("weeks=1")).isEqualTo(604800L);
assertThat(AuthentikSessionPolicy.parseValiditySeconds("seconds=0")).isEqualTo(0L);
assertThat(AuthentikSessionPolicy.parseValiditySeconds("seconds=600.0")).isEqualTo(600L);
assertThat(AuthentikSessionPolicy.parseValiditySeconds("Minutes= 10 ")).isEqualTo(600L);
assertThat(AuthentikSessionPolicy.parseValiditySeconds("milliseconds=1000")).isEqualTo(1L);
assertThat(AuthentikSessionPolicy.parseValiditySeconds("fortnights=1;minutes=5")).isEqualTo(300L);
assertThat(AuthentikSessionPolicy.parseValiditySeconds("fortnights=1")).isNull();
assertThat(AuthentikSessionPolicy.parseValiditySeconds("seconds=0.5")).isNull();
assertThat(AuthentikSessionPolicy.parseValiditySeconds("minutes=-5")).isNull();
assertThat(AuthentikSessionPolicy.parseValiditySeconds("minutes=ten")).isNull();
assertThat(AuthentikSessionPolicy.parseValiditySeconds("minutes")).isNull();
assertThat(AuthentikSessionPolicy.parseValiditySeconds("")).isNull();
assertThat(AuthentikSessionPolicy.parseValiditySeconds(null)).isNull();
}
/*******************************************************************************
** Config errors against a given instance.
*******************************************************************************/
private static List<String> errors(AuthentikQBitConfig config, QInstance instance)
{
List<String> errors = new ArrayList<>();
config.validate(instance, errors);
return (errors);
}
}
@@ -1,122 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.contract;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikSessionPolicy;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The session-policy comparison against a real Authentik: what the provider
** API returns for the validity settings, and that the service account reads
** exactly its own app's provider with an object permission and is refused
** without one (the least-privilege grant the health check documents).
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(65)
class SessionPolicyContractIT
{
private static final String CLIENT_ID = "kof22-website-prod";
private static final String PERMISSION = "authentik_providers_oauth2.view_oauth2provider";
private static final String MODEL = "authentik_providers_oauth2.oauth2provider";
/*******************************************************************************
** Leave no thread context behind.
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
}
/*******************************************************************************
** Without the grant the read is refused; with it, the three validity
** settings and the grant types come back and compare in seconds.
*******************************************************************************/
@Test
void providerValidityIsReadWithAnObjectPermissionOnly() throws Exception
{
AuthentikTestStack stack = AuthentikTestStack.get();
TestApi admin = stack.admin();
AuthentikQBitConfig config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true)
.withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin").withListCacheTtlSeconds(0);
QInstance instance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.loginWithPermissions(instance, Set.of("health.read", "health.write"));
AuthentikActionUtil client = new AuthentikActionUtil();
client.setBackendMetaData((APIBackendMetaData) instance.getBackend(config.getApiBackendName()));
///////////////////////////////////////////////////////////////////////////////
// seen on 2026.8.3: an account that may view no provider at all gets a 403, //
// not an empty list (the health check reports both as "unverified") //
///////////////////////////////////////////////////////////////////////////////
assertThatThrownBy(() -> client.getOAuth2ProviderValidity(CLIENT_ID)).as("no grant: refused").isInstanceOf(QPermissionDeniedException.class);
String role = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
String pk = String.valueOf(admin.first("/providers/oauth2/?client_id=" + CLIENT_ID).get("pk"));
JSONObject grant = new JSONObject().put("permissions", new JSONArray().put(PERMISSION)).put("model", MODEL).put("object_pk", pk);
assertThat(admin.post("/rbac/permissions/assigned_by_roles/" + role + "/assign/", grant).status()).isEqualTo(200);
try
{
List<JSONObject> providers = client.getOAuth2ProviderValidity(CLIENT_ID);
assertThat(providers).as("object permission on one provider: exactly that provider").hasSize(1);
JSONObject provider = providers.getFirst();
assertThat(provider.keySet()).containsExactlyInAnyOrder("access_token_validity", "refresh_token_validity", "refresh_token_threshold", "grant_types");
///////////////////////////////////////////////////////////////////////////////
// the fixture provider states no validity, so these are Authentik's defaults //
///////////////////////////////////////////////////////////////////////////////
assertThat(AuthentikSessionPolicy.parseValiditySeconds(provider.getString("access_token_validity"))).isEqualTo(3600L);
assertThat(AuthentikSessionPolicy.parseValiditySeconds(provider.getString("refresh_token_validity"))).isEqualTo(30L * 86400);
assertThat(AuthentikSessionPolicy.parseValiditySeconds(provider.getString("refresh_token_threshold"))).isEqualTo(0L);
AuthentikSessionPolicy matching = new AuthentikSessionPolicy().withAccessTokenSeconds(3600).withIdleTimeoutSeconds(30 * 86400).withAbsoluteMaximumSeconds(60 * 86400);
assertThat(matching.differencesFrom(provider)).isEmpty();
assertThat(new AuthentikSessionPolicy().withIdleTimeoutSeconds(3600).withAbsoluteMaximumSeconds(43200).differencesFrom(provider))
.containsExactly("access_token_validity is 3600 s; the access token lifetime needs 600 s", "refresh_token_validity is 2592000 s; the idle timeout needs 3600 s");
assertThat(client.getOAuth2ProviderValidity("no-such-client")).isEmpty();
}
finally
{
assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", grant).status()).isEqualTo(204);
}
assertThatThrownBy(() -> client.getOAuth2ProviderValidity(CLIENT_ID)).as("grant removed: refused again").isInstanceOf(QPermissionDeniedException.class);
}
}
@@ -22,7 +22,6 @@ import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikQBitProducer;
import com.kingsrook.qbits.authentik.AuthentikSessionPolicy;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
@@ -36,10 +35,7 @@ import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestBase;
@@ -148,97 +144,6 @@ class AuthentikHealthChecksTest
/*******************************************************************************
** No session policy, no check; a stated one adds an hourly comparison.
*******************************************************************************/
@Test
void sessionPolicyRegistration() throws QException
{
assertThat(HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance)).extracting(c -> c.getName()).doesNotContain("authentik.session_policy");
useSessionPolicy();
var check = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance).stream().filter(c -> c.getName().equals("authentik.session_policy")).findFirst().orElseThrow();
assertThat(check.getIntervalSeconds()).isEqualTo(3600);
assertThat(check.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
}
/*******************************************************************************
** The provider is compared with the policy; it is read, never written, and
** nothing but durations leaves the provider object (not its client secret).
*******************************************************************************/
@Test
void sessionPolicyStates() throws QException
{
useSessionPolicy();
JSONObject provider = new JSONObject().put("client_id", "my-app").put("client_secret", SECRET).put("name", SECRET)
.put("access_token_validity", "minutes=10").put("refresh_token_validity", "hours=1").put("refresh_token_threshold", "seconds=0")
.put("grant_types", new JSONArray(List.of("authorization_code", "refresh_token")));
page(provider);
var up = run("session_policy");
assertThat(up.getStatus()).isEqualTo(HealthStatus.UP);
assertThat(up.getDetails().getValues()).containsOnlyKeys("idleTimeoutSeconds", "absoluteMaximumSeconds", "renewEverySeconds")
.containsEntry("idleTimeoutSeconds", 3600).containsEntry("absoluteMaximumSeconds", 43200).containsEntry("renewEverySeconds", 600);
page(provider.put("access_token_validity", "hours=8").put("refresh_token_validity", "days=30"));
var degraded = run("session_policy");
assertThat(degraded.getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(degraded.getMessage()).contains("access_token_validity is 28800 s", "refresh_token_validity is 2592000 s");
assertThat(degraded.getDetails().getValues()).containsKey("differences");
page(provider.put("refresh_token_validity", SECRET));
assertThat(run("session_policy").getStatus()).isEqualTo(HealthStatus.DEGRADED);
page();
var unreadable = run("session_policy");
assertThat(unreadable.getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(unreadable.getMessage()).contains("view_oauth2provider");
page(provider, provider);
assertThat(run("session_policy").getStatus()).isEqualTo(HealthStatus.DOWN);
FixtureActionUtil.handler = request -> FixtureActionUtil.response(403, "{\"detail\":\"" + SECRET + "\"}");
var forbidden = run("session_policy");
assertThat(forbidden.getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(forbidden.getMessage()).contains("view_oauth2provider");
FixtureActionUtil.handler = request -> FixtureActionUtil.response(500, "{}");
assertThat(run("session_policy").getMessage()).isEqualTo("Authentik health check failed");
assertThat(FixtureActionUtil.REQUESTS).isNotEmpty().allSatisfy(request ->
{
assertThat(request.getMethod()).isEqualTo("GET");
assertThat(request.getURI().getPath()).endsWith("/providers/oauth2/");
assertThat(request.getURI().getQuery()).contains("client_id=my-app");
});
}
/*******************************************************************************
** Re-produce the instance on OAuth2 authentication that carries the policy.
*******************************************************************************/
private void useSessionPolicy() throws QException
{
AuthentikSessionPolicy policy = new AuthentikSessionPolicy().withIdleTimeoutSeconds(3600).withAbsoluteMaximumSeconds(43200);
OAuth2AuthenticationMetaData oauth2 = new OAuth2AuthenticationMetaData().withScopes("openid offline_access").withRefreshTokensEnabled(true);
oauth2.setName("oauth2");
policy.applyTo(oauth2);
config.withAppClientId("my-app").withSessionPolicy(policy);
instance = new QInstance();
instance.withInstanceDefaultAuthentication(oauth2);
new AuthentikQBitProducer().withQBitConfig(config).produce(instance);
AuthentikTestSupport.useActionUtil(instance, config, HealthActionUtil.class);
FixtureActionUtil.reset();
FixtureActionUtil.handler = request -> FixtureActionUtil.response(200, FixtureActionUtil.EMPTY_PAGE);
/////////////////////////////////////////////////////////////////////////////////
// the config was validated against the OAuth2 meta-data above; the probes run //
// under MOCK authentication, as every other test of this class does //
/////////////////////////////////////////////////////////////////////////////////
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
AuthentikTestSupport.loginWithPermissions(instance, Set.of("health.read", "health.write"));
}
/*******************************************************************************
** Version responses are allowlisted before entering details.
*******************************************************************************/
@@ -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]);
}
}