diff --git a/CHANGELOG.md b/CHANGELOG.md index df78aad..5be75eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,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. diff --git a/README.md b/README.md index c3d83d7..d07b235 100644 --- a/README.md +++ b/README.md @@ -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.7 (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)`. | @@ -137,6 +137,29 @@ Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` int 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 diff --git a/pom.xml b/pom.xml index 28f8ffc..2f34316 100644 --- a/pom.xml +++ b/pom.xml @@ -247,7 +247,7 @@ true - 4.1.0-RC.7 + 4.1.0-RC.8 diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java index 4b1ada7..b873082 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java @@ -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; @@ -186,6 +187,7 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer recentActions = new ArrayList<>(); private List 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 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); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java index 8f3831f..609ee57 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java +++ b/src/main/java/com/kingsrook/qbits/authentik/app/AuthentikHomeView.java @@ -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("").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("
"); - 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("
"); + open(html, "Health"); + String target = prefix(data.getConfig()) + "/health_component" + (data.getHealthComponentId() == null ? "" : "/" + data.getHealthComponentId()); + html.append("

Authentik: ").append(esc(data.getHealthStatus())).append(" - System Health

"); close(html); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java index 263e2de..f77b189 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java @@ -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 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 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 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 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; } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java index e4d4925..ee33b54 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java @@ -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")); + } + } } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/TokenBucketThrottle.java b/src/main/java/com/kingsrook/qbits/authentik/client/TokenBucketThrottle.java index eded2c1..321b8fc 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/TokenBucketThrottle.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/TokenBucketThrottle.java @@ -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). *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java b/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java index c409ca3..eecfd8a 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java +++ b/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java @@ -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 runMapperChecks() + { + List findings = new ArrayList<>(); + runCheck(findings, "group attributes", this::checkGroupAttributes); + runCheck(findings, "service-account activity", this::checkServiceAccountActivity); + return (findings); + } + + + /******************************************************************************* ** A check. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecks.java b/src/main/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecks.java new file mode 100644 index 0000000..a403ef1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecks.java @@ -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 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 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")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java index f12f3e1..16a2dd1 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java @@ -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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java index a4b9e74..b69ba76 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/PolicyCutoverTest.java @@ -175,7 +175,7 @@ class PolicyCutoverTest assertThat(PolicyCutoverWidgets.bannerHtml(new AccessComparison.State(AccessComparison.Status.ACTIVE, "ok ", java.time.Instant.now()))).contains("Enforced.").contains("ok <b>"); 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"); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java b/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java index 8db5888..62e861d 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/app/AuthentikAppTest.java @@ -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
4<"); assertThat(html).contains("Inactive
1<"); assertThat(html).contains("Service accounts
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"); + 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 diff --git a/src/test/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecksTest.java b/src/test/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecksTest.java new file mode 100644 index 0000000..8bdd776 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecksTest.java @@ -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)); + } + } +}