From 80bf85a15833382ec537e2099db4d7e01ec3443e Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 18:24:59 -0500 Subject: [PATCH] fix(health): run context-less health checks as the system user CrmHealthChecks.indicators() captured a plain tokenless new QSession() when registered without a caller session (host startup). Token-based authentication modules (OAuth2, Auth0) reject any tokenless session other than QSystemUserSession, so on such hosts every CRM check failed session validation and reported UNKNOWN. With no caller session, each check now runs as core QSystemUserSession(instance), created at check time, the same pattern as qbit-backups#24. A supplied monitoring session is still used unchanged. The checks return only UP/DEGRADED, never values. contextlessIndicatorsRunOnTokenRequiredHost uses a test authentication module that rejects tokenless sessions; it fails without this fix. Co-Authored-By: Claude Opus 5.5 --- .../qbits/crm/health/CrmHealthChecks.java | 14 ++-- .../qbits/crm/CrmHealthBulkLoadTest.java | 34 +++++++++ .../TokenRequiredAuthenticationModule.java | 72 +++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/kingsrook/qbits/crm/TokenRequiredAuthenticationModule.java diff --git a/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java b/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java index 7ff3d04..16bf3b6 100644 --- a/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java +++ b/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java @@ -43,6 +43,7 @@ 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.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; import com.kingsrook.qqq.middleware.health.HealthIndicator; import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult; import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus; @@ -51,7 +52,8 @@ import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus; /******************************************************************************* ** STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9) ** CRM checks for hosts to register with their health middleware. Run in the - ** caller's tenant context; no check elevates access or returns personal values. + ** caller's tenant context when one is supplied, else as the core system user; + ** no check returns personal values. *******************************************************************************/ public class CrmHealthChecks { @@ -86,17 +88,21 @@ public class CrmHealthChecks /******************************************************************************* - ** Expose individual checks through the RC.6 health SPI. + ** Expose individual checks through the RC.6 health SPI. With no caller + ** session (registration at startup), each check runs as the core system + ** user: token-based authentication modules (OAuth2, Auth0) reject any other + ** tokenless session, and the checks report only UP/DEGRADED, never values. *******************************************************************************/ public List indicators() { - return (indicators(QContext.getQSession() == null ? new QSession() : QContext.getQSession())); + return (indicators(QContext.getQSession())); } /******************************************************************************* ** Supply the host's monitoring session explicitly for middleware worker threads. ** Its permissions and tenant keys are preserved; this adapter never grants keys. + ** A null session runs each check as the core system user. *******************************************************************************/ public List indicators(QSession session) { @@ -136,7 +142,7 @@ public class CrmHealthChecks CapturedContext original = QContext.capture(); try { - QContext.init(instance, session); + QContext.init(instance, session != null ? session : new QSystemUserSession(instance)); return (switch(name) { case "crm.email" -> email(); diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java index 408125a..91819fb 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java @@ -35,6 +35,9 @@ import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.context.QContext; 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.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider; import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile; import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus; @@ -186,4 +189,35 @@ class CrmHealthBulkLoadTest extends BaseTest assertThat(QContext.getQInstance()).isSameAs(instance); } + + + /******************************************************************************* + ** Regression: indicators registered with no caller session (at host startup) + ** must still run on a host whose authentication rejects tokenless sessions + ** (OAuth2, Auth0). They used a plain new QSession(), so every check failed + ** session validation and reported UNKNOWN. + *******************************************************************************/ + @Test + void contextlessIndicatorsRunOnTokenRequiredHost() throws Exception + { + var instance = QContext.getQInstance(); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData() + .withName("token-required") + .withType(QAuthenticationType.MOCK) + .withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class))); + var status = java.util.concurrent.CompletableFuture.supplyAsync(() -> + { + try + { + var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators().get(1); + return (indicator.check(instance).getStatus()); + } + catch(Exception e) + { + throw (new AssertionError(e)); + } + }).get(); + assertThat(status).isEqualTo(HealthStatus.UP); + } + } diff --git a/src/test/java/com/kingsrook/qbits/crm/TokenRequiredAuthenticationModule.java b/src/test/java/com/kingsrook/qbits/crm/TokenRequiredAuthenticationModule.java new file mode 100644 index 0000000..5d4bdaa --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/TokenRequiredAuthenticationModule.java @@ -0,0 +1,72 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * 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 + * + * https://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.crm; + + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface; + + +/******************************************************************************* + ** Test authentication module that behaves like the token-based core modules + ** (OAuth2, Auth0): a QSystemUserSession is always valid, a session it created + ** is valid, and any other session has no token and fails validation with the + ** same message the OAuth2 module produces (JWTDecodeException: The token is + ** null). + *******************************************************************************/ +public class TokenRequiredAuthenticationModule implements QAuthenticationModuleInterface +{ + private static final Set ISSUED = ConcurrentHashMap.newKeySet(); + + + + /******************************************************************************* + ** Issue a session that carries a token. + *******************************************************************************/ + @Override + public QSession createSession(QInstance qInstance, Map context) throws QAuthenticationException + { + QSession session = new QSession(); + ISSUED.add(session.getUuid()); + return (session); + } + + + + /******************************************************************************* + ** Accept the system user and issued sessions; reject tokenless sessions. + *******************************************************************************/ + @Override + public boolean isSessionValid(QInstance instance, QSession session) + { + if(session instanceof QSystemUserSession || ISSUED.contains(session.getUuid())) + { + return (true); + } + throw (new IllegalStateException("The token is null")); + } +} -- 2.54.0