Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ff924ebd
|
||
|
|
995a5900aa | ||
|
|
265dbb0468 | ||
|
|
bc92be338f
|
||
|
|
69830a095e
|
||
|
|
33e53263c5 |
No files matched your search
@@ -21,5 +21,6 @@ jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
@@ -4,6 +4,8 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: <permission>`, `malformed <attribute>` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was.
|
||||
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
|
||||
- Replace expired token-delivery and credential-scope reflection bridges with the RC.7 APIs; retain conservative permission narrowing and explicit unavailable-delivery tests.
|
||||
- Ship the expanded, host-neutral QBIT help bundle using QQQ 4.1.0-RC.7 registration and validation, with coverage for optional features and live references. Preserve inline app/table help for hosts without help storage; document HOST bundle overrides.
|
||||
|
||||
|
||||
@@ -1563,7 +1563,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
// Never echo an untrusted broker error body.
|
||||
}
|
||||
throw (new QUserFacingException("Authentik refused to act as you (" + error + ")"));
|
||||
throw (new BrokerRefusedException(error));
|
||||
}
|
||||
throw (mapError(request, path, status, response.getContent(), token));
|
||||
}
|
||||
|
||||
@@ -62,20 +62,40 @@ public class AuthentikTokenBroker
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Request client credentials for health, then discard the bearer immediately.
|
||||
** Prove the broker's client credentials with the grant it actually uses
|
||||
** (token exchange), without a subject token, so nothing is issued and no
|
||||
** user data is sent. Authentik checks the grant type, then the client
|
||||
** secret, then the subject: invalid_request (missing subject) means both
|
||||
** passed; invalid_client means a bad secret; invalid_grant means the
|
||||
** provider does not allow token exchange.
|
||||
*******************************************************************************/
|
||||
public void probeClientCredentials(AuthentikActionUtil util) throws QException
|
||||
public void probeCredentials(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("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
|
||||
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())
|
||||
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret())), StandardCharsets.UTF_8));
|
||||
String code;
|
||||
try
|
||||
{
|
||||
throw (new QUserFacingException("Authentik broker returned no access token"));
|
||||
util.requestBrokerCredentials(request);
|
||||
code = "issued";
|
||||
}
|
||||
catch(BrokerRefusedException e)
|
||||
{
|
||||
code = e.getErrorCode();
|
||||
}
|
||||
switch(code)
|
||||
{
|
||||
case "invalid_request" ->
|
||||
{
|
||||
return;
|
||||
}
|
||||
case "invalid_client" -> throw (new QUserFacingException("Authentik rejected the token broker's client credentials"));
|
||||
case "invalid_grant", "unsupported_grant_type" -> throw (new QUserFacingException("The token broker's Authentik provider does not allow token exchange"));
|
||||
default -> throw (new QUserFacingException("Authentik answered the token broker probe unexpectedly (" + code + ")"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.client;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentik's token endpoint refused a broker request. Carries only the
|
||||
** standard OAuth2 error code (never the untrusted response body).
|
||||
*******************************************************************************/
|
||||
public class BrokerRefusedException extends QUserFacingException
|
||||
{
|
||||
private final String errorCode;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public BrokerRefusedException(String errorCode)
|
||||
{
|
||||
super("Authentik refused to act as you (" + errorCode + ")");
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The OAuth2 error code, or exchange_failed when it was not a standard one.
|
||||
*******************************************************************************/
|
||||
public String getErrorCode()
|
||||
{
|
||||
return (errorCode);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
@@ -61,6 +62,9 @@ public class AuthentikDriftReport
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikDriftReport.class);
|
||||
|
||||
private static final Pattern SAFE_PERMISSION = Pattern.compile("[A-Za-z0-9_.*-]{1,120}");
|
||||
private static final Pattern SAFE_ATTRIBUTE = Pattern.compile("[A-Za-z0-9_./-]{1,60}");
|
||||
|
||||
private final AuthentikActionUtil client;
|
||||
private final AuthentikQBitConfig config;
|
||||
private final AuthentikGuard guard;
|
||||
@@ -373,6 +377,19 @@ public class AuthentikDriftReport
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The attribute name of a GroupGraph malformed entry ("key: detail"), never
|
||||
** the detail, which can echo whatever a person typed.
|
||||
*******************************************************************************/
|
||||
static String attributeOf(String malformedEntry)
|
||||
{
|
||||
int colon = malformedEntry == null ? -1 : malformedEntry.indexOf(": ");
|
||||
String key = colon < 0 ? "" : malformedEntry.substring(0, colon);
|
||||
return (SAFE_ATTRIBUTE.matcher(key).matches() ? key : "attribute");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 6
|
||||
*******************************************************************************/
|
||||
@@ -388,14 +405,16 @@ public class AuthentikDriftReport
|
||||
for(String bad : node.malformed())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings."));
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings.")
|
||||
.withReason("malformed " + attributeOf(bad)));
|
||||
}
|
||||
for(String permission : node.permissions())
|
||||
{
|
||||
if(resolver.expand(permission).isEmpty())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard)."));
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard; a name the app checks only in code, not in its metadata, is not in the catalog).")
|
||||
.withReason("grants nothing: " + (SAFE_PERMISSION.matcher(permission).matches() ? permission : "(unprintable name)")));
|
||||
}
|
||||
}
|
||||
for(String app : node.appAccess())
|
||||
@@ -409,7 +428,8 @@ public class AuthentikDriftReport
|
||||
if(graph.isSuperuserInherited(node.pk()) && !node.permissions().isEmpty())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups."));
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups.")
|
||||
.withReason("superuser group carries qqq.io/permissions"));
|
||||
}
|
||||
}
|
||||
if(issues == 0)
|
||||
|
||||
@@ -25,10 +25,33 @@ package com.kingsrook.qbits.authentik.drift;
|
||||
** @param check the check's short name, e.g. "blueprints"
|
||||
** @param subject what the finding is about, e.g. a group name
|
||||
** @param message plain-language explanation and what to do
|
||||
** @param reason a short, safe summary for health outcomes (no attribute
|
||||
** values a person typed beyond validated permission names);
|
||||
** null when the finding has none
|
||||
*******************************************************************************/
|
||||
public record DriftFinding(Severity severity, String check, String subject, String message)
|
||||
public record DriftFinding(Severity severity, String check, String subject, String message, String reason)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** A finding without a health reason.
|
||||
*******************************************************************************/
|
||||
public DriftFinding(Severity severity, String check, String subject, String message)
|
||||
{
|
||||
this(severity, check, subject, message, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** This finding with a short, safe reason for health outcomes.
|
||||
*******************************************************************************/
|
||||
public DriftFinding withReason(String reason)
|
||||
{
|
||||
return (new DriftFinding(severity, check, subject, message, reason));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Severity, most severe first.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -19,7 +19,10 @@ package com.kingsrook.qbits.authentik.health;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
@@ -45,6 +48,8 @@ import org.json.JSONObject;
|
||||
*******************************************************************************/
|
||||
public final class AuthentikHealthChecks
|
||||
{
|
||||
private static final String GROUP_ATTRIBUTES = "group attributes";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
@@ -151,30 +156,91 @@ public final class AuthentikHealthChecks
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Findings carry fixed codes only, never subjects or diagnostic messages.
|
||||
** Findings carry fixed codes, plus the flagged group names and each group
|
||||
** finding's safe reason (DriftFinding.reason), so a degraded mapper says
|
||||
** what to fix. Service-account activity subjects (people) stay private.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome mapper(AuthentikActionUtil util, AuthentikQBitConfig config)
|
||||
static HealthCheckOutcome mapper(AuthentikActionUtil util, AuthentikQBitConfig config)
|
||||
{
|
||||
HealthStatus status = HealthStatus.UP;
|
||||
TreeSet<String> codes = new TreeSet<>();
|
||||
for(DriftFinding finding : new AuthentikDriftReport(util, config).runMapperChecks())
|
||||
return (mapperOutcome(new AuthentikDriftReport(util, config).runMapperChecks()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The mapper outcome for a set of drift findings.
|
||||
*******************************************************************************/
|
||||
static HealthCheckOutcome mapperOutcome(List<DriftFinding> findings)
|
||||
{
|
||||
HealthStatus status = HealthStatus.UP;
|
||||
TreeSet<String> codes = new TreeSet<>();
|
||||
TreeMap<String, TreeMap<String, TreeSet<String>>> byGroup = new TreeMap<>();
|
||||
TreeSet<String> other = new TreeSet<>();
|
||||
for(DriftFinding finding : findings)
|
||||
{
|
||||
if(finding.severity() == DriftFinding.Severity.ERROR || finding.severity() == DriftFinding.Severity.WARNING)
|
||||
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)
|
||||
continue;
|
||||
}
|
||||
boolean groupCheck = GROUP_ATTRIBUTES.equals(finding.check());
|
||||
codes.add((groupCheck ? "group_attributes" : "service_account_activity") + "." + finding.severity().name().toLowerCase(Locale.ROOT));
|
||||
status = finding.severity() == DriftFinding.Severity.ERROR || status == HealthStatus.DOWN ? HealthStatus.DOWN : HealthStatus.DEGRADED;
|
||||
if(groupCheck && finding.reason() != null)
|
||||
{
|
||||
String reason = finding.reason();
|
||||
int colon = reason.indexOf(": ");
|
||||
TreeSet<String> values = byGroup.computeIfAbsent(safeName(finding.subject()), k -> new TreeMap<>())
|
||||
.computeIfAbsent(colon < 0 ? reason : reason.substring(0, colon), k -> new TreeSet<>());
|
||||
if(colon >= 0)
|
||||
{
|
||||
status = HealthStatus.DOWN;
|
||||
}
|
||||
else if(status != HealthStatus.DOWN)
|
||||
{
|
||||
status = HealthStatus.DEGRADED;
|
||||
values.add(reason.substring(colon + 2));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
other.add(groupCheck ? "group attributes check could not run" : "service-account activity (see the identity drift report)");
|
||||
}
|
||||
}
|
||||
return (new HealthCheckOutcome().withStatus(status).withMessage(status == HealthStatus.UP ? "Mapper checks passed" : "Mapper drift detected")
|
||||
.withDetails(new HealthDetails().withValue("findingCodes", String.join(",", codes))));
|
||||
if(status == HealthStatus.UP)
|
||||
{
|
||||
return (HealthCheckOutcome.up("Mapper checks passed").withDetails(new HealthDetails().withValue("findingCodes", "")));
|
||||
}
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
byGroup.forEach((group, reasons) ->
|
||||
{
|
||||
List<String> texts = new ArrayList<>();
|
||||
reasons.forEach((label, values) -> texts.add(values.isEmpty() ? label : label + ": " + String.join(", ", values)));
|
||||
parts.add(group + " (" + String.join("; ", texts) + ")");
|
||||
});
|
||||
parts.addAll(other);
|
||||
String summary = String.join("; ", parts);
|
||||
return (new HealthCheckOutcome().withStatus(status).withMessage(truncate("Mapper drift: " + summary, 500))
|
||||
.withDetails(new HealthDetails()
|
||||
.withValue("findingCodes", String.join(",", codes))
|
||||
.withValue("flaggedGroups", truncate(String.join(", ", byGroup.keySet()), 500))
|
||||
.withValue("summary", truncate(summary, 500))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group name fit for a health message: printable and bounded.
|
||||
*******************************************************************************/
|
||||
private static String safeName(String name)
|
||||
{
|
||||
String printable = name == null ? "" : name.replaceAll("\\p{Cntrl}", "").trim();
|
||||
return (printable.isEmpty() ? "(unnamed group)" : truncate(printable, 100));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cut a text to a length, marking the cut.
|
||||
*******************************************************************************/
|
||||
private static String truncate(String text, int max)
|
||||
{
|
||||
return (text.length() <= max ? text : text.substring(0, max - 3) + "...");
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +254,7 @@ public final class AuthentikHealthChecks
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("not configured"));
|
||||
}
|
||||
new AuthentikTokenBroker().probeClientCredentials(util);
|
||||
new AuthentikTokenBroker().probeCredentials(util);
|
||||
return (HealthCheckOutcome.up("Token broker authenticated"));
|
||||
}
|
||||
}
|
||||
@@ -186,4 +186,20 @@ class AuthentikDriftReportTest
|
||||
assertThatThrownBy(() -> new AuthentikDriftReportWidgetRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A malformed entry's health reason keeps the attribute name, never the
|
||||
** value a person typed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void malformedReasonKeepsOnlyTheAttribute()
|
||||
{
|
||||
assertThat(AuthentikDriftReport.attributeOf("qqq.io/permissions: {\"x\":\"typed value\"}")).isEqualTo("qqq.io/permissions");
|
||||
assertThat(AuthentikDriftReport.attributeOf("qqq.io/policy: line 1: bad")).isEqualTo("qqq.io/policy");
|
||||
assertThat(AuthentikDriftReport.attributeOf("no separator")).isEqualTo("attribute");
|
||||
assertThat(AuthentikDriftReport.attributeOf("bad key with spaces: x")).isEqualTo("attribute");
|
||||
assertThat(AuthentikDriftReport.attributeOf(null)).isEqualTo("attribute");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ 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.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
@@ -176,22 +177,49 @@ class AuthentikHealthChecksTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the two relevant drift checks run; their subjects stay private.
|
||||
** Only the two relevant drift checks run. Group findings name the group and
|
||||
** a safe reason; typed attribute values and people (service-account
|
||||
** activity 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();
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// a group with no qqq.io/* attributes (kof22-admins on its static floor) is //
|
||||
// not drift //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
JSONObject floorOnly = new JSONObject().put("pk", "g0").put("name", "kof22-admins").put("attributes", new JSONObject());
|
||||
groups(floorOnly);
|
||||
var outcome = run("mapper");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper checks passed");
|
||||
|
||||
JSONObject unknown = new JSONObject().put("pk", "g1").put("name", "file-gateway-admins")
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("file_gateway.view").put("file_gateway.admin")));
|
||||
JSONObject malformed = new JSONObject().put("pk", "g2").put("name", "editors")
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put(new JSONObject().put("x", SECRET))));
|
||||
JSONObject superuser = new JSONObject().put("pk", "g3").put("name", "authentik Admins").put("is_superuser", true)
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.users.read")));
|
||||
groups(floorOnly, unknown, malformed, superuser);
|
||||
outcome = run("mapper");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("findingCodes", "group_attributes.warning");
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: authentik Admins (superuser group carries qqq.io/permissions); "
|
||||
+ "editors (malformed qqq.io/permissions); file-gateway-admins (grants nothing: file_gateway.admin, file_gateway.view)");
|
||||
assertThat(outcome.getDetails().getValues())
|
||||
.containsEntry("findingCodes", "group_attributes.warning")
|
||||
.containsEntry("flaggedGroups", "authentik Admins, editors, file-gateway-admins")
|
||||
.containsEntry("summary", outcome.getMessage().substring("Mapper drift: ".length()));
|
||||
assertClean(outcome);
|
||||
|
||||
groups(unknown);
|
||||
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);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: file-gateway-admins (grants nothing: file_gateway.admin, file_gateway.view); "
|
||||
+ "service-account activity (see the identity drift report)");
|
||||
assertClean(outcome);
|
||||
assertThat(FixtureActionUtil.REQUESTS).allSatisfy(request -> assertThat(request.getURI().getPath())
|
||||
.doesNotContain("/admin/version/", "/core/tokens/", "/managed/blueprints/"));
|
||||
@@ -200,19 +228,74 @@ class AuthentikHealthChecksTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A broker check exercises the grant without exposing the returned bearer.
|
||||
** A group check that cannot run, unprintable names and long summaries stay
|
||||
** readable and within the health limits.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mapperOutcomeEdges()
|
||||
{
|
||||
var outcome = AuthentikHealthChecks.mapperOutcome(List.of(
|
||||
DriftFinding.error("group attributes", "-", "Check could not run: " + SECRET),
|
||||
DriftFinding.info("group attributes", "x", "info only").withReason("ignored"),
|
||||
DriftFinding.warning("group attributes", "bad\u0007name", "m").withReason("grants nothing: (unprintable name)"),
|
||||
DriftFinding.warning("group attributes", "", "m").withReason("malformed attribute")));
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: (unnamed group) (malformed attribute); badname (grants nothing: (unprintable name)); "
|
||||
+ "group attributes check could not run");
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("findingCodes", "group_attributes.error,group_attributes.warning");
|
||||
assertClean(outcome);
|
||||
|
||||
List<DriftFinding> many = new java.util.ArrayList<>();
|
||||
for(int i = 0; i < 60; i++)
|
||||
{
|
||||
many.add(DriftFinding.warning("group attributes", "group-number-" + i, "m").withReason("grants nothing: some.long.permission.name." + i));
|
||||
}
|
||||
outcome = AuthentikHealthChecks.mapperOutcome(many);
|
||||
assertThat(outcome.getMessage()).hasSize(500).endsWith("...");
|
||||
assertThat(outcome.getDetails().toJson().getBytes(java.nio.charset.StandardCharsets.UTF_8).length).isLessThanOrEqualTo(4096);
|
||||
assertThat(outcome.getDetails().getValues()).containsKeys("findingCodes", "flaggedGroups", "summary");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve these groups as the group graph.
|
||||
*******************************************************************************/
|
||||
private static void groups(JSONObject... groups)
|
||||
{
|
||||
JSONArray results = new JSONArray();
|
||||
for(JSONObject group : groups)
|
||||
{
|
||||
results.put(group);
|
||||
}
|
||||
FixtureActionUtil.groupGraphBody = new JSONObject(FixtureActionUtil.EMPTY_PAGE).put("results", results).toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The broker check uses the grant the broker uses (token exchange) with no
|
||||
** subject: invalid_request proves the credentials; nothing is ever issued.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void brokerStates() throws Exception
|
||||
{
|
||||
respond(new JSONObject().put("access_token", SECRET));
|
||||
refuse("invalid_request");
|
||||
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=");
|
||||
String body = EntityUtils.toString(request.getEntity());
|
||||
assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange", "client_id=", "client_secret=");
|
||||
assertThat(body).doesNotContain("client_credentials", "subject_token");
|
||||
assertThat(request.getFirstHeader("Authorization")).isNull();
|
||||
assertThat(request.getURI().toString()).isEqualTo(config.getTokenEndpointUrl());
|
||||
respond(new JSONObject());
|
||||
|
||||
refuse("invalid_client");
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
refuse("invalid_grant");
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
respond(new JSONObject().put("access_token", SECRET));
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
|
||||
config.withTokenBrokerClientSecret(null);
|
||||
FixtureActionUtil.REQUESTS.clear();
|
||||
var outcome = run("token_broker");
|
||||
@@ -223,6 +306,16 @@ class AuthentikHealthChecksTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve a token-endpoint refusal with a standard OAuth2 error code.
|
||||
*******************************************************************************/
|
||||
private void refuse(String errorCode)
|
||||
{
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(400, new JSONObject().put("error", errorCode).toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both response bodies and thrown errors may echo every configured secret.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user