feat(maintenance): applyPolicy runs as the maintenance service principal #38

Merged
KofTwentyTwo merged 4 commits from feature/maintenance-service-principal into develop 2026-10-16 18:53:54 +00:00
6 changed files with 153 additions and 55 deletions

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255).
- Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: <permission>`, `malformed <attribute>` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was.
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
- Replace expired token-delivery and credential-scope reflection bridges with the RC.7 APIs; retain conservative permission narrowing and explicit unavailable-delivery tests.
+1 -1
View File
@@ -249,7 +249,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -66,6 +66,7 @@ public class AuthentikQBitConfig implements QBitConfig
private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}";
private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}";
private String tokenEndpointUrl = null;
private String maintenancePrincipal = "maintenance";
private String appPublicUrl = null;
private String authentikPublicUrl = null;
private String appClientId = null;
@@ -1737,6 +1738,29 @@ public class AuthentikQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for maintenancePrincipal: the service principal (declared by the
** host, docs/SERVICE-IDENTITY.md in qqq) that AuthentikMaintenance runs as.
** Its Authentik policy must hold policies.manage and every grant it applies.
*******************************************************************************/
public String getMaintenancePrincipal()
{
return (this.maintenancePrincipal);
}
/*******************************************************************************
** Fluent setter for maintenancePrincipal (default maintenance).
*******************************************************************************/
public AuthentikQBitConfig withMaintenancePrincipal(String maintenancePrincipal)
{
this.maintenancePrincipal = maintenancePrincipal;
return (this);
}
/*******************************************************************************
** Getter for appPublicUrl.
*******************************************************************************/
@@ -63,6 +63,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -329,8 +330,8 @@ public class AuthentikAdmin
/*******************************************************************************
** The operator, resolved once per instance from live Authentik data. When
** the operator is not an Authentik user (static or development sessions),
** the session's own permissions stand in. Maintenance sessions are local job
** identities and never resolved as Authentik users. bootstrapOperatorSubjects additionally
** the session's own permissions stand in. Service principal sessions
** (maintenance) are never resolved as Authentik users by email. bootstrapOperatorSubjects additionally
** hold every authentik.* permission.
*******************************************************************************/
public Operator operator() throws QException
@@ -341,7 +342,7 @@ public class AuthentikAdmin
}
String email = guard.operatorEmail();
boolean maintenance = AuthentikMaintenance.isMaintenanceSession(QContext.getQSession());
boolean maintenance = ServiceIdentities.isMintedServiceSession(QContext.getQSession());
boolean bootstrap = !maintenance && email != null && com.kingsrook.qbits.authentik.security.BootstrapOperators.matchesUniqueUser(config.getResolvedBootstrapSubjects(), util, email);
Integer pk = null;
Set<String> permissions = new TreeSet<>();
@@ -21,20 +21,17 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
import com.kingsrook.qbits.authentik.policy.PolicyParser;
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.json.JSONObject;
@@ -60,13 +57,18 @@ public final class AuthentikMaintenance
** plan and execute path. Requires an initialized QContext with the host's
** QInstance. The caller's context is restored on success, refusal or failure.
**
** The operator must start with maintenance: or run-once: and have a nonblank
** suffix. Its name and the required reason are recorded verbatim. A dedicated
** non-system session holds policies.manage and the document's current grants.
** Existing scope, protection, readOnly, feature and stale checks still apply.
** Equivalent policies return NO_CHANGE without a write or an audit row.
** With policyTwoPersonApproval enabled, a change queues an approval instead
** of writing to Authentik; this API never bypasses second-person approval.
** Runs as the host's maintenance service principal (config
** maintenancePrincipal, default "maintenance"; docs/SERVICE-IDENTITY.md in
** qqq): under OAuth2 a real token for its Authentik service account, so its
** authority is exactly what the owner granted that account (policies.manage
** and every grant the document applies - the subset rule still holds). No
** authority is created here. The audit operator is service:{principal};
** the job name (maintenance: or run-once:, nonblank suffix) prefixes the
** required reason. Existing scope, protection, readOnly, feature and stale
** checks still apply. Equivalent policies return NO_CHANGE without a write
** or an audit row. With policyTwoPersonApproval enabled, a change queues an
** approval instead of writing to Authentik; this API never bypasses
** second-person approval.
*******************************************************************************/
public static AuthentikAdmin.AdminResult applyPolicy(String apiBackendName, String groupName, String policyJson, String operatorName, String reason) throws QException
{
@@ -87,18 +89,16 @@ public final class AuthentikMaintenance
CapturedContext caller = QContext.capture();
try
{
MaintenanceSession session = new MaintenanceSession();
session.setUser(new QUser().withIdReference(operatorName).withFullName("Maintenance (" + operatorName + ")"));
session.setPermissions(new TreeSet<>(Set.of(IdentityPermission.POLICIES_MANAGE.getSessionPermission())));
QContext.init(caller.qInstance(), session);
String principal = AuthentikRuntime.forBackend(apiBackendName).getConfig().getMaintenancePrincipal();
QContext.init(caller.qInstance(), ServiceIdentities.session(caller.qInstance(), principal));
AuthentikAdmin admin = AuthentikAdmin.forBackend(apiBackendName);
AuthentikRuntime runtime = admin.getUtil().getRuntime();
runtime.getGuard().authorize(AdminAction.SAVE_POLICY);
Set<String> grants = StringUtils.hasContent(policyJson)
? PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog()).permissions() : Set.of();
session.getPermissions().addAll(grants.contains(PolicyPermissions.FULL_ACCESS)
? PolicyPermissions.allPermissions(caller.qInstance(), runtime.getPermissionCatalog()) : grants);
if(StringUtils.hasContent(policyJson))
{
PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
}
runtime.invalidateCache();
List<JSONObject> groups = admin.getUtil().getAllPages("/core/groups/?name=" + URLEncoder.encode(groupName, StandardCharsets.UTF_8)
@@ -108,7 +108,15 @@ public final class AuthentikMaintenance
throw (new QUserFacingException("Expected exactly one Authentik group named " + groupName + "."));
}
JSONObject group = groups.get(0);
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), reason)
if(isServicePrincipalGroup(group))
{
////////////////////////////////////////////////////////////////////
// a principal never changes a service principal's grants (its own //
// or another's): only the owner does, in Identity //
////////////////////////////////////////////////////////////////////
throw (new QUserFacingException("Service principal groups are changed only by the owner in Identity."));
}
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), operatorName + ": " + reason)
.with(AuthentikAdmin.POLICY_DOCUMENT, policyJson)
.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
return (admin.execute(request));
@@ -125,6 +133,29 @@ public final class AuthentikMaintenance
/*******************************************************************************
** A service principal role group: named service:..., or a child of
** qqq-service-accounts.
*******************************************************************************/
static boolean isServicePrincipalGroup(JSONObject group)
{
if(group.optString("name", "").startsWith("service:"))
{
return (true);
}
org.json.JSONArray parents = group.optJSONArray("parents_obj");
for(int i = 0; parents != null && i < parents.length(); i++)
{
if("qqq-service-accounts".equals(parents.optJSONObject(i) == null ? null : parents.getJSONObject(i).optString("name")))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** Canonical compact full-access document using the host's spec and version.
*******************************************************************************/
@@ -139,23 +170,4 @@ public final class AuthentikMaintenance
throw (new QException("Could not generate the full-access policy.", e));
}
}
/*******************************************************************************
** Recognize only sessions created here, never an ordinary user's name prefix.
*******************************************************************************/
static boolean isMaintenanceSession(QSession session)
{
return (session instanceof MaintenanceSession);
}
/*******************************************************************************
** A local job identity, never an Authentik user or a QSystemUserSession.
*******************************************************************************/
private static final class MaintenanceSession extends QSession
{
}
}
@@ -33,6 +33,9 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.serviceidentity.QServicePrincipalMetaData;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentitiesMetaData;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -41,13 +44,16 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Host maintenance uses the audited Save Policy path with a local job identity.
** Host maintenance uses the audited Save Policy path as the maintenance service
** principal (a local principal here: the test instance uses MOCK auth).
*******************************************************************************/
class AuthentikMaintenanceTest
{
private static final String GROUP = "kof-website-analysts";
private static final String OPERATOR = "run-once:KOF22_RUN_ONCE";
private static final String REASON = " owner request 2026-10-14: full-access policy for kof22-admins ";
private static final String PRINCIPAL_ID = "service:maintenance";
private static final String AUDIT_REASON = OPERATOR + ": " + REASON;
private AdminTestSupport t;
private String fullAccess;
@@ -62,13 +68,17 @@ class AuthentikMaintenanceTest
{
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.GROUPS, AuthentikFeature.POLICIES));
fullAccess = AuthentikMaintenance.fullAccessDocument(t.config);
Set<String> all = new java.util.TreeSet<>(PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog()));
all.add(IdentityPermission.POLICIES_MANAGE.getSessionPermission());
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("maintenance").withLocalPermissions(all.toArray(new String[0])));
}
/*******************************************************************************
** A system job gets a dedicated, fully expanded session. The audit precedes
** PATCH, the event identifies the job, and an equivalent document is a no-op.
** A system job runs as the maintenance principal (its own grants, nothing
** minted here). The audit precedes PATCH, the operator is the principal,
** the reason names the job, and an equivalent document is a no-op.
*******************************************************************************/
@Test
void appliesFullAccessAuditedAndIdempotently() throws Exception
@@ -84,8 +94,8 @@ class AuthentikMaintenanceTest
{
QSession session = QContext.getQSession();
assertThat(session).isNotInstanceOf(QSystemUserSession.class);
assertThat(session.getUser().getIdReference()).isEqualTo(OPERATOR);
assertThat(session.getUser().getFullName()).isEqualTo("Maintenance (" + OPERATOR + ")");
assertThat(ServiceIdentities.isMintedServiceSession(session)).isTrue();
assertThat(session.getUser().getIdReference()).isEqualTo(PRINCIPAL_ID);
assertThat(session.getPermissions()).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
assertThat(QContext.getActionStack()).isNotSameAs(caller.actionStack());
assertThat(QContext.getAuthorizedActionScopeDepth()).isZero();
@@ -112,15 +122,15 @@ class AuthentikMaintenanceTest
assertThat(t.authentik.groupByName(GROUP).getJSONObject("attributes").getString(t.config.getPolicyAttribute())).isEqualTo(fullAccess);
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
{
assertThat(row.getValueString("operator")).isEqualTo(OPERATOR);
assertThat(row.getValueString("reason")).isEqualTo(REASON);
assertThat(row.getValueString("operator")).isEqualTo(PRINCIPAL_ID);
assertThat(row.getValueString("reason")).isEqualTo(AUDIT_REASON);
assertThat(row.getValueString("status")).isEqualTo("SUCCEEDED");
});
assertThat(t.authentik.events).singleElement().satisfies(event ->
{
assertThat(event.getString("action")).isEqualTo("custom_qqq_admin_action");
assertThat(event.getJSONObject("context").getString("operator")).isEqualTo(OPERATOR);
assertThat(event.getJSONObject("context").getString("reason")).isEqualTo(REASON);
assertThat(event.getJSONObject("context").getString("operator")).isEqualTo(PRINCIPAL_ID);
assertThat(event.getJSONObject("context").getString("reason")).isEqualTo(AUDIT_REASON);
});
assertThat(result.eventPk()).isNotBlank();
int writes = t.writes().size();
@@ -134,12 +144,16 @@ class AuthentikMaintenanceTest
/*******************************************************************************
** Ordinary policies need only their grants and policies.manage, and honor
** Ordinary policies need only their grants and policies.manage (held by the
** principal, not added here), and honor
** the configured spec/version. The maintenance: prefix is accepted too.
*******************************************************************************/
@Test
void appliesNamedPolicyWithLimitedSession() throws Exception
{
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("policy-maintenance")
.withLocalPermissions("cms_page.read", IdentityPermission.POLICIES_MANAGE.getSessionPermission()));
t.config.withMaintenancePrincipal("policy-maintenance");
t.config.withPolicySpec("host-iam");
String document = AuthentikMaintenance.fullAccessDocument(t.config).replace("\"Action\":[\"*\"]", "\"Action\":[\"read\"]")
.replace("\"Resource\":[\"*\"]", "\"Resource\":[\"cms_page\"]");
@@ -245,8 +259,8 @@ class AuthentikMaintenanceTest
assertThat(row.getValueString("status")).isEqualTo("PENDING_APPROVAL"));
assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST)).singleElement().satisfies(row ->
{
assertThat(row.getValueString("requester")).isEqualTo(OPERATOR);
assertThat(row.getValueString("reason")).isEqualTo(REASON);
assertThat(row.getValueString("requester")).isEqualTo(PRINCIPAL_ID);
assertThat(row.getValueString("reason")).isEqualTo(AUDIT_REASON);
});
assertThat(QContext.capture()).isEqualTo(caller);
}
@@ -314,6 +328,52 @@ class AuthentikMaintenanceTest
/*******************************************************************************
** Without a declared principal, or with one that lacks the grants, nothing
** is written: maintenance never creates authority.
*******************************************************************************/
@Test
void needsAPrincipalHoldingTheGrants() throws Exception
{
CapturedContext caller = QContext.capture();
t.config.withMaintenancePrincipal("undeclared");
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("not declared");
assertThat(QContext.capture()).isEqualTo(caller);
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("weak")
.withLocalPermissions(IdentityPermission.POLICIES_MANAGE.getSessionPermission()));
t.config.withMaintenancePrincipal("weak");
AuthentikAdmin.AdminResult result;
try
{
result = apply(GROUP, fullAccess);
assertThat(result.ok()).isFalse();
}
catch(QException e)
{
assertThat(e).isNotNull();
}
assertThat(t.writes()).isEmpty();
assertThat(QContext.capture()).isEqualTo(caller);
}
/*******************************************************************************
** Maintenance never changes a service principal's role group.
*******************************************************************************/
@Test
void refusesServicePrincipalGroups() throws Exception
{
t.authentik.group("service:kof22-scheduler", false, List.of(), List.of());
assertThatThrownBy(() -> apply("service:kof22-scheduler", fullAccess)).hasMessageContaining("Service principal groups");
assertThat(AuthentikMaintenance.isServicePrincipalGroup(new JSONObject().put("name", "x")
.put("parents_obj", new org.json.JSONArray().put(new JSONObject().put("name", "qqq-service-accounts"))))).isTrue();
assertThat(t.writes()).isEmpty();
}
/*******************************************************************************
** Apply using the production public API.
*******************************************************************************/