From 86b4ca5ff477752f74f61dc3ea3f572a0f169bdc Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 16 Oct 2026 08:30:04 -0500 Subject: [PATCH 1/2] fix(routing): per-recipient checks run via the alerts service principal Token-less automated sessions are rejected under OAuth2, so visibility, follower and audience checks never worked there. With the host's alerts principal declared they run on the recipient's behalf through it. Recording/routing bookkeeping stays system so alerting never depends on the IdP. Undeclared: unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../qbits/alerts/AlertsQBitConfig.java | 27 ++++++++++ .../qbits/alerts/AlertsSecurity.java | 25 +++++++++ .../qbits/alerts/engine/AlertRouter.java | 14 ++--- .../notifications/AlertsAudienceResolver.java | 15 ++---- .../alerts/AlertsServicePrincipalTest.java | 52 +++++++++++++++++++ 6 files changed, 112 insertions(+), 22 deletions(-) create mode 100644 src/test/java/com/kingsrook/qbits/alerts/AlertsServicePrincipalTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ce944..0620f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project are documented here. The format follows Keep ## [0.1.0] - Unreleased ### Fixed +- Under OAuth2, per-recipient checks (alert visibility for external channels, follower record access, audience sessions) never worked: they used token-less automated sessions, which OAuth2 rejects, so external sends fell back to the conservative rule. When the host declares the QQQ `alerts` service principal (`AlertsQBitConfig.withServicePrincipal`, default `alerts`), these checks run on the recipient's behalf through it: the recipient's current access intersected with the principal's. Alert recording and routing bookkeeping stay on the system session, so alerting does not depend on the identity provider being up. With no principal declared, behavior is unchanged. Needs the QQQ release with service identities (qqq#1255, #1256). - Audit noise: every monitor run (lease claim, then run result) and every reminder check wrote a "Record was Edited" audit (about 7,000 `alerts_monitor` and 1,800 `alerts_alert` audits in 6 hours on one production host, most with no field details). Engine updates that touch only bookkeeping fields (`RuntimeFields`: monitor `claimedBy`, `claimedUntil`, `lastRunAt`, `nextRunAt`, `lastStatus`, `lastValue`, `lastError`, `lastElapsedMs`, `consecutiveFailures`, `conditionSince`; alert `timedNotificationCheckedAt`, `lastNotifiedAt`, `recordRefCount`) are written with `omitDmlAudit`. Configuration edits, raises and alert state changes are audited as before, as are UI and API edits of any field. - Read the QBit metadata version from the Maven-filtered artifact version so release builds report their release version. - Record-view child lists (e.g. a notification's Recipients) failed with "Error in JSON Serialization": the child table's meta-data includes the qbit config, and `AlertsQBitConfig.getRuntime()` points at a runtime that points back at the config, so Jackson recursed to its 1000-level nesting limit. `getRuntime()` and `getAudienceResolver()` are now `@JsonIgnore`, as in the other qbits' configs. A regression test renders and serializes every child-list widget. diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java index 4405740..4e330f5 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java @@ -80,6 +80,7 @@ public class AlertsQBitConfig implements QBitConfig private Boolean legacyAlertmanagerPath = false; private String backendName; + private String servicePrincipal = "alerts"; private String schedulerName; private String messagingProviderName; private String fromAddress; @@ -893,4 +894,30 @@ public class AlertsQBitConfig implements QBitConfig this.legacyAlertmanagerPath = legacyAlertmanagerPath; return (this); } + + + /******************************************************************************* + ** Getter for servicePrincipal: when the host declares this QQQ service + ** principal, per-recipient checks (alert visibility, follower record access, + ** audience) run on the recipient's behalf through it - their current access + ** intersected with the principal's - instead of a token-less automated + ** session (rejected under OAuth2). Alert recording and routing bookkeeping + ** stay on the system session so alerting never depends on the identity + ** provider being up. + *******************************************************************************/ + public String getServicePrincipal() + { + return (this.servicePrincipal); + } + + + + /******************************************************************************* + ** Fluent setter for servicePrincipal (default alerts). + *******************************************************************************/ + public AlertsQBitConfig withServicePrincipal(String servicePrincipal) + { + this.servicePrincipal = servicePrincipal; + return (this); + } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsSecurity.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsSecurity.java index ca8a175..0ac9112 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsSecurity.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsSecurity.java @@ -26,6 +26,7 @@ import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; 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.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock; @@ -34,6 +35,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; 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.QAuthenticationModuleDispatcher; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface; +import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -307,4 +311,25 @@ public final class AlertsSecurity { return (QContext.getQSession() instanceof QSystemUserSession); } + + + /******************************************************************************* + ** A session for a recipient: on their behalf through the alerts service + ** principal when the host declares it (QQQ service identities), else the + ** authentication module's automated session (previous behavior). Null when + ** neither can be made - callers fail closed. + *******************************************************************************/ + public static QSession recipientSession(QInstance qInstance, String principalName, String userId) throws QException + { + if(qInstance == null || qInstance.getAuthentication() == null) + { + return (null); + } + if(StringUtils.hasContent(principalName) && ServiceIdentities.isDeclared(qInstance, principalName)) + { + return (ServiceIdentities.sessionOnBehalfOf(qInstance, principalName, userId)); + } + QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication()); + return (module.createAutomatedSessionForUser(qInstance, userId)); + } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java index 4482b28..8a267d9 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java @@ -66,8 +66,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent; import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower; import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference; import com.kingsrook.qqq.backend.core.model.session.QSession; -import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher; -import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -882,18 +880,12 @@ public class AlertRouter /******************************************************************************* - ** A session for a user, from the instance's authentication module (null - ** when it can't make one). + ** A session for a user: on their behalf via the alerts service principal + ** when declared, else the authentication module's (null when neither). *******************************************************************************/ private QSession automatedSession(String userId) throws QException { - QInstance qInstance = runtime.getQInstance(); - if(qInstance.getAuthentication() == null) - { - return (null); - } - QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication()); - return (module.createAutomatedSessionForUser(qInstance, userId)); + return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId)); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsAudienceResolver.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsAudienceResolver.java index bb3133a..68e6703 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsAudienceResolver.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsAudienceResolver.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import com.kingsrook.qbits.alerts.AlertsRuntime; +import com.kingsrook.qbits.alerts.AlertsSecurity; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -33,8 +34,6 @@ import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.session.QSession; -import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher; -import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -218,20 +217,14 @@ public class AlertsAudienceResolver /******************************************************************************* - ** A session for a user from the instance's authentication module (null - ** when it can't make one). + ** A session for a user: on their behalf via the alerts service principal + ** when declared, else the authentication module's (null when neither). *******************************************************************************/ private QSession automatedSession(String userId) { try { - QInstance qInstance = runtime.getQInstance(); - if(qInstance.getAuthentication() == null) - { - return (null); - } - QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication()); - return (module.createAutomatedSessionForUser(qInstance, userId)); + return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId)); } catch(Exception e) { diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsServicePrincipalTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsServicePrincipalTest.java new file mode 100644 index 0000000..3d3904a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsServicePrincipalTest.java @@ -0,0 +1,52 @@ +/* + * 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.alerts; + + +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.serviceidentity.QDelegatedSession; +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.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Per-recipient checks run on the recipient's behalf through the alerts + ** service principal when the host declares it; otherwise unchanged. + *******************************************************************************/ +class AlertsServicePrincipalTest extends BaseAlertsTest +{ + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void recipientSessionsGoThroughTheDeclaredPrincipal() throws Exception + { + QSession before = AlertsSecurity.recipientSession(qInstance, config.getServicePrincipal(), "alice"); + assertThat(before).isNotInstanceOf(QDelegatedSession.class); + assertThat(before.getUser().getIdReference()).isEqualTo("alice"); + + ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("alerts").withMayActOnBehalfOfUsers(true)); + QSession after = AlertsSecurity.recipientSession(qInstance, config.getServicePrincipal(), "alice"); + assertThat(after).isInstanceOf(QDelegatedSession.class); + assertThat(after.getUser().getIdReference()).isEqualTo("alice"); + assertThat(ServiceIdentities.principalOf(after)).isEqualTo("alerts"); + assertThat(ServiceIdentities.attributionName(after)).endsWith("via service:alerts"); + } +} -- 2.54.0 From b89c95f749005f2e6fc607fbc5183fa6bb8d205c Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 16 Oct 2026 13:19:08 -0500 Subject: [PATCH 2/2] 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 b3506e2..5a3ce28 100644 --- a/pom.xml +++ b/pom.xml @@ -267,7 +267,7 @@ true - 4.1.0-RC.15 + 4.1.0-RC.19 -- 2.54.0