diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0806c..30c146e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ All notable changes to this project are documented here. The format follows Keep - `MarketingTablesGuardrailTest` and `ChannelRulesStalenessTest`. ### Changed +- The Marketing home is business content only: **Configuration Status** and **Agent Activity** (provider configuration, model/tool calls and tokens) are off the home and exposed as `MarketingWidgets.ADMIN_WIDGETS` for a host to place on its Admin area; they need `marketing.admin.read` (hidden without it). **Needs Attention** gains one row per active channel that cannot send ("Sends on are paused - ask an admin"), linking to the channel. +- Home and record widgets require `marketing.read` through `MarketingPermissionChecker` (HIDDEN): a user with app access but no read no longer sees a "You need marketing.read ..." shell; the widgets are omitted. - `QbitCrmGateway` uses qbit-crm 0.2.0's `crm_*` snake_case table names (`crm_contact`, ...). qbit-crm 0.1.0's camelCase tables are no longer supported. - Make the agent limits distributed (#4): `maxConcurrentAgentRuns` counts Running `marketing_agent_run` rows in the database (a new `heartbeatAt` column, updated on each model call, lets runs whose server stopped be marked Failed first), and `maxModelCallsPerMinute` counts model-call steps in the last 60 seconds and makes a run wait rather than fail. The in-memory semaphore and rate window are gone; the settings and Marketing Policy override keep their names and defaults (the policy `maxConcurrentAgentRuns` override now applies). Hosts add `heartbeat_at` to `marketing_agent_run`. The count-then-insert window is still racy; a conditional-update claim (qqq#1178) is a follow-up. - Move to the assistant provider SPI `0.1.0-RC.12` (package `com.kingsrook.qbits.assistant.spi`) and `qbit-build-parent` `2.1.2`, and drop the per-repository guardrail allow-lists. `qbit-assistant-provider-openai` becomes a test dependency; hosts that register it add it themselves. diff --git a/src/main/java/com/kingsrook/qbits/marketing/security/MarketingPermissionChecker.java b/src/main/java/com/kingsrook/qbits/marketing/security/MarketingPermissionChecker.java new file mode 100644 index 0000000..621834b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/marketing/security/MarketingPermissionChecker.java @@ -0,0 +1,122 @@ +/* + * 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.marketing.security; + + +import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** Gates a widget on one exact permission name (marketing.read for the home + ** widgets, marketing.admin.read for the admin-placeable ones). QQQ's widget + ** rules can only express ".hasAccess", so this checker is how a widget + ** requires a read permission. Denied widgets are HIDDEN: omitted from the + ** dashboard rather than shown locked. + *******************************************************************************/ +public class MarketingPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference +{ + public static final String PERMISSION_PROPERTY = "permissionName"; + + private String permissionName; + + + + /******************************************************************************* + ** Permission rules requiring the named permission; hidden when missing. + *******************************************************************************/ + public static QPermissionRules rules(String permissionName) + { + return (new QPermissionRules() + .withLevel(PermissionLevel.HAS_ACCESS_PERMISSION) + .withDenyBehavior(DenyBehavior.HIDDEN) + .withCustomPermissionChecker(new QCodeReferenceWithProperties(MarketingPermissionChecker.class, Map.of(PERMISSION_PROPERTY, permissionName)))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void initialize(QCodeReference codeReference) + { + if(codeReference instanceof QCodeReferenceWithProperties withProperties) + { + this.permissionName = ValueUtils.getValueAsString(withProperties.getProperties().get(PERMISSION_PROPERTY)); + } + } + + + + /******************************************************************************* + ** Deny unless the session holds the permission (fail closed). The system + ** user (scheduler) passes. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException + { + QSession session = QContext.getQSession(); + if(session instanceof QSystemUserSession) + { + return; + } + if(permissionName == null || session == null || !session.hasPermission(permissionName)) + { + throw (new QPermissionDeniedException("Permission denied.")); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public boolean handlesBuildAvailablePermission() + { + return (true); + } + + + + /******************************************************************************* + ** The exact permission name. + *******************************************************************************/ + @Override + public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType) + { + AvailablePermission permission = buildBaseAvailablePermission(rules, permissionSubType, metaDataWithName, objectType); + return (permission == null || permissionName == null ? null : permission.withName(permissionName)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/marketing/widgets/MarketingWidgets.java b/src/main/java/com/kingsrook/qbits/marketing/widgets/MarketingWidgets.java index 0c20d6c..f9f9da6 100644 --- a/src/main/java/com/kingsrook/qbits/marketing/widgets/MarketingWidgets.java +++ b/src/main/java/com/kingsrook/qbits/marketing/widgets/MarketingWidgets.java @@ -48,11 +48,14 @@ import com.kingsrook.qbits.marketing.model.enums.ContentStatus; import com.kingsrook.qbits.marketing.model.enums.ExecutionStatus; import com.kingsrook.qbits.marketing.model.enums.FindingSeverity; import com.kingsrook.qbits.marketing.model.enums.FindingStatus; +import com.kingsrook.qbits.marketing.security.MarketingPermissionChecker; import com.kingsrook.qbits.marketing.security.MarketingPermissions; +import com.kingsrook.qbits.marketing.transport.MarketingTransport; import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder; import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; @@ -75,15 +78,19 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils; ** The Marketing widgets: the app home dashboard (the one Marketing ** overview - other apps only link to it) and two record widgets. ** - ** - Home: Needs Attention, Campaigns, Agent Activity, Configuration Status. + ** - Home: Needs Attention, Campaigns (business content only). + ** - Admin-placeable (not on the home): Agent Activity (model/tool calls and + ** tokens) and Configuration Status (provider configuration) are system + ** content; a host places ADMIN_WIDGETS on its Admin area. They need + ** marketing.admin.read. ** - Campaign record: Results (per stage; "Not measured" is never shown as 0). ** - Content record: Proposed Publication (the exact text, destination, ** recipients and schedule a reviewer approves). ** ** All are full-width tables of at most three columns (or one HTML block), ** so they read at every width without inner scrolling. All data is real; - ** with none, they say so. Renderers require marketing.read and read with - ** the viewer's record locks. + ** with none, they say so. Home widgets require marketing.read (omitted, never + ** shown locked, without it) and read with the viewer's record locks. *******************************************************************************/ public final class MarketingWidgets { @@ -96,7 +103,12 @@ public final class MarketingWidgets private static final DateTimeFormatter WHEN_FORMAT = DateTimeFormatter.ofPattern("MMM d, yyyy h:mm a z", Locale.US); - public static final List HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS, AGENT_ACTIVITY, CONFIGURATION); + public static final List HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS); + + ///////////////////////////////////////////////////////////////////////// + // system content: for a host to place on its Admin area, not the home // + ///////////////////////////////////////////////////////////////////////// + public static final List ADMIN_WIDGETS = List.of(AGENT_ACTIVITY, CONFIGURATION); @@ -115,12 +127,12 @@ public final class MarketingWidgets public static List buildWidgets() { return (List.of( - widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class), - widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class), - widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class), - widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class), - widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class), - widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class))); + widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class, MarketingPermissions.READ), + widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class, MarketingPermissions.READ), + widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class, MarketingPermissions.ADMIN_READ), + widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class, MarketingPermissions.ADMIN_READ), + widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class, MarketingPermissions.READ), + widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class, MarketingPermissions.READ))); } @@ -128,7 +140,7 @@ public final class MarketingWidgets /******************************************************************************* ** *******************************************************************************/ - private static QWidgetMetaData widget(String name, String label, WidgetType type, Class renderer) + private static QWidgetMetaData widget(String name, String label, WidgetType type, Class renderer, String permission) { return (new QWidgetMetaData() .withName(name) @@ -136,7 +148,7 @@ public final class MarketingWidgets .withType(type.getType()) .withGridColumns(12) .withIsCard(true) - .withPermissionRules(MarketingPermissions.hasAccessRules(MarketingPermissions.BASE)) + .withPermissionRules(MarketingPermissionChecker.rules(permission)) .withCodeReference(new QCodeReference(renderer))); } @@ -147,16 +159,26 @@ public final class MarketingWidgets *******************************************************************************/ abstract static class Base extends AbstractWidgetRenderer { + /******************************************************************************* + ** The permission this widget needs (rendering is refused without it, behind + ** the widget's own rules: no "you need ..." text is ever returned). + *******************************************************************************/ + String requiredPermission() + { + return (MarketingPermissions.READ); + } + + + /******************************************************************************* ** *******************************************************************************/ @Override public RenderWidgetOutput render(RenderWidgetInput input) throws QException { - QSession session = QContext.getQSession(); - if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(MarketingPermissions.READ))) + if(!MarketingPermissions.has(requiredPermission())) { - return (new RenderWidgetOutput(new RawHTML(null, "

