diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50992f6..a7c5114 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
### Fixed
+- Scheduled analyses and watches work under OAuth2. When the host declares the `data-analysis` service principal (QQQ service identities, `DataAnalysisQBitConfig.withServicePrincipal`), schedule and watch inventory runs as that principal, and owner and recipient runs go on their behalf through it: their current access intersected with the principal's. The self-made `data-analysis-scheduler` session was rejected under OAuth2 (`Invalid session in request` on every `data_analysis_run_schedules` run). With no principal declared, behavior is unchanged. Needs the QQQ release with service identities (qqq#1255, #1256).
- QBit metadata now reports the Maven artifact version, including release versions, from a filtered build resource.
### Added
diff --git a/pom.xml b/pom.xml
index e3ff7b7..6ba151f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -243,7 +243,7 @@
true
- 4.1.0-RC.15
+ 4.1.0-RC.19
diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitConfig.java b/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitConfig.java
index 64858c6..922c88e 100644
--- a/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitConfig.java
+++ b/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitConfig.java
@@ -427,6 +427,28 @@ public class DataAnalysisQBitConfig implements QBitConfig
return (this);
}
+ private String servicePrincipal = "data-analysis";
+
+ /*******************************************************************************
+ ** The service principal schedules and watches run as when the host declares
+ ** it (QQQ service identities): inventory as the principal, owner and
+ ** recipient runs on their behalf through it. Undeclared: the previous local
+ ** sessions.
+ *******************************************************************************/
+ public String getServicePrincipal()
+ {
+ return (servicePrincipal);
+ }
+
+ /*******************************************************************************
+ ** Fluent setter for servicePrincipal (default data-analysis).
+ *******************************************************************************/
+ public DataAnalysisQBitConfig withServicePrincipal(String servicePrincipal)
+ {
+ this.servicePrincipal = servicePrincipal;
+ return (this);
+ }
+
private String messagingProviderName;
private String fromAddress;
private transient java.util.function.Function recipientAddressSupplier;
diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/AnalysisSessions.java b/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/AnalysisSessions.java
new file mode 100644
index 0000000..4814926
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/AnalysisSessions.java
@@ -0,0 +1,90 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2026. 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.dataanalysis.schedule;
+
+
+import java.util.function.Supplier;
+import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
+import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
+
+
+/*******************************************************************************
+ ** Sessions for scheduled analysis work. With the host's data-analysis service
+ ** principal declared (QQQ service identities): inventory runs as the
+ ** principal, and owner or recipient runs go on their behalf through it (their
+ ** current access intersected with the principal's). Without it: the previous
+ ** local sessions (single-node default unchanged). Never a system session.
+ *******************************************************************************/
+public final class AnalysisSessions
+{
+ /*******************************************************************************
+ ** Utility class.
+ *******************************************************************************/
+ private AnalysisSessions()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** The session for schedule/watch inventory and status updates.
+ *******************************************************************************/
+ public static QSession maintenance(DataAnalysisQBitConfig config, Supplier legacy) throws QException
+ {
+ if(usesPrincipal(config))
+ {
+ return (ServiceIdentities.session(QContext.getQInstance(), config.getServicePrincipal()));
+ }
+ return (legacy.get());
+ }
+
+
+
+ /*******************************************************************************
+ ** The session for running as a user (owner or recipient).
+ *******************************************************************************/
+ public static QSession forUser(DataAnalysisQBitConfig config, String userId) throws QException
+ {
+ if(config.getOwnerSessionSupplier() != null)
+ {
+ return (config.getOwnerSessionSupplier().apply(userId));
+ }
+ if(usesPrincipal(config))
+ {
+ return (ServiceIdentities.sessionOnBehalfOf(QContext.getQInstance(), config.getServicePrincipal(), userId));
+ }
+ return (new QAuthenticationModuleDispatcher().getQModule(QContext.getQInstance().getAuthentication()).createAutomatedSessionForUser(QContext.getQInstance(), userId));
+ }
+
+
+
+ /*******************************************************************************
+ ** Whether the host declared the configured principal.
+ *******************************************************************************/
+ private static boolean usesPrincipal(DataAnalysisQBitConfig config)
+ {
+ return (config.getServicePrincipal() != null && ServiceIdentities.isDeclared(QContext.getQInstance(), config.getServicePrincipal()));
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleRunner.java b/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleRunner.java
index 5aee7b8..4d63023 100644
--- a/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleRunner.java
+++ b/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleRunner.java
@@ -58,7 +58,6 @@ 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.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
-import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
/*******************************************************************************
** Scheduled work uses owner and recipient sessions; maintenance only inventories rows.
@@ -83,13 +82,17 @@ public class ScheduleRunner
*******************************************************************************/
public synchronized Integer runDue(Instant now) throws QException
{
- QSession maintenance = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
- .withPermissions("data_analysis.saved.read", "data_analysis.saved.edit")
- .withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
- if(QContext.getQSession().getBackendVariants() != null)
+ QSession maintenance = AnalysisSessions.maintenance(config, () ->
{
- maintenance.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
- }
+ QSession legacy = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
+ .withPermissions("data_analysis.saved.read", "data_analysis.saved.edit")
+ .withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
+ if(QContext.getQSession().getBackendVariants() != null)
+ {
+ legacy.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
+ }
+ return (legacy);
+ });
int[] count = {0};
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), maintenance), () ->
{
@@ -161,9 +164,7 @@ public class ScheduleRunner
QSession session;
try
{
- session = config.getOwnerSessionSupplier() == null
- ? new QAuthenticationModuleDispatcher().getQModule(QContext.getQInstance().getAuthentication()).createAutomatedSessionForUser(QContext.getQInstance(), id)
- : config.getOwnerSessionSupplier().apply(id);
+ session = AnalysisSessions.forUser(config, id);
}
catch(Exception e)
{
diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/watch/WatchEvaluator.java b/src/main/java/com/kingsrook/qbits/dataanalysis/watch/WatchEvaluator.java
index 5a814c7..7f785eb 100644
--- a/src/main/java/com/kingsrook/qbits/dataanalysis/watch/WatchEvaluator.java
+++ b/src/main/java/com/kingsrook/qbits/dataanalysis/watch/WatchEvaluator.java
@@ -49,7 +49,6 @@ 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.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
-import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
/*******************************************************************************
** Transition watches through the core alerts SPI, computing exclusively as owner.
@@ -94,13 +93,17 @@ public class WatchEvaluator
*******************************************************************************/
public synchronized Integer evaluateEnabled(Instant now) throws QException
{
- QSession maintenance = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
- .withPermissions("data_analysis.watch.read", "data_analysis.watch.edit")
- .withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
- if(QContext.getQSession().getBackendVariants() != null)
+ QSession maintenance = com.kingsrook.qbits.dataanalysis.schedule.AnalysisSessions.maintenance(config, () ->
{
- maintenance.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
- }
+ QSession legacy = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
+ .withPermissions("data_analysis.watch.read", "data_analysis.watch.edit")
+ .withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
+ if(QContext.getQSession().getBackendVariants() != null)
+ {
+ legacy.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
+ }
+ return (legacy);
+ });
int[] count = {0};
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), maintenance), () ->
{
@@ -128,9 +131,7 @@ public class WatchEvaluator
QSession session;
try
{
- session = config.getOwnerSessionSupplier() == null
- ? new QAuthenticationModuleDispatcher().getQModule(QContext.getQInstance().getAuthentication()).createAutomatedSessionForUser(QContext.getQInstance(), owner)
- : config.getOwnerSessionSupplier().apply(owner);
+ session = com.kingsrook.qbits.dataanalysis.schedule.AnalysisSessions.forUser(config, owner);
}
catch(Exception e)
{
diff --git a/src/test/java/com/kingsrook/qbits/dataanalysis/SchedulesTest.java b/src/test/java/com/kingsrook/qbits/dataanalysis/SchedulesTest.java
index 0bd49da..04fdaae 100644
--- a/src/test/java/com/kingsrook/qbits/dataanalysis/SchedulesTest.java
+++ b/src/test/java/com/kingsrook/qbits/dataanalysis/SchedulesTest.java
@@ -26,6 +26,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
+import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
import com.kingsrook.qbits.dataanalysis.processes.ScheduleActions;
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
@@ -47,6 +48,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProvide
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
+import com.kingsrook.qqq.backend.core.serviceidentity.QServicePrincipalMetaData;
+import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentitiesMetaData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -160,6 +163,27 @@ class SchedulesTest extends EngineFixture
assertThat(WatchesTest.Sink.raised).isEmpty();
}
+ /*******************************************************************************
+ ** With the host's data-analysis principal declared, schedule inventory runs
+ ** as the principal (its grants decide - a principal without them finds
+ ** nothing due), never as a self-made session.
+ *******************************************************************************/
+ @Test
+ void inventoryRunsAsTheDeclaredPrincipal() throws Exception
+ {
+ schedule("PIVOT", Map.of("rows", List.of(Map.of("field", "status")), "values", List.of(Map.of("operator", "COUNT"))), "CSV");
+ config.withServicePrincipal("analysis-bare");
+ ServiceIdentitiesMetaData.declare(instance, new QServicePrincipalMetaData().withName("analysis-bare"));
+ assertThatThrownBy(() -> runner.runDue(NOW)).isInstanceOf(QException.class);
+ assertThat(Mail.sent).isEmpty();
+
+ ServiceIdentitiesMetaData.declare(instance, new QServicePrincipalMetaData().withName("data-analysis")
+ .withLocalPermissions("data_analysis.saved.read", "data_analysis.saved.edit").withLocalSecurityKeyValue(DataAnalysisPermissions.ADMIN, true));
+ config.withServicePrincipal("data-analysis");
+ assertThat(runner.runDue(NOW)).isEqualTo(1);
+ assertThat(Mail.sent).hasSize(2);
+ }
+
@Test
void providerFailureAndMissingOwnerRaiseVisibleAlerts() throws Exception
{