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("Active4<");
assertThat(html).contains("Inactive1<");
assertThat(html).contains("Service accounts2<");
@@ -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));
+ }
+ }
+}