Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
620dfccb7f
|
||
|
|
f19ddf83ee
|
||
|
|
5bf86725eb
|
||
|
|
848378d3bc
|
No files matched your search
+1
-2
@@ -4,10 +4,9 @@ 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").
|
||||
|
||||
@@ -156,6 +156,7 @@ 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
|
||||
@@ -167,6 +168,46 @@ 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
|
||||
|
||||
@@ -98,7 +98,7 @@ A token with no `groups` claim at all and no policy claim says nothing about the
|
||||
|
||||
**Operators.** For every subset-rule check an operator is credited with a permission only if their own policies grant it, or a host floor (static fallback, host permission source, bootstrap) offers it AND their own policies do not deny it. An operator denied a permission can neither grant it nor release a `Deny` on it. Save Policy must carry the `expectedFingerprint` of the group as reviewed; the fingerprint covers `qqq.io/` attributes and the configured policy attribute even when it lives outside `qqq.io/`.
|
||||
|
||||
**Host customizers.** `PolicyPermissions.fromClaims` and `AuthentikGroupPermissionMapper.resolve` return plain sets and are deprecated: a set cannot tell void from absent or carry a `Deny` to the host's other sources. Use `PolicyPermissions.resultFromClaims(claims, format, catalog)` (or `AuthentikGroupPermissionMapper.resolveResult`), which returns a `PolicyResult` (`isVoid()`, `isAbsent()`, the typed rules and what they grant), and add the host's own service permissions through `result.apply(otherPermissions)`: it subtracts every `Deny` from them and returns nothing when the policy is void. `apply` is a union: `apply(session.getPermissions())` keeps whatever the session already held that no `Deny` covers. A host whose sessions take their permissions from the policies alone (QQQ runs the customizer again on every request of a session, and the session must end with exactly what the current token grants) calls `session.setPermissions(result.replacement())` instead: the grant alone, nothing carried over, nothing when the policy is void or unreadable.
|
||||
**Host customizers.** `PolicyPermissions.fromClaims` and `AuthentikGroupPermissionMapper.resolve` return plain sets and are deprecated: a set cannot tell void from absent or carry a `Deny` to the host's other sources. Use `PolicyPermissions.resultFromClaims(claims, format, catalog)` (or `AuthentikGroupPermissionMapper.resolveResult`), which returns a `PolicyResult` (`isVoid()`, `isAbsent()`, the typed rules and what they grant), and add the host's own service permissions through `result.apply(otherPermissions)`: it subtracts every `Deny` from them and returns nothing when the policy is void.
|
||||
|
||||
The golden vectors in `src/test/resources/policy-vectors-1.1.0.json` define these rules over a small fixed permission universe. Every implementation of the grammar must pass them: this qbit's parser, the Authentik claim validator in the identity CD repository, and Forge's catalog tests.
|
||||
|
||||
@@ -197,7 +197,7 @@ Authentik puts the merged document into the token through a scope mapping. The e
|
||||
- `AuthentikGroupPermissionMapper`, which also reads the claim and the group graph, or
|
||||
- your own customizer, calling `PolicyPermissions.fromClaims(claims, config.getPolicyFormat(), PolicyCatalog.of(qInstance))`.
|
||||
|
||||
The expanded catalog is cached per `QInstance` (the last few instances, keyed by a digest of table permission rules, supplemental metadata and object names); `PolicyCatalog.clearCache()` forgets them. The cache lives in each JVM and is derived only from that JVM's own instance, so nodes of a cluster need no invalidation between them. Every `PolicyCatalog.of` call compares the instance with the digest, so an in-place metadata change is seen by the next call. The permission universe is never cached: it is built from the catalog's names, the host-declared names and the identity names at each resolution, once per resolution however many terms the policy has (`PolicyRules` reads the universe it is given once per call; `PolicyUniverse.once` does the same for a caller's own loop). No decision is cached: the same claim is resolved again on every request. Host-declared names are never cached, and a host-name function runs once per resolution, so it should be cheap. Host names must be dot-separated segments of `[A-Za-z0-9_-]`; a function that returns anything else fails the expansion loudly. The cache key also covers the permission rules of processes, apps, reports and widgets, hidden flags and the registered table-feature list; a feature's applies-to predicate is opaque, so a host that changes only that in place calls `clearCache()`.
|
||||
The expanded catalog and permission universe are cached per `QInstance` (the last few instances, keyed by a digest of table permission rules, supplemental metadata and object names); `PolicyCatalog.clearCache()` forgets them. Host-declared names are never cached. Host names must be dot-separated segments of `[A-Za-z0-9_-]`; a function that returns anything else fails the expansion loudly. The cache key also covers the permission rules of processes, apps, reports and widgets, hidden flags and the registered table-feature list; a feature's applies-to predicate is opaque, so a host that changes only that in place calls `clearCache()`.
|
||||
|
||||
The catalog-aware overloads of `PolicyParser`, `PolicyGenerator`, `PolicyPermissions`, `PolicyStore.fingerprint` and `GroupGraph.fromGroups` support features. The older overloads without a catalog accept standard actions and full access; `PolicyPermissions.fromClaims` uses the current `QContext` instance for full-access expansion when no catalog is supplied. Raw `PolicyParser` results are symbolic and must be resolved before entering a session. Both bundled mappers and the editor pass the catalog, as do group-policy resolution and access comparison. This qbit consumes the available-permission API from the pinned RC.6 core; registered `TableFeaturePermissionsMetaData` features appear in that API.
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ 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;
|
||||
|
||||
@@ -71,6 +72,7 @@ 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();
|
||||
@@ -282,6 +284,28 @@ 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -545,6 +569,7 @@ 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)") + "}");
|
||||
@@ -1897,6 +1922,38 @@ 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* 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,6 +217,34 @@ 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,6 +82,7 @@ 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,12 +25,14 @@ 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;
|
||||
@@ -69,13 +71,24 @@ 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 = Boolean.TRUE.equals(config.getTokensEnabled())
|
||||
? List.of("api", "api_token", "mapper", "token_broker") : List.of("api", "api_token", "mapper");
|
||||
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");
|
||||
}
|
||||
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 : 21600)
|
||||
.withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : name.equals("session_policy") ? 3600 : 21600)
|
||||
.withTimeoutMs(10000).withCheckInstance(input ->
|
||||
{
|
||||
try
|
||||
@@ -87,6 +100,7 @@ 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()
|
||||
@@ -230,6 +244,52 @@ 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.
|
||||
*******************************************************************************/
|
||||
|
||||
+19
-1
@@ -169,7 +169,7 @@ public class EffectivePermissionResolver
|
||||
Set<String> unknown = new TreeSet<>();
|
||||
Set<String> appAccess = new TreeSet<>();
|
||||
List<PolicyRules> policies = new ArrayList<>();
|
||||
PolicyUniverse live = PolicyUniverse.once(universe());
|
||||
PolicyUniverse live = memoized(universe());
|
||||
|
||||
Set<String> visited = new LinkedHashSet<>();
|
||||
boolean missingParent = false;
|
||||
@@ -255,6 +255,24 @@ public class EffectivePermissionResolver
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Evaluate a universe at most once.
|
||||
*******************************************************************************/
|
||||
private static PolicyUniverse memoized(PolicyUniverse source)
|
||||
{
|
||||
List<Set<String>> cache = new ArrayList<>();
|
||||
return (() ->
|
||||
{
|
||||
if(cache.isEmpty())
|
||||
{
|
||||
cache.add(source.names());
|
||||
}
|
||||
return (cache.get(0));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every permission full access grants now (the live universe).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -19,7 +19,6 @@ package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -27,7 +26,6 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.SortedSet;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
@@ -136,12 +134,6 @@ public final class PolicyCatalog
|
||||
private final QInstance qInstance;
|
||||
private final Set<String> availableNames;
|
||||
|
||||
/** permissionNames(), computed once: a catalog never changes after it is built. */
|
||||
private final SortedSet<String> permissionNames;
|
||||
|
||||
/** availableNames and permissionNames together, sorted: what the catalog gives the permission universe. */
|
||||
private final SortedSet<String> universeNames;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -153,70 +145,6 @@ public final class PolicyCatalog
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Receives the pieces of an instance's fingerprint, in the order describe()
|
||||
** produces them.
|
||||
*******************************************************************************/
|
||||
private interface FingerprintSink
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The next piece.
|
||||
*******************************************************************************/
|
||||
void add(String piece);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compares the pieces of an instance's fingerprint with a fingerprint text as
|
||||
** they are produced, so checking that a cached catalog is still current builds
|
||||
** no text: the same comparison as building the fingerprint and calling equals.
|
||||
*******************************************************************************/
|
||||
private static final class FingerprintMatcher implements FingerprintSink
|
||||
{
|
||||
private final String expected;
|
||||
|
||||
private int position;
|
||||
private boolean same = true;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
private FingerprintMatcher(String expected)
|
||||
{
|
||||
this.expected = expected;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void add(String piece)
|
||||
{
|
||||
if(same)
|
||||
{
|
||||
same = expected.regionMatches(position, piece, 0, piece.length());
|
||||
position += piece.length();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether every piece matched and nothing of the expected text is left over.
|
||||
*******************************************************************************/
|
||||
private boolean matched()
|
||||
{
|
||||
return (same && position == expected.length());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
@@ -233,48 +161,25 @@ public final class PolicyCatalog
|
||||
byName.putIfAbsent(resource.name(), resource);
|
||||
}
|
||||
}
|
||||
|
||||
SortedSet<String> grantable = new TreeSet<>();
|
||||
for(Resource resource : byName.values())
|
||||
{
|
||||
for(PolicyAction action : resource.actions())
|
||||
{
|
||||
grantable.add(action.permission(resource.name()));
|
||||
}
|
||||
resource.features().forEach(feature -> grantable.add(resource.name() + "." + feature.name()));
|
||||
}
|
||||
this.permissionNames = Collections.unmodifiableSortedSet(grantable);
|
||||
|
||||
SortedSet<String> all = new TreeSet<>(grantable);
|
||||
all.addAll(this.availableNames);
|
||||
this.universeNames = Collections.unmodifiableSortedSet(all);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The catalog of an instance: the cached one while the metadata it was built
|
||||
** from is unchanged (checked on every call, see describe), else a new one.
|
||||
** Build the catalog of an instance.
|
||||
*******************************************************************************/
|
||||
public static PolicyCatalog of(QInstance qInstance)
|
||||
{
|
||||
Cached cached;
|
||||
String fingerprint = fingerprint(qInstance);
|
||||
synchronized(CACHE)
|
||||
{
|
||||
cached = CACHE.get(qInstance);
|
||||
}
|
||||
if(cached != null)
|
||||
{
|
||||
FingerprintMatcher matcher = new FingerprintMatcher(cached.fingerprint());
|
||||
describe(qInstance, matcher);
|
||||
if(matcher.matched())
|
||||
Cached cached = CACHE.get(qInstance);
|
||||
if(cached != null && cached.fingerprint().equals(fingerprint))
|
||||
{
|
||||
return (cached.catalog());
|
||||
}
|
||||
}
|
||||
|
||||
String fingerprint = fingerprint(qInstance);
|
||||
PolicyCatalog built = build(qInstance);
|
||||
PolicyCatalog built = build(qInstance);
|
||||
synchronized(CACHE)
|
||||
{
|
||||
CACHE.put(qInstance, new Cached(fingerprint, built));
|
||||
@@ -305,111 +210,37 @@ public final class PolicyCatalog
|
||||
*******************************************************************************/
|
||||
static String fingerprint(QInstance qInstance)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder(16384);
|
||||
describe(qInstance, sb::append);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
new TreeMap<>(qInstance.getTables()).forEach((name, table) ->
|
||||
{
|
||||
QPermissionRules rules = table.getPermissionRules();
|
||||
sb.append("t:").append(name).append('|')
|
||||
.append(rules == null ? "-" : rules.getLevel() + ":" + rules.getPermissionBaseName() + ":" + rules.getCustomPermissionChecker()).append('|')
|
||||
.append(table.getSupplementalMetaData() == null ? "" : new TreeSet<>(table.getSupplementalMetaData().keySet())).append(';');
|
||||
});
|
||||
new TreeMap<>(qInstance.getProcesses()).forEach((name, process) -> sb.append("p:").append(name).append('|').append(rulesOf(process.getPermissionRules())).append('|').append(process.getIsHidden()).append(';'));
|
||||
new TreeMap<>(qInstance.getApps()).forEach((name, app) -> sb.append("a:").append(name).append('|').append(rulesOf(app.getPermissionRules())).append(';'));
|
||||
new TreeMap<>(qInstance.getReports()).forEach((name, report) -> sb.append("r:").append(name).append('|').append(rulesOf(report.getPermissionRules())).append(';'));
|
||||
new TreeMap<>(qInstance.getWidgets()).forEach((name, widget) -> sb.append("w:").append(name).append('|').append(rulesOf(widget.getPermissionRules())).append(';'));
|
||||
TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.of(qInstance);
|
||||
if(features != null)
|
||||
{
|
||||
for(TableFeaturePermission feature : features.getPermissions())
|
||||
{
|
||||
sb.append("f:").append(feature.getSuffix()).append('|').append(feature.getLabel()).append(';');
|
||||
}
|
||||
}
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The pieces of the fingerprint, in the instance's own (insertion) order:
|
||||
** every table, process, app, report and widget with its permission rules, the
|
||||
** tables' supplemental metadata keys, the processes' hidden flags and the
|
||||
** registered table features. The same instance content always gives the same
|
||||
** pieces; any change to it gives different ones. This runs on every
|
||||
** PolicyCatalog.of call (every sign-in and session resume), so it creates no
|
||||
** sorted copy and no text of its own.
|
||||
** A permission rules object as a stable string.
|
||||
*******************************************************************************/
|
||||
private static void describe(QInstance qInstance, FingerprintSink sink)
|
||||
private static String rulesOf(QPermissionRules rules)
|
||||
{
|
||||
for(Map.Entry<String, QTableMetaData> entry : qInstance.getTables().entrySet())
|
||||
{
|
||||
QTableMetaData table = entry.getValue();
|
||||
sink.add("t:");
|
||||
sink.add(String.valueOf(entry.getKey()));
|
||||
sink.add("|");
|
||||
describeRules(table.getPermissionRules(), sink);
|
||||
sink.add("|");
|
||||
if(table.getSupplementalMetaData() != null)
|
||||
{
|
||||
sink.add("[");
|
||||
for(String key : table.getSupplementalMetaData().keySet())
|
||||
{
|
||||
sink.add(String.valueOf(key));
|
||||
sink.add(",");
|
||||
}
|
||||
sink.add("]");
|
||||
}
|
||||
sink.add(";");
|
||||
}
|
||||
for(Map.Entry<String, QProcessMetaData> entry : qInstance.getProcesses().entrySet())
|
||||
{
|
||||
sink.add("p:");
|
||||
sink.add(String.valueOf(entry.getKey()));
|
||||
sink.add("|");
|
||||
describeRules(entry.getValue().getPermissionRules(), sink);
|
||||
sink.add("|");
|
||||
sink.add(String.valueOf(entry.getValue().getIsHidden()));
|
||||
sink.add(";");
|
||||
}
|
||||
for(Map.Entry<String, QAppMetaData> entry : qInstance.getApps().entrySet())
|
||||
{
|
||||
describeObject("a:", entry.getKey(), entry.getValue().getPermissionRules(), sink);
|
||||
}
|
||||
for(Map.Entry<String, QReportMetaData> entry : qInstance.getReports().entrySet())
|
||||
{
|
||||
describeObject("r:", entry.getKey(), entry.getValue().getPermissionRules(), sink);
|
||||
}
|
||||
for(Map.Entry<String, QWidgetMetaDataInterface> entry : qInstance.getWidgets().entrySet())
|
||||
{
|
||||
describeObject("w:", entry.getKey(), entry.getValue().getPermissionRules(), sink);
|
||||
}
|
||||
TableFeaturePermissionsMetaData features = TableFeaturePermissionsMetaData.of(qInstance);
|
||||
if(features != null)
|
||||
{
|
||||
for(TableFeaturePermission feature : features.getPermissions())
|
||||
{
|
||||
sink.add("f:");
|
||||
sink.add(String.valueOf(feature.getSuffix()));
|
||||
sink.add("|");
|
||||
sink.add(String.valueOf(feature.getLabel()));
|
||||
sink.add(";");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One app, report or widget: its kind, name and permission rules.
|
||||
*******************************************************************************/
|
||||
private static void describeObject(String kind, String name, QPermissionRules rules, FingerprintSink sink)
|
||||
{
|
||||
sink.add(kind);
|
||||
sink.add(String.valueOf(name));
|
||||
sink.add("|");
|
||||
describeRules(rules, sink);
|
||||
sink.add(";");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A permission rules object: its level, base name and custom checker.
|
||||
*******************************************************************************/
|
||||
private static void describeRules(QPermissionRules rules, FingerprintSink sink)
|
||||
{
|
||||
if(rules == null)
|
||||
{
|
||||
sink.add("-");
|
||||
return;
|
||||
}
|
||||
sink.add(String.valueOf(rules.getLevel()));
|
||||
sink.add(":");
|
||||
sink.add(String.valueOf(rules.getPermissionBaseName()));
|
||||
sink.add(":");
|
||||
sink.add(String.valueOf(rules.getCustomPermissionChecker()));
|
||||
return (rules == null ? "-" : rules.getLevel() + ":" + rules.getPermissionBaseName() + ":" + rules.getCustomPermissionChecker());
|
||||
}
|
||||
|
||||
|
||||
@@ -784,7 +615,16 @@ public final class PolicyCatalog
|
||||
*******************************************************************************/
|
||||
public Set<String> permissionNames()
|
||||
{
|
||||
return (new TreeSet<>(permissionNames));
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(Resource resource : byName.values())
|
||||
{
|
||||
for(PolicyAction action : resource.actions())
|
||||
{
|
||||
rs.add(action.permission(resource.name()));
|
||||
}
|
||||
resource.features().forEach(feature -> rs.add(resource.name() + "." + feature.name()));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
@@ -795,10 +635,11 @@ public final class PolicyCatalog
|
||||
*******************************************************************************/
|
||||
public Set<String> unknownPermissions(Set<String> permissions)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
Set<String> known = permissionNames();
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(String permission : permissions)
|
||||
{
|
||||
if(!permissionNames.contains(permission))
|
||||
if(!known.contains(permission))
|
||||
{
|
||||
rs.add(permission);
|
||||
}
|
||||
@@ -830,18 +671,6 @@ public final class PolicyCatalog
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** availableNames() and permissionNames() together, sorted and read-only: the
|
||||
** catalog's part of the permission universe (PolicyPermissions.allPermissions),
|
||||
** kept so a universe is a copy of this set and not a walk over every resource.
|
||||
*******************************************************************************/
|
||||
SortedSet<String> universeNames()
|
||||
{
|
||||
return (universeNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether at least one resource whose name starts with the prefix registers
|
||||
** this feature action (a prefix resource's feature rule).
|
||||
|
||||
@@ -177,11 +177,7 @@ public final class PolicyParser
|
||||
{
|
||||
throw (new IllegalArgumentException("A universe is required for save-time checks"));
|
||||
}
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// one reading of the universe for the whole document, not one for //
|
||||
// each named permission, prefix and Deny it checks //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
return (parseStrict(document, format, catalog, PolicyUniverse.once(universe)));
|
||||
return (parseStrict(document, format, catalog, universe));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -140,10 +140,8 @@ public final class PolicyPermissions
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The live universe of an instance: allPermissions, built again on EVERY
|
||||
** names() call and never cached, so instance changes are always visible.
|
||||
** PolicyRules reads it once per resolution (PolicyUniverse.once); a caller
|
||||
** that reads names() itself more than once should wrap it the same way.
|
||||
** The live universe of an instance (allPermissions, evaluated when a
|
||||
** symbolic term needs it, never cached).
|
||||
*******************************************************************************/
|
||||
public static PolicyUniverse universe(QInstance qInstance, Collection<String> additional)
|
||||
{
|
||||
@@ -160,24 +158,21 @@ public final class PolicyPermissions
|
||||
public static Set<String> allPermissions(QInstance qInstance, Collection<String> additional)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
if(additional != null)
|
||||
{
|
||||
permissions.addAll(additional);
|
||||
}
|
||||
if(qInstance != null)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// the catalog's names first: they are already sorted, so the set //
|
||||
// is built from them in one pass (the catalog is checked against //
|
||||
// the instance on every call; its names are not walked again) //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
permissions.addAll(PolicyCatalog.of(qInstance).universeNames());
|
||||
PolicyCatalog catalog = PolicyCatalog.of(qInstance);
|
||||
permissions.addAll(catalog.availableNames());
|
||||
permissions.addAll(catalog.permissionNames());
|
||||
permissions.addAll(hostPermissions(qInstance));
|
||||
}
|
||||
else
|
||||
{
|
||||
permissions.addAll(registeredHostPermissions(null));
|
||||
}
|
||||
if(additional != null)
|
||||
{
|
||||
permissions.addAll(additional);
|
||||
}
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
permissions.add(permission.getSessionPermission());
|
||||
|
||||
@@ -105,10 +105,6 @@ public record PolicyResult(PolicyRules rules, Set<String> granted, boolean unava
|
||||
** policy never lets other sources through unfiltered. The other sources are
|
||||
** not modified. NOT FOR SIGN-IN ASSEMBLY by this qbit's mappers (they use
|
||||
** SessionPermissionRules.compute); it is for a host customizer's own sources.
|
||||
**
|
||||
** This is a UNION: apply(session.getPermissions()) keeps everything the session
|
||||
** already held that no Deny covers. A host whose sessions take their permissions
|
||||
** from the policies alone must call replacement() instead.
|
||||
*******************************************************************************/
|
||||
public Set<String> apply(Collection<String> otherSources)
|
||||
{
|
||||
@@ -124,20 +120,4 @@ public record PolicyResult(PolicyRules rules, Set<String> granted, boolean unava
|
||||
rs.removeIf(rules::denies);
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The complete permission set of a session whose ONLY source is these
|
||||
** policies: exactly what they grant (Allow minus Deny) and nothing else, so
|
||||
** nothing the session held before survives. Empty when void or unavailable.
|
||||
** For a host that replaces the session's permissions at every authentication
|
||||
** ("permissions come only from the token"): session.setPermissions(replacement()).
|
||||
** Unlike apply(), it takes no other source, so it cannot union by accident.
|
||||
** The set is new on every call; the caller may keep or change it.
|
||||
*******************************************************************************/
|
||||
public Set<String> replacement()
|
||||
{
|
||||
return (apply(null));
|
||||
}
|
||||
}
|
||||
@@ -235,17 +235,9 @@ public final class PolicyRules
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// every term expands against ONE reading of the universe: a live //
|
||||
// universe builds its names on each names() call, so reading it per //
|
||||
// term made the cost grow with the number of terms (one catalog check //
|
||||
// and one full name set for each named permission of a role) //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
PolicyUniverse once = PolicyUniverse.once(universe);
|
||||
for(PolicyTerm term : allow)
|
||||
{
|
||||
rs.addAll(term.expand(once));
|
||||
rs.addAll(term.expand(universe));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
@@ -286,9 +278,8 @@ public final class PolicyRules
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
PolicyUniverse once = PolicyUniverse.once(universe);
|
||||
Set<String> candidates = new TreeSet<>(once.names());
|
||||
candidates.addAll(allowed(once));
|
||||
Set<String> candidates = new TreeSet<>(universe.names());
|
||||
candidates.addAll(allowed(universe));
|
||||
for(String name : candidates)
|
||||
{
|
||||
if(denies(name))
|
||||
|
||||
@@ -17,9 +17,7 @@ package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.SortedSet;
|
||||
import java.util.TreeSet;
|
||||
|
||||
|
||||
@@ -50,43 +48,4 @@ public interface PolicyUniverse
|
||||
return (() -> copy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A universe that asks the given one for its names at most once, and then
|
||||
** answers with that same read-only set. For ONE resolution: a live universe
|
||||
** (PolicyPermissions.universe) builds its names again on every names() call,
|
||||
** and one resolution must cost one universe however many terms it expands.
|
||||
** Nothing is read until the first names() call. Never keep the result after
|
||||
** the resolution, or instance changes would be hidden. Not thread-safe: a
|
||||
** resolution runs on one thread.
|
||||
*******************************************************************************/
|
||||
static PolicyUniverse once(PolicyUniverse universe)
|
||||
{
|
||||
return (new PolicyUniverse()
|
||||
{
|
||||
private Set<String> names;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Set<String> names()
|
||||
{
|
||||
if(names == null)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// a sorted set stays a sorted set, so a result built from the //
|
||||
// whole universe (full access) is built in one pass //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
Set<String> read = universe.names();
|
||||
names = read instanceof SortedSet<String> sorted ? Collections.unmodifiableSortedSet(sorted) : Collections.unmodifiableSet(read);
|
||||
}
|
||||
return (names);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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,6 +22,7 @@ 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;
|
||||
@@ -35,7 +36,10 @@ 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;
|
||||
@@ -144,6 +148,97 @@ 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,24 +19,17 @@ package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import org.json.JSONArray;
|
||||
@@ -179,84 +172,4 @@ class PolicyCatalogTest
|
||||
assertThat(json.getJSONObject(1).isNull("app")).isTrue();
|
||||
assertThat(PolicyCatalog.of(new QInstance()).getGroups()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The catalog of an instance is kept and handed out again while the metadata
|
||||
** it was built from is unchanged, and EVERY in-place change to that metadata
|
||||
** gives a new catalog on the very next call (no stale catalog, no clearCache):
|
||||
** an object added or removed, a permission level, base name or custom checker,
|
||||
** a table's supplemental metadata, a process's hidden flag, a table feature.
|
||||
** PolicyCatalog.of checks this on every sign-in and session resume by comparing
|
||||
** the instance with the kept fingerprint piece by piece.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyInPlaceMetadataChangeGivesANewCatalogAndAnUnchangedInstanceKeepsItsCatalog()
|
||||
{
|
||||
QInstance instance = instance();
|
||||
PolicyCatalog first = PolicyCatalog.of(instance);
|
||||
assertThat(PolicyCatalog.of(instance)).isSameAs(first);
|
||||
assertThat(PolicyCatalog.of(instance)).isSameAs(first);
|
||||
|
||||
List<Runnable> changes = List.of(
|
||||
() -> instance.addTable(table("added_later", "Added Later", PermissionLevel.HAS_ACCESS_PERMISSION)),
|
||||
() -> instance.getTable("gate").getPermissionRules().setLevel(PermissionLevel.READ_WRITE_PERMISSIONS),
|
||||
() -> instance.getTable("gate").getPermissionRules().setPermissionBaseName("gate_base"),
|
||||
() -> instance.getTable("gate").getPermissionRules().setCustomPermissionChecker(new QCodeReference(Checker.class)),
|
||||
() -> instance.getTable("gate").setPermissionRules(null),
|
||||
() -> instance.getTable("orphan").withSupplementalMetaData(new QSupplementalTableMetaData()
|
||||
{
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("policyCatalogTest");
|
||||
}
|
||||
}),
|
||||
() -> instance.getProcess("loose").setIsHidden(true),
|
||||
() -> instance.getProcess("loose").getPermissionRules().setLevel(PermissionLevel.NOT_PROTECTED),
|
||||
() -> instance.addProcess(new QProcessMetaData().withName("addedProcess").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION))),
|
||||
() -> instance.getApp("emptyApp").getPermissionRules().setLevel(PermissionLevel.HAS_ACCESS_PERMISSION),
|
||||
() -> instance.getReport("pageReport").getPermissionRules().setPermissionBaseName("page_report"),
|
||||
() -> instance.getWidget("pageStats").getPermissionRules().setLevel(PermissionLevel.NOT_PROTECTED),
|
||||
() -> instance.getWidgets().remove("openWidget"),
|
||||
() -> instance.addWidget(new QWidgetMetaData().withName("zLast").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION))),
|
||||
() -> instance.getWidgets().remove("zLast"),
|
||||
() -> TableFeaturePermissionsMetaData.ofOrWithNew(instance).withPermission(new TableFeaturePermission().withSuffix("analyze").withLabel("Analyze")
|
||||
.withAppliesTo((qInstance, table) -> "cms_page".equals(table.getName()))),
|
||||
() -> TableFeaturePermissionsMetaData.of(instance).getPermissions().get(0).setLabel("Analyse"));
|
||||
|
||||
for(int i = 0; i < changes.size(); i++)
|
||||
{
|
||||
PolicyCatalog before = PolicyCatalog.of(instance);
|
||||
changes.get(i).run();
|
||||
PolicyCatalog after = PolicyCatalog.of(instance);
|
||||
assertThat(after).as("change %s gives a new catalog", i).isNotSameAs(before);
|
||||
assertThat(PolicyCatalog.of(instance)).as("and the new catalog is kept after change %s", i).isSameAs(after);
|
||||
assertThat(after.permissionNames()).as("the kept catalog equals a fresh build after change %s", i).isEqualTo(PolicyCatalog.build(instance).permissionNames());
|
||||
assertThat(after.availableNames()).isEqualTo(PolicyCatalog.build(instance).availableNames());
|
||||
}
|
||||
assertThat(PolicyCatalog.of(instance).get("added_later")).isNotNull();
|
||||
assertThat(PolicyCatalog.of(instance).get("cms_page").features()).extracting(PolicyCatalog.Feature::name).containsExactly("analyze");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A custom permission checker that leaves everything to the defaults (a policy
|
||||
** cannot grant an object that has one, so the catalog leaves the object out).
|
||||
*******************************************************************************/
|
||||
public static class Checker implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules)
|
||||
{
|
||||
/////////////////////////////////
|
||||
// allows; never called here //
|
||||
/////////////////////////////////
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,37 +406,6 @@ class PolicyDenyTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PolicyResult.replacement is the whole permission set of a session whose only
|
||||
** source is the policies: exactly Allow minus Deny, with no way to carry over
|
||||
** what a session held before (apply(session.getPermissions()) is a union and
|
||||
** keeps it). Void and unreadable policies give nothing. A Deny is applied even
|
||||
** to a result built by hand, and the set is the caller's own each time.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void policyResultReplacementIsExactlyTheGrantAndNothingCarriedOver() throws Exception
|
||||
{
|
||||
PolicyRules rules = PolicyParser.parseStrict(doc(List.of(stmt("Allow", "read", "billing_invoice"), stmt("Deny", "*", "utils_log"))), FORMAT, CATALOG).rules();
|
||||
PolicyResult result = PolicyResult.of(rules, UNIVERSE);
|
||||
List<String> held = List.of("held_before.read", "service.send", "utils_log.read");
|
||||
|
||||
assertThat(result.apply(held)).as("apply is a union with what the host passes").containsExactlyInAnyOrder("billing_invoice.read", "held_before.read", "service.send");
|
||||
assertThat(result.replacement()).as("replacement is the grant alone").containsExactly("billing_invoice.read").isEqualTo(result.granted());
|
||||
|
||||
Set<String> mine = result.replacement();
|
||||
mine.add("added.by.caller");
|
||||
assertThat(result.replacement()).as("a new set on every call").containsExactly("billing_invoice.read");
|
||||
assertThat(result.granted()).containsExactly("billing_invoice.read");
|
||||
|
||||
assertThat(PolicyResult.of(PolicyRules.voided("x"), UNIVERSE).replacement()).isEmpty();
|
||||
assertThat(PolicyResult.unreadable().replacement()).isEmpty();
|
||||
assertThat(PolicyResult.of(PolicyRules.EMPTY, UNIVERSE).replacement()).isEmpty();
|
||||
assertThat(new PolicyResult(rules, Set.of("billing_invoice.read", "utils_log.read")).replacement()).as("a Deny also covers a hand-built result").containsExactly("billing_invoice.read");
|
||||
assertThat(new PolicyResult(PolicyRules.voided("x"), Set.of("billing_invoice.read")).replacement()).as("void gives nothing whatever it was built with").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.policy;
|
||||
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.document;
|
||||
import static com.kingsrook.qbits.authentik.policy.PolicyParserTest.statement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What one policy resolution costs. QQQ runs a host's authentication
|
||||
** customizer on every request of a session, so the work of turning a policy
|
||||
** claim into permissions must not grow with the number of permissions the
|
||||
** policy names. These tests count that work: the live permission universe is
|
||||
** built ONCE per resolution, whatever the policy holds, and the memory one
|
||||
** resolution allocates stays flat. They also pin that reading the universe once
|
||||
** grants exactly what expanding each term against its own reading grants.
|
||||
**
|
||||
** The counter is a registered host-name function: the qbit calls every one of
|
||||
** them each time it builds the live universe (PolicyPermissions.allPermissions).
|
||||
*******************************************************************************/
|
||||
class PolicyResolutionCostTest
|
||||
{
|
||||
private static final PolicyFormat FORMAT = PolicyFormat.DEFAULT;
|
||||
|
||||
private static final int HOST_NAMES = 40;
|
||||
|
||||
private final AtomicInteger universes = new AtomicInteger();
|
||||
|
||||
private final Function<QInstance, Collection<String>> counter = qInstance ->
|
||||
{
|
||||
universes.incrementAndGet();
|
||||
List<String> names = new ArrayList<>();
|
||||
for(int i = 0; i < HOST_NAMES; i++)
|
||||
{
|
||||
names.add("svc.action" + i);
|
||||
}
|
||||
return (names);
|
||||
};
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count every build of the live universe.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void countUniverses()
|
||||
{
|
||||
PolicyPermissions.clearRegisteredHostPermissions();
|
||||
PolicyPermissions.registerHostPermissions(counter);
|
||||
universes.set(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave no host-name function behind for other tests.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void forgetCounter()
|
||||
{
|
||||
PolicyPermissions.clearRegisteredHostPermissions();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An instance about the size of a real host: 200 tables, 1,000 hidden record
|
||||
** actions, 200 widgets and 20 apps, all protected.
|
||||
*******************************************************************************/
|
||||
private static QInstance largeInstance()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
List<QAppMetaData> apps = new ArrayList<>();
|
||||
for(int a = 0; a < 20; a++)
|
||||
{
|
||||
apps.add(new QAppMetaData().withName("app" + a).withLabel("App " + a).withSortOrder(a).withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
}
|
||||
for(int t = 0; t < 200; t++)
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData().withName("svc" + (t % 20) + "_table" + t).withLabel("Table " + t).withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withPermissionRules(rules(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
|
||||
instance.addTable(table);
|
||||
apps.get(t % 20).withChild(table);
|
||||
}
|
||||
for(int p = 0; p < 1000; p++)
|
||||
{
|
||||
int t = p % 200;
|
||||
instance.addProcess(new QProcessMetaData().withName("svc" + (t % 20) + "_table" + t + "Action" + p).withLabel("Action " + p).withTableName("svc" + (t % 20) + "_table" + t)
|
||||
.withIsHidden(true).withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
}
|
||||
for(int w = 0; w < 200; w++)
|
||||
{
|
||||
instance.addWidget(new QWidgetMetaData().withName("widget" + w).withLabel("Widget " + w).withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
}
|
||||
apps.forEach(instance::addApp);
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission rules of a level.
|
||||
*******************************************************************************/
|
||||
private static QPermissionRules rules(PermissionLevel level)
|
||||
{
|
||||
return (new QPermissionRules().withLevel(level));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A role of this many named permissions (host names the counter declares), as
|
||||
** the claims of a token.
|
||||
*******************************************************************************/
|
||||
private static JSONObject namedRole(int count)
|
||||
{
|
||||
JSONArray names = new JSONArray();
|
||||
for(int i = 0; i < count; i++)
|
||||
{
|
||||
names.put("svc.action" + i);
|
||||
}
|
||||
return (claims(document(new JSONObject().put("Effect", "Allow").put("Permission", names))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Token claims carrying one policy document.
|
||||
*******************************************************************************/
|
||||
private static JSONObject claims(JSONObject policy)
|
||||
{
|
||||
return (new JSONObject().put("email", "a@example.test").put(FORMAT.claim(), policy));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A named-permission statement.
|
||||
*******************************************************************************/
|
||||
private static JSONObject named(String effect, String... names)
|
||||
{
|
||||
return (new JSONObject().put("Effect", effect).put("Permission", new JSONArray(List.of(names))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How many times the live universe was built by this work.
|
||||
*******************************************************************************/
|
||||
private int universesBuiltBy(Runnable work)
|
||||
{
|
||||
universes.set(0);
|
||||
work.run();
|
||||
return (universes.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The reported defect: a role of nine named permissions built nine universes
|
||||
** on every request. Every entry point a host customizer can call builds one,
|
||||
** for one name or thirty.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void oneUniversePerResolutionWhateverTheNumberOfNamedPermissions()
|
||||
{
|
||||
QInstance instance = largeInstance();
|
||||
for(int count : new int[] { 1, 9, 30 })
|
||||
{
|
||||
JSONObject claims = namedRole(count);
|
||||
|
||||
assertThat(universesBuiltBy(() -> assertThat(PolicyPermissions.resultFromClaims(claims, FORMAT, PolicyCatalog.of(instance)).granted()).hasSize(count)))
|
||||
.as("resultFromClaims, %s named permissions", count).isEqualTo(1);
|
||||
|
||||
assertThat(universesBuiltBy(() -> assertThat(deprecatedFromClaims(claims, instance)).hasSize(count)))
|
||||
.as("fromClaims, %s named permissions", count).isEqualTo(1);
|
||||
|
||||
assertThat(universesBuiltBy(() ->
|
||||
{
|
||||
QSession session = new QSession();
|
||||
new AuthentikPolicyClaimMapper().customizeSession(instance, session, Map.of(PolicyPermissions.ID_TOKEN_CONTEXT_KEY, claims));
|
||||
assertThat(session.getPermissions()).hasSize(count);
|
||||
})).as("AuthentikPolicyClaimMapper, %s named permissions", count).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The deprecated plain-set read, still used by hosts.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("deprecation")
|
||||
private static Set<String> deprecatedFromClaims(JSONObject claims, QInstance instance)
|
||||
{
|
||||
return (PolicyPermissions.fromClaims(claims, FORMAT, PolicyCatalog.of(instance)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every kind of term together (exact resources, a prefix, named permissions,
|
||||
** a Deny) is still one universe; a void or absent claim needs none.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mixedTermsReadOneUniverseAndVoidOrAbsentClaimsReadNone()
|
||||
{
|
||||
QInstance instance = largeInstance();
|
||||
JSONObject mixed = claims(document(
|
||||
statement("Allow", List.of("read", "edit"), List.of("svc1_table1", "svc2_table2", "svc3_table3")),
|
||||
statement("Allow", List.of("read"), List.of("svc4_*")),
|
||||
named("Allow", "svc.action1", "svc.action2", "svc.action3", "not.declared"),
|
||||
statement("Deny", List.of("edit"), List.of("svc2_table2")),
|
||||
named("Deny", "svc.action3")));
|
||||
|
||||
PolicyResult[] result = new PolicyResult[1];
|
||||
assertThat(universesBuiltBy(() -> result[0] = PolicyPermissions.resultFromClaims(mixed, FORMAT, PolicyCatalog.of(instance)))).isEqualTo(1);
|
||||
assertThat(result[0].granted()).contains("svc1_table1.read", "svc1_table1.edit", "svc2_table2.read", "svc4_table4.read", "svc4_table24.read", "svc.action1", "svc.action2")
|
||||
.doesNotContain("svc2_table2.edit", "svc.action3", "not.declared", "svc4_table4.edit");
|
||||
|
||||
JSONObject voided = claims(new JSONObject().put("PolicySpec", "not-the-format"));
|
||||
assertThat(universesBuiltBy(() -> assertThat(PolicyPermissions.resultFromClaims(voided, FORMAT, PolicyCatalog.of(instance)).isVoid()).isTrue())).isZero();
|
||||
|
||||
JSONObject absent = new JSONObject().put("email", "a@example.test");
|
||||
assertThat(universesBuiltBy(() -> assertThat(PolicyPermissions.resultFromClaims(absent, FORMAT, PolicyCatalog.of(instance)).isAbsent()).isTrue())).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rule set itself reads whatever universe it is given once per call:
|
||||
** allowed, resolve and withheld, however many terms there are.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rulesReadTheUniverseOncePerCall()
|
||||
{
|
||||
Set<String> names = new TreeSet<>(List.of("a_one.read", "a_one.edit", "a_two.read", "b_one.read", "svc.x", "svc.y", "svc.z"));
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
PolicyUniverse universe = () ->
|
||||
{
|
||||
reads.incrementAndGet();
|
||||
return (new TreeSet<>(names));
|
||||
};
|
||||
PolicyRules rules = PolicyParser.parse(document(
|
||||
statement("Allow", List.of("read", "edit"), List.of("a_one", "a_two", "b_one")),
|
||||
statement("Allow", List.of("read"), List.of("a_*")),
|
||||
named("Allow", "svc.x", "svc.y", "svc.z"),
|
||||
named("Deny", "svc.z")), FORMAT);
|
||||
assertThat(rules.allowTerms()).hasSizeGreaterThan(8);
|
||||
|
||||
assertThat(rules.allowed(universe)).containsExactly("a_one.edit", "a_one.read", "a_two.read", "b_one.read", "svc.x", "svc.y", "svc.z");
|
||||
assertThat(reads.getAndSet(0)).as("allowed").isEqualTo(1);
|
||||
|
||||
assertThat(rules.resolve(universe)).containsExactly("a_one.edit", "a_one.read", "a_two.read", "b_one.read", "svc.x", "svc.y");
|
||||
assertThat(reads.getAndSet(0)).as("resolve").isEqualTo(1);
|
||||
|
||||
assertThat(rules.withheld(universe)).containsExactly("svc.z");
|
||||
assertThat(reads.getAndSet(0)).as("withheld").isEqualTo(1);
|
||||
|
||||
assertThat(PolicyRules.voided("bad").resolve(universe)).isEmpty();
|
||||
assertThat(PolicyRules.EMPTY.resolve(universe)).isEmpty();
|
||||
assertThat(reads.get()).as("void and empty rules need no universe").isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The editor's save-time checks (every named permission, prefix and Deny must
|
||||
** match something) read the universe once for the whole document.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void saveTimeChecksReadTheUniverseOnce() throws Exception
|
||||
{
|
||||
Set<String> names = new TreeSet<>(List.of("a_one.read", "a_one.edit", "a_two.read", "svc.x", "svc.y"));
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
PolicyUniverse universe = () ->
|
||||
{
|
||||
reads.incrementAndGet();
|
||||
return (names);
|
||||
};
|
||||
JSONObject valid = document(statement("Allow", List.of("read"), List.of("a_*")), named("Allow", "svc.x", "svc.y"),
|
||||
statement("Deny", List.of("edit"), List.of("a_one")), statement("Deny", List.of("*"), List.of("a_*")));
|
||||
PolicyParser.parseForSave(valid, FORMAT, PolicyCatalog.ofPermissionNames(names), universe);
|
||||
assertThat(reads.getAndSet(0)).isEqualTo(1);
|
||||
|
||||
JSONObject mistyped = document(named("Allow", "svc.x", "svc.mistyped"));
|
||||
assertThatThrownBy(() -> PolicyParser.parseForSave(mistyped, FORMAT, PolicyCatalog.ofPermissionNames(names), universe))
|
||||
.isInstanceOf(PolicyParseException.class).hasMessageContaining("svc.mistyped");
|
||||
assertThat(reads.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The one-resolution universe: nothing is read before the first use, the
|
||||
** source is read once, and the names it hands out cannot be changed (no term
|
||||
** can alter what a later term sees).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theUniverseOfOneResolutionIsReadLazilyOnceAndIsReadOnly()
|
||||
{
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
PolicyUniverse sorted = PolicyUniverse.once(() ->
|
||||
{
|
||||
reads.incrementAndGet();
|
||||
return (new TreeSet<>(List.of("b.read", "a.read")));
|
||||
});
|
||||
assertThat(reads.get()).isZero();
|
||||
assertThat(sorted.names()).containsExactly("a.read", "b.read");
|
||||
assertThat(sorted.names()).isSameAs(sorted.names());
|
||||
assertThat(reads.get()).isEqualTo(1);
|
||||
assertThatThrownBy(() -> sorted.names().add("c.read")).isInstanceOf(UnsupportedOperationException.class);
|
||||
|
||||
PolicyUniverse unsorted = PolicyUniverse.once(() -> Set.of("a.read"));
|
||||
assertThat(unsorted.names()).containsExactly("a.read");
|
||||
assertThatThrownBy(() -> unsorted.names().clear()).isInstanceOf(UnsupportedOperationException.class);
|
||||
|
||||
assertThat(PolicyUniverse.once(sorted).names()).containsExactly("a.read", "b.read");
|
||||
assertThat(reads.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reading the universe once changes nothing a policy grants: for documents of
|
||||
** every shape, on the large instance, resolve() gives exactly the union of each
|
||||
** Allow term expanded against its own fresh reading of the live universe, minus
|
||||
** the Deny terms (how resolution worked before it read the universe once).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void oneReadingGrantsExactlyWhatAReadingPerTermGrants()
|
||||
{
|
||||
QInstance instance = largeInstance();
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance);
|
||||
PolicyUniverse live = PolicyPermissions.universe(instance, Set.of());
|
||||
List<JSONObject> documents = List.of(
|
||||
document(statement("Allow", List.of("*"), List.of("*"))),
|
||||
document(statement("Allow", List.of("*"), List.of("*")), statement("Deny", List.of("*"), List.of("svc3_*")), named("Deny", "svc.action7")),
|
||||
document(named("Allow", "svc.action0", "svc.action1", "svc.action2", "svc.action3", "svc.action4", "svc.action5", "svc.action6", "svc.action7", "svc.action8")),
|
||||
document(statement("Allow", List.of("read", "insert", "edit", "delete"), List.of("svc1_table1", "svc2_table2", "noSuchTable"))),
|
||||
document(statement("Allow", List.of("read"), List.of("svc5_*", "svc6_*")), statement("Deny", List.of("read"), List.of("svc5_table5"))),
|
||||
document(statement("Allow", List.of("hasAccess"), List.of("app3", "widget17", "svc0_table0Action0")), named("Allow", "not.declared")),
|
||||
document(named("Deny", "svc.action1")),
|
||||
document());
|
||||
|
||||
for(JSONObject document : documents)
|
||||
{
|
||||
PolicyRules rules = PolicyParser.parse(document, FORMAT, catalog);
|
||||
assertThat(rules.isVoid()).isFalse();
|
||||
|
||||
Set<String> perTerm = new TreeSet<>();
|
||||
for(PolicyTerm term : rules.allowTerms())
|
||||
{
|
||||
perTerm.addAll(term.expand(live));
|
||||
}
|
||||
perTerm.removeIf(rules::denies);
|
||||
|
||||
assertThat(rules.resolve(live)).as(document.toString()).isEqualTo(perTerm);
|
||||
assertThat(PolicyPermissions.resultFromClaims(claims(document), FORMAT, catalog).granted()).as(document.toString()).isEqualTo(perTerm);
|
||||
}
|
||||
assertThat(PolicyRules.allowing(List.of("svc.action0")).resolve(live)).containsExactly("svc.action0");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The live universe itself is unchanged by keeping the catalog's names: the
|
||||
** catalog's available names, its grantable names, the host names and the
|
||||
** identity names, and a later instance change is visible on the next call.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theLiveUniverseHoldsTheSameNamesAndStillFollowsTheInstance()
|
||||
{
|
||||
QInstance instance = largeInstance();
|
||||
PolicyCatalog catalog = PolicyCatalog.of(instance);
|
||||
Set<String> expected = new TreeSet<>(catalog.availableNames());
|
||||
expected.addAll(catalog.permissionNames());
|
||||
for(int i = 0; i < HOST_NAMES; i++)
|
||||
{
|
||||
expected.add("svc.action" + i);
|
||||
}
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
expected.add(permission.getSessionPermission());
|
||||
}
|
||||
expected.add("extra.name");
|
||||
assertThat(PolicyPermissions.allPermissions(instance, List.of("extra.name", PolicyPermissions.FULL_ACCESS))).isEqualTo(expected);
|
||||
|
||||
Set<String> names = catalog.permissionNames();
|
||||
names.clear();
|
||||
assertThat(catalog.permissionNames()).as("permissionNames() is the caller's own copy").isNotEmpty();
|
||||
|
||||
instance.addProcess(new QProcessMetaData().withName("addedLater").withPermissionRules(rules(PermissionLevel.HAS_ACCESS_PERMISSION)));
|
||||
assertThat(PolicyPermissions.allPermissions(instance, Set.of())).contains("addedLater.hasAccess");
|
||||
assertThat(PolicyPermissions.resultFromClaims(claims(document(statement("Allow", List.of("hasAccess"), List.of("addedLater")))), FORMAT, PolicyCatalog.of(instance)).granted())
|
||||
.containsExactly("addedLater.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An allocation budget for one resolution on the large instance: it does not
|
||||
** grow with the number of named permissions (thirty names cost about what one
|
||||
** costs), and it stays under 2 MB. Before the universe was read once, nine
|
||||
** names allocated about 8 MB and thirty about 25 MB on this instance; now
|
||||
** about 0.15 MB whatever the count. The bounds leave room for differences
|
||||
** between JVMs; they catch a return to one universe per term.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theMemoryOneResolutionAllocatesDoesNotGrowWithTheNumberOfNamedPermissions()
|
||||
{
|
||||
assumeTrue(ManagementFactory.getThreadMXBean() instanceof com.sun.management.ThreadMXBean bean && bean.isThreadAllocatedMemorySupported(), "needs per-thread allocation counts");
|
||||
QInstance instance = largeInstance();
|
||||
JSONObject one = namedRole(1);
|
||||
JSONObject thirty = namedRole(30);
|
||||
|
||||
long allocatedForOne = medianAllocatedBytes(() -> PolicyPermissions.resultFromClaims(one, FORMAT, PolicyCatalog.of(instance)));
|
||||
long allocatedForThirty = medianAllocatedBytes(() -> PolicyPermissions.resultFromClaims(thirty, FORMAT, PolicyCatalog.of(instance)));
|
||||
|
||||
assertThat(allocatedForThirty).as("thirty named permissions against one (%s bytes)", allocatedForOne).isLessThan(3 * allocatedForOne);
|
||||
assertThat(allocatedForThirty).as("bytes allocated by one resolution of thirty named permissions").isLessThan(2L * 1024 * 1024);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The median number of bytes this thread allocates for one run of the work,
|
||||
** after the work has run enough to be compiled.
|
||||
*******************************************************************************/
|
||||
private static long medianAllocatedBytes(Supplier<Object> work)
|
||||
{
|
||||
com.sun.management.ThreadMXBean bean = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
|
||||
long thread = Thread.currentThread().threadId();
|
||||
for(int i = 0; i < 200; i++)
|
||||
{
|
||||
assertThat(work.get()).isNotNull();
|
||||
}
|
||||
long[] allocated = new long[21];
|
||||
for(int i = 0; i < allocated.length; i++)
|
||||
{
|
||||
long before = bean.getThreadAllocatedBytes(thread);
|
||||
Object result = work.get();
|
||||
allocated[i] = bean.getThreadAllocatedBytes(thread) - before;
|
||||
assertThat(result).isNotNull();
|
||||
}
|
||||
Arrays.sort(allocated);
|
||||
return (allocated[allocated.length / 2]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user