From 4d6140f7bece75df25d7069f4d0918b882e41334 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 16 Oct 2026 08:21:41 -0500 Subject: [PATCH 1/3] feat(maintenance): applyPolicy runs as the maintenance service principal Drop the self-made MaintenanceSession that granted itself policies.manage and the document's grants (rejected under OAuth2). Run as the host's declared maintenance principal: its Authentik service account's grants decide; the subset rule still applies. The audit operator is service:{principal} and the job name prefixes the reason. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../qbits/authentik/AuthentikQBitConfig.java | 24 +++++++ .../qbits/authentik/admin/AuthentikAdmin.java | 7 +- .../authentik/admin/AuthentikMaintenance.java | 59 ++++++---------- .../admin/AuthentikMaintenanceTest.java | 69 +++++++++++++++---- 5 files changed, 106 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be75eb..775f7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). - 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. diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java index 60892e9..d7e0209 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java @@ -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. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java index 7c43c39..c8385b3 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java @@ -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 permissions = new TreeSet<>(); diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java index d68832b..780c3a5 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java @@ -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 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 groups = admin.getUtil().getAllPages("/core/groups/?name=" + URLEncoder.encode(groupName, StandardCharsets.UTF_8) @@ -108,7 +108,7 @@ 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) + 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)); @@ -139,23 +139,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 - { - } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenanceTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenanceTest.java index e428fb9..ddaafa4 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenanceTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenanceTest.java @@ -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 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,37 @@ 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); + } + + + /******************************************************************************* ** Apply using the production public API. *******************************************************************************/ -- 2.54.0 From 1f12334f4f7fe7bd90a08be45f0dff6c95ca3b23 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 16 Oct 2026 09:15:22 -0500 Subject: [PATCH 2/3] fix(maintenance): never change a service principal's role group Maintenance (itself a principal) refuses groups named service:... or under qqq-service-accounts: only the owner changes principal grants (security review H3). Co-Authored-By: Claude Opus 5.5 --- .../authentik/admin/AuthentikMaintenance.java | 31 +++++++++++++++++++ .../admin/AuthentikMaintenanceTest.java | 15 +++++++++ 2 files changed, 46 insertions(+) diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java index 780c3a5..400722e 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenance.java @@ -108,6 +108,14 @@ public final class AuthentikMaintenance throw (new QUserFacingException("Expected exactly one Authentik group named " + groupName + ".")); } JSONObject group = groups.get(0); + 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)); @@ -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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenanceTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenanceTest.java index ddaafa4..f5c9b9a 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenanceTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikMaintenanceTest.java @@ -359,6 +359,21 @@ class AuthentikMaintenanceTest + /******************************************************************************* + ** 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. *******************************************************************************/ -- 2.54.0 From b784bfd09746647c0404695623f34bab0c0355d3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 16 Oct 2026 13:19:05 -0500 Subject: [PATCH 3/3] build: QQQ 4.1.0-RC.19 Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8bf371a..00e6514 100644 --- a/pom.xml +++ b/pom.xml @@ -249,7 +249,7 @@ true - 4.1.0-RC.8 + 4.1.0-RC.19 -- 2.54.0