Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 1d8a2a85c7 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:59:43 -05:00
james.maes eba93e68ca Merge pull request #31 from QRun-IO/feature/health-checks
feat(health): register Authentik API, token, mapper and broker checks with core central health
2026-10-14 21:27:00 -05:00
james.maesandClaude Opus 5.5 0df7bb49e2 Merge origin/develop into feature/health-checks; pin QQQ 4.1.0-RC.8
RC.8 ships the central health kernel (QRun-IO/qqq#1207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:15:11 -05:00
james.maes 8c8ef9fd28 Merge pull request #34 from QRun-IO/feature/help-bundle
feat(help): qbit-authentik help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:29:38 -05:00
james.maesandClaude Opus 5.5 5cf668f8e3 feat(help): ship the qbit-authentik help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:17:00 -05:00
james.maes 590788adf8 Merge pull request #33 from QRun-IO/feature/tables-populated-defaults
feat(help): guided empty states for every authentik table
2026-10-14 17:31:47 -05:00
james.maesandClaude Opus 5.5 24a97823a9 docs: Authentik health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:18 -05:00
james.maesandClaude Opus 5.5 fee7e2ad20 refactor(authentik): Identity home shows central health instead of direct probes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:17 -05:00
james.maesandClaude Opus 5.5 3b117cfff4 feat(health): register Authentik API, token, mapper and broker checks with core central health
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:16 -05:00
james.maesandClaude Opus 5.5 35d9886bbf feat(health): deadline-bounded Authentik client calls for health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:15 -05:00
23 changed files with 2065 additions and 293 deletions

No files matched your search

+11 -1
View File
@@ -4,7 +4,10 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied by the new `AuthentikHelp` stand-in (`STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.7)`), which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes.
- Replace expired token-delivery and credential-scope reflection bridges with the RC.7 APIs; retain conservative permission narrowing and explicit unavailable-delivery tests.
- Ship the expanded, host-neutral QBIT help bundle using QQQ 4.1.0-RC.7 registration and validation, with coverage for optional features and live references. Preserve inline app/table help for hosts without help storage; document HOST bundle overrides.
- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied inline by `AuthentikHelp` as well as registered with core, which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes.
- `AuthentikQBitConfig.fullAccessHostPermissions` lets a host declare permission names core cannot enumerate (custom checkers, named permissions); full access expands to them at use time, for the instance holding that runtime's backend only.
- `AuthentikMaintenance.applyPolicy` lets reviewed host run-once jobs apply a policy to an exact group name through the existing audited Save Policy path. Dedicated maintenance identities, full-access expansion, canonical no-ops, context restoration, stale checks and optional two-person approval are covered; system-session writes remain forbidden. No HTTP route or process is registered.
@@ -14,6 +17,13 @@ All notable changes to this project are documented here. Format: [Keep a Changel
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
### Added (central health)
- `authentik.api`, `authentik.api_token`, `authentik.mapper`, and `authentik.token_broker` dependency checks registered by the producer, with safe details and bounded requests through the existing client.
- Client-credentials broker probe using the existing token transport; its bearer is discarded.
### Removed (central health)
- The Identity home Connection health card, its direct version/token-expiry probes, and its inline mapper status. A single central component status links to System Health. The configuration drift-report widget is retained.
### Added (access tokens 6c)
- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153.
- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked.
+44 -3
View File
@@ -14,7 +14,7 @@ Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065)
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, connection), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, central health), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
Everything is a standard QQQ table, action and widget, so it works in any QQQ UI and over the QQQ API.
@@ -92,7 +92,7 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
| Item | What to do |
|---|---|
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
| QQQ version | QQQ 4.1.0-RC.6 (released BOM, no core SNAPSHOT). Java 21. |
| QQQ version | QQQ 4.1.0-RC.8 (released BOM, including the core health SPI). Java 21. |
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. |
| Tables | Create `authentik_admin_action`, `authentik_approval_request` and `authentik_token_scope` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. |
@@ -128,7 +128,7 @@ Access-token metadata is readable with `authentik.audit.read` or `authentik.toke
The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The temporary session-permission intersection is marked `STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7)`; a reflection bridge also attaches `QCredentialScope` when available. It does not add permissions.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The qbit conservatively narrows session permissions for direct consumers and attaches core's `QCredentialScope` for framework enforcement. It does not add permissions.
The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`.
@@ -136,3 +136,44 @@ The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at
Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added.
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
## Health checks
The producer registers these central checks when Authentik is configured. All use
component `authentik`, category `IDENTITY`, and a 10-second timeout. They reuse the
API client and drift diagnostics, honor the runner deadline, and record only safe
version, expiry-day, and finding-code details.
| Name | Kind | Interval |
| --- | --- | --- |
| `authentik.api` | DEPENDENCY | 300 seconds |
| `authentik.api_token` | DEPENDENCY | 21600 seconds |
| `authentik.mapper` | DEPENDENCY | 21600 seconds |
| `authentik.token_broker` | DEPENDENCY | 300 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
identify one API token; ambiguous tokens or an unavailable expiry report UNKNOWN.
The Identity home links its central status to System Health. The configuration
`driftReport` widget remains available. Disable checks centrally with
`health_check.enabled`; no Authentik table fields are added.
These registrations use the core health SPI released in QQQ 4.1.0-RC.8; no
SNAPSHOT override or enforcer skip is needed.
## In-app help
The qbit ships `src/main/resources/help/qbit-authentik.yaml` and registers it with
QQQ's help bundle registry (QQQ 4.1.0-RC.7). Registration is metadata-only; core
skips startup synchronization when the host has no `helpContent` table. Inline
app and table help remains available without help storage.
To override wording, register a separate bundle with `layer: HOST`, its own bundle
ID and version, and entries with the same keys and roles. HOST entries take
precedence over QBIT entries; do not edit the dependency's resource. Keep deployment
names, navigation and authentication timing in the host bundle.
`AuthentikHelpBundleTest` enables every supported feature and uses
`HelpBundleAssertions` to require coverage and validate keys, summaries and live
references. Run it with `./mvnw test -Dtest=AuthentikHelpBundleTest`.
The bundle version is the literal project version; update it when releasing.
+4 -2
View File
@@ -141,6 +141,7 @@
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-authentik.properties</include>
</includes>
</resource>
@@ -148,6 +149,7 @@
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-authentik.properties</exclude>
</excludes>
</resource>
@@ -239,7 +241,7 @@
<profiles>
<!-- Needs qqq 4.1 (QBit producer API). Until qbit-build-parent imports the -->
<!-- released 4.1 BOM, this profile is active by default and imports -->
<!-- qqq-bom-pom 4.1.0-RC.5 ahead of the parent 4.0.0 BOM, as in -->
<!-- qqq-bom-pom 4.1.0-RC.7 ahead of the parent 4.0.0 BOM, as in -->
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
@@ -247,7 +249,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -24,6 +24,7 @@ import java.util.Properties;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.app.AuthentikRelations;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.health.AuthentikHealthChecks;
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
import com.kingsrook.qbits.authentik.processes.AuthentikProcesses;
@@ -31,6 +32,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
@@ -185,12 +187,15 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
output.addSelfToInstance(qInstance);
AuthentikHealthChecks.register(qInstance, qBitConfig);
addPossibleValueSources(qInstance);
AuthentikRelations.apply(qInstance, qBitConfig);
for(QProcessMetaData process : AuthentikProcesses.processes(qBitConfig))
{
qInstance.addProcess(process);
}
// Registration is metadata-only; core skips startup sync without a helpContent table.
HelpBundles.register(qInstance, "help/qbit-authentik.yaml");
AuthentikHelp.apply(qInstance, qBitConfig);
LOG.info("Authentik QBit produced", logPair("version", VERSION), logPair("appKey", qBitConfig.getAppKey()),
@@ -17,20 +17,18 @@
package com.kingsrook.qbits.authentik.app;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -56,8 +54,6 @@ public class AuthentikHomeData
{
private static final QLogger LOG = QLogger.getLogger(AuthentikHomeData.class);
public static final int TOKEN_WARNING_DAYS = 14;
private boolean peopleAvailable;
private int activePeople;
private int inactivePeople;
@@ -84,8 +80,10 @@ public class AuthentikHomeData
private List<QRecord> recentActions = new ArrayList<>();
private List<String> attention = new ArrayList<>();
private String healthStatus = "UNKNOWN";
private Integer healthComponentId;
private AuthentikQBitConfig config;
private String version;
@@ -104,7 +102,7 @@ public class AuthentikHomeData
data.loadApprovals();
data.loadActions();
data.loadAttention(util);
data.version = util.fetchServerVersion().orElse(null);
data.loadHealth();
return (data);
}
@@ -240,8 +238,7 @@ public class AuthentikHomeData
/*******************************************************************************
** Things that need a person: drift findings, blueprint state, tokens about
** to expire, bootstrap operators still set.
** Configuration review and bootstrap operators still set. Health is central.
*******************************************************************************/
private void loadAttention(AuthentikActionUtil util)
{
@@ -275,26 +272,6 @@ public class AuthentikHomeData
LOG.info("Identity home: drift report unavailable", logPair("error", e.getClass().getSimpleName()));
}
}
if(mayAudit)
{
try
{
Instant soon = Instant.now().plus(TOKEN_WARNING_DAYS, ChronoUnit.DAYS);
for(JSONObject token : util.getAllPages("/core/tokens/?user__username=" + java.net.URLEncoder.encode(config.getEffectiveServiceAccountUsername(), java.nio.charset.StandardCharsets.UTF_8), 50))
{
Instant expires = AuthentikJson.parseInstant(token.optString("expires", null));
if(token.optBoolean("expiring", true) && expires != null && expires.isBefore(soon))
{
attention.add("The qbit's API token (" + token.optString("identifier") + ") expires on "
+ java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(java.time.ZoneOffset.UTC).format(expires) + ": rotate it (owner checklist).");
}
}
}
catch(Exception e)
{
LOG.debug("Identity home: token expiry not checked", e);
}
}
}
@@ -320,11 +297,51 @@ public class AuthentikHomeData
/*******************************************************************************
** Getter for version
** Read the central rollup using the current session's table permissions.
*******************************************************************************/
public String getVersion()
private void loadHealth()
{
return (version);
if(QContext.getQInstance().getTable("health_component") == null)
{
return;
}
try
{
QueryInput query = new QueryInput();
query.setTableName("health_component");
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
query.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "authentik")).withLimit(1));
List<QRecord> records = new QueryAction().execute(query).getRecords();
if(!records.isEmpty())
{
healthStatus = com.kingsrook.qqq.backend.core.health.HealthStatus.valueOf(records.getFirst().getValueString("status")).name();
healthComponentId = records.getFirst().getValueInteger("id");
}
}
catch(Exception e)
{
LOG.debug("Identity home: central health unavailable");
}
}
/*******************************************************************************
** Getter for the central component status.
*******************************************************************************/
public String getHealthStatus()
{
return (healthStatus);
}
/*******************************************************************************
** Getter for the central component record link.
*******************************************************************************/
public Integer getHealthComponentId()
{
return (healthComponentId);
}
@@ -66,7 +66,7 @@ public final class AuthentikHomeView
people(html, data, config);
groups(html, data, config);
activity(html, data, config);
connection(html, data);
health(html, data);
return (html.append("</div>").toString());
}
@@ -204,31 +204,12 @@ public final class AuthentikHomeView
/*******************************************************************************
**
*******************************************************************************/
private static void connection(StringBuilder html, AuthentikHomeData data)
private static void health(StringBuilder html, AuthentikHomeData data)
{
open(html, "Connection");
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Authentik", data.getConfig().getBaseUrl());
row(html, "Version", data.getVersion() == null ? "Not reachable" : data.getVersion());
row(html, "Service account", data.getConfig().getEffectiveServiceAccountUsername());
row(html, "Changes", Boolean.TRUE.equals(data.getConfig().getReadOnly()) ? "Off (read-only)" : "On, every change is audited here and in Authentik");
if(data.getConfig().hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.POLICIES))
{
String state;
try
{
com.kingsrook.qbits.authentik.permissions.AccessComparison.State mapper = com.kingsrook.qbits.authentik.permissions.AccessComparison.state(
com.kingsrook.qbits.authentik.AuthentikRuntime.forBackend(data.getConfig().getApiBackendName()),
com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(data.getConfig().getApiBackendName()));
state = (mapper.enforced() ? "Enforced. " : "Not enforced. ") + mapper.message();
}
catch(Exception e)
{
state = "Unknown (" + e.getMessage() + ")";
}
row(html, "Policies at sign-in", state);
}
html.append("</dl>");
open(html, "Health");
String target = prefix(data.getConfig()) + "/health_component" + (data.getHealthComponentId() == null ? "" : "/" + data.getHealthComponentId());
html.append("<p>Authentik: ").append(esc(data.getHealthStatus())).append(" - <a href=\"")
.append(esc(target)).append("\">System Health</a></p>");
close(html);
}
@@ -64,6 +64,7 @@ import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.apache.http.Header;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.NameValuePair;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
@@ -75,7 +76,6 @@ import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.util.EntityUtils;
import org.json.JSONArray;
import org.json.JSONException;
@@ -116,6 +116,8 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/** Display value for derived fields when the group graph cannot be read. */
public static final String UNAVAILABLE = "Unavailable (Authentik groups could not be read)";
private java.time.Instant deadline;
private EffectivePermissionResolver resolverForCall;
@@ -138,6 +140,94 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** Bound this dedicated health client; ordinary action clients are unchanged.
*******************************************************************************/
public AuthentikActionUtil withHealthDeadline(java.time.Instant deadline) throws QException
{
runtime().getGuard().checkHealth();
this.deadline = java.util.Objects.requireNonNull(deadline);
remainingMillis();
return (this);
}
/*******************************************************************************
** Remaining health budget; zero must never become an unlimited HTTP timeout.
*******************************************************************************/
private int remainingMillis() throws QException
{
long remaining = deadline == null ? Integer.MAX_VALUE : java.time.Duration.between(java.time.Instant.now(), deadline).toMillis();
if(remaining <= 0 || (deadline != null && Thread.currentThread().isInterrupted()))
{
throw (new QException("Authentik health deadline exceeded"));
}
return ((int) Math.min(Integer.MAX_VALUE, remaining));
}
/*******************************************************************************
** Configured zero means unlimited normally, but health always has a deadline.
*******************************************************************************/
private static int healthTimeout(int remaining, int configured)
{
return (configured <= 0 ? remaining : Math.min(remaining, configured));
}
/*******************************************************************************
** Keep retry delays inside the health budget and preserve cancellation.
*******************************************************************************/
private void retrySleep(long millis) throws QException
{
if(deadline == null)
{
SleepUtils.sleep(millis, java.util.concurrent.TimeUnit.MILLISECONDS);
return;
}
if(millis >= remainingMillis())
{
throw (new QException("Authentik health deadline exceeded"));
}
try
{
Thread.sleep(millis);
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Authentik health check interrupted"));
}
}
/*******************************************************************************
** Read the service account API token metadata through the existing paged path.
*******************************************************************************/
public List<JSONObject> getServiceAccountApiTokens() throws QException
{
runtime().getGuard().checkHealth();
return (getAllPages("/core/tokens/?intent=api&user__username=" + java.net.URLEncoder.encode(
runtime().getConfig().getEffectiveServiceAccountUsername(), StandardCharsets.UTF_8), 50));
}
/*******************************************************************************
** Broker credentials use the same secret-safe request loop as token exchange.
*******************************************************************************/
JSONObject requestBrokerCredentials(HttpPost request) throws QException
{
runtime().getGuard().checkHealth();
return (brokerResponse(request));
}
/*******************************************************************************
** The runtime for the backend this util serves.
*******************************************************************************/
@@ -463,6 +553,16 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public JSONObject exchangeToken(WriteAuthorization auth, HttpPost request) throws QException
{
checkAuthorization(auth);
return (brokerResponse(request));
}
/*******************************************************************************
** Parse a broker response without exposing its body.
*******************************************************************************/
private JSONObject brokerResponse(HttpPost request) throws QException
{
if(!request.getURI().toString().equals(runtime().getConfig().getTokenEndpointUrl()))
{
throw (new QException("Bad token endpoint"));
@@ -826,7 +926,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public GroupGraph groupGraph() throws QException
{
AuthentikRuntime runtime = runtime();
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
if(cache)
{
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("all").map(m -> m.getResult());
@@ -880,7 +980,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public GroupGraph groupGraphWithMembers() throws QException
{
AuthentikRuntime runtime = runtime();
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
if(cache)
{
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("members").map(m -> m.getResult());
@@ -1302,7 +1402,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
private String getBody(String url, boolean nullOn404) throws QException
{
AuthentikRuntime runtime = runtime();
if(runtime.isCacheEnabled())
if(deadline == null && runtime.isCacheEnabled())
{
Optional<String> cached = runtime.getResponseCache().getMemoizedResult(url).map(m -> m.getResult());
if(cached.isPresent())
@@ -1322,7 +1422,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
String body = response.getContent();
if(runtime.isCacheEnabled() && body != null)
if(deadline == null && runtime.isCacheEnabled() && body != null)
{
runtime.getResponseCache().storeResult(url, body);
}
@@ -1376,7 +1476,20 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
while(true)
{
attempts++;
runtime().getThrottle().acquire();
remainingMillis();
if(deadline == null)
{
runtime().getThrottle().acquire();
}
else
{
runtime().getThrottle().acquire(deadline);
int remaining = remainingMillis();
request.setConfig(RequestConfig.custom()
.setConnectTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
.setConnectionRequestTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
.setSocketTimeout(healthTimeout(remaining, runtime().getConfig().getSocketTimeoutMillis())).build());
}
if(!exchange)
{
request.setHeader("Authorization", "Bearer " + token);
@@ -1392,6 +1505,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
try
{
response = send(request);
remainingMillis();
}
catch(Exception e)
{
@@ -1399,7 +1513,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
LOG.warn("Authentik API call failed", logPair("method", request.getMethod()), logPair("path", path), logPair("error", message));
if(attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(backoffMs);
backoffMs *= 2;
continue;
}
@@ -1412,17 +1526,20 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
logOutboundCall(request, path, response, token);
}
checkVersionOnce(status);
if(deadline == null)
{
checkVersionOnce(status);
}
if(status == 429 && attempts <= MAX_RETRIES)
{
SleepUtils.sleep(retryAfterMillis(response, backoffMs), java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(retryAfterMillis(response, backoffMs));
backoffMs *= 2;
continue;
}
if(status >= 500 && attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
{
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
retrySleep(backoffMs);
backoffMs *= 2;
continue;
}
@@ -58,4 +58,24 @@ public class AuthentikTokenBroker
}
return (bearer);
}
/*******************************************************************************
** Request client credentials for health, then discard the bearer immediately.
*******************************************************************************/
public void probeClientCredentials(AuthentikActionUtil util) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
request.setEntity(new UrlEncodedFormEntity(List.of(
new BasicNameValuePair("grant_type", "client_credentials"),
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret()),
new BasicNameValuePair("scope", "goauthentik.io/api")), StandardCharsets.UTF_8));
if(util.requestBrokerCredentials(request).optString("access_token", "").isBlank())
{
throw (new QUserFacingException("Authentik broker returned no access token"));
}
}
}
@@ -90,6 +90,30 @@ public class TokenBucketThrottle
/*******************************************************************************
** Reserve a token only when the health deadline permits the throttle delay.
*******************************************************************************/
public void acquire(java.time.Instant deadline) throws com.kingsrook.qqq.backend.core.exceptions.QException
{
long waitNanos;
synchronized(this)
{
refill();
waitNanos = (long) Math.ceil(Math.max(0, 1 - tokens) / tokensPerNano);
if(Thread.currentThread().isInterrupted() || waitNanos >= java.time.Duration.between(java.time.Instant.now(), deadline).toNanos())
{
throw (new com.kingsrook.qqq.backend.core.exceptions.QException("Authentik health deadline exceeded"));
}
tokens -= 1;
}
if(waitNanos > 0)
{
nanoSleeper.accept(waitNanos);
}
}
/*******************************************************************************
** Tokens currently available (may be negative while callers are waiting).
*******************************************************************************/
@@ -32,9 +32,9 @@ import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
import org.json.JSONArray;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
@@ -98,6 +98,19 @@ public class AuthentikDriftReport
/*******************************************************************************
** Reuse only mapper attributes and service-account lockout diagnostics.
*******************************************************************************/
public List<DriftFinding> runMapperChecks()
{
List<DriftFinding> findings = new ArrayList<>();
runCheck(findings, "group attributes", this::checkGroupAttributes);
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
return (findings);
}
/*******************************************************************************
** A check.
*******************************************************************************/
@@ -0,0 +1,194 @@
/*
* 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.health;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
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.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthDetails;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONObject;
/*******************************************************************************
** Central identity checks reuse the qbit client and configuration diagnostics.
*******************************************************************************/
public final class AuthentikHealthChecks
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHealthChecks()
{
}
/*******************************************************************************
** Called only after the producer has validated and registered the runtime.
*******************************************************************************/
public static void register(QInstance instance, AuthentikQBitConfig config)
{
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
for(String name : List.of("api", "api_token", "mapper", "token_broker"))
{
registry.withCheck(new HealthCheckMetaData().withName("authentik." + name).withLabel("Authentik " + name)
.withComponent("authentik").withCategory(HealthCategory.IDENTITY).withKind(HealthCheckKind.DEPENDENCY)
.withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : 21600)
.withTimeoutMs(10000).withCheckInstance(input ->
{
try
{
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName()).withHealthDeadline(input.getDeadline());
HealthCheckOutcome outcome;
switch(name)
{
case "api" -> outcome = api(util, config);
case "api_token" -> outcome = apiToken(util, Instant.now());
case "mapper" -> outcome = mapper(util, config);
default -> outcome = tokenBroker(util, config);
}
return (Instant.now().isBefore(input.getDeadline()) && !Thread.currentThread().isInterrupted()
? outcome : HealthCheckOutcome.down("Authentik health deadline exceeded"));
}
catch(Exception e)
{
// Never copy exception messages, URLs or credential values into outcomes.
return (HealthCheckOutcome.down("Authentik health check failed"));
}
}));
}
}
/*******************************************************************************
** Version compatibility follows the existing drift report's prefix rule.
*******************************************************************************/
private static HealthCheckOutcome api(AuthentikActionUtil util, AuthentikQBitConfig config)
{
String version = util.fetchServerVersion().orElse("");
if(!version.matches("[0-9]{1,4}(\\.[0-9]{1,4}){1,3}"))
{
return (HealthCheckOutcome.down("Authentik server version unavailable"));
}
String expected = config.getExpectedServerVersion();
boolean matches = !StringUtils.hasContent(expected) || version.equals(expected) || version.startsWith(expected + ".");
return ((matches ? HealthCheckOutcome.up("Authentik API reachable") : HealthCheckOutcome.degraded("Authentik server version differs"))
.withDetails(new HealthDetails().withValue("serverVersion", version)));
}
/*******************************************************************************
** The service account should have one API token; never read its key.
*******************************************************************************/
private static HealthCheckOutcome apiToken(AuthentikActionUtil util, Instant now) throws QException
{
List<JSONObject> tokens = util.getServiceAccountApiTokens();
if(tokens.isEmpty())
{
return (HealthCheckOutcome.down("API token missing"));
}
if(tokens.size() != 1)
{
return (HealthCheckOutcome.unknown("API token cannot be identified uniquely"));
}
JSONObject token = tokens.getFirst();
if(!token.optBoolean("expiring", true))
{
return (HealthCheckOutcome.up("API token present without expiry"));
}
Instant expires;
try
{
expires = AuthentikJson.parseInstant(token.optString("expires", null));
}
catch(java.time.format.DateTimeParseException e)
{
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
}
if(expires == null)
{
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
}
HealthCheckOutcome outcome = !expires.isAfter(now) ? HealthCheckOutcome.down("API token expired")
: expires.isBefore(now.plus(14, ChronoUnit.DAYS)) ? HealthCheckOutcome.degraded("API token expires within 14 days")
: HealthCheckOutcome.up("API token present");
return (outcome.withDetails(new HealthDetails().withValue("daysToExpiry", ChronoUnit.DAYS.between(now, expires))));
}
/*******************************************************************************
** Findings carry fixed codes only, never subjects or diagnostic messages.
*******************************************************************************/
private static HealthCheckOutcome mapper(AuthentikActionUtil util, AuthentikQBitConfig config)
{
HealthStatus status = HealthStatus.UP;
TreeSet<String> codes = new TreeSet<>();
for(DriftFinding finding : new AuthentikDriftReport(util, config).runMapperChecks())
{
if(finding.severity() == DriftFinding.Severity.ERROR || finding.severity() == DriftFinding.Severity.WARNING)
{
String code = "group attributes".equals(finding.check()) ? "group_attributes" : "service_account_activity";
codes.add(code + "." + finding.severity().name().toLowerCase(java.util.Locale.ROOT));
if(finding.severity() == DriftFinding.Severity.ERROR)
{
status = HealthStatus.DOWN;
}
else if(status != HealthStatus.DOWN)
{
status = HealthStatus.DEGRADED;
}
}
}
return (new HealthCheckOutcome().withStatus(status).withMessage(status == HealthStatus.UP ? "Mapper checks passed" : "Mapper drift detected")
.withDetails(new HealthDetails().withValue("findingCodes", String.join(",", codes))));
}
/*******************************************************************************
** Authenticate through the existing broker transport, retaining no bearer.
*******************************************************************************/
private static HealthCheckOutcome tokenBroker(AuthentikActionUtil util, AuthentikQBitConfig config) throws QException
{
if(!StringUtils.hasContent(config.getTokenBrokerClientId()) || !StringUtils.hasContent(config.getTokenBrokerClientSecret()))
{
return (HealthCheckOutcome.unknown("not configured"));
}
new AuthentikTokenBroker().probeClientCredentials(util);
return (HealthCheckOutcome.up("Token broker authenticated"));
}
}
@@ -17,17 +17,16 @@
package com.kingsrook.qbits.authentik.metadata;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
@@ -40,8 +39,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
**
** The bundle's keys use the default table prefix; a host's own tableNamePrefix
** is applied to them, so renamed tables get the same text.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.7): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
** Inline fallback for hosts without help storage; core owns bundle validation
** and the producer also registers the bundle for startup synchronization.
*******************************************************************************/
public final class AuthentikHelp
{
@@ -66,33 +65,14 @@ public final class AuthentikHelp
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = AuthentikHelp.class.getResourceAsStream(RESOURCE))
Map<String, JsonNode> entries = new LinkedHashMap<>();
var bundle = HelpBundles.load(RESOURCE.substring(1), AuthentikHelp.class.getClassLoader());
ObjectMapper mapper = new ObjectMapper();
for(var entry : bundle.getEntries())
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-authentik".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
entries.put(entry.getKey(), mapper.valueToTree(entry));
}
return (entries);
}
@@ -103,25 +83,37 @@ public final class AuthentikHelp
*******************************************************************************/
public static void apply(QInstance qInstance, AuthentikQBitConfig config) throws QException
{
for(var bundle : HelpBundles.of(qInstance).getBundles())
{
if("qbit-authentik".equals(bundle.getId()))
{
for(var entry : bundle.getEntries())
{
entry.setKey(configuredNames(entry.getKey(), config));
entry.setSummary(configuredNames(entry.getSummary(), config));
entry.setContent(configuredNames(entry.getContent(), config));
}
}
}
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
{
String key = entry.getKey();
if(key.startsWith("table:"))
if(key.startsWith("table:") && key.endsWith(";slot:help"))
{
String tableKey = key.substring("table:".length(), key.indexOf(';'));
String unprefixed = tableKey.substring(DEFAULT_TABLE_PREFIX.length());
QTableMetaData table = qInstance.getTable(config.applyTablePrefix(unprefixed));
if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry.getValue()));
table.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
else if(key.startsWith("app:"))
else if(key.startsWith("app:") && key.endsWith(";slot:help"))
{
QAppMetaData app = qInstance.getApp(config.applyPrefix(AuthentikAppMetaDataProducer.APP_NAME));
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(entry.getValue()));
app.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
}
@@ -133,9 +125,35 @@ public final class AuthentikHelp
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
static QHelpContent content(JsonNode entry, AuthentikQBitConfig config)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
return (new QHelpContent().withContentAsMarkdown(configuredNames(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip(), config)));
}
/*******************************************************************************
** Follow the host prefix in keys and live references; status enums are fixed.
*******************************************************************************/
private static String configuredNames(String text, AuthentikQBitConfig config)
{
if(text == null)
{
return (null);
}
return (Pattern.compile("authentik_[a-zA-Z0-9_]+|authentik[A-Z][a-zA-Z0-9]*").matcher(text).replaceAll(match ->
{
String name = match.group();
if(name.equals("authentik_action_status") || name.equals("authentik_approval_status"))
{
return (name);
}
if(name.startsWith(DEFAULT_TABLE_PREFIX))
{
return (config.applyTablePrefix(name.substring(DEFAULT_TABLE_PREFIX.length())));
}
return (config.applyPrefix(name.substring("authentik".length())));
}));
}
}
@@ -19,6 +19,7 @@ package com.kingsrook.qbits.authentik.processes;
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
/*******************************************************************************
@@ -43,11 +44,11 @@ public interface TokenKeyDelivery
/*******************************************************************************
** Current adapter; null restores the safe reflection adapter.
** Current adapter; null restores the core response-only adapter.
*******************************************************************************/
static void set(TokenKeyDelivery delivery)
{
Holder.delivery = delivery == null ? new ReflectiveDelivery() : delivery;
Holder.delivery = delivery == null ? new CoreDelivery() : delivery;
}
@@ -90,7 +91,7 @@ public interface TokenKeyDelivery
*******************************************************************************/
final class Holder
{
private static volatile TokenKeyDelivery delivery = new ReflectiveDelivery();
private static volatile TokenKeyDelivery delivery = new CoreDelivery();
/*******************************************************************************
** Utility holder.
@@ -103,56 +104,18 @@ public interface TokenKeyDelivery
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7)
** Reflection keeps RC.2 binary-compatible and never falls back to values.
** Core response-only delivery; credentials never enter process values.
*******************************************************************************/
final class ReflectiveDelivery implements TokenKeyDelivery
final class CoreDelivery implements TokenKeyDelivery
{
/*******************************************************************************
** Check the complete core contract before any irreversible key mutation.
*******************************************************************************/
@Override
public boolean available()
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
type.getConstructor();
for(String field : new String[] { "Label", "Value", "Masked", "UsageText" })
{
type.getMethod("with" + field, String.class);
}
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type);
return (true);
}
catch(ReflectiveOperationException e)
{
return (false);
}
}
/*******************************************************************************
** Add to core's response-only channel; never include failures' contents.
** Add to core's response-only channel.
*******************************************************************************/
@Override
public void deliver(RunBackendStepOutput output, OneTimeKey key)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
Object secret = type.getConstructor().newInstance();
type.getMethod("withLabel", String.class).invoke(secret, key.label());
type.getMethod("withValue", String.class).invoke(secret, key.value());
type.getMethod("withMasked", String.class).invoke(secret, key.masked());
type.getMethod("withUsageText", String.class).invoke(secret, key.usageText());
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type).invoke(output, secret);
}
catch(ReflectiveOperationException e)
{
throw (new IllegalStateException("One-time output unavailable"));
}
output.addOneTimeSecret(new OneTimeSecret().withLabel(key.label()).withValue(key.value())
.withMasked(key.masked()).withUsageText(key.usageText()));
}
}
}
@@ -20,8 +20,8 @@ package com.kingsrook.qbits.authentik.security;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.authentik.AuthentikFeature;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.admin.AdminAction;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
@@ -65,6 +65,21 @@ public class AuthentikGuard
/*******************************************************************************
** Health probes expose only safe outcomes, including to the central runner.
*******************************************************************************/
public void checkHealth() throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
requireUser(session);
if(!has(session, IdentityPermission.ADMIN) && (session.getPermissions() == null || !session.getPermissions().contains("health.read")))
{
throw (new QPermissionDeniedException("Health read permission is required."));
}
}
/*******************************************************************************
** Check that the current session may read a table.
*******************************************************************************/
@@ -17,11 +17,9 @@
package com.kingsrook.qbits.authentik.security;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
@@ -39,6 +37,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
@@ -262,7 +261,7 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7): intersect, never grant.
** Intersect permissions for direct session consumers and attach core scope.
*******************************************************************************/
public static void narrow(QSession session, TokenScope scope)
{
@@ -272,8 +271,8 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** Keep only permitted bases and read operations, then attach core's scope
** when available. Management hasAccess permissions are not read operations.
** Keep conservative permission narrowing for direct session consumers, then
** attach core scope for framework enforcement. Management is not read-only.
*******************************************************************************/
private static void narrow(QInstance instance, QSession session, TokenScope scope)
{
@@ -410,10 +409,6 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** Optional core bridge. An incompatible installed contract fails closed;
** an absent class uses the permission intersection above.
*******************************************************************************/
/*******************************************************************************
** An empty scope dimension means "not restricted" (null for core).
*******************************************************************************/
@@ -425,51 +420,18 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1168, removeBy=4.1.0-RC.7): until the qbit's QQQ
** version has QCredentialScope (qqq#1153), narrow() above enforces the scope
** by intersecting the session's permissions; this attaches the core scope by
** reflection when it exists. Replace both with a direct withCredentialScope.
** Attach the released core scope contract; a revoked token allows nothing.
*******************************************************************************/
private static void attachCoreScope(QSession session, TokenScope scope)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.session.QCredentialScope");
Object value = type.getConstructor().newInstance();
///////////////////////////////////////////////////////////////////////
// core treats a null set as unrestricted and an empty set as //
// "nothing": an unlisted dimension must therefore be passed as null //
///////////////////////////////////////////////////////////////////////
Map<String, Object> fields = new java.util.LinkedHashMap<>();
fields.put("CredentialId", scope.identifier());
fields.put("ReadOnly", scope.readOnly());
fields.put("Apps", nullIfEmpty(scope.apps()));
fields.put("Tables", nullIfEmpty(scope.tables()));
fields.put("Processes", nullIfEmpty(scope.processes()));
fields.put("Reports", nullIfEmpty(scope.reports()));
fields.put("Widgets", nullIfEmpty(scope.widgets()));
fields.put("OperationIds", nullIfEmpty(scope.operationIds()));
for(var field : fields.entrySet())
{
Method setter = Arrays.stream(type.getMethods()).filter(m -> m.getName().equals("with" + field.getKey()) && m.getParameterCount() == 1)
.findFirst().orElseThrow(NoSuchMethodException::new);
Object arg = field.getValue();
if(arg instanceof Set<?> set && List.class.isAssignableFrom(setter.getParameterTypes()[0]))
{
arg = List.copyOf(set);
}
setter.invoke(value, arg);
}
session.getClass().getMethod("withCredentialScope", type).invoke(session, value);
}
catch(ClassNotFoundException ignored)
{
// RC.2 has no credential scope; the stand-in above is active.
}
catch(ReflectiveOperationException | IllegalArgumentException e)
{
session.setPermissions(new TreeSet<>());
LOG.warn("The installed credential-scope contract is incompatible; denying credential permissions.");
}
boolean denied = scope.mode() == Mode.DENY_ALL;
session.withCredentialScope(new QCredentialScope().withCredentialId(scope.identifier())
.withReadOnly(denied || scope.readOnly())
.withApps(denied ? Set.of() : nullIfEmpty(scope.apps()))
.withTables(denied ? Set.of() : nullIfEmpty(scope.tables()))
.withProcesses(denied ? Set.of() : nullIfEmpty(scope.processes()))
.withReports(denied ? Set.of() : nullIfEmpty(scope.reports()))
.withWidgets(denied ? Set.of() : nullIfEmpty(scope.widgets()))
.withOperationIds(denied ? Set.of() : nullIfEmpty(scope.operationIds())));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,77 @@
/*
* 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.EnumSet;
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Coverage of the shipped help with all supported optional metadata present.
*******************************************************************************/
class AuthentikHelpBundleTest
{
/*******************************************************************************
** New objects and non-trivial fields must ship with valid help.
*******************************************************************************/
@Test
void bundleCoversOwnedMetadata() throws Exception
{
var support = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS,
AuthentikFeature.POLICIES, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.DELETE,
AuthentikFeature.MEMBERSHIP, AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS));
HelpBundleAssertions.forInstance(support.qInstance)
.bundles("help/qbit-authentik.yaml")
.owning(target -> target.name().startsWith("authentik"))
.assertComplete();
var bundle = HelpBundles.of(support.qInstance).getBundles().get(0);
assertThat(bundle.getVersion()).isEqualTo(AuthentikQBitProducer.getVersion());
var keys = bundle.getEntries().stream().map(entry -> entry.getKey()).toList();
assertThat(keys).doesNotHaveDuplicates();
// Core's gate currently requires apps, tables, actions and table fields.
// Also pin coverage for this qbit's widgets and possible-value sources.
support.qInstance.getWidgets().keySet().stream().filter(name -> name.startsWith("authentik"))
.forEach(name -> assertThat(keys).contains("widget:" + name + ";slot:label"));
support.qInstance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("authentik"))
.forEach(name -> assertThat(keys).contains("possibleValueSource:" + name + ";slot:help"));
}
/*******************************************************************************
** Metadata-only registration is safe without help storage and follows prefixes.
*******************************************************************************/
@Test
void registersWithoutHelpStorage() throws Exception
{
var instance = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig().withTableNamePrefix("idp"));
assertThat(instance.getTable("helpContent")).isNull();
HelpBundles.syncOnStartup(instance);
var bundles = HelpBundles.of(instance).getBundles();
assertThat(bundles).hasSize(1);
assertThat(bundles.get(0).getEntries()).anySatisfy(entry ->
{
assertThat(entry.getKey()).isEqualTo("table:idp_user;slot:help");
assertThat(entry.getContent()).contains("{{label:idp_user}}").doesNotContain("{{label:authentik");
});
}
}
@@ -24,7 +24,6 @@ import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
@@ -160,11 +159,7 @@ class AuthentikTablesGuardrailTest
}
}
assertThat(instance.getApp("authentikAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT);
for(Map.Entry<String, JsonNode> entry : AuthentikHelp.entries().entrySet())
{
String key = entry.getKey();
assertThat(key.startsWith("app:") || STARTS_EMPTY.containsKey(key.substring(6, key.indexOf(';')))).as("help names only real tables: " + key).isTrue();
}
}
@@ -175,7 +175,7 @@ class PolicyCutoverTest
assertThat(PolicyCutoverWidgets.bannerHtml(new AccessComparison.State(AccessComparison.Status.ACTIVE, "ok <b>", java.time.Instant.now()))).contains("Enforced.").contains("ok &lt;b&gt;");
String home = com.kingsrook.qbits.authentik.app.AuthentikHomeView.render(com.kingsrook.qbits.authentik.app.AuthentikHomeData.load(t.config));
assertThat(home).contains("Policies at sign-in").contains("mapper off");
assertThat(home).contains("System Health").doesNotContain("Policies at sign-in");
}
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
@@ -299,7 +300,26 @@ class TokenManagementTest
t.config.withTokenBrokerClientSecret("test-secret").withSubjectTokenSource(session -> null);
refused(create(), "signed-in Authentik session");
t.config.withSubjectTokenSource(session -> "subject:3");
TokenKeyDelivery.set(null);
TokenKeyDelivery.set(new TokenKeyDelivery()
{
/*******************************************************************************
** Simulate a host without a usable response channel.
*******************************************************************************/
@Override
public boolean available()
{
return (false);
}
/*******************************************************************************
** Delivery must never be attempted while unavailable.
*******************************************************************************/
@Override
public void deliver(RunBackendStepOutput output, OneTimeKey key)
{
throw (new AssertionError("Unavailable delivery was called"));
}
});
refused(create(), "upgrade QQQ");
seed("own", 3);
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "own", "replace"), "upgrade QQQ");
@@ -530,4 +550,21 @@ class TokenManagementTest
assertThat(t.qInstance.getTable("authentik_token_scope").getFields()).doesNotContainKey("key");
assertThat(t.config.toString()).doesNotContain("test-broker-secret");
}
/*******************************************************************************
** RC.7 delivers credentials only through the response-only secret channel.
*******************************************************************************/
@Test
void coreOneTimeDelivery()
{
TokenKeyDelivery.set(null);
RunBackendStepOutput output = new RunBackendStepOutput();
OneTimeKey key = new OneTimeKey("Token", "test-key-not-a-credential", "Copy once");
assertThat(TokenKeyDelivery.get().available()).isTrue();
assertThat(TokenKeyDelivery.tryDeliver(output, key)).isTrue();
assertThat(output.getOneTimeSecrets()).hasSize(1);
assertThat(output.getOneTimeSecrets().get(0).getValue()).isEqualTo(key.value());
assertThat(output.getValues()).doesNotContainValue(key.value());
}
}
@@ -121,11 +121,15 @@ class TokenScopesTest
JSONObject claims = new JSONObject().put("qqq_token", "limited");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactlyInAnyOrder("cms.publish.hasAccess", "post.read");
assertThat(session.getCredentialScope().getReadOnly()).isTrue();
assertThat(session.getCredentialScope().getTables()).isNull();
t.authentik.tokens.get("sam").clear();
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, claims).orElseThrow().mode()).isEqualTo(Mode.DENY_ALL);
assertThat(session.getCredentialScope().getTables()).isEmpty();
assertThat(session.getCredentialScope().getOperationIds()).isEmpty();
assertThat(new TokenScopeStore(t.config).find("limited")).isNotNull();
}
@@ -137,7 +137,7 @@ class AuthentikAppTest
RenderWidgetOutput output = ((AbstractWidgetRenderer) new AuthentikHomeWidget.Renderer()).render(input);
String html = ((RawHTML) output.getWidgetData()).getHtml();
assertThat(html).contains("Needs attention", "People", "Groups and permissions", "Recent changes", "Connection");
assertThat(html).contains("Needs attention", "People", "Groups and permissions", "Recent changes", "Health", "System Health");
assertThat(html).contains("Active</dt><dd style=\"margin:0;\">4<");
assertThat(html).contains("Inactive</dt><dd style=\"margin:0;\">1<");
assertThat(html).contains("Service accounts</dt><dd style=\"margin:0;\">2<");
@@ -154,6 +154,28 @@ class AuthentikAppTest
/*******************************************************************************
** Home reads the persisted central component and links directly to its record.
*******************************************************************************/
@Test
void centralHealthRollup() throws Exception
{
AdminTestSupport t = AdminTestSupport.create();
new com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider().defineAll(t.qInstance, AdminTestSupport.LOCAL, table -> { });
com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore.getInstance().insert(
new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput().withTableName("health_component")
.withRecord(new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("id", 7).withValue("name", "authentik").withValue("status", "DEGRADED")), true);
AuthentikTestSupport.loginWithPermissions(t.qInstance, java.util.Set.of("health.read"));
AuthentikHomeData data = AuthentikHomeData.load(t.config);
assertThat(data.getHealthStatus()).isEqualTo("DEGRADED");
assertThat(AuthentikHomeView.render(data)).contains("Authentik: DEGRADED", "href=\"/app/health_component/7\"")
.doesNotContain("Policies at sign-in", "Version</dt>");
AuthentikTestSupport.loginWithPermissions(t.qInstance, java.util.Set.of());
assertThat(AuthentikHomeData.load(t.config).getHealthStatus()).isEqualTo("UNKNOWN");
}
/*******************************************************************************
** Nothing but AuthentikAdmin may send a change to Authentik: the util's
** write method is only called from there (the guard's authorization is the
@@ -0,0 +1,391 @@
/*
* 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.health;
import java.time.Instant;
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.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
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.QInstance;
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.util.EntityUtils;
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 org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Offline probes run under the central runner's health-only permissions.
*******************************************************************************/
class AuthentikHealthChecksTest
{
private static final String SECRET = "S3NT1NEL-secret";
private AuthentikQBitConfig config;
private QInstance instance;
/*******************************************************************************
** Install the existing send seam with poisoned credentials.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
FixtureActionUtil.reset();
config = AuthentikTestSupport.unitConfig().withApiToken(SECRET).withTokenBrokerClientId(SECRET)
.withTokenBrokerClientSecret(SECRET).withGroupCacheTtlSeconds(0);
instance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(instance, config, HealthActionUtil.class);
AuthentikTestSupport.loginWithPermissions(instance, Set.of("health.read", "health.write"));
FixtureActionUtil.handler = request -> FixtureActionUtil.response(200, FixtureActionUtil.EMPTY_PAGE);
}
/*******************************************************************************
** Do not leak static fixture state or thread context between tests.
*******************************************************************************/
@AfterEach
void tearDown()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
** Metadata is registered by production, with no probes during registration.
*******************************************************************************/
@Test
void registration() throws QException
{
var checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
assertThat(checks).extracting(c -> c.getName()).containsExactly("authentik.api", "authentik.api_token", "authentik.mapper", "authentik.token_broker");
assertThat(checks).allSatisfy(c ->
{
assertThat(c.getComponent()).isEqualTo("authentik");
assertThat(c.getCategory()).isEqualTo(HealthCategory.IDENTITY);
assertThat(c.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
assertThat(c.getIntervalSeconds()).isEqualTo(c.getName().equals("authentik.api") || c.getName().equals("authentik.token_broker") ? 300 : 21600);
assertThat(c.getTimeoutMs()).isEqualTo(10000);
});
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
assertThat(instance.getWidget("authentikDriftReport")).isNotNull();
QInstance empty = new QInstance();
assertThat(new AuthentikQBitProducer().isEnabled()).isFalse();
assertThatThrownBy(() -> new AuthentikQBitProducer().produce(empty)).isInstanceOf(QException.class);
assertThat(HealthChecksMetaData.ofOrWithNew(empty).getChecks()).isEmpty();
}
/*******************************************************************************
** Version responses are allowlisted before entering details.
*******************************************************************************/
@Test
void apiVersions() throws QException
{
respond(new JSONObject().put("version_current", "2026.8.3"));
assertThat(run("api").getDetails().getValues()).containsEntry("serverVersion", "2026.8.3");
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.UP);
config.withExpectedServerVersion("2025.10");
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DEGRADED);
config.withExpectedServerVersion(null);
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.UP);
respond(new JSONObject().put("version_current", SECRET));
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
assertClean(run("api"));
respond(new JSONObject());
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
}
/*******************************************************************************
** Present, approaching, expired, missing and ambiguous token metadata.
*******************************************************************************/
@Test
void apiTokenStates() throws QException
{
JSONObject token = new JSONObject().put("intent", "api").put("identifier", SECRET).put("expiring", true);
token.put("expires", Instant.now().plusSeconds(30 * 86400).toString());
page(token);
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UP);
token.put("expires", Instant.now().plusSeconds(13 * 86400).toString());
page(token);
var outcome = run("api_token");
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(outcome.getDetails().getValues()).containsOnlyKeys("daysToExpiry");
token.put("expires", Instant.now().minusSeconds(60).toString());
page(token);
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.DOWN);
token.put("expiring", false);
page(token);
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UP);
token.put("expiring", true).put("expires", SECRET);
page(token);
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UNKNOWN);
page(token, token);
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UNKNOWN);
page();
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(FixtureActionUtil.REQUESTS).allSatisfy(request ->
{
assertThat(request.getURI().getPath()).endsWith("/core/tokens/");
assertThat(request.getURI().getQuery()).contains("intent=api", "user__username=");
});
}
/*******************************************************************************
** Only the two relevant drift checks run; their subjects stay private.
*******************************************************************************/
@Test
void mapperStates() throws QException
{
assertThat(run("mapper").getStatus()).isEqualTo(HealthStatus.UP);
JSONObject group = new JSONObject().put("pk", "g1").put("name", SECRET)
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put(SECRET)));
FixtureActionUtil.groupGraphBody = new JSONObject(FixtureActionUtil.EMPTY_PAGE).put("results", new JSONArray().put(group)).toString();
var outcome = run("mapper");
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(outcome.getDetails().getValues()).containsEntry("findingCodes", "group_attributes.warning");
page(new JSONObject().put("action", "model_deleted").put("context", new JSONObject()
.put("model", new JSONObject().put("model_name", "user").put("pk", "1").put("name", SECRET))));
outcome = run("mapper");
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
assertClean(outcome);
assertThat(FixtureActionUtil.REQUESTS).allSatisfy(request -> assertThat(request.getURI().getPath())
.doesNotContain("/admin/version/", "/core/tokens/", "/managed/blueprints/"));
}
/*******************************************************************************
** A broker check exercises the grant without exposing the returned bearer.
*******************************************************************************/
@Test
void brokerStates() throws Exception
{
respond(new JSONObject().put("access_token", SECRET));
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.UP);
HttpPost request = (HttpPost) FixtureActionUtil.REQUESTS.getLast();
assertThat(EntityUtils.toString(request.getEntity())).contains("grant_type=client_credentials", "client_id=", "client_secret=");
assertThat(request.getFirstHeader("Authorization")).isNull();
assertThat(request.getURI().toString()).isEqualTo(config.getTokenEndpointUrl());
respond(new JSONObject());
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
config.withTokenBrokerClientSecret(null);
FixtureActionUtil.REQUESTS.clear();
var outcome = run("token_broker");
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UNKNOWN);
assertThat(outcome.getMessage()).isEqualTo("not configured");
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
}
/*******************************************************************************
** Both response bodies and thrown errors may echo every configured secret.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { "api", "api_token", "mapper", "token_broker" })
void failuresNeverExposeSecrets(String name) throws QException
{
FixtureActionUtil.groupGraphHandler = request -> FixtureActionUtil.response(401, SECRET);
FixtureActionUtil.handler = request -> FixtureActionUtil.response(401, SECRET);
assertThat(run(name).getStatus()).isEqualTo(HealthStatus.DOWN);
FixtureActionUtil.handler = request ->
{
throw new IllegalStateException(SECRET);
};
assertThat(run(name).getStatus()).isEqualTo(HealthStatus.DOWN);
}
/*******************************************************************************
** Expired input performs no I/O, including unconfigured and mapper checks.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { "api", "api_token", "mapper", "token_broker" })
void expiredDeadline(String name) throws QException
{
assertThat(run(name, Instant.now().minusSeconds(1)).getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
}
/*******************************************************************************
** Retries and HTTP timeouts use the input budget, never the 30-second default.
*******************************************************************************/
@Test
void boundsRequestsAndRetries() throws QException
{
FixtureActionUtil.handler = request -> FixtureActionUtil.response(429, SECRET, FixtureActionUtil.retryAfter(30));
assertThat(run("api_token", Instant.now().plusSeconds(2)).getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(FixtureActionUtil.REQUESTS).hasSize(1);
var settings = FixtureActionUtil.REQUESTS.getFirst().getConfig();
assertThat(settings.getSocketTimeout()).isBetween(1, 2000);
assertThat(settings.getConnectTimeout()).isBetween(1, 2000);
assertThat(settings.getConnectionRequestTimeout()).isBetween(1, 2000);
}
/*******************************************************************************
** Unlimited normal client settings cannot override the central deadline.
*******************************************************************************/
@Test
void boundsUnlimitedTimeouts() throws QException
{
config.withConnectTimeoutMillis(0).withSocketTimeoutMillis(0);
run("api_token", Instant.now().plusSeconds(2));
var settings = FixtureActionUtil.REQUESTS.getFirst().getConfig();
assertThat(settings.getSocketTimeout()).isBetween(1, 2000);
assertThat(settings.getConnectTimeout()).isBetween(1, 2000);
}
/*******************************************************************************
** A saturated throttle refuses a reservation beyond the health deadline.
*******************************************************************************/
@Test
void boundsThrottle() throws QException
{
TokenBucketThrottle throttle = new TokenBucketThrottle(1, 1);
throttle.acquire(Instant.now().plusSeconds(1));
assertThatThrownBy(() -> throttle.acquire(Instant.now().plusMillis(10))).isInstanceOf(QException.class);
}
/*******************************************************************************
** Central permission does not grant Authentik mutations or arbitrary users.
*******************************************************************************/
@Test
void requiresHealthPermission() throws QException
{
AuthentikTestSupport.loginWithPermissions(instance, Set.of());
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
assertThatThrownBy(() -> AuthentikActionUtil.forBackend(config.getApiBackendName()).withHealthDeadline(Instant.now().plusSeconds(1)))
.hasMessageContaining("permission");
}
/*******************************************************************************
** Run the registered production check, with a short normal budget.
*******************************************************************************/
private HealthCheckOutcome run(String name) throws QException
{
return (run(name, Instant.now().plusSeconds(5)));
}
/*******************************************************************************
** HealthRunner.sanitize is package-private; assert the raw outcome is clean.
*******************************************************************************/
private HealthCheckOutcome run(String name, Instant deadline) throws QException
{
var definition = HealthChecksMetaData.ofOrWithNew(instance).getChecks().stream()
.filter(c -> c.getName().equals("authentik." + name)).findFirst().orElseThrow();
var outcome = definition.getCheckInstance().run(new HealthCheckInput().withQInstance(instance).withDeadline(deadline));
assertClean(outcome);
return (outcome);
}
/*******************************************************************************
** No credential can appear even before core sanitization.
*******************************************************************************/
private void assertClean(HealthCheckOutcome outcome)
{
assertThat(outcome.getMessage() + outcome.getDetails().toJson()).doesNotContain(SECRET, "authentik.example.test", "operator@example.test");
}
/*******************************************************************************
** Serve a JSON response through the existing fixture handler.
*******************************************************************************/
private void respond(JSONObject body)
{
FixtureActionUtil.handler = request -> FixtureActionUtil.response(200, body.toString());
}
/*******************************************************************************
** Serve paginated metadata without any network.
*******************************************************************************/
private void page(JSONObject... records)
{
respond(new JSONObject(FixtureActionUtil.EMPTY_PAGE).put("results", new JSONArray(List.of(records))));
}
/*******************************************************************************
** Let version probes use the same configurable fixture handler as other calls.
*******************************************************************************/
public static class HealthActionUtil extends FixtureActionUtil
{
/***************************************************************************
** Preserve the existing group graph seam and capture version requests.
***************************************************************************/
@Override
protected QHttpResponse send(HttpRequestBase request)
{
if(request.getURI().getPath().endsWith("/admin/version/"))
{
REQUESTS.add(request);
return (handler.apply(request));
}
return (super.send(request));
}
}
}