From d486153ca80ba699a521363c3991ef88331ad6d5 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:49:21 -0500 Subject: [PATCH 1/3] feat(security): Slack request signature verifier and signed button tokens v0 HMAC-SHA256 over timestamp and raw body, 300 s window, constant-time compare; button values are server-signed (message, action, record, expiry). Co-Authored-By: Claude Opus 5.5 --- .../slack/security/SlackActionToken.java | 122 ++++++++++++++++++ .../slack/security/SlackRequestVerifier.java | 66 ++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/SlackActionToken.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/security/SlackRequestVerifier.java diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackActionToken.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackActionToken.java new file mode 100644 index 0000000..e04913a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackActionToken.java @@ -0,0 +1,122 @@ +/* + * 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.slack.security; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import org.json.JSONObject; + +/******************************************************************************* + ** Stateless, domain-separated button capabilities bound to one linked message. + *******************************************************************************/ +public final class SlackActionToken +{ + /******************************************************************************* + ** Authenticated claims; expiry is checked by the interaction handler. + *******************************************************************************/ + public record Claims(int messageId, String actionName, String tableName, String recordId, long expiry) + { + } + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackActionToken() + { + } + + /******************************************************************************* + ** Default seven-day lifetime. + *******************************************************************************/ + public static String issue(SlackQBitConfig config, int messageId, String action, String table, String record) + { + String payload = encode(new JSONObject().put("message", messageId).put("action", action).put("table", table) + .put("record", record).put("expiry", SlackRuntime.clock().instant().getEpochSecond() + 604800).toString().getBytes(StandardCharsets.UTF_8)); + String token = payload + "." + encode(sign(config, payload)); + if(token.length() > 2000) + { + throw (new IllegalArgumentException("Slack button value exceeds limit")); + } + return (token); + } + + /******************************************************************************* + ** Verify before decoding claims. Never include token contents in failures. + *******************************************************************************/ + public static Claims verify(SlackQBitConfig config, String token) + { + try + { + if(token == null || token.length() > 2000) + { + throw (new IllegalArgumentException()); + } + String[] parts = token.split("\\.", -1); + if(parts.length != 2 || !MessageDigest.isEqual(sign(config, parts[0]), Base64.getUrlDecoder().decode(parts[1]))) + { + throw (new IllegalArgumentException()); + } + JSONObject json = new JSONObject(new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8)); + return (new Claims(json.getInt("message"), json.getString("action"), json.getString("table"), json.getString("record"), json.getLong("expiry"))); + } + catch(Exception e) + { + throw (new IllegalArgumentException("Invalid Slack button")); + } + } + + /******************************************************************************* + ** Derive a purpose-specific signing key from the configured token key. + *******************************************************************************/ + private static byte[] sign(SlackQBitConfig config, String payload) + { + try + { + byte[] key = Base64.getDecoder().decode(SlackSecrets.get(config, config.getTokenKeyEnvName())); + if(key.length != 32) + { + throw (new IllegalArgumentException()); + } + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + byte[] derived = mac.doFinal("slack-action".getBytes(StandardCharsets.UTF_8)); + mac.init(new SecretKeySpec(derived, "HmacSHA256")); + return (mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } + catch(Exception e) + { + throw (new IllegalArgumentException("Slack signing unavailable")); + } + } + + /******************************************************************************* + ** URL-safe unpadded encoding. + *******************************************************************************/ + private static String encode(byte[] bytes) + { + return (Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackRequestVerifier.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackRequestVerifier.java new file mode 100644 index 0000000..64c260e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackRequestVerifier.java @@ -0,0 +1,66 @@ +/* + * 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.slack.security; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.support.SlackRuntime; + +/******************************************************************************* + ** Authenticate raw callback bytes before parsing or retaining any payload. + *******************************************************************************/ +public final class SlackRequestVerifier +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackRequestVerifier() + { + } + + /******************************************************************************* + ** Fail closed with no secret or request data in diagnostics. + *******************************************************************************/ + public static boolean verify(SlackQBitConfig config, String timestamp, String signature, byte[] body) + { + try + { + long seconds = Long.parseLong(timestamp); + long now = SlackRuntime.clock().instant().getEpochSecond(); + if(seconds < now - 300 || seconds > now + 300 || signature == null || !signature.matches("v0=[0-9a-f]{64}")) + { + return (false); + } + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SlackSecrets.get(config, config.getSigningSecretEnvName()).getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + mac.update(("v0:" + timestamp + ":").getBytes(StandardCharsets.UTF_8)); + return (MessageDigest.isEqual(mac.doFinal(body), HexFormat.of().parseHex(signature.substring(3)))); + } + catch(Exception e) + { + return (false); + } + } +} -- 2.54.0 From fb93a01a4122986e46a15399de988c121e821e9c Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:49:22 -0500 Subject: [PATCH 2/3] feat(metadata): slack_interaction audit table (child of messages) Co-Authored-By: Claude Opus 5.5 --- docs/ddl/slack-postgres.sql | 19 +++++++++ .../qbits/slack/metadata/SlackEnums.java | 8 ++++ .../qbits/slack/metadata/SlackTables.java | 41 ++++++++++++++++--- .../qbits/slack/SlackMetadataTest.java | 6 +-- src/test/resources/slack-h2.sql | 19 +++++++++ 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/docs/ddl/slack-postgres.sql b/docs/ddl/slack-postgres.sql index ae060ae..2bb2550 100644 --- a/docs/ddl/slack-postgres.sql +++ b/docs/ddl/slack-postgres.sql @@ -166,3 +166,22 @@ CREATE INDEX slack_message_parent ON slack_message (parent_message_id); CREATE INDEX slack_connection_workspace ON slack_connection (workspace_id); CREATE INDEX slack_channel_workspace ON slack_channel (workspace_id); CREATE INDEX slack_user_group_workspace ON slack_user_group (workspace_id); + +CREATE TABLE slack_interaction ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + workspace_id INTEGER, + message_id INTEGER, + team_id VARCHAR(500), + slack_user_id VARCHAR(500), + user_id VARCHAR(500), + action_name VARCHAR(500), + delivery_key VARCHAR(500), + outcome VARCHAR(500), + error VARCHAR(500), + completed_date TIMESTAMP, + UNIQUE (delivery_key) +); +CREATE INDEX slack_interaction_message ON slack_interaction (message_id); +CREATE INDEX slack_interaction_workspace ON slack_interaction (workspace_id); diff --git a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackEnums.java b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackEnums.java index cda8b09..f3f10cb 100644 --- a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackEnums.java +++ b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackEnums.java @@ -195,4 +195,12 @@ public final class SlackEnums AUTH_FAILURE, LEASE_EXPIRED } + + /******************************************************************************* + ** Terminal interaction outcomes; an uncompleted row is in flight. + *******************************************************************************/ + public enum InteractionOutcome implements SlackEnum + { + DONE, DENIED, FAILED, REJECTED, EXPIRED, UNLINKED + } } diff --git a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java index aad1779..73a485d 100644 --- a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java +++ b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java @@ -47,7 +47,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; /******************************************************************************* ** The Slack qbit's tables (docs/DESIGN.md sections 3.1, 3.2, 5.1, 6.2, 6.3 and ** 14.1): workspaces, connections, registered channels, user groups, templates, - ** the message outbox and its attempts. slack_interaction is phase 2. + ** the message outbox, attempts and interaction audits. ** ** Security, in one place: ** - The host's tenant lock (config securityFields and recordSecurityLocks) goes @@ -69,17 +69,20 @@ public final class SlackTables public static final String USER_GROUP = "slack_user_group"; public static final String TEMPLATE = "slack_template"; public static final String MESSAGE = "slack_message"; + public static final String INTERACTION = "slack_interaction"; + public static final String JOIN_MESSAGE_INTERACTION = MESSAGE + "_join_" + INTERACTION; + public static final String JOIN_WORKSPACE_INTERACTION = WORKSPACE + "_join_" + INTERACTION; public static final String ATTEMPT = "slack_message_attempt"; public static final List MAIN = List.of(MESSAGE, CONNECTION, WORKSPACE, CHANNEL, USER_GROUP, TEMPLATE); - public static final List ALL = List.of(WORKSPACE, CONNECTION, CHANNEL, USER_GROUP, TEMPLATE, MESSAGE, ATTEMPT); + public static final List ALL = List.of(WORKSPACE, CONNECTION, CHANNEL, USER_GROUP, TEMPLATE, MESSAGE, ATTEMPT, INTERACTION); ///////////////////////////////////////////////////////////////////////////// // written by the qbit itself (the dispatcher and the OAuth callback), never // // by users through a table screen // ///////////////////////////////////////////////////////////////////////////// - public static final List SYSTEM_WRITTEN = List.of(MESSAGE, ATTEMPT); + public static final List SYSTEM_WRITTEN = List.of(MESSAGE, ATTEMPT, INTERACTION); public static final String JOIN_WORKSPACE_CONNECTION = WORKSPACE + "_join_" + CONNECTION; public static final String JOIN_WORKSPACE_CHANNEL = WORKSPACE + "_join_" + CHANNEL; @@ -127,6 +130,7 @@ public final class SlackTables result.add(QPossibleValueSource.newForEnum("slack_message_operation", SlackEnums.Operation.values())); result.add(QPossibleValueSource.newForEnum("slack_target_type", SlackEnums.TargetType.values())); result.add(QPossibleValueSource.newForEnum("slack_post_as", SlackEnums.PostAs.values())); + result.add(QPossibleValueSource.newForEnum("slack_interaction_outcome", SlackEnums.InteractionOutcome.values())); result.add(QPossibleValueSource.newForEnum("slack_attempt_outcome", SlackEnums.AttemptOutcome.values())); for(String name : ALL) @@ -158,6 +162,7 @@ public final class SlackTables tables.add(template(config)); tables.add(message(config)); tables.add(attempt(config)); + tables.add(interaction(config)); for(QTableMetaData table : tables) { @@ -168,6 +173,7 @@ public final class SlackTables case CONNECTION -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_CONNECTION)); case CHANNEL -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_CHANNEL)); case USER_GROUP -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_USER_GROUP)); + case INTERACTION -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_INTERACTION)); case MESSAGE -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_MESSAGE)); case ATTEMPT -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_MESSAGE, JOIN_MESSAGE_ATTEMPT)); default -> throw (new IllegalStateException("Unexpected table")); @@ -229,7 +235,7 @@ public final class SlackTables table.withRecordSecurityLock(clone); } - if(CONNECTION.equals(table.getName())) + if(CONNECTION.equals(table.getName()) || INTERACTION.equals(table.getName())) { table.withRecordSecurityLock(SlackSecurity.ownerLock("userId")); } @@ -481,6 +487,7 @@ public final class SlackTables table.withSection(SectionFactory.defaultT1("id", "status", "ownerUserId", "workspaceId", "targetType", "channelLabel")); table.withSection(SectionFactory.defaultT2("connectionId", "postAs", "operation", "targetRef", "slackChannelId", "category", "templateName", "text", "blocksJson", "variablesSnapshot", "idempotencyKey", "parentMessageId", "threadTs", "targetMessageId", "attachmentStorageTable", "attachmentStorageRef", "attachmentName", "attachmentBytes", "slackFileId", "fileReconcileRequired", "slackTs", "permalink", "attemptCount", "nextAttemptDate", "leaseExpiresDate", "lastErrorCode", "lastError", "sentDate", "deadLetterDate", "linkedTableName", "linkedRecordId", "senderName", "deliveryLogId")); table.withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + table.withSection(SectionFactory.customT2("interactions", new QIcon("list")).withLabel("Interactions").withWidgetName(childWidgetName(INTERACTION))); table.withSection(SectionFactory.customT2("attempts", new QIcon("list")).withLabel("Attempts").withWidgetName(childWidgetName(ATTEMPT))); return (table); } @@ -524,6 +531,8 @@ public final class SlackTables join(JOIN_WORKSPACE_CHANNEL, WORKSPACE, CHANNEL, "workspaceId"), join(JOIN_WORKSPACE_USER_GROUP, WORKSPACE, USER_GROUP, "workspaceId"), join(JOIN_WORKSPACE_MESSAGE, WORKSPACE, MESSAGE, "workspaceId"), + join(JOIN_MESSAGE_INTERACTION, MESSAGE, INTERACTION, "messageId"), + join(JOIN_WORKSPACE_INTERACTION, WORKSPACE, INTERACTION, "workspaceId"), join(JOIN_MESSAGE_ATTEMPT, MESSAGE, ATTEMPT, "messageId"), join(JOIN_MESSAGE_PARENT_CHILD, MESSAGE, MESSAGE, "parentMessageId"))); } @@ -561,7 +570,7 @@ public final class SlackTables List widgets = new ArrayList<>(); for(QJoinMetaData join : joins()) { - if(JOIN_MESSAGE_PARENT_CHILD.equals(join.getName())) + if(JOIN_MESSAGE_PARENT_CHILD.equals(join.getName()) || JOIN_WORKSPACE_INTERACTION.equals(join.getName())) { continue; } @@ -570,4 +579,26 @@ public final class SlackTables } return (widgets); } + + /******************************************************************************* + ** Payload-free interaction audit. The unique delivery key arbitrates retries. + *******************************************************************************/ + private static QTableMetaData interaction(SlackQBitConfig config) + { + return (base(config, INTERACTION, "Interactions") + .withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE)) + .withField(field("messageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE)) + .withField(field("teamId", QFieldType.STRING).withMaxLength(500)) + .withField(field("slackUserId", QFieldType.STRING).withMaxLength(500)) + .withField(field("userId", QFieldType.STRING).withMaxLength(500)) + .withField(field("actionName", QFieldType.STRING).withMaxLength(500)) + .withField(field("deliveryKey", QFieldType.STRING).withMaxLength(500)) + .withField(field("error", QFieldType.STRING).withMaxLength(500)) + .withField(field("outcome", QFieldType.STRING).withMaxLength(500).withPossibleValueSourceName("slack_interaction_outcome")) + .withField(field("completedDate", QFieldType.DATE_TIME)) + .withUniqueKey(new UniqueKey("deliveryKey")) + .withSection(SectionFactory.defaultT1("id", "workspaceId", "messageId", "userId", "actionName", "outcome")) + .withSection(SectionFactory.defaultT2("teamId", "slackUserId", "error", "completedDate", "deliveryKey")) + .withSection(SectionFactory.defaultT3("createDate", "modifyDate"))); + } } diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackMetadataTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackMetadataTest.java index eabeae5..f5d9d3f 100644 --- a/src/test/java/com/kingsrook/qbits/slack/SlackMetadataTest.java +++ b/src/test/java/com/kingsrook/qbits/slack/SlackMetadataTest.java @@ -49,7 +49,7 @@ class SlackMetadataTest { /******************************************************************************* - ** All seven tables are produced and the instance validates (H2Support runs + ** All eight tables are produced and the instance validates (H2Support runs ** QInstanceValidator), with unique keys and relations as designed. *******************************************************************************/ @Test @@ -59,7 +59,7 @@ class SlackMetadataTest { }); - assertThat(SlackTables.ALL).hasSize(7); + assertThat(SlackTables.ALL).hasSize(8); for(String name : SlackTables.ALL) { QTableMetaData table = instance.getTable(name); @@ -69,7 +69,7 @@ class SlackMetadataTest assertThat(table.getSourceQBitName()).isNotNull(); assertThat(table.getFields().get("id").getBackendName()).isEqualTo("id"); } - assertThat(instance.getTable("slack_interaction")).isNull(); + assertThat(instance.getTable("slack_interaction")).isNotNull(); assertThat(uniqueKeys(instance.getTable(SlackTables.WORKSPACE))).contains(List.of("teamId", "tenantKey")); assertThat(uniqueKeys(instance.getTable(SlackTables.CONNECTION))).contains(List.of("userId", "workspaceId")); diff --git a/src/test/resources/slack-h2.sql b/src/test/resources/slack-h2.sql index 288af2a..b3df680 100644 --- a/src/test/resources/slack-h2.sql +++ b/src/test/resources/slack-h2.sql @@ -168,3 +168,22 @@ CREATE INDEX slack_message_parent ON slack_message (parent_message_id); CREATE INDEX slack_connection_workspace ON slack_connection (workspace_id); CREATE INDEX slack_channel_workspace ON slack_channel (workspace_id); CREATE INDEX slack_user_group_workspace ON slack_user_group (workspace_id); + +CREATE TABLE slack_interaction ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + workspace_id INTEGER, + message_id INTEGER, + team_id VARCHAR(500), + slack_user_id VARCHAR(500), + user_id VARCHAR(500), + action_name VARCHAR(500), + delivery_key VARCHAR(500), + outcome VARCHAR(500), + error VARCHAR(500), + completed_date TIMESTAMP, + UNIQUE (delivery_key) +); +CREATE INDEX slack_interaction_message ON slack_interaction (message_id); +CREATE INDEX slack_interaction_workspace ON slack_interaction (workspace_id); -- 2.54.0 From 0c298f893e2dfac4708e433a04e49dca880ce88b Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:49:23 -0500 Subject: [PATCH 3/3] feat(interactivity): button actions run as the clicking user; Events API revocations POST /qqq/hooks/slack/interactions: verified, acknowledged, token checked against the stored message, Slack user mapped to a connection in the same workspace row, process run in that user's session with permission, tenant and record locks; idempotent message update; ephemeral replies only to hooks.slack.com. POST /qqq/hooks/slack/events: url_verification, tokens_revoked and app_uninstalled across every tenant row of the team. Built-in acknowledge_alert / resolve_alert when those processes exist. Co-Authored-By: Claude Opus 5.5 --- .../qbits/slack/SlackQBitConfig.java | 27 ++ .../qbits/slack/SlackQBitProducer.java | 1 + .../qbits/slack/api/SlackActionMetaData.java | 155 +++++++ .../kingsrook/qbits/slack/api/SlackApi.java | 2 +- .../qbits/slack/api/SlackApiRequest.java | 57 ++- .../slack/interactivity/SlackActions.java | 132 ++++++ .../SlackCallbackRouteProvider.java | 155 +++++++ .../slack/interactivity/SlackEvents.java | 83 ++++ .../interactivity/SlackInteractions.java | 252 +++++++++++ .../slack/oauth/SlackOAuthRouteProvider.java | 5 + .../qbits/slack/outbox/SlackSendRequest.java | 19 + .../qbits/slack/outbox/SlackService.java | 67 ++- .../qbits/slack/transport/SlackClient.java | 37 ++ .../qbits/slack/FakeSlackServer.java | 2 + .../slack/SlackInteractionRouteTest.java | 417 ++++++++++++++++++ 15 files changed, 1390 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/slack/api/SlackActionMetaData.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/interactivity/SlackActions.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/interactivity/SlackCallbackRouteProvider.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/interactivity/SlackEvents.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/interactivity/SlackInteractions.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackInteractionRouteTest.java diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java index 1ac7c80..eff6fef 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java @@ -40,6 +40,33 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; *******************************************************************************/ public class SlackQBitConfig implements QBitConfig { + private final java.util.Map actions = new java.util.LinkedHashMap<>(); + + /******************************************************************************* + ** Register a server-owned action. Approve and reject are application-defined. + *******************************************************************************/ + public SlackQBitConfig withAction(com.kingsrook.qbits.slack.api.SlackActionMetaData action) + { + if(action == null || action.getName() == null || !action.getName().matches("[a-zA-Z][a-zA-Z0-9_]{0,79}") + || action.getLabel() == null || action.getLabel().isBlank() || action.getLabel().length() > 75 + || action.getProcessName() == null || action.getPermissionName() == null + || action.getStyle() != null && !List.of("primary", "danger").contains(action.getStyle()) + || (action.getConfirmTitle() == null) != (action.getConfirmText() == null)) + { + throw (new IllegalArgumentException("Invalid Slack action metadata")); + } + actions.put(action.getName(), action); + return (this); + } + + /******************************************************************************* + ** Registered actions keyed by name. + *******************************************************************************/ + public java.util.Map getActions() + { + return (java.util.Collections.unmodifiableMap(actions)); + } + public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$"); private String clientIdEnvName = "SLACK_CLIENT_ID"; diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java index 1e5689e..553d82b 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -152,6 +152,7 @@ public class SlackQBitProducer implements QBitMetaDataProducer com.kingsrook.qbits.slack.app.SlackApp.widgets().forEach(qInstance::addWidget); com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter.register(qInstance); com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider.register(qInstance); + com.kingsrook.qbits.slack.interactivity.SlackActions.registerBuiltIns(qInstance, getQBitConfig()); try { Class.forName("com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckMetaData"); diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackActionMetaData.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackActionMetaData.java new file mode 100644 index 0000000..d5f9ede --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackActionMetaData.java @@ -0,0 +1,155 @@ +/* + * 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.slack.api; + +/******************************************************************************* + ** Server-owned button definition. Applications register approve and reject with + ** their own process and permission names; neither is a built-in approval policy. + *******************************************************************************/ +public class SlackActionMetaData +{ + private String name; + private String label; + private String style; + private String confirmTitle; + private String confirmText; + private String processName; + private String permissionName; + + /******************************************************************************* + ** Get name. + *******************************************************************************/ + public String getName() + { + return (name); + } + + /******************************************************************************* + ** Configure name. + *******************************************************************************/ + public SlackActionMetaData withName(String value) + { + name = value; + return (this); + } + + /******************************************************************************* + ** Get label. + *******************************************************************************/ + public String getLabel() + { + return (label); + } + + /******************************************************************************* + ** Configure label. + *******************************************************************************/ + public SlackActionMetaData withLabel(String value) + { + label = value; + return (this); + } + + /******************************************************************************* + ** Get style. + *******************************************************************************/ + public String getStyle() + { + return (style); + } + + /******************************************************************************* + ** Configure style. + *******************************************************************************/ + public SlackActionMetaData withStyle(String value) + { + style = value; + return (this); + } + + /******************************************************************************* + ** Get confirmTitle. + *******************************************************************************/ + public String getConfirmTitle() + { + return (confirmTitle); + } + + /******************************************************************************* + ** Configure confirmTitle. + *******************************************************************************/ + public SlackActionMetaData withConfirmTitle(String value) + { + confirmTitle = value; + return (this); + } + + /******************************************************************************* + ** Get confirmText. + *******************************************************************************/ + public String getConfirmText() + { + return (confirmText); + } + + /******************************************************************************* + ** Configure confirmText. + *******************************************************************************/ + public SlackActionMetaData withConfirmText(String value) + { + confirmText = value; + return (this); + } + + /******************************************************************************* + ** Get processName. + *******************************************************************************/ + public String getProcessName() + { + return (processName); + } + + /******************************************************************************* + ** Configure processName. + *******************************************************************************/ + public SlackActionMetaData withProcessName(String value) + { + processName = value; + return (this); + } + + /******************************************************************************* + ** Get permissionName. + *******************************************************************************/ + public String getPermissionName() + { + return (permissionName); + } + + /******************************************************************************* + ** Configure permissionName. + *******************************************************************************/ + public SlackActionMetaData withPermissionName(String value) + { + permissionName = value; + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java index 4d19cf1..6af5769 100644 --- a/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java @@ -289,7 +289,7 @@ public final class SlackApi { return (null); } - return (new SlackSendRequest().withOwnerUserId(request.ownerUserId()).withTo(request.to()).withWorkspaceId(request.workspaceId()) + return (new SlackSendRequest().withActions(request.actions()).withOwnerUserId(request.ownerUserId()).withTo(request.to()).withWorkspaceId(request.workspaceId()) .withText(request.text()).withBlocksJson(request.blocksJson()).withTemplateName(request.templateName()).withVariables(request.variables()) .withIdempotencyKey(request.idempotencyKey()).withCategory(request.category()).withPostAs(request.postAs() == null ? null : SlackEnums.PostAs.valueOf(request.postAs())) .withThreadOfMessageId(request.threadOfMessageId()).withLinkedRecord(request.linkedTableName(), request.linkedRecordId()).withSenderName(request.senderName()) diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiRequest.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiRequest.java index 6e9d86c..5e93e54 100644 --- a/src/main/java/com/kingsrook/qbits/slack/api/SlackApiRequest.java +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApiRequest.java @@ -24,14 +24,32 @@ package com.kingsrook.qbits.slack.api; ** Immutable enqueue request. Fluent withX methods return a new request. ** QUEUED means durable outbox storage, never synchronous delivery. *******************************************************************************/ -public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId, String text, String blocksJson, String templateName, java.util.Map variables, String idempotencyKey, String category, String postAs, Integer threadOfMessageId, String linkedTableName, String linkedRecordId, String senderName, String contentClassification, String title, String link) +public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId, String text, String blocksJson, String templateName, java.util.Map variables, String idempotencyKey, String category, String postAs, Integer threadOfMessageId, String linkedTableName, String linkedRecordId, String senderName, String contentClassification, String title, String link, java.util.List actions) { + /******************************************************************************* + ** Compatibility constructor for requests without buttons. + *******************************************************************************/ + public SlackApiRequest(String ownerUserId, String to, Integer workspaceId, String text, String blocksJson, String templateName, java.util.Map variables, String idempotencyKey, String category, String postAs, Integer threadOfMessageId, String linkedTableName, String linkedRecordId, String senderName, String contentClassification, String title, String link) + { + this(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, java.util.List.of()); + } + + /******************************************************************************* + ** Add a registered button to the immutable request. + *******************************************************************************/ + public SlackApiRequest withAction(String name) + { + java.util.List copy = new java.util.ArrayList<>(actions); + copy.add(name); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, copy)); + } + /******************************************************************************* ** Empty request for fluent construction. *******************************************************************************/ public SlackApiRequest() { - this(null, null, null, null, null, null, java.util.Map.of(), null, null, null, null, null, null, null, null, null, null); + this(null, null, null, null, null, null, java.util.Map.of(), null, null, null, null, null, null, null, null, null, null, java.util.List.of()); } /******************************************************************************* @@ -39,6 +57,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest { + actions = actions == null ? java.util.List.of() : java.util.List.copyOf(actions); java.util.Map copy = new java.util.LinkedHashMap<>(); if(variables != null) { @@ -52,7 +71,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withOwnerUserId(String value) { - return (new SlackApiRequest(value, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(value, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -60,7 +79,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withTo(String value) { - return (new SlackApiRequest(ownerUserId, value, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, value, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -68,7 +87,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withWorkspaceId(Integer value) { - return (new SlackApiRequest(ownerUserId, to, value, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, value, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -76,7 +95,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withText(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, value, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, value, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -84,7 +103,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withBlocksJson(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, value, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, value, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -92,7 +111,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withTemplateName(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, value, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, value, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -100,7 +119,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withVariables(java.util.Map value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, value, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, value, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -108,7 +127,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withIdempotencyKey(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, value, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, value, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -116,7 +135,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withCategory(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, value, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, value, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -124,7 +143,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withPostAs(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, value, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, value, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -132,7 +151,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withThreadOfMessageId(Integer value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, value, linkedTableName, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, value, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -140,7 +159,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withLinkedTableName(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, value, linkedRecordId, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, value, linkedRecordId, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -148,7 +167,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withLinkedRecordId(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, value, senderName, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, value, senderName, contentClassification, title, link, actions)); } /******************************************************************************* @@ -156,7 +175,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withSenderName(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, value, contentClassification, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, value, contentClassification, title, link, actions)); } /******************************************************************************* @@ -164,7 +183,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withContentClassification(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, value, title, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, value, title, link, actions)); } /******************************************************************************* @@ -172,7 +191,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withTitle(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, value, link)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, value, link, actions)); } /******************************************************************************* @@ -180,7 +199,7 @@ public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId *******************************************************************************/ public SlackApiRequest withLink(String value) { - return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, value)); + return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, value, actions)); } /******************************************************************************* diff --git a/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackActions.java b/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackActions.java new file mode 100644 index 0000000..b2031bd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackActions.java @@ -0,0 +1,132 @@ +/* + * 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.slack.interactivity; + +import java.util.List; +import com.kingsrook.qbits.slack.SlackQBitConfig; +import com.kingsrook.qbits.slack.api.SlackActionMetaData; +import com.kingsrook.qbits.slack.outbox.SlackSendRequest; +import com.kingsrook.qbits.slack.phi.SlackPayloadGuard; +import com.kingsrook.qbits.slack.security.SlackActionToken; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import org.json.JSONArray; +import org.json.JSONObject; + +/******************************************************************************* + ** Server-rendered action blocks, signed only after the message has its row id. + *******************************************************************************/ +public final class SlackActions +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackActions() + { + } + + /******************************************************************************* + ** Optional alert integration by process name, without an alerts dependency. + *******************************************************************************/ + public static void registerBuiltIns(QInstance instance, SlackQBitConfig config) + { + for(String name : List.of("acknowledge_alert", "resolve_alert")) + { + if(instance.getProcess(name) != null && !config.getActions().containsKey(name)) + { + config.withAction(new SlackActionMetaData().withName(name).withLabel("acknowledge_alert".equals(name) ? "Acknowledge" : "Resolve") + .withProcessName(name).withPermissionName(name)); + } + } + } + + /******************************************************************************* + ** Validate before inserting a message; action text obeys the payload guard. + *******************************************************************************/ + public static void validate(SlackSendRequest request) + { + if(request.getActions().isEmpty()) + { + return; + } + if(request.getActions().size() > 25 || request.getLinkedTableName() == null || request.getLinkedRecordId() == null + || "SECURITY".equals(request.getCategory()) || !request.getAttachments().isEmpty()) + { + throw (new IllegalArgumentException("Slack actions require a linked message")); + } + for(String name : request.getActions()) + { + SlackActionMetaData action = SlackRuntime.config().getActions().get(name); + if(action == null) + { + throw (new IllegalArgumentException("Unknown Slack action")); + } + SlackPayloadGuard.text(action.getLabel(), null); + SlackPayloadGuard.text(action.getConfirmTitle(), null); + SlackPayloadGuard.text(action.getConfirmText(), null); + } + } + + /******************************************************************************* + ** Retain rendered template blocks and append signed buttons. + *******************************************************************************/ + public static String blocks(QRecord row, SlackSendRequest request) + { + JSONArray blocks = row.getValueString("blocksJson") == null ? new JSONArray() : new JSONArray(row.getValueString("blocksJson")); + if(blocks.isEmpty()) + { + blocks.put(new JSONObject().put("type", "section").put("text", new JSONObject().put("type", "mrkdwn").put("text", row.getValueString("text")))); + } + JSONArray buttons = new JSONArray(); + for(String name : request.getActions()) + { + SlackActionMetaData action = SlackRuntime.config().getActions().get(name); + JSONObject button = new JSONObject().put("type", "button").put("action_id", name) + .put("text", plain(action.getLabel())).put("value", SlackActionToken.issue(SlackRuntime.config(), row.getValueInteger("id"), name, + request.getLinkedTableName(), request.getLinkedRecordId())); + if(action.getStyle() != null) + { + button.put("style", action.getStyle()); + } + if(action.getConfirmTitle() != null) + { + button.put("confirm", new JSONObject().put("title", plain(action.getConfirmTitle())).put("text", plain(action.getConfirmText())) + .put("confirm", plain("Confirm")).put("deny", plain("Cancel"))); + } + buttons.put(button); + } + blocks.put(new JSONObject().put("type", "actions").put("elements", buttons)); + if(blocks.length() > 50 || blocks.toString().length() >= 40000) + { + throw (new IllegalArgumentException("Slack actions exceed message limits")); + } + return (blocks.toString()); + } + + /******************************************************************************* + ** Literal text never introduces Slack markup. + *******************************************************************************/ + private static JSONObject plain(String value) + { + return (new JSONObject().put("type", "plain_text").put("text", value)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackCallbackRouteProvider.java b/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackCallbackRouteProvider.java new file mode 100644 index 0000000..850777b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackCallbackRouteProvider.java @@ -0,0 +1,155 @@ +/* + * 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.slack.interactivity; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import com.kingsrook.qbits.slack.security.SlackRequestVerifier; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qqq.backend.core.context.CapturedContext; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface; +import io.javalin.config.JavalinConfig; +import io.javalin.http.Context; +import org.json.JSONObject; + +/******************************************************************************* + ** Signature-gated Slack callbacks. Bounded admission returns 503 when saturated + ** so Slack retries instead of silently dropping a click. Worker context is fresh. + *******************************************************************************/ +public class SlackCallbackRouteProvider implements QJavalinRouteProviderInterface +{ + private static final Executor WORKERS = new ThreadPoolExecutor(2, 2, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), task -> + { + Thread thread = new Thread(task, "slack-callback"); + thread.setDaemon(true); + return (thread); + }, new ThreadPoolExecutor.AbortPolicy()); + + private Executor executor = WORKERS; + private QInstance instance; + + /******************************************************************************* + ** Synchronous execution hook for HTTP integration tests. + *******************************************************************************/ + public SlackCallbackRouteProvider withExecutor(Executor value) + { + executor = java.util.Objects.requireNonNull(value); + return (this); + } + + /******************************************************************************* + ** Receive the host instance. + *******************************************************************************/ + @Override + public void setQInstance(QInstance value) + { + instance = value; + } + + /******************************************************************************* + ** Slack callbacks are exempt from UI authentication, not signature checks. + *******************************************************************************/ + @Override + public void acceptJavalinConfig(JavalinConfig config) + { + config.routes.post("/qqq/hooks/slack/interactions", context -> callback(context, false)); + config.routes.post("/qqq/hooks/slack/events", context -> callback(context, true)); + } + + /******************************************************************************* + ** Verify the exact bytes before form or JSON decoding. No body is echoed on + ** errors, including malformed JSON and missing secrets. + *******************************************************************************/ + private void callback(Context context, boolean events) + { + CapturedContext previous = QContext.capture(); + context.header("Cache-Control", "no-store"); + try + { + QContext.init(instance, new QSystemUserSession(instance)); + if(!SlackRequestVerifier.verify(SlackRuntime.config(instance), context.header("X-Slack-Request-Timestamp"), + context.header("X-Slack-Signature"), context.bodyAsBytes())) + { + context.status(401).result(""); + SlackInteractions.rejected(); + return; + } + String payload = events ? context.body() : context.formParam("payload"); + if(events && "url_verification".equals(new JSONObject(payload).optString("type"))) + { + context.json(java.util.Map.of("challenge", new JSONObject(payload).getString("challenge"))); + return; + } + executor.execute(() -> work(payload, events)); + context.status(200).result(""); + } + catch(RejectedExecutionException e) + { + context.status(503).result(""); + } + catch(Exception e) + { + if(context.status() != io.javalin.http.HttpStatus.UNAUTHORIZED) + { + context.status(400).result(""); + } + } + finally + { + QContext.init(previous); + } + } + + /******************************************************************************* + ** No request session or transaction leaks onto pooled threads. Processing + ** errors carry fixed diagnostics; interaction failures are recorded by handler. + *******************************************************************************/ + private void work(String payload, boolean events) + { + CapturedContext previous = QContext.capture(); + try + { + QContext.init(instance, new QSystemUserSession(instance)); + if(events) + { + SlackEvents.handle(new JSONObject(payload)); + } + else + { + SlackInteractions.handle(payload); + } + } + catch(Exception e) + { + com.kingsrook.qqq.backend.core.logging.QLogger.getLogger(SlackCallbackRouteProvider.class).warn("Slack callback processing failed"); + } + finally + { + QContext.init(previous); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackEvents.java b/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackEvents.java new file mode 100644 index 0000000..31a7f8d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackEvents.java @@ -0,0 +1,83 @@ +/* + * 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.slack.interactivity; + +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONArray; +import org.json.JSONObject; + +/******************************************************************************* + ** Slack-authoritative revocations intentionally affect every tenant install of + ** the team. No tenant session is used to narrow an authenticated Slack event. + *******************************************************************************/ +public final class SlackEvents +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackEvents() + { + } + + /******************************************************************************* + ** Idempotently clear revoked credentials across all matching workspace rows. + *******************************************************************************/ + public static void handle(JSONObject payload) throws QException + { + if(!"event_callback".equals(payload.optString("type"))) + { + return; + } + JSONObject event = payload.getJSONObject("event"); + String type = event.optString("type"); + if(!java.util.List.of("tokens_revoked", "app_uninstalled").contains(type)) + { + return; + } + JSONObject tokens = event.optJSONObject("tokens"); + JSONArray users = tokens == null ? new JSONArray() : tokens.optJSONArray("oauth"); + JSONArray bots = tokens == null ? new JSONArray() : tokens.optJSONArray("bot"); + boolean uninstall = "app_uninstalled".equals(type); + for(QRecord workspace : SlackStore.query(SlackTables.WORKSPACE, SlackStore.equal("teamId", payload.getString("team_id")))) + { + int id = workspace.getValueInteger("id"); + if(uninstall || bots != null && !bots.isEmpty()) + { + SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", id).withValue("status", uninstall ? "UNINSTALLED" : "REVOKED") + .withValue("botToken", null).withValue("botTokenFingerprint", null), null); + } + for(QRecord connection : SlackStore.query(SlackTables.CONNECTION, SlackStore.equal("workspaceId", id))) + { + if(uninstall || users != null && users.toList().contains(connection.getValueString("slackUserId"))) + { + SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", connection.getValueInteger("id")) + .withValue("status", "REVOKED").withValue("userToken", null).withValue("userTokenFingerprint", null) + .withValue("revokedAt", SlackRuntime.clock().instant()), null); + } + } + } + SlackRuntime.membership().clear(); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackInteractions.java b/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackInteractions.java new file mode 100644 index 0000000..673dde3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/interactivity/SlackInteractions.java @@ -0,0 +1,252 @@ +/* + * 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.slack.interactivity; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import com.kingsrook.qbits.slack.api.SlackActionMetaData; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.outbox.SlackSendResult; +import com.kingsrook.qbits.slack.outbox.SlackService; +import com.kingsrook.qbits.slack.security.SlackActionToken; +import com.kingsrook.qbits.slack.security.SlackOwnerSession; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +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.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import org.json.JSONObject; + +/******************************************************************************* + ** Authenticate message binding, claim retries durably, and run as the clicker. + ** No callback payload, response URL or button token is retained in the audit. + *******************************************************************************/ +public final class SlackInteractions +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackInteractions() + { + } + + /******************************************************************************* + ** An untrusted request gets a payload-free rejection row. + *******************************************************************************/ + public static void rejected() throws QException + { + SlackStore.insert(SlackTables.INTERACTION, new QRecord().withValue("outcome", "REJECTED") + .withValue("completedDate", SlackRuntime.clock().instant()), null); + } + + /******************************************************************************* + ** The database unique delivery key is the cross-node replay barrier. Once a + ** process starts, retries never run it again, including after an uncertain crash. + *******************************************************************************/ + public static void handle(String rawPayload) throws QException + { + String key; + try + { + key = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(rawPayload.getBytes(StandardCharsets.UTF_8))); + } + catch(Exception e) + { + throw (new QException("Could not identify Slack interaction")); + } + if(!SlackStore.query(SlackTables.INTERACTION, SlackStore.equal("deliveryKey", key)).isEmpty()) + { + return; + } + QRecord audit; + try + { + audit = SlackStore.insert(SlackTables.INTERACTION, new QRecord().withValue("deliveryKey", key), null); + if(audit.getValueInteger("id") == null || audit.getErrors() != null && !audit.getErrors().isEmpty()) + { + return; + } + } + catch(QException e) + { + if(!SlackStore.query(SlackTables.INTERACTION, SlackStore.equal("deliveryKey", key)).isEmpty()) + { + return; + } + throw (new QException("Could not record Slack interaction")); + } + try + { + execute(new JSONObject(rawPayload), audit); + } + catch(Exception e) + { + finish(audit, "REJECTED", "Invalid interaction"); + } + } + + /******************************************************************************* + ** Only authenticated token claims can bind an audit to an existing message. + *******************************************************************************/ + private static void execute(JSONObject payload, QRecord audit) throws QException + { + if(!"block_actions".equals(payload.optString("type")) || payload.getJSONArray("actions").length() != 1) + { + finish(audit, "REJECTED", "Unsupported interaction"); + return; + } + JSONObject button = payload.getJSONArray("actions").getJSONObject(0); + SlackActionToken.Claims claims = SlackActionToken.verify(SlackRuntime.config(), button.getString("value")); + if(claims.expiry() <= SlackRuntime.clock().instant().getEpochSecond()) + { + finish(audit, "EXPIRED", null); + return; + } + QRecord message = SlackStore.get(SlackTables.MESSAGE, claims.messageId()); + JSONObject container = payload.getJSONObject("container"); + if(message == null || !"SENT".equals(message.getValueString("status")) + || !Objects.equals(claims.actionName(), button.getString("action_id")) + || !Objects.equals(claims.tableName(), message.getValueString("linkedTableName")) + || !Objects.equals(claims.recordId(), message.getValueString("linkedRecordId")) + || !Objects.equals(container.getString("message_ts"), message.getValueString("slackTs")) + || !Objects.equals(container.getString("channel_id"), message.getValueString("slackChannelId"))) + { + finish(audit, "REJECTED", "Invalid message binding"); + return; + } + int workspaceId = message.getValueInteger("workspaceId"); + QRecord workspace = SlackStore.get(SlackTables.WORKSPACE, workspaceId); + String team = payload.getJSONObject("team").getString("id"); + String slackUser = payload.getJSONObject("user").getString("id"); + if(workspace == null || !"ACTIVE".equals(workspace.getValueString("status")) || !team.equals(workspace.getValueString("teamId"))) + { + finish(audit, "REJECTED", "Invalid workspace binding"); + return; + } + audit.withValue("workspaceId", workspaceId).withValue("messageId", claims.messageId()).withValue("teamId", team) + .withValue("slackUserId", slackUser).withValue("actionName", claims.actionName()); + List connections = SlackStore.query(SlackTables.CONNECTION, SlackStore.equal("workspaceId", workspaceId)).stream() + .filter(row -> "ACTIVE".equals(row.getValueString("status")) && slackUser.equals(row.getValueString("slackUserId"))).toList(); + if(connections.size() != 1) + { + finish(audit, "UNLINKED", null); + respond(payload, "Connect Slack in Preferences to use this button"); + return; + } + audit.withValue("userId", connections.getFirst().getValueString("userId")); + SlackActionMetaData action = SlackRuntime.config().getActions().get(claims.actionName()); + if(action == null) + { + finish(audit, "REJECTED", "Unknown action"); + return; + } + String completion; + try + { + completion = SlackOwnerSession.call(audit.getValueString("userId"), () -> run(action, claims, workspaceId)); + } + catch(QPermissionDeniedException e) + { + finish(audit, "DENIED", "Permission denied"); + respond(payload, "You don't have permission to " + action.getLabel()); + return; + } + catch(Exception e) + { + finish(audit, "FAILED", "Process could not complete"); + respond(payload, action.getLabel() + " failed: Process could not complete"); + return; + } + finish(audit, "DONE", null); + try + { + SlackSendResult result = SlackService.updateInteraction(message, audit.getValueInteger("id"), completion); + if(result.status() == SlackSendResult.Status.REFUSED) + { + finish(audit, "DONE", "Message update unavailable"); + } + } + catch(Exception e) + { + finish(audit, "DONE", "Message update unavailable"); + } + } + + /******************************************************************************* + ** Explicit record lookup enforces locks even for processes without an extract + ** step; the normal process callback re-fetches by primary key as this user. + *******************************************************************************/ + private static String run(SlackActionMetaData action, SlackActionToken.Claims claims, int workspaceId) throws QException + { + QSession session = QContext.getQSession(); + var process = QContext.getQInstance().getProcess(action.getProcessName()); + var table = QContext.getQInstance().getTable(claims.tableName()); + if(!session.hasPermission(action.getPermissionName()) || process == null || table == null + || !Objects.equals(process.getTableName(), claims.tableName()) + || SlackStore.queryAsCurrent(SlackTables.WORKSPACE, SlackStore.equal("id", workspaceId)).isEmpty() + || SlackStore.queryAsCurrent(claims.tableName(), SlackStore.equal(table.getPrimaryKeyField(), claims.recordId())).size() != 1) + { + throw (new QPermissionDeniedException("Permission denied")); + } + var result = new RunProcessAction().execute(new RunProcessInput().withProcessName(action.getProcessName()) + .withCallback(QProcessCallbackFactory.forPrimaryKey(table.getPrimaryKeyField(), claims.recordId())) + .withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)); + if(result.getException().isPresent()) + { + throw (new QException("Process could not complete")); + } + String label = Objects.toString(session.getUser().getFullName(), session.getUser().getIdReference()); + ZoneId zone = SlackRuntime.clock().getZone(); + Object configuredZone = session.getValue(QSession.VALUE_KEY_USER_TIMEZONE); + if(configuredZone instanceof String zoneName) + { + zone = ZoneId.of(zoneName); + } + return (action.getLabel() + " by " + label + " at " + DateTimeFormatter.ofPattern("HH:mm").withZone(zone).format(SlackRuntime.clock().instant())); + } + + /******************************************************************************* + ** Only fixed, payload-free diagnostic reasons reach storage. + *******************************************************************************/ + private static void finish(QRecord audit, String outcome, String error) throws QException + { + audit.withValue("outcome", outcome).withValue("error", error).withValue("completedDate", SlackRuntime.clock().instant()); + SlackStore.update(SlackTables.INTERACTION, audit, null); + } + + /******************************************************************************* + ** Response URL validation lives in the transport, and never forwards a token. + *******************************************************************************/ + private static void respond(JSONObject payload, String text) + { + SlackRuntime.client(SlackRuntime.config()).respondEphemeral(payload.optString("response_url"), text); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java index 8e718c9..9b97196 100644 --- a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java +++ b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java @@ -45,6 +45,11 @@ public class SlackOAuthRouteProvider implements QJavalinRouteProviderInterface public static void register(QInstance instance) { QJavalinMetaData metadata = QJavalinMetaData.ofOrWithNew(instance); + if(metadata.getAdditionalRouteProviderReferences() == null || metadata.getAdditionalRouteProviderReferences().stream() + .noneMatch(r -> com.kingsrook.qbits.slack.interactivity.SlackCallbackRouteProvider.class.getName().equals(r.getName()))) + { + metadata.withAdditionalRouteProviderReference(new QCodeReference(com.kingsrook.qbits.slack.interactivity.SlackCallbackRouteProvider.class)); + } if(metadata.getAdditionalRouteProviderReferences() == null || metadata.getAdditionalRouteProviderReferences().stream() .noneMatch(r -> SlackOAuthRouteProvider.class.getName().equals(r.getName()))) { diff --git a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java index cbaab1a..dce1d39 100644 --- a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java +++ b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java @@ -32,6 +32,25 @@ import com.kingsrook.qbits.slack.metadata.SlackEnums; *******************************************************************************/ public class SlackSendRequest { + private java.util.List actions = java.util.List.of(); + + /******************************************************************************* + ** Registered action names. + *******************************************************************************/ + public java.util.List getActions() + { + return (actions); + } + + /******************************************************************************* + ** Snapshot the requested actions. + *******************************************************************************/ + public SlackSendRequest withActions(java.util.List values) + { + actions = java.util.List.copyOf(values); + return (this); + } + /******************************************************************************* ** A storage reference to stream later, in the owner's session. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java index 1a48bee..6f38e6b 100644 --- a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java +++ b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java @@ -177,6 +177,7 @@ public final class SlackService QRecord row; try { + com.kingsrook.qbits.slack.interactivity.SlackActions.validate(request); row = baseRow(request, storedOwner, send, SlackEnums.Operation.POST); } catch(IllegalArgumentException e) @@ -194,6 +195,10 @@ public final class SlackService row.withValue("nextAttemptDate", null); } } + if(!request.getActions().isEmpty()) + { + return (insertActions(row, request)); + } return (request.getAttachments().isEmpty() ? insert(row) : insertDelivery(row, request.getAttachments())); } @@ -283,6 +288,37 @@ public final class SlackService ** because Slack only lets a token edit its own messages. *******************************************************************************/ public static SlackSendResult update(Integer messageId, SlackSendRequest request) throws QException + { + return (update(messageId, request, false)); + } + + /******************************************************************************* + ** Build only the trusted completion replacement; public requests still require + ** the raw-block permission. The original owner is reauthorized for the update. + *******************************************************************************/ + public static SlackSendResult updateInteraction(QRecord message, int interactionId, String context) throws QException + { + com.kingsrook.qbits.slack.phi.SlackPayloadGuard.text(context, null); + JSONArray blocks = message.getValueString("blocksJson") == null ? new JSONArray() : new JSONArray(message.getValueString("blocksJson")); + JSONArray replacement = new JSONArray(); + for(int i = 0; i < blocks.length(); i++) + { + if(!"actions".equals(blocks.getJSONObject(i).optString("type"))) + { + replacement.put(blocks.getJSONObject(i)); + } + } + replacement.put(new org.json.JSONObject().put("type", "context").put("elements", new JSONArray() + .put(new org.json.JSONObject().put("type", "plain_text").put("text", context)))); + return (SlackStore.asSystem(() -> update(message.getValueInteger("id"), new SlackSendRequest() + .withOwnerUserId(message.getValueString("ownerUserId")).withText(message.getValueString("text")) + .withBlocksJson(replacement.toString()).withIdempotencyKey("interaction:" + interactionId), true))); + } + + /******************************************************************************* + ** Shared authorization and outbox path for updates. + *******************************************************************************/ + private static SlackSendResult update(Integer messageId, SlackSendRequest request, boolean generatedBlocks) throws QException { SlackQBitConfig config = SlackRuntime.config(); if(config == null) @@ -305,7 +341,7 @@ public final class SlackService { return (SlackSendResult.refused("Only the owner can update a Slack message")); } - String denied = permissionProblem(owner, !"SECURITY".equals(request.getCategory()) && request.getBlocksJson() != null && !request.getBlocksJson().isBlank()); + String denied = permissionProblem(owner, !generatedBlocks && !"SECURITY".equals(request.getCategory()) && request.getBlocksJson() != null && !request.getBlocksJson().isBlank()); if(denied != null) { return (SlackSendResult.refused(denied)); @@ -591,4 +627,33 @@ public final class SlackService } return (existing); } + + + + /******************************************************************************* + ** Publish the row and signed action values atomically to the dispatcher. + *******************************************************************************/ + private static SlackSendResult insertActions(QRecord row, SlackSendRequest request) throws QException + { + try(var tx = com.kingsrook.qqq.backend.core.actions.QBackendTransaction.openFor( + new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput(SlackTables.MESSAGE))) + { + QRecord inserted = SlackStore.insert(SlackTables.MESSAGE, row, tx); + if(inserted.getValueInteger("id") == null || inserted.getErrors() != null && !inserted.getErrors().isEmpty()) + { + throw (new QException("Could not queue Slack actions")); + } + row.withValue("id", inserted.getValueInteger("id")); + SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", inserted.getValueInteger("id")) + .withValue("blocksJson", com.kingsrook.qbits.slack.interactivity.SlackActions.blocks(row, request)), tx); + tx.commit(); + return (new SlackSendResult(SlackSendResult.Status.QUEUED, inserted.getValueInteger("id"), List.of())); + } + catch(Exception e) + { + SlackSendResult existing = duplicate(row.getValueString("idempotencyKey"), row.getValueString("ownerUserId")); + return (existing == null ? SlackSendResult.refused("Could not queue Slack actions") : existing); + } + } + } diff --git a/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java b/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java index 6612ea2..ab0dca6 100644 --- a/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java +++ b/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java @@ -448,4 +448,41 @@ public class SlackClient return (DEFAULT_RETRY_AFTER_SECONDS); } } + + + + /******************************************************************************* + ** Ephemeral callback response without credentials or redirects. Test URLs must + ** be beneath the explicitly configured loopback API base, not just its host. + *******************************************************************************/ + public SlackResponse respondEphemeral(String url, String text) + { + try + { + URI uri = URI.create(url); + URI api = URI.create(baseUrl); + boolean slack = "https".equals(uri.getScheme()) && "hooks.slack.com".equals(uri.getHost()) && uri.getPort() == -1; + boolean test = "http".equals(api.getScheme()) && java.util.List.of("127.0.0.1", "localhost", "[::1]").contains(api.getHost()) + && java.util.Objects.equals(uri.getScheme(), api.getScheme()) && java.util.Objects.equals(uri.getHost(), api.getHost()) + && uri.getPort() == api.getPort() && uri.getPath().startsWith(api.getPath().replaceAll("/+$", "") + "/"); + if(uri.getUserInfo() != null || uri.getFragment() != null || !(slack || test)) + { + return (SlackResponse.failure(0, "invalid_response_url")); + } + var response = http.send(HttpRequest.newBuilder(uri).timeout(requestTimeout).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(new JSONObject().put("response_type", "ephemeral").put("text", text).toString())).build(), + HttpResponse.BodyHandlers.discarding()); + return (new SlackResponse(response.statusCode(), response.statusCode() >= 200 && response.statusCode() < 300, null, null, null, null)); + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + return (SlackResponse.failure(0, "response_interrupted")); + } + catch(Exception e) + { + return (SlackResponse.failure(0, "response_failed")); + } + } + } diff --git a/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java b/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java index c22f913..222624f 100644 --- a/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java +++ b/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java @@ -310,6 +310,8 @@ public class FakeSlackServer implements AutoCloseable switch(request.method()) { + case "response": + return (Reply.of(200, "ok")); case "files.getUploadURLExternal": String fileId = "F" + tsCount.incrementAndGet(); return (Reply.of(200, new JSONObject().put("ok", true).put("file_id", fileId).put("upload_url", baseUrl() + "/upload/" + fileId).toString())); diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackInteractionRouteTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackInteractionRouteTest.java new file mode 100644 index 0000000..4a519c6 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackInteractionRouteTest.java @@ -0,0 +1,417 @@ +/* + * 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.slack; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import com.kingsrook.qbits.slack.api.SlackActionMetaData; +import com.kingsrook.qbits.slack.api.SlackApi; +import com.kingsrook.qbits.slack.api.SlackApiRequest; +import com.kingsrook.qbits.slack.interactivity.SlackActions; +import com.kingsrook.qbits.slack.interactivity.SlackCallbackRouteProvider; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.security.SlackActionToken; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qbits.slack.template.SlackTemplates; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import io.javalin.Javalin; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** Real HTTP callbacks against H2 and a fake Slack transport, with real user + ** sessions and RunProcessAction. No test process-execution bypass is used. + *******************************************************************************/ +class SlackInteractionRouteTest +{ + private static final String SIGNING_CANARY = "signing-canary-never-store-this"; + private SlackTestEnv env; + private Javalin app; + private int workspace; + private int record; + private final HttpClient http = HttpClient.newHttpClient(); + + /******************************************************************************* + ** A real process which reads the callback's selected record under user locks. + *******************************************************************************/ + public static class ApproveStep implements BackendStep + { + static final AtomicInteger CALLS = new AtomicInteger(); + static String user; + static boolean fail; + + /******************************************************************************* + ** Record proof of the authorized identity and primary-key callback. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + assertThat(SlackStore.queryAsCurrent(SlackTables.CHANNEL, input.getCallback().getQueryFilter())).hasSize(1); + user = QContext.getQSession().getUser().getIdReference(); + CALLS.incrementAndGet(); + if(fail) + { + throw (new QException("Intentional process failure")); + } + } + } + + /******************************************************************************* + ** Two tenants can install the same Slack team; callbacks must select the row. + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + ApproveStep.CALLS.set(0); + ApproveStep.fail = false; + env = SlackTestEnv.create(true, c -> c.withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : SIGNING_CANARY) + .withAction(action("approve").withStyle("primary").withConfirmTitle("Confirm approval").withConfirmText("Approve this record?"))); + env.tenant("alice", 1); + env.tenant("bob", 1); + env.tenant("other", 2); + workspace = env.workspace("T1", 1, "xoxb-test", "UBOT"); + env.connection("alice", workspace, "UA", "xoxp-test"); + env.slack().members("C1", "UA", "UB", "UBOT"); + record = env.channel(workspace, "general", "C1", "general"); + env.instance().addProcess(process("approve")); + SlackTemplates.seed(); + SlackCallbackRouteProvider routes = new SlackCallbackRouteProvider().withExecutor(Runnable::run); + routes.setQInstance(env.instance()); + app = Javalin.create(routes::acceptJavalinConfig).start(0); + } + + /******************************************************************************* + ** Stop HTTP services and reset global test state. + *******************************************************************************/ + @AfterEach + void close() + { + app.stop(); + env.close(); + } + + /******************************************************************************* + ** Application-owned metadata. + *******************************************************************************/ + private static SlackActionMetaData action(String name) + { + return (new SlackActionMetaData().withName(name).withLabel("Approve").withProcessName(name).withPermissionName("slack.send")); + } + + /******************************************************************************* + ** Frontend steps must be skipped on callbacks. + *******************************************************************************/ + private static QProcessMetaData process(String name) + { + return (new QProcessMetaData().withName(name).withTableName(SlackTables.CHANNEL).withPermissionRules(new com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules() + .withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("slack")) + .withStep(new QFrontendStepMetaData().withName("review")) + .withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(ApproveStep.class)))); + } + + /******************************************************************************* + ** Queue through the public API and dispatch so Slack identifiers are genuine. + *******************************************************************************/ + private QRecord message(String template) throws Exception + { + env.asOwner("alice"); + var result = SlackApi.send(new SlackApiRequest().withTo("channel:" + workspace + "/C1").withText("Review this record") + .withTemplateName(template).withVariables(Map.of("title", "Review", "body", "Ready", "link", "https://example.test/record")) + .withLinkedTableName(SlackTables.CHANNEL).withLinkedRecordId(String.valueOf(record)).withAction("approve")); + assertThat(result.status()).isEqualTo("QUEUED"); + env.dispatch(); + QRecord message = env.row(SlackTables.MESSAGE, result.messageId()); + assertThat(message.getValueString("status")).isEqualTo("SENT"); + return (message); + } + + /******************************************************************************* + ** Build a click using the actual delivered button. + *******************************************************************************/ + private JSONObject click(QRecord message, String user) + { + JSONArray blocks = new JSONArray(message.getValueString("blocksJson")); + JSONObject button = blocks.getJSONObject(blocks.length() - 1).getJSONArray("elements").getJSONObject(0); + return (new JSONObject().put("type", "block_actions").put("team", new JSONObject().put("id", "T1")) + .put("user", new JSONObject().put("id", user)).put("actions", new JSONArray().put(button)) + .put("container", new JSONObject().put("message_ts", message.getValueString("slackTs")).put("channel_id", "C1")) + .put("response_url", env.slack().baseUrl() + "/response")); + } + + /******************************************************************************* + ** Slack's signature is computed over the exact form or JSON bytes sent. + *******************************************************************************/ + private HttpResponse post(String route, String body, long timestamp, boolean valid) throws Exception + { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SIGNING_CANARY.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String signature = "v0=" + HexFormat.of().formatHex(mac.doFinal(("v0:" + timestamp + ":" + body).getBytes(StandardCharsets.UTF_8))); + return (http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + app.port() + "/qqq/hooks/slack/" + route)) + .header("Content-Type", "events".equals(route) ? "application/json" : "application/x-www-form-urlencoded") + .header("X-Slack-Request-Timestamp", String.valueOf(timestamp)).header("X-Slack-Signature", valid ? signature : "v0=" + "0".repeat(64)) + .POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString())); + } + + /******************************************************************************* + ** Send a signed click and require an empty acknowledgement. + *******************************************************************************/ + private void click(JSONObject payload) throws Exception + { + var response = post("interactions", "payload=" + URLEncoder.encode(payload.toString(), StandardCharsets.UTF_8), env.clock().instant().getEpochSecond(), true); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).isEmpty(); + } + + /******************************************************************************* + ** Audit rows are available only through privileged test inspection. + *******************************************************************************/ + private List interactions() throws Exception + { + return (SlackStore.query(SlackTables.INTERACTION, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())); + } + + /******************************************************************************* + ** Bad signatures, stale/future timestamps and secret lookup failures reject + ** without retaining or echoing even a malicious canary-bearing request body. + *******************************************************************************/ + @Test + void signaturesAndSecretLeakage() throws Exception + { + try(LogCapture logs = new LogCapture()) + { + long now = env.clock().instant().getEpochSecond(); + for(long timestamp : List.of(now, now - 301, now + 301)) + { + var response = post("interactions", "payload=" + SIGNING_CANARY, timestamp, timestamp != now); + assertThat(response.statusCode()).isEqualTo(401); + assertThat(response.body()).isEmpty(); + } + env.config().withAllowedEnvNames(java.util.Set.of("SLACK_TOKEN_KEY")); + assertThat(post("events", SIGNING_CANARY, now, true).statusCode()).isEqualTo(401); + env.config().withAllowedEnvNames(java.util.Set.of("SLACK_TOKEN_KEY", "SLACK_SIGNING_SECRET")) + .withSecretResolver(name -> + { + throw new IllegalArgumentException(SIGNING_CANARY); + }); + assertThat(post("interactions", SIGNING_CANARY, now, true).statusCode()).isEqualTo(401); + assertThat(interactions()).hasSize(5).allSatisfy(row -> + { + assertThat(row.getValueString("outcome")).isEqualTo("REJECTED"); + assertThat(row.getValueString("messageId")).isNull(); + assertThat(row.getValues().toString()).doesNotContain(SIGNING_CANARY); + }); + assertThat(String.join("\n", logs.lines())).doesNotContain(SIGNING_CANARY); + } + } + + /******************************************************************************* + ** Forged, expired and transplanted buttons never reach a process. + *******************************************************************************/ + @Test + void rejectsInvalidButtons() throws Exception + { + QRecord message = message(null); + JSONObject forged = click(message, "UA"); + forged.getJSONArray("actions").getJSONObject(0).put("value", "forged"); + click(forged); + JSONObject wrongChannel = click(message, "UA"); + wrongChannel.getJSONObject("container").put("channel_id", "C2"); + click(wrongChannel); + JSONObject wrongTime = click(message, "UA"); + wrongTime.getJSONObject("container").put("message_ts", "other"); + click(wrongTime); + env.clock().advance(Duration.ofDays(8)); + click(click(message, "UA")); + assertThat(interactions()).extracting(row -> row.getValueString("outcome")).containsExactly("REJECTED", "REJECTED", "REJECTED", "EXPIRED"); + assertThat(ApproveStep.CALLS).hasValue(0); + } + + /******************************************************************************* + ** Same team id is insufficient: a connection in another tenant's workspace + ** remains unlinked, as does a user who never connected Slack. + *******************************************************************************/ + @Test + void requiresConnectionInExactWorkspace() throws Exception + { + QRecord message = message(null); + int otherWorkspace = env.workspace("T1", 2, "xoxb-other", "UBOT"); + env.connection("other", otherWorkspace, "UOTHER", "xoxp-other"); + click(click(message, "UOTHER")); + click(click(message, "UNCONNECTED")); + assertThat(interactions()).extracting(row -> row.getValueString("outcome")).containsExactly("UNLINKED", "UNLINKED"); + assertThat(ApproveStep.CALLS).hasValue(0); + assertThat(env.slack().requests("response")).hasSize(2).allSatisfy(response -> + { + assertThat(response.authorization()).isNull(); + assertThat(response.json().getString("response_type")).isEqualTo("ephemeral"); + assertThat(response.json().getString("text")).isEqualTo("Connect Slack in Preferences to use this button"); + }); + } + + /******************************************************************************* + ** Permissions and current tenant/record locks are checked after mapping. + *******************************************************************************/ + @Test + void deniesMissingPermissionAndChangedTenant() throws Exception + { + QRecord message = message(null); + SlackTestEnv.TestAuthCustomizer.NO_ACCESS.add("alice"); + click(click(message, "UA")); + SlackTestEnv.TestAuthCustomizer.NO_ACCESS.clear(); + env.connection("bob", workspace, "UB", "xoxp-bob"); + env.tenant("bob", 2); + click(click(message, "UB")); + assertThat(interactions()).extracting(row -> row.getValueString("outcome")).containsExactly("DENIED", "DENIED"); + assertThat(ApproveStep.CALLS).hasValue(0); + assertThat(env.slack().requests("response").getFirst().json().getString("text")).isEqualTo("You don't have permission to Approve"); + } + + /******************************************************************************* + ** Run as the clicker; update as the original owner. An identical HTTP retry + ** produces one audit, one process run, one UPDATE and one chat.update call. + *******************************************************************************/ + @Test + void approvesExactlyOnce() throws Exception + { + QRecord message = message("notification"); + env.connection("bob", workspace, "UB", "xoxp-bob"); + JSONObject payload = click(message, "UB"); + click(payload); + click(payload); + assertThat(ApproveStep.CALLS).hasValue(1); + assertThat(ApproveStep.user).isEqualTo("bob"); + assertThat(interactions()).hasSize(1); + QRecord audit = interactions().getFirst(); + assertThat(audit.getValueString("outcome")).isEqualTo("DONE"); + assertThat(audit.getValueString("error")).isNull(); + List updates = SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("operation", "UPDATE")); + assertThat(updates).hasSize(1); + assertThat(updates.getFirst().getValueString("ownerUserId")).isEqualTo("alice"); + assertThat(updates.getFirst().getValueString("idempotencyKey")).isEqualTo("interaction:" + audit.getValueInteger("id")); + assertThat(updates.getFirst().getValueString("blocksJson")).doesNotContain("action_id").contains("Approve by", "12:00"); + env.clock().advance(Duration.ofSeconds(2)); + env.dispatch(); + env.dispatch(); + assertThat(env.slack().requests("chat.update")).hasSize(1); + env.asOwner("alice"); + assertThat(SlackStore.queryAsCurrent(SlackTables.INTERACTION, SlackStore.equal("id", audit.getValueInteger("id")))).isEmpty(); + env.asOwner("bob"); + assertThat(SlackStore.queryAsCurrent(SlackTables.INTERACTION, SlackStore.equal("id", audit.getValueInteger("id")))).hasSize(1); + } + + /******************************************************************************* + ** Application exceptions record fixed short diagnostics and an ephemeral reply. + *******************************************************************************/ + @Test + void recordsProcessFailure() throws Exception + { + QRecord message = message("delivery"); + ApproveStep.fail = true; + click(click(message, "UA")); + assertThat(interactions().getFirst().getValueString("outcome")).isEqualTo("FAILED"); + assertThat(env.slack().requests("response").getFirst().json().getString("text")).isEqualTo("Approve failed: Process could not complete"); + assertThat(SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("operation", "UPDATE"))).isEmpty(); + } + + /******************************************************************************* + ** Challenge verification and authoritative revocations affect every tenant + ** row for a team, leaving other teams and non-revoked users untouched. + *******************************************************************************/ + @Test + void eventsAcrossTenantRows() throws Exception + { + long now = env.clock().instant().getEpochSecond(); + var challenge = post("events", new JSONObject().put("type", "url_verification").put("challenge", "challenge-value").toString(), now, true); + assertThat(challenge.statusCode()).isEqualTo(200); + assertThat(new JSONObject(challenge.body()).getString("challenge")).isEqualTo("challenge-value"); + int second = env.workspace("T1", 2, "xoxb-second", "UBOT"); + int unrelated = env.workspace("T2", 2, "xoxb-unrelated", "UBOT2"); + int revoked = env.connection("other", second, "UA", "xoxp-second"); + int retained = env.connection("bob", workspace, "UB", "xoxp-bob"); + JSONObject event = new JSONObject().put("type", "event_callback").put("team_id", "T1").put("event", new JSONObject() + .put("type", "tokens_revoked").put("tokens", new JSONObject().put("oauth", new JSONArray().put("UA")).put("bot", new JSONArray().put("UBOT")))); + assertThat(post("events", event.toString(), now, true).statusCode()).isEqualTo(200); + for(int id : List.of(workspace, second)) + { + QRecord row = SlackStore.getWithToken(SlackTables.WORKSPACE, id); + assertThat(row.getValueString("status")).isEqualTo("REVOKED"); + assertThat(row.getValueString("botToken")).isNull(); + } + assertThat(SlackStore.getWithToken(SlackTables.CONNECTION, revoked).getValueString("userToken")).isNull(); + assertThat(env.row(SlackTables.CONNECTION, retained).getValueString("status")).isEqualTo("ACTIVE"); + assertThat(env.row(SlackTables.WORKSPACE, unrelated).getValueString("status")).isEqualTo("ACTIVE"); + event.put("event", new JSONObject().put("type", "app_uninstalled")); + post("events", event.toString(), now, true); + post("events", event.toString(), now, true); + for(int id : List.of(workspace, second)) + { + assertThat(env.row(SlackTables.WORKSPACE, id).getValueString("status")).isEqualTo("UNINSTALLED"); + } + assertThat(SlackStore.getWithToken(SlackTables.CONNECTION, retained).getValueString("userToken")).isNull(); + } + + /******************************************************************************* + ** Optional integrations appear only when a same-named process exists. + *******************************************************************************/ + @Test + void builtInsAndTransportBoundary() throws Exception + { + assertThat(env.config().getActions()).doesNotContainKeys("acknowledge_alert", "resolve_alert", "reject"); + env.instance().addProcess(process("acknowledge_alert")); + env.instance().addProcess(process("resolve_alert")); + SlackActions.registerBuiltIns(env.instance(), env.config()); + assertThat(env.config().getActions()).containsKeys("acknowledge_alert", "resolve_alert"); + QRecord message = message(null); + String token = click(message, "UA").getJSONArray("actions").getJSONObject(0).getString("value"); + assertThat(token.length()).isLessThanOrEqualTo(2000); + assertThat(SlackActionToken.verify(env.config(), token).messageId()).isEqualTo(message.getValueInteger("id")); + var client = com.kingsrook.qbits.slack.support.SlackRuntime.client(env.config()); + for(String url : List.of("https://hooks.slack.com.evil.test/a", "http://hooks.slack.com/a", "https://user@hooks.slack.com/a", "https://hooks.slack.com:444/a", "http://127.0.0.1:1/a")) + { + assertThat(client.respondEphemeral(url, "test").isOk()).isFalse(); + } + assertThat(env.slack().requests("response")).isEmpty(); + } +} -- 2.54.0