You need " + MarketingPermissions.READ + " to see marketing data.

"))); + throw (new QPermissionDeniedException("Permission denied.")); } return (renderFor(input)); } @@ -283,9 +305,33 @@ public final class MarketingWidgets "what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", when(r.getValue("createDate")))); } + /////////////////////////////////////////////////////////////////////////////////// + // a channel that cannot send: said in business words (the provider detail is in // + // the admin-only Configuration Status widget) // + /////////////////////////////////////////////////////////////////////////////////// + for(QRecord r : MarketingStore.query(MarketingChannel.TABLE_NAME, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)).withOrderBy(new QFilterOrderBy("id")).withLimit(50))) + { + if(StringUtils.hasContent(r.getValueString("transportName")) && !canSend(r.getValueString("transportName"))) + { + rows.add(row("item", link(MarketingChannel.TABLE_NAME, r.getValue("id"), r.getValueString("name")), "what", "Sends on " + r.getValueString("name") + " are paused - ask an admin", + "since", "")); + } + } + return (new RenderWidgetOutput(table(List.of(column("Item", "item", true), column("What", "what", false), column("Since", "since", false)), rows, "Nothing needs attention."))); } + + + + /******************************************************************************* + ** Can the named transport send (registered, and operational or test-only)? + *******************************************************************************/ + private static boolean canSend(String transportName) throws QException + { + MarketingTransport transport = MarketingRuntime.current().getTransport(transportName); + return (transport != null && transport.getStatus().isUsable()); + } } @@ -338,6 +384,17 @@ public final class MarketingWidgets *******************************************************************************/ public static class AgentActivity extends Base { + /******************************************************************************* + ** System content: marketing.admin.read. + *******************************************************************************/ + @Override + String requiredPermission() + { + return (MarketingPermissions.ADMIN_READ); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -379,6 +436,17 @@ public final class MarketingWidgets *******************************************************************************/ public static class Configuration extends Base { + /******************************************************************************* + ** System content: marketing.admin.read. + *******************************************************************************/ + @Override + String requiredPermission() + { + return (MarketingPermissions.ADMIN_READ); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/marketing/ComponentsTest.java b/src/test/java/com/kingsrook/qbits/marketing/ComponentsTest.java index e4b8843..fc05335 100644 --- a/src/test/java/com/kingsrook/qbits/marketing/ComponentsTest.java +++ b/src/test/java/com/kingsrook/qbits/marketing/ComponentsTest.java @@ -29,13 +29,16 @@ import com.kingsrook.qbits.marketing.model.MarketingContent; import com.kingsrook.qbits.marketing.model.MarketingExecution; import com.kingsrook.qbits.marketing.model.MarketingFinding; import com.kingsrook.qbits.marketing.model.MarketingProduct; +import com.kingsrook.qbits.marketing.model.enums.AiContentPolicy; import com.kingsrook.qbits.marketing.model.enums.CampaignGoal; +import com.kingsrook.qbits.marketing.model.enums.ChannelType; import com.kingsrook.qbits.marketing.model.enums.ClaimStatus; import com.kingsrook.qbits.marketing.model.enums.FindingSeverity; import com.kingsrook.qbits.marketing.model.enums.FindingStatus; import com.kingsrook.qbits.marketing.model.enums.FindingType; import com.kingsrook.qbits.marketing.processes.MarketingActions; import com.kingsrook.qbits.marketing.readiness.ReadinessChecker; +import com.kingsrook.qbits.marketing.security.MarketingPermissions; import com.kingsrook.qbits.marketing.seed.MarketingStarterData; import com.kingsrook.qbits.marketing.transport.MarketingTransport; import com.kingsrook.qbits.marketing.transport.QqqMessagingEmailTransport; @@ -49,8 +52,11 @@ import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolContext; import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolInput; import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; @@ -64,6 +70,7 @@ import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; import com.kingsrook.qqq.backend.core.model.data.QRecord; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /******************************************************************************* @@ -117,7 +124,11 @@ class ComponentsTest extends BaseMarketingTest assertThat(campaigns.getRows()).hasSize(4); assertThat(campaigns.getColumns()).hasSizeLessThanOrEqualTo(3); assertThat(((TableData) render(MarketingWidgets.AGENT_ACTIVITY, null).getWidgetData()).getNoRowsFoundHTML()).contains("not run yet"); - assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty(); + ////////////////////////////////////////////////////////////////////////////////////////// + // the starter email channel's transport is configured but not verified: sends paused // + ////////////////////////////////////////////////////////////////////////////////////////// + assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()) + .singleElement().satisfies(r -> assertThat(String.valueOf(r.get("what"))).endsWith("are paused - ask an admin")); TableData results = (TableData) render(MarketingWidgets.CAMPAIGN_RESULTS, campaignId).getWidgetData(); assertThat(results.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("result"))).startsWith("0 unique actors")); @@ -127,8 +138,81 @@ class ComponentsTest extends BaseMarketingTest RawHTML proposal = (RawHTML) render(MarketingWidgets.CONTENT_PROPOSAL, contentId).getWidgetData(); assertThat(proposal.getHtml()).contains("<script>").doesNotContain("